mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Release v2.2.0 (#741)
* Migrate to TailwindCSS v4 (#740) * Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4 * Clean up Tailwind configs before upgrade * Run HeroUI upgrade * Run TailwindCSS upgrade * Replace PostCSS with Vite * Migrate custom styles to v4 * Remove tailwind.config.ts * Add heroui.ts Add tailwind vite plugin * Fix small UI color inconsistency * Fix theming system Rename purple theme to pink * Re-implement stepper in HeroUI * Fix RoleChip colors * Migrate icon names (#743) * Add migration script for phosphor-icons * Migrate icon usages * Update version to 2.2.0-preview * Revert accidental rename of menu title * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve library scanning (#749) * Update script to generate example libraries using SteamSpy API * Refactor library scanning process * Display Flyway startup log by default * Fix race condition in CompanyService * Fix race condition in ImageService Remove obsolete table * Fix SMTP config requiring an email as username (#755) * Disable length limit for config values (#757) * Deprecate DockerHub image (#759) * Remove deprecation warning from web UI * Reworked the CICD pipelines * Optimize container image (#761) * Fix Gradle warning * Rework Docker image to improve layer caching * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Multi platform support (#764) * Remove migrate-phosphor-icons.js since migration has been successful * Refactor GameMetadata into separate files * Add Platform enum * Implement platform support in Plugin API * Implement platform support in Steam Plugin * Implement platform support in IGDB Plugin * Add database migration for platform support * Implement platform support in GameService * Implement platform support on most endpoints and features, some are still missing Implemented platform support in all bundled plugins (although not finished polishing yet) * Implement platforms in UI * Make GameRequest platform aware * Return headerImages from IGDB * Implement proper PlatformMapper for IGDB plugin * Fix various smaller issues and inconsistencies * Replace placeholder in LibraryOverviewCard (#767) * Bump actions/download-artifact from 5 to 6 (#769) * Bump actions/upload-artifact from 4 to 5 (#770) * Multi platform support (#773) * Fix bug in Plugin API related to state loading/saving * Hide Flyway query logs by default * Extend migration script for multi platform tables * Plugins now store their data and state in ./plugindata * Add "plugindata" directory to entrypoint scripts * Improve download handling (#756) * Process download in background thread to avoid session timeout affecting it * Increase default session timeout to 24h * Use virtual thread pool for download task in background * Make KSP extensions.idx generation more robust * Implement download bandwidth limiter Implement SliderInput Refactor NumberInput * Implement download bandwidth throttling Implement real-time download monitoring * Improve UI for DownloadManagement Track more stats in SessionStats * Update Hilla Use React 19 * Implement real-time graph to track bandwidth usage Implement downloaded data sum over last day Small bug fixes Small refactorings * Update docker-compose.example.yml * Improve DownloadSessionCard (#784) * Fix unit on y-axis of download graph * Show game size and library in tooltip Make game chips interactive in DownloadSessionCard (leads to game page when clicked) Optimize graph settings * Migrate torrent plugin to libtorrent (#775) * Disable TorrentDownloadPlugin in Alpine based Docker image * Improve test coverage (#785) * Fix potential divide by zero bug * Add mockk dependency * Add tests for org.gameyfin.app.core.download * Add tests for Filesytem package Fix DownloadServiceTest * Fix FilesystemServiceTest * Add tests for "job" package * Upgrade Gradle wrapper Enable Gradle config cache * Added more tests * Added tests for the "security" package * Add tests for "game" package * Fix AsyncFileTailer not shutting down properly on Windows * Fix GameServiceTest * Added tests for "libraries" package * Added tests for "media" package * Fix warning in ImageService * Add tests fpr "messages" package Make sure transport is closed even in case an exception is thrown * Add tests for "platforms" package * Add tests for "requests" package * Moved "token" package to "core" package (from "shared") * Add tests for "token" package * Fix issue in RoleEnum.safeValueOf() throwing Exception * Fix potential issue in UserEndpoint.getUserInfo() when auth is null * Added tests for "user" package * Migrate package for "token" in FE * Publish test report in CI * Fix workflow permissions * Remove test because of timing issue in CI * Replaced "unmatched paths" with "ignored paths" (#791) * Use new "AutoComplete" component (#793) * Use ArrayInputAutocomplete in EditGameMetadataModal * Add test for getEnumPropertyValues --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
# Exclude VCS and IDE files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Gradle caches
|
||||||
|
.gradle/
|
||||||
|
**/.gradle/
|
||||||
|
|
||||||
|
# Node modules and app build cache
|
||||||
|
app/node_modules/
|
||||||
|
app/.pnpm-store/
|
||||||
|
app/.npm/
|
||||||
|
app/.yarn/
|
||||||
|
app/.vite/
|
||||||
|
app/dist/
|
||||||
|
|
||||||
|
# General build outputs (keep only the jars we actually need)
|
||||||
|
**/build/
|
||||||
|
!app/build/
|
||||||
|
!app/build/libs/
|
||||||
|
!app/build/libs/app.jar
|
||||||
|
|
||||||
|
# Only keep plugin jars in build/libs
|
||||||
|
plugins/**
|
||||||
|
!plugins/*/build/
|
||||||
|
!plugins/*/build/libs/
|
||||||
|
!plugins/*/build/libs/*.jar
|
||||||
|
|
||||||
|
# Large local/runtime data not needed in image context
|
||||||
|
data/
|
||||||
|
db/
|
||||||
|
logs/
|
||||||
|
plugindata/
|
||||||
|
|
||||||
|
# Docker intermediate artifacts
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
name: 'Docker Build and Push'
|
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:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ inputs.dockerhub_username }}
|
|
||||||
password: ${{ inputs.dockerhub_token }}
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -21,6 +15,7 @@ runs:
|
|||||||
|
|
||||||
- name: Prepare Ubuntu tags
|
- name: Prepare Ubuntu tags
|
||||||
id: ubuntu_tags
|
id: ubuntu_tags
|
||||||
|
if: ${{ inputs.variant != 'alpine' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ inputs.tags }}"
|
TAGS="${{ inputs.tags }}"
|
||||||
@@ -28,6 +23,7 @@ runs:
|
|||||||
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
|
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push Docker image (Alpine)
|
- name: Build and push Docker image (Alpine)
|
||||||
|
if: ${{ inputs.variant != 'ubuntu' }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ${{ inputs.context }}
|
context: ${{ inputs.context }}
|
||||||
@@ -39,6 +35,7 @@ runs:
|
|||||||
cache-to: type=gha
|
cache-to: type=gha
|
||||||
|
|
||||||
- name: Build and push Docker image (Ubuntu)
|
- name: Build and push Docker image (Ubuntu)
|
||||||
|
if: ${{ inputs.variant != 'alpine' }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ${{ inputs.context }}
|
context: ${{ inputs.context }}
|
||||||
@@ -50,12 +47,6 @@ runs:
|
|||||||
cache-to: type=gha
|
cache-to: type=gha
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
dockerhub_username:
|
|
||||||
required: true
|
|
||||||
description: 'Docker Hub username'
|
|
||||||
dockerhub_token:
|
|
||||||
required: true
|
|
||||||
description: 'Docker Hub token'
|
|
||||||
ghcr_username:
|
ghcr_username:
|
||||||
required: true
|
required: true
|
||||||
description: 'GHCR username'
|
description: 'GHCR username'
|
||||||
@@ -74,3 +65,7 @@ inputs:
|
|||||||
tags:
|
tags:
|
||||||
required: true
|
required: true
|
||||||
description: 'Comma-separated list of image tags'
|
description: 'Comma-separated list of image tags'
|
||||||
|
variant:
|
||||||
|
required: true
|
||||||
|
default: 'both'
|
||||||
|
description: 'Image variant to build: alpine, ubuntu, or both'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Delete Docker Tag on Merge
|
name: Delete Image Tags on Merge
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,10 +9,11 @@ jobs:
|
|||||||
delete-docker-tag:
|
delete-docker-tag:
|
||||||
if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/')
|
if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: Cleanup Image Tags from GHCR
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Extract merged branch name and tag
|
- name: Extract tag from branch name
|
||||||
id: extract_branch
|
id: extract_branch
|
||||||
run: |
|
run: |
|
||||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
@@ -26,50 +27,8 @@ jobs:
|
|||||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Delete image tag from Docker Hub
|
- name: Delete tags
|
||||||
if: steps.extract_branch.outputs.tag != ''
|
if: steps.extract_branch.outputs.tag != ''
|
||||||
env:
|
uses: dataaxiom/ghcr-cleanup-action@v1
|
||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
with:
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
tags: ${{ steps.extract_branch.outputs.tag }},${{ steps.extract_branch.outputs.tag }}-ubuntu
|
||||||
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
|
|
||||||
|
|||||||
@@ -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' }}
|
|
||||||
@@ -6,10 +6,12 @@ on:
|
|||||||
- 'fix/*'
|
- 'fix/*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
checks: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -25,6 +27,39 @@ jobs:
|
|||||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||||
|
|
||||||
|
- name: Publish Test Report
|
||||||
|
uses: mikepenz/action-junit-report@v6
|
||||||
|
if: success() || failure() # always run even if the previous step fails
|
||||||
|
with:
|
||||||
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
|
- name: Upload build outputs
|
||||||
|
uses: actions/upload-artifact@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
|
- name: Extract tag from branch name
|
||||||
id: extract_tag
|
id: extract_tag
|
||||||
run: |
|
run: |
|
||||||
@@ -32,14 +67,13 @@ jobs:
|
|||||||
TAG="${BRANCH_NAME#fix/}"
|
TAG="${BRANCH_NAME#fix/}"
|
||||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image (${{ matrix.variant }})
|
||||||
uses: ./.github/actions/docker-build-push
|
uses: ./.github/actions/docker-build-push
|
||||||
with:
|
with:
|
||||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
ghcr_username: ${{ github.actor }}
|
ghcr_username: ${{ github.actor }}
|
||||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
platforms: linux/arm64/v8,linux/amd64
|
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 }}
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ on:
|
|||||||
- 'release/*'
|
- 'release/*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
checks: write
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.extract_version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
@@ -20,26 +26,79 @@ jobs:
|
|||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
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
|
- name: Run production build
|
||||||
env:
|
env:
|
||||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||||
|
|
||||||
- name: Extract tag from branch name
|
- name: Publish Test Report
|
||||||
id: extract_tag
|
uses: mikepenz/action-junit-report@v6
|
||||||
run: |
|
if: success() || failure() # always run even if the previous step fails
|
||||||
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
|
with:
|
||||||
TAG="${BRANCH_NAME#release/}-preview"
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- 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
|
||||||
|
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
|
uses: ./.github/actions/docker-build-push
|
||||||
with:
|
with:
|
||||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
ghcr_username: ${{ github.actor }}
|
ghcr_username: ${{ github.actor }}
|
||||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
platforms: linux/arm64/v8,linux/amd64
|
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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,18 +50,20 @@ jobs:
|
|||||||
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
||||||
|
|
||||||
- name: Upload modified files
|
- name: Upload modified files
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
path: |
|
path: |
|
||||||
build.gradle.kts
|
build.gradle.kts
|
||||||
app/package.json
|
app/package.json
|
||||||
|
|
||||||
docker:
|
build:
|
||||||
needs: setup
|
needs: setup
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
checks: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -69,7 +71,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
@@ -86,33 +88,70 @@ jobs:
|
|||||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||||
|
|
||||||
|
- name: Publish Test Report
|
||||||
|
uses: mikepenz/action-junit-report@v6
|
||||||
|
if: success() || failure() # always run even if the previous step fails
|
||||||
|
with:
|
||||||
|
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||||
|
|
||||||
|
- name: Upload build outputs
|
||||||
|
uses: actions/upload-artifact@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
|
- name: Generate container image tags
|
||||||
id: docker_tags
|
id: docker_tags
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.setup.outputs.release_version }}"
|
VERSION='${{ needs.setup.outputs.release_version }}'
|
||||||
DOCKERHUB_TAGS="grimsi/gameyfin:$VERSION"
|
|
||||||
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION"
|
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION"
|
||||||
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||||
MAJOR=${BASH_REMATCH[1]}
|
MAJOR=${BASH_REMATCH[1]}
|
||||||
MINOR=${BASH_REMATCH[2]}
|
MINOR=${BASH_REMATCH[2]}
|
||||||
PATCH=${BASH_REMATCH[3]}
|
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"
|
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
|
fi
|
||||||
TAGS="$DOCKERHUB_TAGS,$GHCR_TAGS"
|
TAGS="$GHCR_TAGS"
|
||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
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
|
uses: ./.github/actions/docker-build-push
|
||||||
with:
|
with:
|
||||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
ghcr_username: ${{ github.actor }}
|
ghcr_username: ${{ github.actor }}
|
||||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
platforms: linux/arm64/v8,linux/amd64
|
platforms: linux/arm64/v8,linux/amd64
|
||||||
tags: ${{ steps.docker_tags.outputs.tags }}
|
tags: ${{ steps.docker_tags.outputs.tags }}
|
||||||
|
variant: ${{ matrix.variant }}
|
||||||
|
|
||||||
plugin_api:
|
plugin_api:
|
||||||
needs: setup
|
needs: setup
|
||||||
@@ -124,7 +163,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
@@ -155,13 +194,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download modified files
|
- name: Download modified files
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: modified-files
|
name: modified-files
|
||||||
|
|
||||||
- name: Commit version bump
|
- name: Commit version bump
|
||||||
if: ${{ github.event.inputs.update_version }}
|
if: ${{ github.event.inputs.update_version }}
|
||||||
uses: stefanzweifel/git-auto-commit-action@v6
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
with:
|
with:
|
||||||
commit_message: 'chore: release v${{ github.event.inputs.version }}'
|
commit_message: 'chore: release v${{ github.event.inputs.version }}'
|
||||||
tagging_message: v${{ github.event.inputs.version }}
|
tagging_message: v${{ github.event.inputs.version }}
|
||||||
|
|||||||
+6
-1
@@ -48,9 +48,14 @@ out/
|
|||||||
/packaged_plugins
|
/packaged_plugins
|
||||||
/logs
|
/logs
|
||||||
/templates
|
/templates
|
||||||
|
/docker/docker-compose.yml
|
||||||
/app/src/main/bundles/
|
/app/src/main/bundles/
|
||||||
/app/src/main/frontend/**/*.js
|
/app/src/main/frontend/**/*.js
|
||||||
/app/src/main/frontend/**/*.js.map
|
/app/src/main/frontend/**/*.js.map
|
||||||
/app/src/main/frontend/generated/
|
/app/src/main/frontend/generated/
|
||||||
/torrent_dotfiles/
|
**/torrent_dotfiles/
|
||||||
*.state.json
|
*.state.json
|
||||||
|
/plugins/data/
|
||||||
|
/plugins/state/
|
||||||
|
/plugindata/
|
||||||
|
/docker-debug/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<module name="Gameyfin.app.main" />
|
<module name="Gameyfin.app.main" />
|
||||||
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
|
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
|
||||||
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" />
|
<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">
|
<extension name="coverage">
|
||||||
<pattern>
|
<pattern>
|
||||||
<option name="PATTERN" value="org.gameyfin.app.*" />
|
<option name="PATTERN" value="org.gameyfin.app.*" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="externalSystemIdString" value="GRADLE" />
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
<option name="scriptParameters" value="" />
|
<option name="scriptParameters" value="-x test" />
|
||||||
<option name="taskDescriptions">
|
<option name="taskDescriptions">
|
||||||
<list />
|
<list />
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<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" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
group = "de.grimsi"
|
group = "org.gameyfin"
|
||||||
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
|
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -78,8 +78,15 @@ dependencies {
|
|||||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
|
||||||
|
// Testing
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test") {
|
||||||
|
exclude(group = "org.mockito", module = "mockito-core")
|
||||||
|
}
|
||||||
|
testImplementation("io.mockk:mockk:${rootProject.extra["mockkVersion"]}")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
|
testImplementation("io.projectreactor:reactor-test")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyManagement {
|
dependencyManagement {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
};
|
|
||||||
Generated
+5456
-5978
File diff suppressed because it is too large
Load Diff
+148
-147
@@ -1,51 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "gameyfin",
|
"name": "gameyfin",
|
||||||
"version": "2.1.2",
|
"version": "2.2.0-preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroui/react": "2.7.9",
|
"@heroui/react": "^2.8.5",
|
||||||
"@material-tailwind/react": "^2.1.10",
|
|
||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@polymer/polymer": "3.5.2",
|
"@polymer/polymer": "3.5.2",
|
||||||
"@react-stately/data": "^3.12.2",
|
"@react-stately/data": "^3.12.2",
|
||||||
"@react-types/shared": "^3.28.0",
|
"@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/common-frontend": "0.0.19",
|
||||||
"@vaadin/hilla-file-router": "24.9.0",
|
"@vaadin/hilla-file-router": "24.9.4",
|
||||||
"@vaadin/hilla-frontend": "24.9.0",
|
"@vaadin/hilla-frontend": "24.9.4",
|
||||||
"@vaadin/hilla-lit-form": "24.9.0",
|
"@vaadin/hilla-lit-form": "24.9.4",
|
||||||
"@vaadin/hilla-react-auth": "24.9.0",
|
"@vaadin/hilla-react-auth": "24.9.4",
|
||||||
"@vaadin/hilla-react-crud": "24.9.0",
|
"@vaadin/hilla-react-crud": "24.9.4",
|
||||||
"@vaadin/hilla-react-form": "24.9.0",
|
"@vaadin/hilla-react-form": "24.9.4",
|
||||||
"@vaadin/hilla-react-i18n": "24.9.0",
|
"@vaadin/hilla-react-i18n": "24.9.4",
|
||||||
"@vaadin/hilla-react-signals": "24.9.0",
|
"@vaadin/hilla-react-signals": "24.9.4",
|
||||||
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
"@vaadin/polymer-legacy-adapter": "24.9.4",
|
||||||
"@vaadin/react-components": "24.9.0",
|
"@vaadin/react-components": "24.9.4",
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
"@vaadin/vaadin-lumo-styles": "24.9.4",
|
||||||
"@vaadin/vaadin-material-styles": "24.9.0",
|
"@vaadin/vaadin-material-styles": "24.9.4",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
"@vaadin/vaadin-themable-mixin": "24.9.4",
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"framer-motion": "^12.5.0",
|
"framer-motion": "^12.23.22",
|
||||||
"fzf": "^0.5.2",
|
"fzf": "^0.5.2",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"lit": "3.3.0",
|
"lit": "3.3.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-import": "^16.1.1",
|
||||||
"rand-seed": "^2.1.7",
|
"rand-seed": "^2.1.7",
|
||||||
"react": "18.3.1",
|
"react": "19.1.1",
|
||||||
"react-accessible-treeview": "^2.11.1",
|
"react-accessible-treeview": "^2.11.1",
|
||||||
"react-aria-components": "^1.7.1",
|
"react-aria-components": "^1.7.1",
|
||||||
"react-confetti-boom": "^1.0.0",
|
"react-confetti-boom": "^1.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-player": "^2.16.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",
|
"remark-breaks": "^4.0.0",
|
||||||
"swiper": "^11.2.6",
|
"swiper": "^11.2.6",
|
||||||
"valtio": "^2.1.5",
|
"valtio": "^2.1.5",
|
||||||
@@ -55,39 +58,35 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"@lit-labs/react": "^2.1.3",
|
"@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/plugin-replace": "6.0.2",
|
||||||
"@rollup/pluginutils": "5.1.4",
|
"@rollup/pluginutils": "5.3.0",
|
||||||
"@types/node": "^22.4.0",
|
"@types/node": "^22.4.0",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "19.1.17",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "19.1.11",
|
||||||
"@vaadin/hilla-generator-cli": "24.9.0",
|
"@vaadin/hilla-generator-cli": "24.9.4",
|
||||||
"@vaadin/hilla-generator-core": "24.9.0",
|
"@vaadin/hilla-generator-core": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
"@vaadin/hilla-generator-plugin-client": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
"@vaadin/hilla-generator-plugin-model": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
"@vaadin/hilla-generator-plugin-push": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
|
||||||
"@vaadin/hilla-generator-utils": "24.9.0",
|
"@vaadin/hilla-generator-utils": "24.9.4",
|
||||||
"@vitejs/plugin-react": "4.5.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"async": "3.2.6",
|
"glob": "11.0.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"magic-string": "0.30.19",
|
||||||
"glob": "11.0.2",
|
|
||||||
"magic-string": "0.30.17",
|
|
||||||
"postcss": "^8.4.41",
|
|
||||||
"postcss-import": "^16.1.0",
|
|
||||||
"rollup-plugin-brotli": "3.1.0",
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
"rollup-plugin-visualizer": "5.14.0",
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
"strip-css-comments": "5.0.0",
|
"strip-css-comments": "5.0.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "4.1.13",
|
||||||
"transform-ast": "2.4.4",
|
"transform-ast": "2.4.4",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.6",
|
"vite": "6.4.1",
|
||||||
"vite-plugin-checker": "0.9.3",
|
"vite-plugin-checker": "0.10.3",
|
||||||
"workbox-build": "7.3.0",
|
"workbox-build": "7.3.0",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
@@ -105,10 +104,8 @@
|
|||||||
"@phosphor-icons/react": "$@phosphor-icons/react",
|
"@phosphor-icons/react": "$@phosphor-icons/react",
|
||||||
"formik": "$formik",
|
"formik": "$formik",
|
||||||
"yup": "$yup",
|
"yup": "$yup",
|
||||||
"next-themes": "$next-themes",
|
|
||||||
"@heroui/react": "$@heroui/react",
|
"@heroui/react": "$@heroui/react",
|
||||||
"framer-motion": "$framer-motion",
|
"framer-motion": "$framer-motion",
|
||||||
"@material-tailwind/react": "$@material-tailwind/react",
|
|
||||||
"http-status-codes": "$http-status-codes",
|
"http-status-codes": "$http-status-codes",
|
||||||
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
||||||
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
||||||
@@ -142,127 +139,131 @@
|
|||||||
"valtio": "$valtio",
|
"valtio": "$valtio",
|
||||||
"valtio-reactive": "$valtio-reactive",
|
"valtio-reactive": "$valtio-reactive",
|
||||||
"fzf": "$fzf",
|
"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/router": "2.0.0",
|
||||||
"@vaadin/vertical-layout": "24.9.0",
|
"@tailwindcss/vite": "$@tailwindcss/vite",
|
||||||
"@vaadin/virtual-list": "24.9.0"
|
"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": {
|
"vaadin": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@polymer/polymer": "3.5.2",
|
"@polymer/polymer": "3.5.2",
|
||||||
"@vaadin/bundles": "24.9.0",
|
"@vaadin/bundles": "24.9.4",
|
||||||
"@vaadin/common-frontend": "0.0.19",
|
"@vaadin/common-frontend": "0.0.19",
|
||||||
"@vaadin/hilla-file-router": "24.9.0",
|
"@vaadin/hilla-file-router": "24.9.4",
|
||||||
"@vaadin/hilla-frontend": "24.9.0",
|
"@vaadin/hilla-frontend": "24.9.4",
|
||||||
"@vaadin/hilla-lit-form": "24.9.0",
|
"@vaadin/hilla-lit-form": "24.9.4",
|
||||||
"@vaadin/hilla-react-auth": "24.9.0",
|
"@vaadin/hilla-react-auth": "24.9.4",
|
||||||
"@vaadin/hilla-react-crud": "24.9.0",
|
"@vaadin/hilla-react-crud": "24.9.4",
|
||||||
"@vaadin/hilla-react-form": "24.9.0",
|
"@vaadin/hilla-react-form": "24.9.4",
|
||||||
"@vaadin/hilla-react-i18n": "24.9.0",
|
"@vaadin/hilla-react-i18n": "24.9.4",
|
||||||
"@vaadin/hilla-react-signals": "24.9.0",
|
"@vaadin/hilla-react-signals": "24.9.4",
|
||||||
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
"@vaadin/polymer-legacy-adapter": "24.9.4",
|
||||||
"@vaadin/react-components": "24.9.0",
|
"@vaadin/react-components": "24.9.4",
|
||||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||||
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
"@vaadin/vaadin-lumo-styles": "24.9.4",
|
||||||
"@vaadin/vaadin-material-styles": "24.9.0",
|
"@vaadin/vaadin-material-styles": "24.9.4",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
"@vaadin/vaadin-themable-mixin": "24.9.4",
|
||||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||||
"construct-style-sheets-polyfill": "3.1.0",
|
"construct-style-sheets-polyfill": "3.1.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"lit": "3.3.0",
|
"lit": "3.3.1",
|
||||||
"react": "18.3.1",
|
"react": "19.1.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.1.1",
|
||||||
"react-router": "7.6.1"
|
"react-router": "7.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-react": "7.27.1",
|
"@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/plugin-replace": "6.0.2",
|
||||||
"@rollup/pluginutils": "5.1.4",
|
"@rollup/pluginutils": "5.3.0",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "19.1.17",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "19.1.11",
|
||||||
"@vaadin/hilla-generator-cli": "24.9.0",
|
"@vaadin/hilla-generator-cli": "24.9.4",
|
||||||
"@vaadin/hilla-generator-core": "24.9.0",
|
"@vaadin/hilla-generator-core": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
"@vaadin/hilla-generator-plugin-backbone": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
"@vaadin/hilla-generator-plugin-barrel": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
"@vaadin/hilla-generator-plugin-client": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
"@vaadin/hilla-generator-plugin-model": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
"@vaadin/hilla-generator-plugin-push": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
"@vaadin/hilla-generator-plugin-signals": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
"@vaadin/hilla-generator-plugin-subtypes": "24.9.4",
|
||||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.4",
|
||||||
"@vaadin/hilla-generator-utils": "24.9.0",
|
"@vaadin/hilla-generator-utils": "24.9.4",
|
||||||
"@vitejs/plugin-react": "4.5.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"async": "3.2.6",
|
"glob": "11.0.3",
|
||||||
"glob": "11.0.2",
|
"magic-string": "0.30.19",
|
||||||
"magic-string": "0.30.17",
|
|
||||||
"rollup-plugin-brotli": "3.1.0",
|
"rollup-plugin-brotli": "3.1.0",
|
||||||
"rollup-plugin-visualizer": "5.14.0",
|
"rollup-plugin-visualizer": "5.14.0",
|
||||||
"strip-css-comments": "5.0.0",
|
"strip-css-comments": "5.0.0",
|
||||||
"transform-ast": "2.4.4",
|
"transform-ast": "2.4.4",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "6.3.6",
|
"vite": "6.4.1",
|
||||||
"vite-plugin-checker": "0.9.3",
|
"vite-plugin-checker": "0.10.3",
|
||||||
"workbox-build": "7.3.0",
|
"workbox-build": "7.3.0",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
},
|
},
|
||||||
"disableUsageStatistics": true,
|
"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 {ThemeProvider as NextThemesProvider} from "next-themes";
|
||||||
import {themeNames} from "Frontend/theming/themes";
|
import {themeNames} from "Frontend/theming/themes";
|
||||||
import {AuthProvider, useAuth} from "Frontend/util/auth";
|
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 client from "Frontend/generated/connect-client.default";
|
||||||
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
|
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
|
||||||
import {initializeLibraryState} from "Frontend/state/LibraryState";
|
import {initializeLibraryState} from "Frontend/state/LibraryState";
|
||||||
@@ -16,6 +16,9 @@ import {isAdmin} from "Frontend/util/utils";
|
|||||||
import {useRouteMetadata} from "Frontend/util/routing";
|
import {useRouteMetadata} from "Frontend/util/routing";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||||
|
import {initializePlatformState} from "Frontend/state/PlatformState";
|
||||||
|
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||||
|
import {initializeUserState} from "Frontend/state/UserState";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
client.middlewares = [ErrorHandlingMiddleware];
|
client.middlewares = [ErrorHandlingMiddleware];
|
||||||
@@ -46,11 +49,14 @@ function ViewWithAuth() {
|
|||||||
|
|
||||||
initializeLibraryState();
|
initializeLibraryState();
|
||||||
initializeGameState();
|
initializeGameState();
|
||||||
|
initializePlatformState();
|
||||||
initializeGameRequestState();
|
initializeGameRequestState();
|
||||||
initializePluginState();
|
initializePluginState();
|
||||||
|
|
||||||
if (isAdmin(auth)) {
|
if (isAdmin(auth)) {
|
||||||
initializeScanState();
|
initializeScanState();
|
||||||
|
initializeDownloadSessionState();
|
||||||
|
initializeUserState();
|
||||||
}
|
}
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ function ViewWithAuth() {
|
|||||||
radius: "sm",
|
radius: "sm",
|
||||||
variant: "flat",
|
variant: "flat",
|
||||||
hideIcon: true,
|
hideIcon: true,
|
||||||
closeIcon: <X/>,
|
closeIcon: <XIcon/>,
|
||||||
classNames: {
|
classNames: {
|
||||||
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
|
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
|
||||||
progressTrack: "h-1",
|
progressTrack: "h-1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useAuth} from "Frontend/util/auth";
|
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 {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import Avatar from "Frontend/components/general/Avatar";
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
@@ -13,23 +13,23 @@ export default function ProfileMenu() {
|
|||||||
const profileMenuItems = [
|
const profileMenuItems = [
|
||||||
{
|
{
|
||||||
label: "My Profile",
|
label: "My Profile",
|
||||||
icon: <User/>,
|
icon: <UserIcon/>,
|
||||||
onClick: () => navigate("/settings/profile")
|
onClick: () => navigate("/settings/profile")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Administration",
|
label: "Administration",
|
||||||
icon: <GearFine/>,
|
icon: <GearFineIcon/>,
|
||||||
onClick: () => navigate("/administration/libraries"),
|
onClick: () => navigate("/administration/libraries"),
|
||||||
showIf: isAdmin(auth)
|
showIf: isAdmin(auth)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Help",
|
label: "Help",
|
||||||
icon: <Question/>,
|
icon: <QuestionIcon/>,
|
||||||
onClick: () => window.open("https://gameyfin.org", "_blank")
|
onClick: () => window.open("https://gameyfin.org", "_blank")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Sign Out",
|
label: "Sign Out",
|
||||||
icon: <SignOut/>,
|
icon: <SignOutIcon/>,
|
||||||
onClick: auth.logout,
|
onClick: auth.logout,
|
||||||
color: "primary"
|
color: "primary"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Input from "Frontend/components/general/input/Input";
|
|||||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||||
import SelectInput from "Frontend/components/general/input/SelectInput";
|
import SelectInput from "Frontend/components/general/input/SelectInput";
|
||||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
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) {
|
export default function ConfigFormField({configElement, ...props}: any) {
|
||||||
function inputElement(configElement: ConfigEntryDto) {
|
function inputElement(configElement: ConfigEntryDto) {
|
||||||
@@ -27,13 +29,22 @@ export default function ConfigFormField({configElement, ...props}: any) {
|
|||||||
);
|
);
|
||||||
case "float":
|
case "float":
|
||||||
return (
|
return (
|
||||||
<Input label={configElement.description} name={configElement.key} type="number"
|
<NumberInput label={configElement.description} name={configElement.key}
|
||||||
step="0.1" {...props}/>
|
step={0.1} {...props}/>
|
||||||
);
|
);
|
||||||
case "int":
|
case "int":
|
||||||
|
if (configElement.min != null && configElement.max != null) {
|
||||||
return (
|
return (
|
||||||
<Input label={configElement.description} name={configElement.key} type="number"
|
<SliderInput label={configElement.description} name={configElement.key}
|
||||||
step="1" {...props}/>
|
min={configElement.min}
|
||||||
|
max={configElement.max}
|
||||||
|
step={configElement.step ?? 1}
|
||||||
|
{...props}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NumberInput label={configElement.description} name={configElement.key}
|
||||||
|
step={1} {...props}/>
|
||||||
);
|
);
|
||||||
case "array":
|
case "array":
|
||||||
return (
|
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 * as Yup from 'yup';
|
||||||
import "Frontend/util/yup-extensions";
|
import "Frontend/util/yup-extensions";
|
||||||
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
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 {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
|
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
|
||||||
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
|
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>
|
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
|
||||||
<Tooltip content="Add new library">
|
<Tooltip content="Add new library">
|
||||||
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
|
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
|
||||||
<Plus/>
|
<PlusIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,8 +83,6 @@ function LibraryManagementLayout({getConfig, formik}: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<LibraryCreationModal
|
<LibraryCreationModal
|
||||||
// @ts-ignore
|
|
||||||
libraries={state.sorted}
|
|
||||||
isOpen={libraryCreationModal.isOpen}
|
isOpen={libraryCreationModal.isOpen}
|
||||||
onOpenChange={libraryCreationModal.onOpenChange}
|
onOpenChange={libraryCreationModal.onOpenChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
|||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
|
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) {
|
function LogManagementLayout({getConfig, formik}: any) {
|
||||||
const [logEntries, setLogEntries] = useState<string[]>([]);
|
const [logEntries, setLogEntries] = useState<string[]>([]);
|
||||||
@@ -51,7 +51,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<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>
|
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
<Tooltip content="Soft-wrap" placement="bottom">
|
<Tooltip content="Soft-wrap" placement="bottom">
|
||||||
@@ -59,7 +59,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
|||||||
onPress={() => setSoftWrap(!softWrap)}
|
onPress={() => setSoftWrap(!softWrap)}
|
||||||
variant={softWrap ? "solid" : "ghost"}
|
variant={softWrap ? "solid" : "ghost"}
|
||||||
>
|
>
|
||||||
<ArrowUDownLeft/>
|
<ArrowUDownLeftIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Auto-scroll" placement="bottom">
|
<Tooltip content="Auto-scroll" placement="bottom">
|
||||||
@@ -67,7 +67,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
|||||||
onPress={() => setAutoScroll(!autoScroll)}
|
onPress={() => setAutoScroll(!autoScroll)}
|
||||||
variant={autoScroll ? "solid" : "ghost"}
|
variant={autoScroll ? "solid" : "ghost"}
|
||||||
>
|
>
|
||||||
<SortAscending/>
|
<SortAscendingIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
|
|||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
|
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
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 MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
|
||||||
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
||||||
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
||||||
@@ -31,7 +31,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
|||||||
password: formik.values.messages.providers.email.password
|
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) {
|
if (areCredentialsValid) {
|
||||||
addToast({
|
addToast({
|
||||||
@@ -91,7 +97,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onPress={() => openEditor(template)}
|
onPress={() => openEditor(template)}
|
||||||
>
|
>
|
||||||
<Pencil/>
|
<PencilIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Send test notification">
|
<Tooltip content="Send test notification">
|
||||||
@@ -100,7 +106,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
|||||||
onPress={() => openTestNotification(template)}
|
onPress={() => openTestNotification(template)}
|
||||||
isDisabled={!formik.values.messages.providers.email.enabled}
|
isDisabled={!formik.values.messages.providers.email.enabled}
|
||||||
>
|
>
|
||||||
<PaperPlaneRight/>
|
<PaperPlaneRightIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<p className="text-lg">{template.description}</p>
|
<p className="text-lg">{template.description}</p>
|
||||||
@@ -137,7 +143,6 @@ const validationSchema = Yup.object({
|
|||||||
.min(0, "Port must be between 0 and 65535")
|
.min(0, "Port must be between 0 and 65535")
|
||||||
.max(65535, "Port must be between 0 and 65535"),
|
.max(65535, "Port must be between 0 and 65535"),
|
||||||
username: Yup.string()
|
username: Yup.string()
|
||||||
.email("Invalid email address")
|
|
||||||
.required("Username is required"),
|
.required("Username is required"),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function PluginManagement() {
|
|||||||
|
|
||||||
return state.isLoaded && (
|
return state.isLoaded && (
|
||||||
<div className="flex flex-col">
|
<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>
|
<h2 className="text-2xl font-bold">Plugins</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Section from "Frontend/components/general/Section";
|
|||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
|
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
|
||||||
import {Form, Formik} from "formik";
|
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 React, {useEffect, useState} from "react";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
@@ -82,14 +82,14 @@ export default function ProfileManagement() {
|
|||||||
>
|
>
|
||||||
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
|
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
|
||||||
<Form>
|
<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>
|
<h2 className="text-2xl font-bold">My Profile</h2>
|
||||||
{auth.state.user?.managedBySso &&
|
{auth.state.user?.managedBySso &&
|
||||||
<p className="text-warning">Your account is managed externally.</p>}
|
<p className="text-warning">Your account is managed externally.</p>}
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
{formik.values.newPassword.length > 0 &&
|
{formik.values.newPassword.length > 0 &&
|
||||||
<SmallInfoField icon={Info}
|
<SmallInfoField icon={InfoIcon}
|
||||||
message="You will be logged out of all current sessions"
|
message="You will be logged out of all current sessions"
|
||||||
className="text-default-500"
|
className="text-default-500"
|
||||||
/>
|
/>
|
||||||
@@ -100,7 +100,7 @@ export default function ProfileManagement() {
|
|||||||
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,12 +117,12 @@ export default function ProfileManagement() {
|
|||||||
color="success">Upload</Button>
|
color="success">Upload</Button>
|
||||||
<Tooltip content="Remove your current avatar">
|
<Tooltip content="Remove your current avatar">
|
||||||
<Button onPress={removeAvatar} isIconOnly color="danger"
|
<Button onPress={removeAvatar} isIconOnly color="danger"
|
||||||
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
|
isDisabled={auth.state.user?.managedBySso}><TrashIcon/></Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex flex-col grow">
|
||||||
<Section title="Personal information"/>
|
<Section title="Personal information"/>
|
||||||
<Input name="username" label="Username" type="text" autocomplete="username"
|
<Input name="username" label="Username" type="text" autocomplete="username"
|
||||||
isDisabled={auth.state.user?.managedBySso}/>
|
isDisabled={auth.state.user?.managedBySso}/>
|
||||||
@@ -145,14 +145,14 @@ export default function ProfileManagement() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-14"
|
className="size-14"
|
||||||
>
|
>
|
||||||
<ArrowCounterClockwise size={26}/>
|
<ArrowCounterClockwiseIcon size={26}/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{!messagesEnabled &&
|
{!messagesEnabled &&
|
||||||
<div className="flex flex-row gap-2 text-warning -mt-5">
|
<div className="flex flex-row gap-2 text-warning -mt-5">
|
||||||
<Info/>
|
<InfoIcon/>
|
||||||
<small>
|
<small>
|
||||||
Email services are disabled. Please contact your administrator.
|
Email services are disabled. Please contact your administrator.
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as Yup from 'yup';
|
|||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
|
import {addToast, Button, 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) {
|
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
|||||||
Automatically create new users after registration
|
Automatically create new users after registration
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
|
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
|
||||||
<Warning weight="fill"/>
|
<WarningIcon weight="fill"/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</CheckboxGroup>
|
</CheckboxGroup>
|
||||||
@@ -89,7 +89,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
|||||||
<Button
|
<Button
|
||||||
isDisabled={isAutoPopulateDisabled()}
|
isDisabled={isAutoPopulateDisabled()}
|
||||||
onPress={autoPopulate}
|
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>
|
</div>
|
||||||
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
|
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
|
||||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Section from "Frontend/components/general/Section";
|
|||||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {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 {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
@@ -21,7 +21,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex flex-col grow">
|
||||||
|
|
||||||
<Section title="Sign-Ups"/>
|
<Section title="Sign-Ups"/>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
@@ -33,12 +33,12 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
<div className="flex flex-row items-baseline justify-between">
|
<div className="flex flex-row items-baseline justify-between">
|
||||||
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
|
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
|
||||||
{!getConfig("sso.oidc.auto-register-new-users").value &&
|
{!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"/>
|
message="Automatic user registration for SSO users is disabled"/>
|
||||||
}
|
}
|
||||||
<Tooltip content="Invite new user">
|
<Tooltip content="Invite new user">
|
||||||
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
|
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
|
||||||
<UserPlus/>
|
<UserPlusIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
|||||||
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
|
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import {Button, Skeleton} from "@heroui/react";
|
import {Button, Skeleton} from "@heroui/react";
|
||||||
import {Check, Info} from "@phosphor-icons/react";
|
import { CheckIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
|
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
@@ -92,11 +92,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
>
|
>
|
||||||
{(formik) => (
|
{(formik) => (
|
||||||
<Form>
|
<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>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
{saveMessage && <SmallInfoField icon={Info}
|
{saveMessage && <SmallInfoField icon={InfoIcon}
|
||||||
message={saveMessage}
|
message={saveMessage}
|
||||||
className="text-warning"/>}
|
className="text-warning"/>}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
|
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +119,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
|||||||
)}
|
)}
|
||||||
</Formik> :
|
</Formik> :
|
||||||
[...Array(4)].map((_e, i) =>
|
[...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-10 w-full rounded-md"/>
|
||||||
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-row gap-8">
|
||||||
|
|||||||
@@ -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 {
|
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";
|
||||||
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 React, {useEffect} from "react";
|
import React, {useEffect} from "react";
|
||||||
|
|
||||||
export default function IconBackgroundPattern() {
|
export default function IconBackgroundPattern() {
|
||||||
@@ -54,29 +29,29 @@ export default function IconBackgroundPattern() {
|
|||||||
|
|
||||||
return <div ref={containerRef} className="absolute w-full h-full opacity-50">
|
return <div ref={containerRef} className="absolute w-full h-full opacity-50">
|
||||||
<IconContext.Provider value={{size: iconSize}}>
|
<IconContext.Provider value={{size: iconSize}}>
|
||||||
<GameController className="absolute fill-primary top-[8%] left-[8%] rotate-[350deg]"/>
|
<GameControllerIcon className="absolute fill-primary top-[8%] left-[8%] rotate-350"/>
|
||||||
<SoccerBall className="absolute fill-primary top-[48%] left-[96%] rotate-[60deg]"/>
|
<SoccerBallIcon className="absolute fill-primary top-[48%] left-[96%] rotate-60"/>
|
||||||
<Joystick className="absolute top-[28%] left-[52%] rotate-[90deg]"/>
|
<JoystickIcon className="absolute top-[28%] left-[52%] rotate-90"/>
|
||||||
<Strategy className="absolute fill-primary top-[52%] left-[68%] rotate-[30deg]"/>
|
<StrategyIcon className="absolute fill-primary top-[52%] left-[68%] rotate-30"/>
|
||||||
<Sword className="absolute top-[72%] left-[12%] rotate-[60deg]"/>
|
<SwordIcon className="absolute top-[72%] left-[12%] rotate-60"/>
|
||||||
<Alien className="absolute fill-primary top-[12%] left-[88%] rotate-[15deg]"/>
|
<AlienIcon className="absolute fill-primary top-[12%] left-[88%] rotate-15"/>
|
||||||
<CastleTurret className="absolute top-[6%] left-[38%] rotate-[320deg]"/>
|
<CastleTurretIcon className="absolute top-[6%] left-[38%] rotate-320"/>
|
||||||
<Ghost className="absolute fill-primary top-[38%] left-[6%] rotate-[300deg]"/>
|
<GhostIcon className="absolute fill-primary top-[38%] left-[6%] rotate-300"/>
|
||||||
<Skull className="absolute top-[82%] left-[28%] rotate-[90deg]"/>
|
<SkullIcon className="absolute top-[82%] left-[28%] rotate-90"/>
|
||||||
<Trophy className="absolute fill-primary top-[12%] left-[62%] rotate-[45deg]"/>
|
<TrophyIcon className="absolute fill-primary top-[12%] left-[62%] rotate-45"/>
|
||||||
<Lego className="absolute top-[32%] left-[18%] rotate-[30deg]"/>
|
<LegoIcon className="absolute top-[32%] left-[18%] rotate-30"/>
|
||||||
<TreasureChest className="absolute top-[68%] left-[48%] rotate-[75deg]"/>
|
<TreasureChestIcon className="absolute top-[68%] left-[48%] rotate-75"/>
|
||||||
<Basketball className="absolute fill-primary top-[22%] left-[37%] rotate-[10deg]"/>
|
<BasketballIcon className="absolute fill-primary top-[22%] left-[37%] rotate-10"/>
|
||||||
<Baseball className="absolute top-[92%] left-[82%] rotate-[340deg]"/>
|
<BaseballIcon className="absolute top-[92%] left-[82%] rotate-340"/>
|
||||||
<DiceFive className="absolute top-[62%] left-[22%] rotate-[120deg]"/>
|
<DiceFiveIcon className="absolute top-[62%] left-[22%] rotate-120"/>
|
||||||
<Medal className="absolute fill-primary top-[18%] left-[28%] rotate-[300deg]"/>
|
<MedalIcon className="absolute fill-primary top-[18%] left-[28%] rotate-300"/>
|
||||||
<PuzzlePiece className="absolute top-[42%] left-[78%] rotate-[45deg]"/>
|
<PuzzlePieceIcon className="absolute top-[42%] left-[78%] rotate-45"/>
|
||||||
<Rocket className="absolute fill-primary top-[88%] left-[52%] rotate-[15deg]"/>
|
<RocketIcon className="absolute fill-primary top-[88%] left-[52%] rotate-15"/>
|
||||||
<Star className="absolute top-[28%] left-[72%] rotate-[60deg]"/>
|
<StarIcon className="absolute top-[28%] left-[72%] rotate-60"/>
|
||||||
<Target className="absolute fill-primary top-[68%] left-[62%] rotate-[330deg]"/>
|
<TargetIcon className="absolute fill-primary top-[68%] left-[62%] rotate-330"/>
|
||||||
<ThumbsUp className="absolute top-[82%] left-[12%] rotate-[80deg]"/>
|
<ThumbsUpIcon className="absolute top-[82%] left-[12%] rotate-80"/>
|
||||||
<User className="absolute fill-primary top-[38%] left-[62%] rotate-[20deg]"/>
|
<UserIcon className="absolute fill-primary top-[38%] left-[62%] rotate-20"/>
|
||||||
<Volleyball className="absolute top-[78%] left-[92%] rotate-[100deg]"/>
|
<VolleyballIcon className="absolute top-[78%] left-[92%] rotate-100"/>
|
||||||
</IconContext.Provider>
|
</IconContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
|
|||||||
|
|
||||||
export default function RoleChip({role}: { role: string }) {
|
export default function RoleChip({role}: { role: string }) {
|
||||||
return (
|
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)}
|
{roleToRoleName(role)}
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {useSnapshot} from "valtio/react";
|
|||||||
import {scanState} from "Frontend/state/ScanState";
|
import {scanState} from "Frontend/state/ScanState";
|
||||||
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
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 {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
@@ -45,7 +45,7 @@ export default function ScanProgressPopover() {
|
|||||||
classNames={{
|
classNames={{
|
||||||
spinnerBars: "bg-foreground-500",
|
spinnerBars: "bg-foreground-500",
|
||||||
}}/> :
|
}}/> :
|
||||||
<Target className="fill-foreground-500"/>
|
<TargetIcon className="fill-foreground-500"/>
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -103,7 +103,7 @@ export default function ScanProgressPopover() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
{scan.status === LibraryScanStatus.FAILED &&
|
{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)
|
Scan failed (check logs for details)
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Autocomplete, AutocompleteItem} from "@heroui/react";
|
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 {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
@@ -41,7 +41,7 @@ export default function SearchBar() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
placeholder="Type to search..."
|
placeholder="Type to search..."
|
||||||
startContent={<MagnifyingGlass/>}
|
startContent={<MagnifyingGlassIcon/>}
|
||||||
isVirtualized={true}
|
isVirtualized={true}
|
||||||
maxListboxHeight={300}
|
maxListboxHeight={300}
|
||||||
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
|
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><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
|
||||||
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
|
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
|
||||||
</div>
|
</div>
|
||||||
<CaretRight/>
|
<CaretRightIcon/>
|
||||||
</div>
|
</div>
|
||||||
</AutocompleteItem>
|
</AutocompleteItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||||
|
import {Card, Chip, Tooltip} from "@heroui/react";
|
||||||
|
import {InfoIcon} from "@phosphor-icons/react";
|
||||||
|
import {convertBpsToMbps, hslToHex, humanFileSize, timeUntil} from "Frontend/util/utils";
|
||||||
|
import {gameState} from "Frontend/state/GameState";
|
||||||
|
import RealtimeChart, {RealtimeChartData, RealtimeChartOptions} from "react-realtime-chart";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {useNavigate} from "react-router";
|
||||||
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
|
|
||||||
|
export function DownloadSessionCard({sessionId}: { sessionId: string }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const session = useSnapshot(downloadSessionState).byId[sessionId];
|
||||||
|
const games = useSnapshot(gameState).state;
|
||||||
|
const libraries = useSnapshot(libraryState).state;
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||||
|
const [chartData, setChartData] = useState<RealtimeChartData[][]>([]);
|
||||||
|
const [foregroundColor, setForegroundColor] = useState<string>("#00F");
|
||||||
|
|
||||||
|
// Get theme colors from CSS variables
|
||||||
|
useEffect(() => {
|
||||||
|
const chartColor = window.getComputedStyle(document.body).getPropertyValue('--heroui-foreground');
|
||||||
|
if (chartColor) {
|
||||||
|
setForegroundColor(hslToHex(chartColor.trim()));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
const dataPoints: RealtimeChartData[] = session.bandwidthHistory.map((bps, idx) => {
|
||||||
|
let date = new Date();
|
||||||
|
date.setSeconds(currentTime.getSeconds() - session.bandwidthHistory.length + idx + 1);
|
||||||
|
return {
|
||||||
|
date: date,
|
||||||
|
value: convertBpsToMbps(bps)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setChartData([dataPoints]);
|
||||||
|
}
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
const chartOptions: RealtimeChartOptions = {
|
||||||
|
fps: 60,
|
||||||
|
timeSlots: 30,
|
||||||
|
colors: [foregroundColor],
|
||||||
|
margin: {left: 60},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
area: true,
|
||||||
|
areaColor: foregroundColor,
|
||||||
|
areaOpacity: 0.03,
|
||||||
|
lineWidth: 2,
|
||||||
|
curve: "basis",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
yGrid: {
|
||||||
|
min: 0,
|
||||||
|
color: foregroundColor,
|
||||||
|
opacity: 0.25,
|
||||||
|
size: 1,
|
||||||
|
tickNumber: 7,
|
||||||
|
tickFormat: (v) => `${v}Mb/s`
|
||||||
|
},
|
||||||
|
xGrid: {
|
||||||
|
color: foregroundColor,
|
||||||
|
opacity: 0.25,
|
||||||
|
size: 1,
|
||||||
|
tickNumber: 5
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (session &&
|
||||||
|
<Card
|
||||||
|
className={`flex flex-col gap-2 m-0.5 p-4 border-2
|
||||||
|
${(session.currentBytesPerSecond > 0) ? "border-primary bg-primary/10" : "border-default"}`}>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<p className="flex flex-row items-center flex-1">
|
||||||
|
<b>User:</b>
|
||||||
|
{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] &&
|
||||||
|
<Tooltip key={gameId}
|
||||||
|
size="sm"
|
||||||
|
content={`Size: ${humanFileSize(games[gameId].metadata.fileSize)} / Library: ${libraries[games[gameId].libraryId]?.name || "Unknown"}`}
|
||||||
|
placement="bottom">
|
||||||
|
<Chip size="sm" radius="sm"
|
||||||
|
onClick={() => navigate(`/game/${gameId}`)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>{games[gameId].title}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-48">
|
||||||
|
<RealtimeChart options={chartOptions} data={chartData}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
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 ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||||
|
import ChipList from "Frontend/components/general/ChipList";
|
||||||
|
|
||||||
interface LibraryOverviewCardProps {
|
interface LibraryOverviewCardProps {
|
||||||
library: LibraryAdminDto;
|
library: LibraryAdminDto;
|
||||||
@@ -50,17 +51,17 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
|||||||
<div className="absolute right-0 top-0 flex flex-row">
|
<div className="absolute right-0 top-0 flex flex-row">
|
||||||
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
||||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
||||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
||||||
<MagnifyingGlassPlus/>
|
<MagnifyingGlassPlusIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||||
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||||
<SlidersHorizontal/>
|
<SlidersHorizontalIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +74,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
|||||||
<p>Platforms</p>
|
<p>Platforms</p>
|
||||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||||
<Chip size="sm">PC</Chip>
|
<ChipList items={library.platforms} maxVisible={0} defaultContent="All"/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
|
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import {
|
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||||
CheckCircle,
|
|
||||||
IconContext,
|
|
||||||
PauseCircle,
|
|
||||||
PlayCircle,
|
|
||||||
Power,
|
|
||||||
Question,
|
|
||||||
QuestionMark,
|
|
||||||
SealCheck,
|
|
||||||
SealQuestion,
|
|
||||||
SealWarning,
|
|
||||||
SlidersHorizontal,
|
|
||||||
StopCircle,
|
|
||||||
WarningCircle,
|
|
||||||
XCircle
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import PluginState from "Frontend/generated/org/pf4j/PluginState";
|
import PluginState from "Frontend/generated/org/pf4j/PluginState";
|
||||||
import React, {ReactNode} from "react";
|
import React, {ReactNode} from "react";
|
||||||
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
||||||
@@ -54,17 +39,17 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
|||||||
function stateToIcon(state: PluginState | undefined): ReactNode {
|
function stateToIcon(state: PluginState | undefined): ReactNode {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case PluginState.STARTED:
|
case PluginState.STARTED:
|
||||||
return <PlayCircle/>;
|
return <PlayCircleIcon/>;
|
||||||
case PluginState.DISABLED:
|
case PluginState.DISABLED:
|
||||||
return <PauseCircle/>;
|
return <PauseCircleIcon/>;
|
||||||
case PluginState.STOPPED:
|
case PluginState.STOPPED:
|
||||||
case PluginState.FAILED:
|
case PluginState.FAILED:
|
||||||
return <StopCircle/>;
|
return <StopCircleIcon/>;
|
||||||
case PluginState.UNLOADED:
|
case PluginState.UNLOADED:
|
||||||
case PluginState.RESOLVED:
|
case PluginState.RESOLVED:
|
||||||
return <XCircle/>;
|
return <XCircleIcon/>;
|
||||||
default:
|
default:
|
||||||
return <QuestionMark/>;
|
return <QuestionMarkIcon/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,19 +58,19 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
|||||||
case PluginConfigValidationResultType.VALID:
|
case PluginConfigValidationResultType.VALID:
|
||||||
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
||||||
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
||||||
<CheckCircle/>
|
<CheckCircleIcon/>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
case PluginConfigValidationResultType.INVALID:
|
case PluginConfigValidationResultType.INVALID:
|
||||||
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
|
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
|
||||||
<Chip size="sm" radius="sm" className="text-xs" color="danger">
|
<Chip size="sm" radius="sm" className="text-xs" color="danger">
|
||||||
<WarningCircle/>
|
<WarningCircleIcon/>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
default:
|
default:
|
||||||
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
|
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
|
||||||
<Chip size="sm" radius="sm" className="text-xs">
|
<Chip size="sm" radius="sm" className="text-xs">
|
||||||
<Question/>
|
<QuestionIcon/>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -95,23 +80,23 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
|||||||
switch (trustLevel) {
|
switch (trustLevel) {
|
||||||
case PluginTrustLevel.OFFICIAL:
|
case PluginTrustLevel.OFFICIAL:
|
||||||
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
||||||
<SealCheck className="fill-success"/>
|
<SealCheckIcon className="fill-success"/>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
case PluginTrustLevel.BUNDLED:
|
case PluginTrustLevel.BUNDLED:
|
||||||
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
||||||
<SealCheck/>
|
<SealCheckIcon/>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
case PluginTrustLevel.THIRD_PARTY:
|
case PluginTrustLevel.THIRD_PARTY:
|
||||||
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
||||||
<SealWarning/>
|
<SealWarningIcon/>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
case PluginTrustLevel.UNTRUSTED:
|
case PluginTrustLevel.UNTRUSTED:
|
||||||
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
|
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
|
||||||
<SealWarning className="fill-danger"/>
|
<SealWarningIcon className="fill-danger"/>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
default:
|
default:
|
||||||
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
||||||
<SealQuestion/>
|
<SealQuestionIcon/>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,12 +126,12 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
|||||||
onPress={() => togglePluginEnabled()}
|
onPress={() => togglePluginEnabled()}
|
||||||
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
|
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
|
||||||
>
|
>
|
||||||
<Power/>
|
<PowerIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||||
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
|
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
|
||||||
<SlidersHorizontal/>
|
<SlidersHorizontalIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
|
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 React, {useEffect, useState} from "react";
|
||||||
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
import Avatar from "Frontend/components/general/Avatar";
|
import Avatar from "Frontend/components/general/Avatar";
|
||||||
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
||||||
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
||||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||||
import RoleChip from "Frontend/components/general/RoleChip";
|
import RoleChip from "Frontend/components/general/RoleChip";
|
||||||
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
||||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
@@ -112,7 +112,7 @@ export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
|
|||||||
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
|
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button isIconOnly variant="light">
|
<Button isIconOnly variant="light">
|
||||||
<DotsThreeVertical/>
|
<DotsThreeVerticalIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
|
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import {ArrowRight} from "@phosphor-icons/react";
|
import {ArrowRightIcon} from "@phosphor-icons/react";
|
||||||
import {useNavigate} from "react-router";
|
|
||||||
|
|
||||||
interface CoverRowProps {
|
interface CoverRowProps {
|
||||||
games: GameDto[];
|
games: GameDto[];
|
||||||
@@ -16,7 +15,6 @@ const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for
|
|||||||
|
|
||||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [visibleCount, setVisibleCount] = useState(games.length);
|
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"
|
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||||
onClick={onPressShowMore}>
|
onClick={onPressShowMore}>
|
||||||
<div className="absolute h-full w-1/4 right-0 bottom-0
|
<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"/>
|
transition-all duration-300 ease-in-out hover:opacity-80"/>
|
||||||
<div
|
<div
|
||||||
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
|
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>
|
<p className="text-xl font-semibold">Show more</p>
|
||||||
<ArrowRight weight="bold"/>
|
<ArrowRightIcon weight="bold"/>
|
||||||
</div>
|
</div>
|
||||||
</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" : ""}`}>
|
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||||
<Image
|
<Image
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="z-0 object-cover aspect-[12/17]"
|
className="z-0 object-cover aspect-12/17"
|
||||||
src={`images/cover/${game.coverId}`}
|
src={`images/cover/${game.coverId}`}
|
||||||
radius={radius}
|
radius={radius}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import "swiper/css/navigation";
|
|||||||
import "swiper/css/pagination";
|
import "swiper/css/pagination";
|
||||||
import "swiper/css/autoplay";
|
import "swiper/css/autoplay";
|
||||||
import {useEffect, useState} from "react";
|
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 {
|
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-col gap-2 items-center">
|
||||||
<div className="w-full flex flex-row items-center">
|
<div className="w-full flex flex-row items-center">
|
||||||
<IconContext.Provider value={{size: 50}}>
|
<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
|
<Swiper
|
||||||
modules={[Pagination, Navigation, Autoplay]}
|
modules={[Pagination, Navigation, Autoplay]}
|
||||||
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
|
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
|
<Image
|
||||||
src={e.url}
|
src={e.url}
|
||||||
alt={`Game screenshot slide ${index}`}
|
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)}
|
onClick={() => showImagePopup(e.url)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
|
className={`w-full h-full aspect-video ${!isActive ? "scale-90" : ""}`}>
|
||||||
<ReactPlayer
|
<ReactPlayer
|
||||||
url={e.url}
|
url={e.url}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -105,7 +105,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
|||||||
light={true}
|
light={true}
|
||||||
controls={true}
|
controls={true}
|
||||||
playing={isActive}
|
playing={isActive}
|
||||||
playIcon={<Play weight="fill"/>}
|
playIcon={<PlayIcon weight="fill"/>}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -115,7 +115,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
|||||||
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
|
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
|
||||||
onOpenChange={imagePopup.onOpenChange}/>
|
onOpenChange={imagePopup.onOpenChange}/>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
<CaretRightIcon className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||||
</IconContext.Provider>
|
</IconContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -137,7 +137,7 @@ function ImagePopup({imageUrl, isOpen, onOpenChange}: {
|
|||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
|
||||||
<ModalContent className="bg-transparent">
|
<ModalContent className="bg-transparent">
|
||||||
{(onClose) => (
|
{(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}>
|
onClick={onClose}>
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {FieldArray, useField} from "formik";
|
import {FieldArray, useField} from "formik";
|
||||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||||
import {KeyboardEvent, useState} from "react";
|
import {KeyboardEvent, useState} from "react";
|
||||||
import {Plus} from "@phosphor-icons/react";
|
import { PlusIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const ArrayInput = ({label, ...props}) => {
|
const ArrayInput = ({label, ...props}) => {
|
||||||
@@ -41,7 +41,7 @@ const ArrayInput = ({label, ...props}) => {
|
|||||||
))}
|
))}
|
||||||
<Popover placement="bottom" showArrow={true}>
|
<Popover placement="bottom" showArrow={true}>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
|
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent>
|
<PopoverContent>
|
||||||
<Input
|
<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,
|
DropdownTrigger,
|
||||||
SharedSelection
|
SharedSelection
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import {CaretDown} from "@phosphor-icons/react";
|
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||||
|
|
||||||
export interface ComboButtonOption {
|
export interface ComboButtonOption {
|
||||||
@@ -52,7 +52,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return options[selectedOptionValue] && (
|
return options[selectedOptionValue] && (
|
||||||
<ButtonGroup className="gap-[1px]">
|
<ButtonGroup className="gap-px">
|
||||||
<Button color="primary" className="w-52"
|
<Button color="primary" className="w-52"
|
||||||
onPress={options[selectedOptionValue].action}>
|
onPress={options[selectedOptionValue].action}>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -63,7 +63,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
|||||||
<Dropdown placement="bottom-end">
|
<Dropdown placement="bottom-end">
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button isIconOnly color="primary">
|
<Button isIconOnly color="primary">
|
||||||
<CaretDown/>
|
<CaretDownIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
className="min-h-20 flex-grow"
|
className="min-h-20 grow"
|
||||||
showMonthAndYearPickers
|
showMonthAndYearPickers
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Button, Code, useDisclosure} from "@heroui/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 PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
|
||||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
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>
|
<p className="font-bold">Directories</p>
|
||||||
<Button isIconOnly variant="light" size="sm" color="default"
|
<Button isIconOnly variant="light" size="sm" color="default"
|
||||||
onPress={pathPickerModal.onOpen}>
|
onPress={pathPickerModal.onOpen}>
|
||||||
<Plus/>
|
<PlusIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{(field.value || []).map((directory) => (
|
{(field.value || []).map((directory) => (
|
||||||
@@ -43,8 +43,8 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
|||||||
/>
|
/>
|
||||||
{directory.externalPath && (
|
{directory.externalPath && (
|
||||||
<>
|
<>
|
||||||
<div className="flex-shrink-0 flex items-center justify-center mx-2">
|
<div className="shrink-0 flex items-center justify-center mx-2">
|
||||||
<ArrowRight size={20}/>
|
<ArrowRightIcon size={20}/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -62,13 +62,13 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
|||||||
onPress={() => removeDirectoryMapping(directory)}
|
onPress={() => removeDirectoryMapping(directory)}
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
>
|
>
|
||||||
<Minus/>
|
<MinusIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Code>
|
</Code>
|
||||||
))}
|
))}
|
||||||
<div className="min-h-6 text-danger">
|
<div className="min-h-6 text-danger">
|
||||||
{meta.touched && meta.error && (
|
{meta.touched && meta.error && (
|
||||||
<SmallInfoField icon={XCircle} message={meta.error}/>
|
<SmallInfoField icon={XCircleIcon} message={meta.error}/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
|
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 {useEffect, useState} from "react";
|
||||||
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
|
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
|
||||||
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
|
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 }) {
|
function FolderIcon({isOpen}: { isOpen: boolean }) {
|
||||||
return isOpen ? <FolderOpen/> : <Folder/>;
|
return isOpen ? <PhFolderOpenIcon/> : <PhFolderIcon/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileIcon({fileName}: { fileName: string }) {
|
function FileIcon({fileName}: { fileName: string }) {
|
||||||
return <File/>;
|
return <PhFileIcon/>;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {useField} from "formik";
|
import {useField} from "formik";
|
||||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||||
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -14,13 +14,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
|||||||
const gameCoverPickerModal = useDisclosure();
|
const gameCoverPickerModal = useDisclosure();
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50"
|
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
|
||||||
onClick={gameCoverPickerModal.onOpenChange}>
|
onClick={gameCoverPickerModal.onOpenChange}>
|
||||||
{field.value || game.coverId ?
|
{field.value || game.coverId ?
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
className="z-0 object-cover group-hover:brightness-25"
|
||||||
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -30,13 +30,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
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>
|
<p>No cover image available</p>
|
||||||
</div>}
|
</div>}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
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>
|
<p>Edit cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Image, useDisclosure} from "@heroui/react";
|
import {Image, useDisclosure} from "@heroui/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {useField} from "formik";
|
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";
|
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">
|
<div className="size-full overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
className="z-0 object-cover group-hover:brightness-25"
|
||||||
src={field.value ? field.value : `images/cover/${game.headerId}`}
|
src={field.value ? field.value : `images/cover/${game.headerId}`}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -30,13 +30,13 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
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>
|
<p>No header image available</p>
|
||||||
</div>}
|
</div>}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
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>
|
<p>Edit header image</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useField} from "formik";
|
import {useField} from "formik";
|
||||||
import {Input as NextUiInput} from "@heroui/react";
|
import {Input as HeroUiInput} from "@heroui/react";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||||
@@ -7,8 +7,8 @@ const Input = ({label, showErrorUntouched = false, ...props}) => {
|
|||||||
const [field, meta] = useField(props);
|
const [field, meta] = useField(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextUiInput
|
<HeroUiInput
|
||||||
className="min-h-20 flex-grow"
|
className="min-h-20 grow"
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...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}));
|
const items = values.map((v: string) => ({key: v, label: v}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-20 flex-grow">
|
<div className="min-h-20 grow">
|
||||||
<Select
|
<Select
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
{...field}
|
{...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 (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||||
import {Check} from "@phosphor-icons/react";
|
import {CheckIcon} from "@phosphor-icons/react";
|
||||||
import {addToast, Button} from "@heroui/react";
|
import {addToast, Button} from "@heroui/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {Form, Formik} from "formik";
|
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 Section from "Frontend/components/general/Section";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import * as Yup from "yup";
|
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 {
|
interface LibraryManagementDetailsProps {
|
||||||
library: LibraryDto;
|
library: LibraryDto;
|
||||||
@@ -19,6 +22,7 @@ interface LibraryManagementDetailsProps {
|
|||||||
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
|
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [librarySaved, setLibrarySaved] = React.useState(false);
|
const [librarySaved, setLibrarySaved] = React.useState(false);
|
||||||
|
const availablePlatforms = useSnapshot(platformState).available;
|
||||||
|
|
||||||
async function handleSubmit(values: LibraryDto): Promise<void> {
|
async function handleSubmit(values: LibraryDto): Promise<void> {
|
||||||
const changed = deepDiff(library, values) as LibraryUpdateDto;
|
const changed = deepDiff(library, values) as LibraryUpdateDto;
|
||||||
@@ -66,7 +70,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
|||||||
>
|
>
|
||||||
{(formik) => (
|
{(formik) => (
|
||||||
<Form>
|
<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>
|
<h1 className="text-2xl font-bold">Edit library details</h1>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -74,12 +78,14 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
|||||||
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
|
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : librarySaved ? <CheckIcon/> : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input label="Library name" name="name"/>
|
<Input label="Library name" name="name"/>
|
||||||
|
|
||||||
|
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
|
||||||
|
|
||||||
<DirectoryMappingInput name="directories"/>
|
<DirectoryMappingInput name="directories"/>
|
||||||
|
|
||||||
<Section title="Danger zone"/>
|
<Section title="Danger zone"/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
useDisclosure
|
useDisclosure
|
||||||
} from "@heroui/react";
|
} 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 {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
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 {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||||
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
|
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
|
||||||
import {metadataCompleteness} from "Frontend/util/utils";
|
import {metadataCompleteness} from "Frontend/util/utils";
|
||||||
|
import ChipList from "Frontend/components/general/ChipList";
|
||||||
|
|
||||||
interface LibraryManagementGamesProps {
|
interface LibraryManagementGamesProps {
|
||||||
library: LibraryDto;
|
library: LibraryDto;
|
||||||
@@ -162,6 +163,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
}>
|
}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn key="title" allowsSorting>Game</TableColumn>
|
<TableColumn key="title" allowsSorting>Game</TableColumn>
|
||||||
|
<TableColumn key="platforms">Platforms</TableColumn>
|
||||||
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
||||||
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
||||||
<TableColumn>Path</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"})
|
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ChipList items={item.platforms} maxVisible={1} defaultContent="Unspecified"/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -196,10 +201,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
||||||
{item.metadata.matchConfirmed ?
|
{item.metadata.matchConfirmed ?
|
||||||
<Tooltip content="Unconfirm match">
|
<Tooltip content="Unconfirm match">
|
||||||
<CheckCircle weight="fill" className="fill-success"/>
|
<CheckCircleIcon weight="fill" className="fill-success"/>
|
||||||
</Tooltip> :
|
</Tooltip> :
|
||||||
<Tooltip content="Confirm match">
|
<Tooltip content="Confirm match">
|
||||||
<CheckCircle/>
|
<CheckCircleIcon/>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button isIconOnly size="sm" onPress={() => {
|
<Button isIconOnly size="sm" onPress={() => {
|
||||||
@@ -207,7 +212,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
editGameModal.onOpenChange();
|
editGameModal.onOpenChange();
|
||||||
}}>
|
}}>
|
||||||
<Tooltip content="Edit metadata">
|
<Tooltip content="Edit metadata">
|
||||||
<Pencil/>
|
<PencilIcon/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
<Button isIconOnly size="sm" onPress={() => {
|
<Button isIconOnly size="sm" onPress={() => {
|
||||||
@@ -215,13 +220,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
|||||||
matchGameModal.onOpenChange();
|
matchGameModal.onOpenChange();
|
||||||
}}>
|
}}>
|
||||||
<Tooltip content="Match game">
|
<Tooltip content="Match game">
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
<Button isIconOnly size="sm" color="danger"
|
<Button isIconOnly size="sm" color="danger"
|
||||||
onPress={() => deleteGame(item)}>
|
onPress={() => deleteGame(item)}>
|
||||||
<Tooltip content="Remove from library">
|
<Tooltip content="Remove from library">
|
||||||
<Trash/>
|
<TrashIcon/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-21
@@ -12,35 +12,45 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
useDisclosure
|
useDisclosure
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
|
import {MagnifyingGlassIcon, TrashIcon} from "@phosphor-icons/react";
|
||||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {useMemo, useState} from "react";
|
import {useMemo, useState} from "react";
|
||||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||||
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
|
import {fileNameFromPath} from "Frontend/util/utils";
|
||||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||||
|
import IgnoredPathDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathDto";
|
||||||
|
import IgnoredPathSourceTypeDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathSourceTypeDto";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
|
import {userState} from "Frontend/state/UserState";
|
||||||
|
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||||
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
|
|
||||||
interface LibraryManagementUnmatchedPathsProps {
|
interface LibraryManagementIgnoredPathsProps {
|
||||||
library: LibraryAdminDto;
|
library: LibraryAdminDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
|
export default function LibraryManagementIgnoredPaths({library}: LibraryManagementIgnoredPathsProps) {
|
||||||
|
const plugins = useSnapshot(pluginState).state;
|
||||||
|
const users = useSnapshot(userState).state;
|
||||||
|
|
||||||
const matchGameModal = useDisclosure();
|
const matchGameModal = useDisclosure();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const rowsPerPage = 25;
|
const rowsPerPage = 25;
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
|
const [selectedPath, setSelectedPath] = useState(library.ignoredPaths ? library.ignoredPaths[0] : null);
|
||||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
||||||
|
|
||||||
const pages = useMemo(() => {
|
const pages = useMemo(() => {
|
||||||
return Math.ceil(getFilteredPaths().length / rowsPerPage);
|
return Math.ceil(getFilteredPaths().length / rowsPerPage);
|
||||||
}, [library.unmatchedPaths, searchTerm]);
|
}, [library.ignoredPaths, searchTerm]);
|
||||||
|
|
||||||
const filteredPaths = useMemo(() => {
|
const filteredPaths = useMemo(() => {
|
||||||
return library.unmatchedPaths!
|
return library.ignoredPaths!
|
||||||
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
|
.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
.map((path) => ({key: hashCode(path), path}));
|
.map((path) => ({key: path.id, path}));
|
||||||
}, [library, searchTerm]);
|
}, [library, searchTerm]);
|
||||||
|
|
||||||
const sortedPaths = useMemo(() => {
|
const sortedPaths = useMemo(() => {
|
||||||
@@ -48,7 +58,7 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
|||||||
let cmp: number;
|
let cmp: number;
|
||||||
switch (sortDescriptor.column) {
|
switch (sortDescriptor.column) {
|
||||||
case "path":
|
case "path":
|
||||||
cmp = a.path.localeCompare(b.path);
|
cmp = a.path.path.localeCompare(b.path.path);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
cmp = 0;
|
cmp = 0;
|
||||||
@@ -66,22 +76,44 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
|||||||
return sortedPaths.slice(start, end);
|
return sortedPaths.slice(start, end);
|
||||||
}, [page, sortedPaths]);
|
}, [page, sortedPaths]);
|
||||||
|
|
||||||
async function deleteUnmatchedPath(unmatchedPath: string) {
|
async function deleteIgnoredPath(ignoredPath: IgnoredPathDto) {
|
||||||
const libraryUpdateDto: LibraryUpdateDto = {
|
const libraryUpdateDto: LibraryUpdateDto = {
|
||||||
id: library.id,
|
id: library.id,
|
||||||
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
|
ignoredPaths: library.ignoredPaths!.filter((path) => path.id !== ignoredPath.id)
|
||||||
}
|
}
|
||||||
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilteredPaths() {
|
function getFilteredPaths() {
|
||||||
return library.unmatchedPaths!!.filter((path) =>
|
return library.ignoredPaths!!.filter((path) =>
|
||||||
path.toLowerCase().includes(searchTerm.toLowerCase())
|
path.path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSource(ignoredPath: IgnoredPathDto) {
|
||||||
|
if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.USER) {
|
||||||
|
const userId = Number(ignoredPath.source);
|
||||||
|
const user = users[userId];
|
||||||
|
return user ? `Manually added by user (${user.username})` : "Unknown user";
|
||||||
|
} else if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.PLUGIN) {
|
||||||
|
const pluginIds: string[] = JSON.parse(ignoredPath.source)
|
||||||
|
return pluginIds ?
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<p>Automatically added by plugins (</p>
|
||||||
|
{pluginIds.map(id => {
|
||||||
|
const p = plugins[id];
|
||||||
|
return p ? <PluginIcon key={id} plugin={p as PluginDto}/>
|
||||||
|
: "Unknown plugin";
|
||||||
|
})}
|
||||||
|
<p>)</p>
|
||||||
|
</div>
|
||||||
|
: "Unknown plugins"
|
||||||
|
}
|
||||||
|
return ignoredPath.source;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="flex flex-col gap-4">
|
return <div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
|
<h1 className="text-2xl font-bold">Manage ignored paths</h1>
|
||||||
<Input
|
<Input
|
||||||
className="w-96"
|
className="w-96"
|
||||||
isClearable
|
isClearable
|
||||||
@@ -109,13 +141,17 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
|||||||
}>
|
}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn key="path" allowsSorting>Path</TableColumn>
|
<TableColumn key="path" allowsSorting>Path</TableColumn>
|
||||||
|
<TableColumn key="source">Source</TableColumn>
|
||||||
<TableColumn width={1}>Actions</TableColumn>
|
<TableColumn width={1}>Actions</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
|
<TableBody emptyContent="This library has no ignored paths." items={pagedPaths}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<TableRow key={item.key}>
|
<TableRow key={item.key}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.path}
|
{item.path.path}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{renderSource(item.path)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
@@ -124,12 +160,12 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
|||||||
setSelectedPath(item.path);
|
setSelectedPath(item.path);
|
||||||
matchGameModal.onOpenChange();
|
matchGameModal.onOpenChange();
|
||||||
}}>
|
}}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Remove entry from list">
|
<Tooltip content="Remove entry from list">
|
||||||
<Button isIconOnly size="sm" color="danger"
|
<Button isIconOnly size="sm" color="danger"
|
||||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,9 +174,9 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{selectedPath && <MatchGameModal path={selectedPath}
|
{selectedPath && <MatchGameModal path={selectedPath.path}
|
||||||
libraryId={library.id}
|
libraryId={library.id}
|
||||||
initialSearchTerm={fileNameFromPath(selectedPath, false)}
|
initialSearchTerm={fileNameFromPath(selectedPath.path, false)}
|
||||||
isOpen={matchGameModal.isOpen}
|
isOpen={matchGameModal.isOpen}
|
||||||
onOpenChange={matchGameModal.onOpenChange}/>
|
onOpenChange={matchGameModal.onOpenChange}/>
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRol
|
|||||||
placeholder="Select roles"
|
placeholder="Select roles"
|
||||||
renderValue={(items: SelectedItems<Role>) => {
|
renderValue={(items: SelectedItems<Role>) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-wrap gap-2">
|
<div className="flex grow flex-wrap gap-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<RoleChip key={item.key} role={item.textValue as string}/>
|
<RoleChip key={item.key} role={item.textValue as string}/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import {Form, Formik} from "formik";
|
import {Form, Formik} from "formik";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import React from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||||
|
import GameEnumPropertyValuesDto from "Frontend/generated/org/gameyfin/app/games/dto/GameEnumPropertyValuesDto";
|
||||||
import {deepDiff} from "Frontend/util/utils";
|
import {deepDiff} from "Frontend/util/utils";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||||
@@ -21,6 +22,9 @@ import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
|||||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
||||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||||
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
|
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 {
|
interface EditGameMetadataModalProps {
|
||||||
game: GameDto;
|
game: GameDto;
|
||||||
@@ -29,7 +33,14 @@ interface EditGameMetadataModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||||
return (
|
const availablePlatforms = useSnapshot(platformState).available;
|
||||||
|
const [propertyEnumValues, setPropertyEnumValues] = useState<GameEnumPropertyValuesDto>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
GameEndpoint.getEnumPropertyValues().then(setPropertyEnumValues);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return propertyEnumValues && (
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => {
|
{(onClose) => {
|
||||||
@@ -69,6 +80,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
<DatePickerInput key="release" name="release" label="Release"
|
<DatePickerInput key="release" name="release" label="Release"
|
||||||
className="w-fit"/>
|
className="w-fit"/>
|
||||||
</div>
|
</div>
|
||||||
|
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
|
||||||
|
name="platforms" label="Platforms"/>
|
||||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||||
<Accordion variant="splitted"
|
<Accordion variant="splitted"
|
||||||
@@ -81,14 +94,21 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
|||||||
title="Additional Metadata">
|
title="Additional Metadata">
|
||||||
<ArrayInput key="developers" name="developers" label="Developers"/>
|
<ArrayInput key="developers" name="developers" label="Developers"/>
|
||||||
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
|
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
|
||||||
<ArrayInput key="genres" name="genres" label="Genres"/>
|
<ArrayInputAutocomplete options={propertyEnumValues.genres}
|
||||||
<ArrayInput key="themes" name="themes" label="Themes"/>
|
defaultSelected={game.genres}
|
||||||
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
|
key="genres" name="genres" label="Genres"/>
|
||||||
<ArrayInput key="features" name="features" label="Features"/>
|
<ArrayInputAutocomplete options={propertyEnumValues.themes}
|
||||||
<ArrayInput key="perspectives" name="perspectives"
|
defaultSelected={game.themes}
|
||||||
|
key="themes" name="themes" label="Themes"/>
|
||||||
|
<ArrayInputAutocomplete options={propertyEnumValues.features}
|
||||||
|
defaultSelected={game.features}
|
||||||
|
key="features" name="features"
|
||||||
|
label="Features"/>
|
||||||
|
<ArrayInputAutocomplete options={propertyEnumValues.perspectives}
|
||||||
|
defaultSelected={game.perspectives}
|
||||||
|
key="perspectives" name="perspectives"
|
||||||
label="Perspectives"/>
|
label="Perspectives"/>
|
||||||
<ArrayInput key="keywords" name="keywords"
|
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
|
||||||
label="Keywords"/>
|
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
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 PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {pluginState} from "Frontend/state/PluginState";
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
@@ -33,7 +33,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
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);
|
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
|
||||||
setSearchResults(validResults);
|
setSearchResults(validResults);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
@@ -59,7 +59,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
setCoverUrl(coverUrl);
|
setCoverUrl(coverUrl);
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 mb-4">
|
<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}>
|
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{searchResults.length === 0 && !isSearching &&
|
{searchResults.length === 0 && !isSearching &&
|
||||||
@@ -103,7 +103,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt={cover.title}
|
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}
|
src={cover.url}
|
||||||
radius="none"
|
radius="none"
|
||||||
height={216}
|
height={216}
|
||||||
@@ -113,7 +113,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
|
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
|
||||||
blurred={false} showTooltip={false}/>
|
blurred={false} showTooltip={false}/>
|
||||||
<p className="text-s text-center">{cover.title}</p>
|
<p className="text-s text-center">{cover.title}</p>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
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 PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {pluginState} from "Frontend/state/PluginState";
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
@@ -33,7 +33,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
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);
|
let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
|
||||||
setSearchResults(validResults);
|
setSearchResults(validResults);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
@@ -59,7 +59,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
|||||||
setHeaderUrl(headerUrl);
|
setHeaderUrl(headerUrl);
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 mb-4">
|
<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}>
|
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{searchResults.length === 0 && !isSearching &&
|
{searchResults.length === 0 && !isSearching &&
|
||||||
@@ -103,7 +103,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt={header.title}
|
alt={header.title}
|
||||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
className="z-0 object-cover group-hover:brightness-25"
|
||||||
src={header.url}
|
src={header.url}
|
||||||
radius="none"
|
radius="none"
|
||||||
/>
|
/>
|
||||||
@@ -112,7 +112,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
|||||||
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
|
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
|
||||||
blurred={false} showTooltip={false}/>
|
blurred={false} showTooltip={false}/>
|
||||||
<p className="text-s text-center">{header.title}</p>
|
<p className="text-s text-center">{header.title}</p>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||||
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||||
import {Form, Formik, FormikErrors} from "formik";
|
import {Form, Formik, FormikErrors} from "formik";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
|
|||||||
@@ -7,21 +7,22 @@ import Input from "Frontend/components/general/input/Input";
|
|||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
import 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 {
|
interface LibraryCreationModalProps {
|
||||||
libraries: LibraryDto[];
|
|
||||||
setLibraries: (libraries: LibraryDto[]) => void;
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LibraryCreationModal({
|
export default function LibraryCreationModal({
|
||||||
libraries,
|
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: LibraryCreationModalProps) {
|
}: LibraryCreationModalProps) {
|
||||||
|
|
||||||
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
||||||
|
const availablePlatforms = useSnapshot(platformState).available;
|
||||||
|
|
||||||
async function createLibrary(library: LibraryDto) {
|
async function createLibrary(library: LibraryDto) {
|
||||||
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
|
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">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<Formik initialValues={{name: "", directories: []}}
|
<Formik initialValues={{name: "", directories: [], platforms: []}}
|
||||||
validationSchema={Yup.object({
|
validationSchema={Yup.object({
|
||||||
name: Yup.string()
|
name: Yup.string()
|
||||||
.required("Library name is required")
|
.required("Library name is required")
|
||||||
@@ -65,6 +66,11 @@ export default function LibraryCreationModal({
|
|||||||
value={formik.values.name}
|
value={formik.values.name}
|
||||||
required
|
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"/>
|
<DirectoryMappingInput name="directories"/>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import React, {useEffect, useState} from "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 {GameEndpoint} from "Frontend/generated/endpoints";
|
||||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import PluginIcon from "../plugin/PluginIcon";
|
import PluginIcon from "../plugin/PluginIcon";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {pluginState} from "Frontend/state/PluginState";
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
import 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 {
|
interface MatchGameModalProps {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -44,6 +46,7 @@ export default function MatchGameModal({
|
|||||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||||
|
|
||||||
const state = useSnapshot(pluginState).state;
|
const state = useSnapshot(pluginState).state;
|
||||||
|
const librariesState = useSnapshot(libraryState).state;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTerm(initialSearchTerm);
|
setSearchTerm(initialSearchTerm);
|
||||||
@@ -56,7 +59,7 @@ export default function MatchGameModal({
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
const results = await GameEndpoint.getPotentialMatches(searchTerm, (librariesState[libraryId] as LibraryAdminDto).platforms);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,7 @@ export default function MatchGameModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ export default function MatchGameModal({
|
|||||||
setIsMatching(null);
|
setIsMatching(null);
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||||
import {Input as NextInput} from "@heroui/input";
|
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";
|
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
|
||||||
interface PasswordResetModalProps {
|
interface PasswordResetModalProps {
|
||||||
@@ -47,7 +47,7 @@ export default function PasswordResetModal({
|
|||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
/> :
|
/> :
|
||||||
<div className="flex flex-row items-center gap-4 text-warning">
|
<div className="flex flex-row items-center gap-4 text-warning">
|
||||||
<WarningCircle size={40}/>
|
<WarningCircleIcon size={40}/>
|
||||||
<p>
|
<p>
|
||||||
Password self-service is disabled.<br/>
|
Password self-service is disabled.<br/>
|
||||||
To reset your password please contact your administrator.
|
To reset your password please contact your administrator.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||||
import {timeUntil} from "Frontend/util/utils";
|
import {timeUntil} from "Frontend/util/utils";
|
||||||
|
|
||||||
interface PasswordResetTokenModalProps {
|
interface PasswordResetTokenModalProps {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React, {useEffect, useState} from "react";
|
|||||||
import Input from "Frontend/components/general/input/Input";
|
import Input from "Frontend/components/general/input/Input";
|
||||||
import FileTreeView from "Frontend/components/general/input/FileTreeView";
|
import FileTreeView from "Frontend/components/general/input/FileTreeView";
|
||||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
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 {
|
interface PathPickerModalProps {
|
||||||
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||||
@@ -45,7 +45,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
|
|||||||
isDisabled
|
isDisabled
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<ArrowRight className="mb-8"/>
|
<ArrowRightIcon className="mb-8"/>
|
||||||
<Input
|
<Input
|
||||||
name="externalPath"
|
name="externalPath"
|
||||||
label="External path (optional)"
|
label="External path (optional)"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Markdown from "react-markdown";
|
|||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
import {ArrowClockwise} from "@phosphor-icons/react";
|
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
|
||||||
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||||
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
|
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);
|
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
|
||||||
}}>
|
}}>
|
||||||
<ArrowClockwise/>
|
<ArrowClockwiseIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||||
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
|
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 {useListData} from "@react-stately/data";
|
||||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||||
@@ -91,7 +91,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
|
|||||||
</Chip>
|
</Chip>
|
||||||
<p className="font-normal text-small">{plugin.name}</p>
|
<p className="font-normal text-small">{plugin.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<CaretUpDown/>
|
<CaretUpDownIcon/>
|
||||||
</ListBoxItem>
|
</ListBoxItem>
|
||||||
)}
|
)}
|
||||||
</ListBox>
|
</ListBox>
|
||||||
@@ -101,7 +101,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
|
|||||||
<Button variant="light" onPress={onClose}>
|
<Button variant="light" onPress={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
|
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addToast,
|
addToast,
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteItem,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
Tooltip
|
Tooltip
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import React, {useEffect, useState} from "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 {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||||
import PluginIcon from "../plugin/PluginIcon";
|
import PluginIcon from "../plugin/PluginIcon";
|
||||||
@@ -22,22 +24,29 @@ import {useSnapshot} from "valtio/react";
|
|||||||
import {pluginState} from "Frontend/state/PluginState";
|
import {pluginState} from "Frontend/state/PluginState";
|
||||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
|
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 {
|
interface RequestGameModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: () => void;
|
onOpenChange: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Maybe make this configurable in the admin settings?
|
||||||
|
const DEFAULT_PLATFORM_FOR_NEW_REQUESTS = "PC (Microsoft Windows)";
|
||||||
|
|
||||||
export default function RequestGameModal({
|
export default function RequestGameModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: RequestGameModalProps) {
|
}: RequestGameModalProps) {
|
||||||
|
const [selectedPlatform, setSelectedPlatform] = useState<string>(DEFAULT_PLATFORM_FOR_NEW_REQUESTS);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
||||||
|
|
||||||
const plugins = useSnapshot(pluginState).state;
|
const plugins = useSnapshot(pluginState).state;
|
||||||
|
const availablePlatforms = useSnapshot(platformState).available;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
@@ -47,7 +56,9 @@ export default function RequestGameModal({
|
|||||||
async function requestGame(game: GameSearchResultDto) {
|
async function requestGame(game: GameSearchResultDto) {
|
||||||
const request: GameRequestCreationDto = {
|
const request: GameRequestCreationDto = {
|
||||||
title: game.title,
|
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 {
|
try {
|
||||||
@@ -66,7 +77,7 @@ export default function RequestGameModal({
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
const results = await GameEndpoint.getPotentialMatches(searchTerm, [selectedPlatform] as Platform[]);
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
@@ -83,6 +94,18 @@ export default function RequestGameModal({
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<h2 className="text-xl font-semibold">Request a game</h2>
|
<h2 className="text-xl font-semibold">Request a game</h2>
|
||||||
</div>
|
</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">
|
<div className="flex flex-row gap-2 mb-4">
|
||||||
<Input value={searchTerm}
|
<Input value={searchTerm}
|
||||||
onValueChange={setSearchTerm}
|
onValueChange={setSearchTerm}
|
||||||
@@ -93,8 +116,11 @@ export default function RequestGameModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
<Button isIconOnly
|
||||||
<MagnifyingGlass/>
|
color="primary"
|
||||||
|
onPress={search}
|
||||||
|
isLoading={isSearching}>
|
||||||
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +177,7 @@ export default function RequestGameModal({
|
|||||||
setIsRequesting(null);
|
setIsRequesting(null);
|
||||||
onClose();
|
onClose();
|
||||||
}}>
|
}}>
|
||||||
<ArrowRight/>
|
<ArrowRightIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Image, Tooltip} from "@heroui/react";
|
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";
|
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||||
|
|
||||||
interface PluginIconProps {
|
interface PluginIconProps {
|
||||||
@@ -18,7 +18,7 @@ export default function PluginIcon({
|
|||||||
const icon = plugin.hasLogo
|
const icon = plugin.hasLogo
|
||||||
?
|
?
|
||||||
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
|
<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
|
return showTooltip
|
||||||
? <Tooltip content={plugin.name}>{icon}</Tooltip>
|
? <Tooltip content={plugin.name}>{icon}</Tooltip>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Button, Tooltip, useDisclosure} from "@heroui/react";
|
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 {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
||||||
@@ -16,7 +16,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<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>
|
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
||||||
|
|
||||||
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
||||||
@@ -24,7 +24,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={pluginPrioritiesModal.onOpen}
|
onPress={pluginPrioritiesModal.onOpen}
|
||||||
isDisabled={plugins.length === 0}>
|
isDisabled={plugins.length === 0}>
|
||||||
<ListNumbers/>
|
<ListNumbersIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
|
<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
|
${theme.name}-dark
|
||||||
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
|
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
|
||||||
<div className="flex-1 bg-primary"/>
|
<div className="flex-1 bg-primary"/>
|
||||||
|
|||||||
@@ -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 React, {ReactNode, useState} from "react";
|
||||||
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
|
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 {Button} from "@heroui/react";
|
||||||
import {Step, Stepper} from "@material-tailwind/react";
|
import Stepper from "./Stepper";
|
||||||
|
|
||||||
const Wizard = ({children, initialValues, onSubmit}: {
|
type WizardProps = {
|
||||||
children: ReactNode,
|
children: ReactNode;
|
||||||
initialValues: any,
|
initialValues: any;
|
||||||
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<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 [stepNumber, setStepNumber] = useState(0);
|
||||||
const steps = React.Children.toArray(children);
|
|
||||||
const [snapshot, setSnapshot] = useState(initialValues);
|
const [snapshot, setSnapshot] = useState(initialValues);
|
||||||
|
|
||||||
const step = steps[stepNumber];
|
const step = allSteps[stepNumber];
|
||||||
const totalSteps = steps.length;
|
const totalSteps = allSteps.length;
|
||||||
const isFirstStep = stepNumber === 0;
|
const isFirstStep = stepNumber === 0;
|
||||||
const isLastStep = stepNumber === totalSteps - 1;
|
const isLastStep = stepNumber === totalSteps - 1;
|
||||||
|
|
||||||
@@ -28,10 +30,11 @@ const Wizard = ({children, initialValues, onSubmit}: {
|
|||||||
setStepNumber(Math.max(stepNumber - 1, 0));
|
setStepNumber(Math.max(stepNumber - 1, 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
|
const handleSubmit = async (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => {
|
||||||
/*// @ts-ignore*/
|
// per-step custom submit if provided
|
||||||
|
// @ts-ignore
|
||||||
if (step.props.onSubmit) {
|
if (step.props.onSubmit) {
|
||||||
/*// @ts-ignore*/
|
// @ts-ignore
|
||||||
await step.props.onSubmit(values, bag);
|
await step.props.onSubmit(values, bag);
|
||||||
}
|
}
|
||||||
if (isLastStep) {
|
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 (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={snapshot}
|
initialValues={snapshot}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
/*// @ts-ignore*/
|
// @ts-ignore
|
||||||
validationSchema={step.props.validationSchema}
|
validationSchema={step.props?.validationSchema}
|
||||||
|
enableReinitialize={false}
|
||||||
>
|
>
|
||||||
{(formik) => (
|
{(formik) => (
|
||||||
<Form className="flex flex-col h-full">
|
<Form className="flex flex-col h-full">
|
||||||
<div className="w-full mb-8">
|
<div className="w-full mb-8">
|
||||||
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
|
<Stepper
|
||||||
lineClassName="bg-foreground"
|
steps={stepsMeta}
|
||||||
placeholder={undefined}
|
currentStep={stepNumber}
|
||||||
onPointerEnterCapture={undefined}
|
onStepChange={(idx) => {
|
||||||
onPointerLeaveCapture={undefined}
|
// only allow backwards navigation to keep validation flow
|
||||||
onResize={undefined}
|
if (idx <= stepNumber) setStepNumber(idx);
|
||||||
onResizeCapture={undefined}>
|
}}
|
||||||
{steps.map((child, index) => (
|
hideProgressBars={false}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex grow">
|
<div className="flex grow">{step}</div>
|
||||||
{step}
|
<div className="left-8 right-8 absolute bottom-8">
|
||||||
</div>
|
|
||||||
<div className="left-8 right-8 absolute bottom-8 -z-1">
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
|
|
||||||
<ArrowLeft/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={formik.isSubmitting}
|
onPress={() => previous(formik.values)}
|
||||||
type="submit"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer utilities {
|
@plugin './heroui.ts';
|
||||||
.gradient-primary {
|
|
||||||
|
@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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-added custom utilities (Tailwind v4 style) */
|
||||||
|
@utility gradient-primary {
|
||||||
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
||||||
}
|
}
|
||||||
|
@utility button-secondary {
|
||||||
.button-secondary {
|
|
||||||
@apply bg-primary-300 text-background/80;
|
@apply bg-primary-300 text-background/80;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List box drag & drop */
|
|
||||||
.react-aria-ListBoxItem {
|
|
||||||
&[data-dragging] {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-aria-DropIndicator[data-drop-target] {
|
|
||||||
outline: 1px solid theme(colors.primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shine animation for game covers */
|
||||||
.shine {
|
.shine {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -72,3 +66,29 @@
|
|||||||
left: 125%;
|
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 ErrorView from "Frontend/views/ErrorView";
|
||||||
import GameRequestView from "Frontend/views/GameRequestView";
|
import GameRequestView from "Frontend/views/GameRequestView";
|
||||||
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
|
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
|
||||||
|
import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
|
||||||
|
|
||||||
export const {router, routes} = new RouterConfigurationBuilder()
|
export const {router, routes} = new RouterConfigurationBuilder()
|
||||||
.withReactRoutes([
|
.withReactRoutes([
|
||||||
@@ -99,6 +100,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
|||||||
element: <GameRequestManagement/>,
|
element: <GameRequestManagement/>,
|
||||||
handle: {title: 'Administration - Game Requests'}
|
handle: {title: 'Administration - Game Requests'}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'downloads',
|
||||||
|
element: <DownloadManagement/>,
|
||||||
|
handle: {title: 'Administration - Downloads'}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
element: <UserManagement/>,
|
element: <UserManagement/>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {proxy} from "valtio";
|
||||||
|
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||||
|
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||||
|
|
||||||
|
type UserState = {
|
||||||
|
isLoaded: boolean;
|
||||||
|
state: Record<number, ExtendedUserInfoDto>;
|
||||||
|
users: ExtendedUserInfoDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userState = proxy<UserState>({
|
||||||
|
isLoaded: false,
|
||||||
|
state: {},
|
||||||
|
get users() {
|
||||||
|
return Object.values<ExtendedUserInfoDto>(this.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fetch and cache all users **/
|
||||||
|
export async function initializeUserState() {
|
||||||
|
if (userState.isLoaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allUsers = await UserEndpoint.getAllUsers();
|
||||||
|
allUsers.forEach((user: ExtendedUserInfoDto) => {
|
||||||
|
userState.state[user.id] = user;
|
||||||
|
});
|
||||||
|
userState.isLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load users:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {GameyfinClassic} from "./themes/gameyfin-classic";
|
import {GameyfinClassic} from "./themes/gameyfin-classic";
|
||||||
import {GameyfinBlue} from "./themes/gameyfin-blue";
|
import {GameyfinBlue} from "./themes/gameyfin-blue";
|
||||||
import {GameyfinViolet} from "./themes/gameyfin-violet";
|
import {GameyfinViolet} from "./themes/gameyfin-violet";
|
||||||
import {Purple} from "./themes/purple";
|
import {Pink} from "./themes/pink";
|
||||||
import {Neutral} from "./themes/neutral";
|
import {Neutral} from "./themes/neutral";
|
||||||
import {Slate} from "./themes/slate";
|
import {Slate} from "./themes/slate";
|
||||||
import {Red} from "./themes/red";
|
import {Red} from "./themes/red";
|
||||||
@@ -44,4 +44,4 @@ export function themeNames(): string[] {
|
|||||||
return Object.keys(compileThemes(themes));
|
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];
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import {Theme} from "../theme";
|
import {Theme} from "../theme";
|
||||||
|
|
||||||
export const Purple: Theme = {
|
export const Pink: Theme = {
|
||||||
name: 'purple',
|
name: 'pink',
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: '#DD62ED',
|
DEFAULT: '#DD62ED',
|
||||||
@@ -37,13 +37,13 @@ export function hashCode(string: string) {
|
|||||||
export function roleToColor(role: string) {
|
export function roleToColor(role: string) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "ROLE_SUPERADMIN":
|
case "ROLE_SUPERADMIN":
|
||||||
return "red";
|
return "bg-red-500";
|
||||||
case "ROLE_ADMIN":
|
case "ROLE_ADMIN":
|
||||||
return "orange";
|
return "bg-orange-500";
|
||||||
case "ROLE_USER":
|
case "ROLE_USER":
|
||||||
return "blue";
|
return "bg-blue-500";
|
||||||
default:
|
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.
|
* 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} instantString - The Instant string returned by the backend.
|
||||||
* @param {string} timeZone - The user's timezone.
|
* @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.
|
* @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 givenDate = moment.tz(instantString, timeZone);
|
||||||
const now = moment.tz(timeZone);
|
const now = moment.tz(timeZone);
|
||||||
const diffInSeconds = givenDate.diff(now, 'seconds');
|
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) {
|
for (const unit of units) {
|
||||||
const value = Math.floor(absDiffInSeconds / unit.seconds);
|
const value = Math.floor(absDiffInSeconds / unit.seconds);
|
||||||
if (value >= 1) {
|
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) {
|
export function metadataCompleteness(game: GameDto) {
|
||||||
// Total number of fields considered for completeness
|
// Total number of fields considered for completeness
|
||||||
// Includes all fields except "comment"
|
// Includes all fields except "comment" and "platforms"
|
||||||
const totalFields = 21;
|
const totalFields = 21;
|
||||||
|
|
||||||
const filledFields = Object.values(game).filter(value => {
|
const filledFields = Object.values(game).filter(value => {
|
||||||
@@ -227,7 +231,8 @@ export function metadataCompleteness(game: GameDto) {
|
|||||||
return true;
|
return true;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return Math.round((filledFields / totalFields) * 100);
|
const completeness = Math.round((filledFields / totalFields) * 100);
|
||||||
|
return Math.min(100, completeness); // Never exceed 100%
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,7 +281,7 @@ export function compoundRating(game: GameDto, scale = [0, 100]): number {
|
|||||||
* @param game The GameDto object containing the ratings.
|
* @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.
|
* @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 starRange = [1, 5];
|
||||||
|
|
||||||
const rating = compoundRating(game, starRange);
|
const rating = compoundRating(game, starRange);
|
||||||
@@ -284,3 +289,57 @@ export function starRatingAsString(game: GameDto) {
|
|||||||
|
|
||||||
return rating.toFixed(1);
|
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";
|
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: "Libraries",
|
title: "Libraries",
|
||||||
url: "libraries",
|
url: "libraries",
|
||||||
icon: <GameController/>
|
icon: <GameControllerIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Game Requests",
|
title: "Game Requests",
|
||||||
url: "requests",
|
url: "requests",
|
||||||
icon: <Disc/>
|
icon: <DiscIcon/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Downloads",
|
||||||
|
url: "downloads",
|
||||||
|
icon: <DownloadSimpleIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
url: "users",
|
url: "users",
|
||||||
icon: <Users/>
|
icon: <UsersIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "SSO",
|
title: "SSO",
|
||||||
url: "sso",
|
url: "sso",
|
||||||
icon: <LockKey/>
|
icon: <LockKeyIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Messages",
|
title: "Messages",
|
||||||
url: "messages",
|
url: "messages",
|
||||||
icon: <Envelope/>
|
icon: <EnvelopeIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
url: "plugins",
|
url: "plugins",
|
||||||
icon: <Plug/>
|
icon: <PlugIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Logs",
|
title: "Logs",
|
||||||
url: "logs",
|
url: "logs",
|
||||||
icon: <Log/>
|
icon: <LogIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "System",
|
title: "System",
|
||||||
url: "system",
|
url: "system",
|
||||||
icon: <Wrench/>
|
icon: <WrenchIcon/>
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {Card, CardBody, CardHeader} from "@heroui/react";
|
import {Card, CardBody, CardHeader} from "@heroui/react";
|
||||||
import {useNavigate, useSearchParams} from "react-router";
|
import {useNavigate, useSearchParams} from "react-router";
|
||||||
import React, {useEffect, useState} from "react";
|
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 TokenValidationResult from "Frontend/generated/org/gameyfin/app/core/token/TokenValidationResult";
|
||||||
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
|
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
|
|
||||||
export default function EmailConfirmationView() {
|
export default function EmailConfirmationView() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
|
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export default function EmailConfirmationView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]">
|
<Card className="p-4 min-w-[468px]">
|
||||||
<CardHeader className="mb-4">
|
<CardHeader className="mb-4">
|
||||||
<img
|
<img
|
||||||
@@ -46,7 +46,7 @@ export default function EmailConfirmationView() {
|
|||||||
<CardBody className="flex flex-row justify-center">
|
<CardBody className="flex flex-row justify-center">
|
||||||
{validationResult === TokenValidationResult.VALID ?
|
{validationResult === TokenValidationResult.VALID ?
|
||||||
<div className="flex flex-row items-center gap-4 text-success">
|
<div className="flex flex-row items-center gap-4 text-success">
|
||||||
<CheckCircle size={40}/>
|
<CheckCircleIcon size={40}/>
|
||||||
<p>
|
<p>
|
||||||
Email confirmed<br/>
|
Email confirmed<br/>
|
||||||
You will be redirected shortly
|
You will be redirected shortly
|
||||||
@@ -54,7 +54,7 @@ export default function EmailConfirmationView() {
|
|||||||
</div>
|
</div>
|
||||||
: validationResult === TokenValidationResult.EXPIRED ?
|
: validationResult === TokenValidationResult.EXPIRED ?
|
||||||
<div className="flex flex-row items-center gap-4 text-warning">
|
<div className="flex flex-row items-center gap-4 text-warning">
|
||||||
<WarningCircle size={40}/>
|
<WarningCircleIcon size={40}/>
|
||||||
<p>
|
<p>
|
||||||
Expired token<br/>
|
Expired token<br/>
|
||||||
Please request a new one
|
Please request a new one
|
||||||
@@ -62,7 +62,7 @@ export default function EmailConfirmationView() {
|
|||||||
</div>
|
</div>
|
||||||
:
|
:
|
||||||
<div className="flex flex-row items-center gap-4 text-danger">
|
<div className="flex flex-row items-center gap-4 text-danger">
|
||||||
<Warning size={40}/>
|
<WarningIcon size={40}/>
|
||||||
<p>
|
<p>
|
||||||
Invalid token<br/>
|
Invalid token<br/>
|
||||||
Please try again
|
Please try again
|
||||||
|
|||||||
@@ -1,23 +1,6 @@
|
|||||||
import {Button} from "@heroui/react";
|
import {Button} from "@heroui/react";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
import {
|
import { AlienIcon, CompassIcon, CubeIcon, DiceFiveIcon, FlagCheckeredIcon, GameControllerIcon, GhostIcon, Icon, IconContext, JoystickIcon, MagicWandIcon, PuzzlePieceIcon, RocketLaunchIcon, SkullIcon, SmileyXEyesIcon, SwordIcon } from "@phosphor-icons/react";
|
||||||
Alien,
|
|
||||||
Compass,
|
|
||||||
Cube,
|
|
||||||
DiceFive,
|
|
||||||
FlagCheckered,
|
|
||||||
GameController,
|
|
||||||
Ghost,
|
|
||||||
Icon,
|
|
||||||
IconContext,
|
|
||||||
Joystick,
|
|
||||||
MagicWand,
|
|
||||||
PuzzlePiece,
|
|
||||||
RocketLaunch,
|
|
||||||
Skull,
|
|
||||||
SmileyXEyes,
|
|
||||||
Sword
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import React, {ReactElement, useState} from "react";
|
import React, {ReactElement, useState} from "react";
|
||||||
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
||||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||||
@@ -37,85 +20,85 @@ export default function ErrorView() {
|
|||||||
"title": "404 – Level Not Found!",
|
"title": "404 – Level Not Found!",
|
||||||
"subtitle": "You’ve wandered off the map. This level doesn’t exist—or maybe it’s still in development.",
|
"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",
|
"buttonText": "Go back to the main menu",
|
||||||
"icon": <Joystick/>
|
"icon": <JoystickIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Quest Failed",
|
"title": "404 – Quest Failed",
|
||||||
"subtitle": "The path you seek does not exist. Maybe it was just a side quest after all.",
|
"subtitle": "The path you seek does not exist. Maybe it was just a side quest after all.",
|
||||||
"buttonText": "Return to the guild hall",
|
"buttonText": "Return to the guild hall",
|
||||||
"icon": <Compass/>
|
"icon": <CompassIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – You’ve encountered a glitch in the system!",
|
"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.",
|
"subtitle": "The page you’re looking for couldn’t load. Don’t worry, no coins were lost.",
|
||||||
"buttonText": "Retry mission",
|
"buttonText": "Retry mission",
|
||||||
"icon": <Alien/>
|
"icon": <AlienIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Game Cartridge Not Inserted",
|
"title": "404 – Game Cartridge Not Inserted",
|
||||||
"subtitle": "This page failed to load. Did you blow on the cartridge and try again?",
|
"subtitle": "This page failed to load. Did you blow on the cartridge and try again?",
|
||||||
"buttonText": "Reset the console",
|
"buttonText": "Reset the console",
|
||||||
"icon": <DiceFive/>
|
"icon": <DiceFiveIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – You are in the wrong zone…",
|
"title": "404 – You are in the wrong zone…",
|
||||||
"subtitle": "This area is off-limits… or was never meant to be explored. Tread carefully.",
|
"subtitle": "This area is off-limits… or was never meant to be explored. Tread carefully.",
|
||||||
"buttonText": "Find a safe path",
|
"buttonText": "Find a safe path",
|
||||||
"icon": <SmileyXEyes/>
|
"icon": <SmileyXEyesIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – You Missed the Jump!",
|
"title": "404 – You Missed the Jump!",
|
||||||
"subtitle": "The platform you were trying to reach isn’t here. Maybe it was a hidden level?",
|
"subtitle": "The platform you were trying to reach isn’t here. Maybe it was a hidden level?",
|
||||||
"buttonText": "Respawn at Start",
|
"buttonText": "Respawn at Start",
|
||||||
"icon": <GameController/>
|
"icon": <GameControllerIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Signal Lost in Deep Space",
|
"title": "404 – Signal Lost in Deep Space",
|
||||||
"subtitle": "We've lost contact with this page. All we have is static and void.",
|
"subtitle": "We've lost contact with this page. All we have is static and void.",
|
||||||
"buttonText": "Return to Command Center",
|
"buttonText": "Return to Command Center",
|
||||||
"icon": <RocketLaunch/>
|
"icon": <RocketLaunchIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – The Page Has Vanished in a Puff of Smoke",
|
"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.",
|
"subtitle": "A forbidden spell may have erased the page from existence. Try another path.",
|
||||||
"buttonText": "Return to the Grimoire",
|
"buttonText": "Return to the Grimoire",
|
||||||
"icon": <MagicWand/>
|
"icon": <MagicWandIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Block Not Found",
|
"title": "404 – Block Not Found",
|
||||||
"subtitle": "The page you're looking for hasn't been crafted yet. Gather more resources and try again.",
|
"subtitle": "The page you're looking for hasn't been crafted yet. Gather more resources and try again.",
|
||||||
"buttonText": "Back to Base",
|
"buttonText": "Back to Base",
|
||||||
"icon": <Cube/>
|
"icon": <CubeIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Puzzle Piece Missing",
|
"title": "404 – Puzzle Piece Missing",
|
||||||
"subtitle": "This page doesn’t quite fit. Try rotating it… or just go back.",
|
"subtitle": "This page doesn’t quite fit. Try rotating it… or just go back.",
|
||||||
"buttonText": "Solve a different puzzle",
|
"buttonText": "Solve a different puzzle",
|
||||||
"icon": <PuzzlePiece/>
|
"icon": <PuzzlePieceIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – You Took a Wrong Turn!",
|
"title": "404 – You Took a Wrong Turn!",
|
||||||
"subtitle": "You drifted off course and into the digital void.",
|
"subtitle": "You drifted off course and into the digital void.",
|
||||||
"buttonText": "Return to the Starting Line",
|
"buttonText": "Return to the Starting Line",
|
||||||
"icon": <FlagCheckered/>
|
"icon": <FlagCheckeredIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – This Page Didn’t Survive",
|
"title": "404 – This Page Didn’t Survive",
|
||||||
"subtitle": "Only ruins remain. Whatever was here is long gone.",
|
"subtitle": "Only ruins remain. Whatever was here is long gone.",
|
||||||
"buttonText": "Search for safe house",
|
"buttonText": "Search for safe house",
|
||||||
"icon": <Skull/>
|
"icon": <SkullIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – Instance Not Found",
|
"title": "404 – Instance Not Found",
|
||||||
"subtitle": "This dungeon has been removed or doesn’t exist on this realm.",
|
"subtitle": "This dungeon has been removed or doesn’t exist on this realm.",
|
||||||
"buttonText": "Return to your stronghold",
|
"buttonText": "Return to your stronghold",
|
||||||
"icon": <Sword/>
|
"icon": <SwordIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "404 – The Page Was… Never Really Here…",
|
"title": "404 – The Page Was… Never Really Here…",
|
||||||
"subtitle": "You were warned not to look. But you clicked anyway.",
|
"subtitle": "You were warned not to look. But you clicked anyway.",
|
||||||
"buttonText": "Turn Back Now",
|
"buttonText": "Turn Back Now",
|
||||||
"icon": <Ghost/>
|
"icon": <GhostIcon/>
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
useDisclosure
|
useDisclosure
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
|
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 React, {useEffect, useMemo, useState} from "react";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||||
@@ -145,9 +145,10 @@ export default function GameRequestView() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case GameRequestStatus.APPROVED:
|
case GameRequestStatus.APPROVED:
|
||||||
return <Chip size="sm" radius="sm"
|
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:
|
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:
|
case GameRequestStatus.REJECTED:
|
||||||
return <Chip size="sm" radius="sm"
|
return <Chip size="sm" radius="sm"
|
||||||
className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>;
|
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>
|
<h1 className="text-2xl font-bold">Game Requests</h1>
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
{!areGameRequestsEnabled &&
|
{!areGameRequestsEnabled &&
|
||||||
<SmallInfoField icon={Info}
|
<SmallInfoField icon={InfoIcon}
|
||||||
message="Request submission is disabled"
|
message="Request submission is disabled"
|
||||||
className="text-default-500"/>
|
className="text-default-500"/>
|
||||||
}
|
}
|
||||||
<Button className="w-fit"
|
<Button className="w-fit"
|
||||||
color="primary"
|
color="primary"
|
||||||
startContent={<PlusCircle weight="fill"/>}
|
startContent={<PlusCircleIcon weight="fill"/>}
|
||||||
onPress={requestGameModal.onOpen}
|
onPress={requestGameModal.onOpen}
|
||||||
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
|
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
|
||||||
Request a Game
|
Request a Game
|
||||||
@@ -219,6 +220,7 @@ export default function GameRequestView() {
|
|||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn key="title" allowsSorting>Title & Release</TableColumn>
|
<TableColumn key="title" allowsSorting>Title & Release</TableColumn>
|
||||||
|
<TableColumn key="platform">Platform</TableColumn>
|
||||||
<TableColumn>Submitted by</TableColumn>
|
<TableColumn>Submitted by</TableColumn>
|
||||||
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
|
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
|
||||||
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
|
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
|
||||||
@@ -232,6 +234,9 @@ export default function GameRequestView() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="sm" radius="sm" className="text-xs max-w-32 truncate">{item.platform}</Chip>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<p className="text-default-500">
|
<p className="text-default-500">
|
||||||
{item.requester ?
|
{item.requester ?
|
||||||
@@ -259,7 +264,7 @@ export default function GameRequestView() {
|
|||||||
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
|
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
|
||||||
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
|
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
|
||||||
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
|
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
|
||||||
startContent={<ArrowUp/>}
|
startContent={<ArrowUpIcon/>}
|
||||||
onPress={async () => await toggleVote(item.id)}>
|
onPress={async () => await toggleVote(item.id)}>
|
||||||
{item.voters.length}
|
{item.voters.length}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -272,7 +277,7 @@ export default function GameRequestView() {
|
|||||||
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
|
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
|
||||||
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
||||||
onPress={async () => await toggleApprove(item as GameRequestDto)}>
|
onPress={async () => await toggleApprove(item as GameRequestDto)}>
|
||||||
<Check/>
|
<CheckIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Reject this request">
|
<Tooltip content="Reject this request">
|
||||||
@@ -281,7 +286,7 @@ export default function GameRequestView() {
|
|||||||
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
|
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
|
||||||
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
isDisabled={item.status === GameRequestStatus.FULFILLED}
|
||||||
onPress={async () => await toggleReject(item as GameRequestDto)}>
|
onPress={async () => await toggleReject(item as GameRequestDto)}>
|
||||||
<X/>
|
<XIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -290,7 +295,7 @@ export default function GameRequestView() {
|
|||||||
<Button size="sm" isIconOnly
|
<Button size="sm" isIconOnly
|
||||||
color="danger"
|
color="danger"
|
||||||
onPress={async () => await deleteRequest(item.id)}>
|
onPress={async () => await deleteRequest(item.id)}>
|
||||||
<Trash/>
|
<TrashIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
|
|||||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
||||||
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
|
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
|
||||||
import {humanFileSize, isAdmin, starRatingAsString, toTitleCase} from "Frontend/util/utils";
|
import {humanFileSize, isAdmin, starRatingAsString} from "Frontend/util/utils";
|
||||||
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {useSnapshot} from "valtio/react";
|
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 {useAuth} from "Frontend/util/auth";
|
||||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||||
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
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 Markdown from "react-markdown";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||||
|
import ChipList from "Frontend/components/general/ChipList";
|
||||||
|
|
||||||
export default function GameView() {
|
export default function GameView() {
|
||||||
const {gameId} = useParams();
|
const {gameId} = useParams();
|
||||||
@@ -93,12 +102,12 @@ export default function GameView() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full h-96 bg-secondary relative"/>
|
<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>
|
||||||
<div className="flex flex-col gap-4 mx-24">
|
<div className="flex flex-col gap-4 mx-24">
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<div className="mt-[-16.25rem]">
|
<div className="-mt-65">
|
||||||
<GameCover game={game} size={320} radius="none"/>
|
<GameCover game={game} size={320} radius="none"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -107,7 +116,7 @@ export default function GameView() {
|
|||||||
{game.title}
|
{game.title}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
|
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
|
||||||
<Star weight="fill"/>
|
<StarIcon weight="fill"/>
|
||||||
{starRatingAsString(game)}
|
{starRatingAsString(game)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,10 +125,11 @@ export default function GameView() {
|
|||||||
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
||||||
<p className="text-default-500">no data</p>}
|
<p className="text-default-500">no data</p>}
|
||||||
</p>
|
</p>
|
||||||
|
<ChipList items={game.platforms} maxVisible={1}/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
||||||
placement="right">
|
placement="right">
|
||||||
<Info/>
|
<InfoIcon/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,20 +139,20 @@ export default function GameView() {
|
|||||||
<Button isIconOnly onPress={toggleMatchConfirmed}>
|
<Button isIconOnly onPress={toggleMatchConfirmed}>
|
||||||
{game.metadata.matchConfirmed ?
|
{game.metadata.matchConfirmed ?
|
||||||
<Tooltip content="Unconfirm match">
|
<Tooltip content="Unconfirm match">
|
||||||
<CheckCircle weight="fill" className="fill-success"/>
|
<CheckCircleIcon weight="fill" className="fill-success"/>
|
||||||
</Tooltip> :
|
</Tooltip> :
|
||||||
<Tooltip content="Confirm match">
|
<Tooltip content="Confirm match">
|
||||||
<CheckCircle/>
|
<CheckCircleIcon/>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip content="Edit metadata">
|
<Tooltip content="Edit metadata">
|
||||||
<Button isIconOnly onPress={editGameModal.onOpenChange}>
|
<Button isIconOnly onPress={editGameModal.onOpenChange}>
|
||||||
<Pencil/>
|
<PencilIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Search for metadata">
|
<Tooltip content="Search for metadata">
|
||||||
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
|
<Button isIconOnly onPress={matchGameModal.onOpenChange}>
|
||||||
<MagnifyingGlass/>
|
<MagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Remove from library">
|
<Tooltip content="Remove from library">
|
||||||
@@ -151,7 +161,7 @@ export default function GameView() {
|
|||||||
await deleteGame();
|
await deleteGame();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}}>
|
}}>
|
||||||
<Trash/>
|
<TrashIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -168,7 +178,7 @@ export default function GameView() {
|
|||||||
<AccordionItem key="information"
|
<AccordionItem key="information"
|
||||||
aria-label="Information"
|
aria-label="Information"
|
||||||
title="Information"
|
title="Information"
|
||||||
startContent={<Info weight="fill"/>}>
|
startContent={<InfoIcon weight="fill"/>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkBreaks]}
|
remarkPlugins={[remarkBreaks]}
|
||||||
components={{
|
components={{
|
||||||
@@ -195,11 +205,12 @@ export default function GameView() {
|
|||||||
<p>No summary available</p>
|
<p>No summary available</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1 gap-2">
|
<div className="flex flex-col flex-1">
|
||||||
<p className="text-default-500">Details</p>
|
<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>
|
<tbody>
|
||||||
<tr className="h-6">
|
<tr>
|
||||||
<td className="text-default-500 w-0 min-w-32">Developed by</td>
|
<td className="text-default-500 w-0 min-w-32">Developed by</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.developers && game.developers.length > 0
|
{game.developers && game.developers.length > 0
|
||||||
@@ -213,64 +224,73 @@ export default function GameView() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <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>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="h-6">
|
<tr>
|
||||||
<td className="text-default-500 w-0 min-w-32">Published by</td>
|
<td className="text-default-500 w-0 min-w-32">Published by</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.publishers && game.publishers.length > 0
|
{game.publishers && game.publishers.length > 0
|
||||||
? [...game.publishers].sort().join(" / ")
|
? [...game.publishers].sort().join(" / ")
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <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>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="h-6">
|
<tr>
|
||||||
<td className="text-default-500 w-0 min-w-32">Genres</td>
|
<td className="text-default-500 w-0 min-w-32">Genres</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.genres && game.genres.length > 0
|
{game.genres && game.genres.length > 0
|
||||||
? [...game.genres].sort().map(genre =>
|
? [...game.genres].sort().map(genre =>
|
||||||
<Link key={genre} href={`/search?genre=${encodeURIComponent(genre)}`}>
|
<Link key={genre} href={`/search?genre=${encodeURIComponent(genre)}`}>
|
||||||
<Chip radius="sm">{toTitleCase(genre)}</Chip>
|
<Chip radius="sm" size="sm"
|
||||||
|
className="text-sm">
|
||||||
|
{genre}
|
||||||
|
</Chip>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <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>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="h-6">
|
<tr>
|
||||||
<td className="text-default-500 w-0 min-w-32">Themes</td>
|
<td className="text-default-500 w-0 min-w-32">Themes</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.themes && game.themes.length > 0
|
{game.themes && game.themes.length > 0
|
||||||
? [...game.themes].sort().map(theme =>
|
? [...game.themes].sort().map(theme =>
|
||||||
<Link key={theme} href={`/search?theme=${encodeURIComponent(theme)}`}>
|
<Link key={theme} href={`/search?theme=${encodeURIComponent(theme)}`}>
|
||||||
<Chip radius="sm">{toTitleCase(theme)}</Chip>
|
<Chip radius="sm" size="sm"
|
||||||
|
className="text-sm">
|
||||||
|
{theme}
|
||||||
|
</Chip>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <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>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="h-6">
|
<tr>
|
||||||
<td className="text-default-500 w-0 min-w-32">Features</td>
|
<td className="text-default-500 w-0 min-w-32">Features</td>
|
||||||
<td className="flex flex-row gap-1">
|
<td className="flex flex-row gap-1">
|
||||||
{game.features && game.features.length > 0
|
{game.features && game.features.length > 0
|
||||||
? [...game.features].sort().map(feature =>
|
? [...game.features].sort().map(feature =>
|
||||||
<Link key={feature}
|
<Link key={feature}
|
||||||
href={`/search?feature=${encodeURIComponent(feature)}`}>
|
href={`/search?feature=${encodeURIComponent(feature)}`}>
|
||||||
<Chip radius="sm">{toTitleCase(feature)}</Chip>
|
<Chip radius="sm" size="sm"
|
||||||
|
className="text-sm">
|
||||||
|
{feature}
|
||||||
|
</Chip>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
: <Tooltip content="Missing data" color="foreground" placement="right">
|
: <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>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Input from "Frontend/components/general/input/Input";
|
|||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
|
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Warning} from "@phosphor-icons/react";
|
import { WarningIcon } from "@phosphor-icons/react";
|
||||||
import UserInvitationAcceptanceResult
|
import UserInvitationAcceptanceResult
|
||||||
from "Frontend/generated/org/gameyfin/app/users/enums/UserInvitationAcceptanceResult";
|
from "Frontend/generated/org/gameyfin/app/users/enums/UserInvitationAcceptanceResult";
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export default function InvitationRegistrationView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]">
|
<Card className="p-4 min-w-[468px]">
|
||||||
<CardHeader className="mb-4">
|
<CardHeader className="mb-4">
|
||||||
<img
|
<img
|
||||||
@@ -114,8 +114,8 @@ export default function InvitationRegistrationView() {
|
|||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
:
|
:
|
||||||
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
|
<p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
|
||||||
<Warning weight="fill"/>
|
<WarningIcon weight="fill"/>
|
||||||
Invalid token
|
Invalid token
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import {useLocation, useNavigate, useParams} from "react-router";
|
|||||||
import React, {useEffect} from "react";
|
import React, {useEffect} from "react";
|
||||||
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
|
import LibraryHeader from "Frontend/components/general/covers/LibraryHeader";
|
||||||
import {Button, Tab, Tabs} from "@heroui/react";
|
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 LibraryManagementDetails from "Frontend/components/general/library/LibraryManagementDetails";
|
||||||
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
|
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths";
|
import LibraryManagementIgnoredPaths from "Frontend/components/general/library/LibraryManagementIgnoredPaths";
|
||||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default function LibraryManagementView() {
|
|||||||
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
|
return libraryId && state.state[parseInt(libraryId)] && <div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-row gap-4 items-center">
|
<div className="flex flex-row gap-4 items-center">
|
||||||
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
|
<Button isIconOnly variant="light" onPress={() => navigate("/administration/libraries")}>
|
||||||
<ArrowLeft/>
|
<ArrowLeftIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">Manage library</h1>
|
<h1 className="text-2xl font-bold">Manage library</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,8 +40,8 @@ export default function LibraryManagementView() {
|
|||||||
<Tab key="#games" title="Games">
|
<Tab key="#games" title="Games">
|
||||||
<LibraryManagementGames library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
|
<LibraryManagementGames library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab key="#unmatched-paths" title="Unmatched paths">
|
<Tab key="#ignored-paths" title="Ignored paths">
|
||||||
<LibraryManagementUnmatchedPaths library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
|
<LibraryManagementIgnoredPaths library={state.state[parseInt(libraryId)] as LibraryAdminDto}/>
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function LoginView() {
|
|||||||
alt="Gameyfin Logo"
|
alt="Gameyfin Logo"
|
||||||
/>
|
/>
|
||||||
</CardHeader>
|
</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
|
<Formik
|
||||||
initialValues={{}}
|
initialValues={{}}
|
||||||
onSubmit={tryLogin}>
|
onSubmit={tryLogin}>
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
|||||||
import * as PackageJson from "../../../../package.json";
|
import * as PackageJson from "../../../../package.json";
|
||||||
import {Outlet, useLocation, useNavigate} from "react-router";
|
import {Outlet, useLocation, useNavigate} from "react-router";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
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 Confetti, {ConfettiProps} from "react-confetti-boom";
|
||||||
import {useTheme} from "next-themes";
|
import {useTheme} from "next-themes";
|
||||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||||
@@ -14,7 +22,6 @@ import {useSnapshot} from "valtio/react";
|
|||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
|
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
|
||||||
import {isAdmin} from "Frontend/util/utils";
|
import {isAdmin} from "Frontend/util/utils";
|
||||||
import DockerHubDeprecationPopover from "Frontend/components/temp/DockerHubDeprecationPopover";
|
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -71,10 +78,10 @@ export default function MainLayout() {
|
|||||||
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
|
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Button isIconOnly onPress={() => history.back()} variant="light">
|
<Button isIconOnly onPress={() => history.back()} variant="light">
|
||||||
<ArrowLeft size={26} weight="bold"/>
|
<ArrowLeftIcon size={26} weight="bold"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button isIconOnly onPress={() => navigate("/")} variant="light">
|
<Button isIconOnly onPress={() => navigate("/")} variant="light">
|
||||||
<House size={26} weight="fill"/>
|
<HouseIcon size={26} weight="fill"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -84,13 +91,13 @@ export default function MainLayout() {
|
|||||||
<Button isIconOnly variant="light"
|
<Button isIconOnly variant="light"
|
||||||
onPress={() => navigate("/game/" + getRandomGameId())}
|
onPress={() => navigate("/game/" + getRandomGameId())}
|
||||||
isDisabled={gameState.games.length === 0}>
|
isDisabled={gameState.games.length === 0}>
|
||||||
<DiceSix/>
|
<DiceSixIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SearchBar/>
|
<SearchBar/>
|
||||||
<Tooltip content="Advanced search" placement="bottom">
|
<Tooltip content="Advanced search" placement="bottom">
|
||||||
<Button isIconOnly variant="light" onPress={() => navigate("/search")}>
|
<Button isIconOnly variant="light" onPress={() => navigate("/search")}>
|
||||||
<ListMagnifyingGlass/>
|
<ListMagnifyingGlassIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavbarContent>}
|
</NavbarContent>}
|
||||||
@@ -100,20 +107,13 @@ export default function MainLayout() {
|
|||||||
<Button color="primary"
|
<Button color="primary"
|
||||||
isDisabled={window.location.pathname.startsWith("/requests")}
|
isDisabled={window.location.pathname.startsWith("/requests")}
|
||||||
onPress={() => navigate("/requests")}
|
onPress={() => navigate("/requests")}
|
||||||
startContent={<Disc weight="fill"/>}>
|
startContent={<DiscIcon weight="fill"/>}>
|
||||||
Requests
|
Requests
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
{isAdmin(auth) &&
|
{isAdmin(auth) &&
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<NavbarItem>
|
|
||||||
<Tooltip content="Important information" placement="bottom">
|
|
||||||
<div>
|
|
||||||
<DockerHubDeprecationPopover/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</NavbarItem>
|
|
||||||
<NavbarItem>
|
<NavbarItem>
|
||||||
<Tooltip content="View library scan results" placement="bottom">
|
<Tooltip content="View library scan results" placement="bottom">
|
||||||
<div>
|
<div>
|
||||||
@@ -139,7 +139,7 @@ export default function MainLayout() {
|
|||||||
This triggers Hilla to redirect to the correct login page (integrated or SSO) automatically.
|
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 */
|
Otherwise, SSO login would not be possible if we redirect to "/login" directly */
|
||||||
onPress={() => window.location.href = "/loginredirect"}>
|
onPress={() => window.location.href = "/loginredirect"}>
|
||||||
<SignIn fill="text-background/80"/>
|
<SignInIcon fill="text-background/80"/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
@@ -147,7 +147,7 @@ export default function MainLayout() {
|
|||||||
</NavbarContent>
|
</NavbarContent>
|
||||||
</Navbar>
|
</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/>
|
<Outlet/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export default function MainLayout() {
|
|||||||
<p>Gameyfin {PackageJson.version}</p>
|
<p>Gameyfin {PackageJson.version}</p>
|
||||||
<p className="flex flex-row gap-1 items-baseline">
|
<p className="flex flex-row gap-1 items-baseline">
|
||||||
Made with
|
Made with
|
||||||
<Heart size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
|
<HeartIcon size={16} weight="fill" className="text-primary" onClick={easterEgg}/>
|
||||||
by
|
by
|
||||||
<Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and
|
<Link href="https://github.com/grimsi" target="_blank">grimsi</Link> and
|
||||||
<Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank">
|
<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 * as Yup from "yup";
|
||||||
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||||
import React, {useEffect, useState} from "react";
|
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";
|
import TokenValidationResult from "Frontend/generated/org/gameyfin/app/core/token/TokenValidationResult";
|
||||||
|
|
||||||
export default function PasswordResetView() {
|
export default function PasswordResetView() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [token, setToken] = useState<string>();
|
const [token, setToken] = useState<string>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export default function PasswordResetView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]">
|
<Card className="p-4 min-w-[468px]">
|
||||||
<CardHeader className="mb-4">
|
<CardHeader className="mb-4">
|
||||||
<img
|
<img
|
||||||
@@ -91,8 +91,8 @@ export default function PasswordResetView() {
|
|||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
:
|
:
|
||||||
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
|
<p className="flex flex-row grow justify-center items-center gap-2 text-danger text-2xl font-bold">
|
||||||
<Warning weight="fill"/>
|
<WarningIcon weight="fill"/>
|
||||||
Invalid token
|
Invalid token
|
||||||
</p>
|
</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";
|
import withSideMenu from "Frontend/components/general/withSideMenu";
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
title: "My Profile",
|
title: "My Profile",
|
||||||
url: "profile",
|
url: "profile",
|
||||||
icon: <User/>
|
icon: <UserIcon/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Appearance",
|
title: "Appearance",
|
||||||
url: "appearance",
|
url: "appearance",
|
||||||
icon: <Palette/>
|
icon: <PaletteIcon/>
|
||||||
},
|
},
|
||||||
/* TODO: Implement account self management
|
/* TODO: Implement account self management
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
|
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 {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
@@ -9,7 +15,7 @@ import {Fzf} from "fzf";
|
|||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||||
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
||||||
import {compoundRating, toTitleCase} from "Frontend/util/utils";
|
import {compoundRating} from "Frontend/util/utils";
|
||||||
|
|
||||||
export default function SearchView() {
|
export default function SearchView() {
|
||||||
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||||
@@ -249,7 +255,7 @@ export default function SearchView() {
|
|||||||
const stars = [];
|
const stars = [];
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
stars.push(
|
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">
|
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">
|
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
|
||||||
<Input
|
<Input
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "w-full lg:w-96 flex-shrink-0",
|
base: "w-full lg:w-96 shrink-0",
|
||||||
mainWrapper: "h-full",
|
mainWrapper: "h-full",
|
||||||
inputWrapper:
|
inputWrapper:
|
||||||
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
|
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
|
||||||
}}
|
}}
|
||||||
placeholder="Type to search..."
|
placeholder="Type to search..."
|
||||||
startContent={<MagnifyingGlass/>}
|
startContent={<MagnifyingGlassIcon/>}
|
||||||
type="search"
|
type="search"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
isClearable
|
isClearable
|
||||||
@@ -276,7 +282,7 @@ export default function SearchView() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Select
|
<Select
|
||||||
startContent={<SortAscending/>}
|
startContent={<SortAscendingIcon/>}
|
||||||
selectedKeys={[sortBy]}
|
selectedKeys={[sortBy]}
|
||||||
disallowEmptySelection
|
disallowEmptySelection
|
||||||
selectionMode="single"
|
selectionMode="single"
|
||||||
@@ -301,7 +307,7 @@ export default function SearchView() {
|
|||||||
onPress={() => setShowFilters(!showFilters)}
|
onPress={() => setShowFilters(!showFilters)}
|
||||||
aria-label="Toggle Filters"
|
aria-label="Toggle Filters"
|
||||||
>
|
>
|
||||||
<FunnelSimple/>
|
<FunnelSimpleIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Clear All Filters">
|
<Tooltip content="Clear All Filters">
|
||||||
@@ -318,7 +324,7 @@ export default function SearchView() {
|
|||||||
}}
|
}}
|
||||||
aria-label="Clear All Filters"
|
aria-label="Clear All Filters"
|
||||||
>
|
>
|
||||||
<FunnelSimpleX/>
|
<FunnelSimpleXIcon/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +392,7 @@ export default function SearchView() {
|
|||||||
onSelectionChange={setSelectedGenres}
|
onSelectionChange={setSelectedGenres}
|
||||||
>
|
>
|
||||||
{Array.from(knownGenres).map((genre) => (
|
{Array.from(knownGenres).map((genre) => (
|
||||||
<SelectItem key={genre}>{toTitleCase(genre)}</SelectItem>
|
<SelectItem key={genre}>{genre}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
@@ -399,7 +405,7 @@ export default function SearchView() {
|
|||||||
onSelectionChange={setSelectedThemes}
|
onSelectionChange={setSelectedThemes}
|
||||||
>
|
>
|
||||||
{Array.from(knownThemes).map((theme) => (
|
{Array.from(knownThemes).map((theme) => (
|
||||||
<SelectItem key={theme}>{toTitleCase(theme)}</SelectItem>
|
<SelectItem key={theme}>{theme}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
@@ -412,7 +418,7 @@ export default function SearchView() {
|
|||||||
onSelectionChange={setSelectedFeatures}
|
onSelectionChange={setSelectedFeatures}
|
||||||
>
|
>
|
||||||
{Array.from(knownFeatures).map((feature) => (
|
{Array.from(knownFeatures).map((feature) => (
|
||||||
<SelectItem key={feature}>{toTitleCase(feature)}</SelectItem>
|
<SelectItem key={feature}>{feature}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
@@ -425,7 +431,7 @@ export default function SearchView() {
|
|||||||
onSelectionChange={setSelectedPerspectives}
|
onSelectionChange={setSelectedPerspectives}
|
||||||
>
|
>
|
||||||
{Array.from(knownPerspectives).map((perspective) => (
|
{Array.from(knownPerspectives).map((perspective) => (
|
||||||
<SelectItem key={perspective}>{toTitleCase(perspective)}</SelectItem>
|
<SelectItem key={perspective}>{perspective}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
|
|||||||
import Wizard from "Frontend/components/wizard/Wizard";
|
import Wizard from "Frontend/components/wizard/Wizard";
|
||||||
import WizardStep from "Frontend/components/wizard/WizardStep";
|
import WizardStep from "Frontend/components/wizard/WizardStep";
|
||||||
import Input from "Frontend/components/general/input/Input";
|
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 {addToast, Card} from "@heroui/react";
|
||||||
import {SetupEndpoint} from "Frontend/generated/endpoints";
|
import {SetupEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
||||||
@@ -35,7 +35,7 @@ function WelcomeStep() {
|
|||||||
|
|
||||||
function ThemeStep() {
|
function ThemeStep() {
|
||||||
return (
|
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>
|
<p className="text-2xl font-bold">Choose your style</p>
|
||||||
<ThemeSelector/>
|
<ThemeSelector/>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +44,7 @@ function ThemeStep() {
|
|||||||
|
|
||||||
function UserStep() {
|
function UserStep() {
|
||||||
return (
|
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">
|
<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 className="text-2xl font-bold">Create your account</p>
|
||||||
<p>This will set up the initial admin user 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/>
|
<WelcomeStep/>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
<WizardStep icon={<Palette/>}>
|
<WizardStep icon={<PaletteIcon/>}>
|
||||||
<ThemeStep/>
|
<ThemeStep/>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
<WizardStep
|
<WizardStep
|
||||||
@@ -128,7 +128,7 @@ function SetupView() {
|
|||||||
.equals([Yup.ref('password')], 'Passwords do not match')
|
.equals([Yup.ref('password')], 'Passwords do not match')
|
||||||
.required('Required')
|
.required('Required')
|
||||||
})}
|
})}
|
||||||
icon={<User/>}
|
icon={<UserIcon/>}
|
||||||
>
|
>
|
||||||
<UserStep/>
|
<UserStep/>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package org.gameyfin.app
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import org.springframework.scheduling.annotation.EnableAsync
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import org.springframework.transaction.annotation.EnableTransactionManagement
|
import org.springframework.transaction.annotation.EnableTransactionManagement
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@EnableAsync
|
|
||||||
class GameyfinApplication
|
class GameyfinApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user