mirror of
https://github.com/grimsi/gameyfin.git
synced 2026-02-06 11:27:07 +00:00
Compare commits
20 Commits
v2.2.0.rc2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd369cd30 | ||
|
|
111e164fab | ||
|
|
e5ad8b90d6 | ||
|
|
0fe22cb1b8 | ||
|
|
4b3f692667 | ||
|
|
2139df3ca8 | ||
|
|
005a1611ce | ||
|
|
abc12f146b | ||
|
|
386374f39c | ||
|
|
400c4d1c61 | ||
|
|
bb6f0ac931 | ||
|
|
cd0149bb64 | ||
|
|
09953a3f78 | ||
|
|
608a0b5ac1 | ||
|
|
8d8dca32d8 | ||
|
|
38b95ae102 | ||
|
|
5a3077d219 | ||
|
|
bb9e70b578 | ||
|
|
5184c2501d | ||
|
|
717a423449 |
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@ -0,0 +1,39 @@
|
||||
# Exclude VCS and IDE files
|
||||
.git
|
||||
.gitignore
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Gradle caches
|
||||
.gradle/
|
||||
**/.gradle/
|
||||
|
||||
# Node modules and app build cache
|
||||
app/node_modules/
|
||||
app/.pnpm-store/
|
||||
app/.npm/
|
||||
app/.yarn/
|
||||
app/.vite/
|
||||
app/dist/
|
||||
|
||||
# General build outputs (keep only the jars we actually need)
|
||||
**/build/
|
||||
!app/build/
|
||||
!app/build/libs/
|
||||
!app/build/libs/app.jar
|
||||
|
||||
# Only keep plugin jars in build/libs
|
||||
plugins/**
|
||||
!plugins/*/build/
|
||||
!plugins/*/build/libs/
|
||||
!plugins/*/build/libs/*.jar
|
||||
|
||||
# Large local/runtime data not needed in image context
|
||||
data/
|
||||
db/
|
||||
logs/
|
||||
plugindata/
|
||||
|
||||
# Docker intermediate artifacts
|
||||
**/.DS_Store
|
||||
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,32 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**General info (please complete the following information):**
|
||||
- Browser (if bug is in the Web UI) [e.g. chrome, safari]
|
||||
- Gameyfin version [e.g. v2.0.0.beta3]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
Screenshots can also be attached here.
|
||||
128
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
128
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@ -0,0 +1,128 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
type: bug
|
||||
title: '[Bug] '
|
||||
labels:
|
||||
- Bug
|
||||
assignees:
|
||||
- grimsi
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!IMPORTANT]
|
||||
> **Before submitting your bug report**
|
||||
>
|
||||
> To help us resolve your issue efficiently, please ensure you have reviewed our [FAQs](https://gameyfin.org/faq/) and [Getting started guide](https://gameyfin.org/installation/getting-started/).
|
||||
>
|
||||
> Issues that could have been resolved by following these resources may be closed to allow us to focus on genuine bugs.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm you have read and understood the following resources
|
||||
options:
|
||||
- label: I have read and understood the [FAQs](https://gameyfin.org/faq/)
|
||||
required: true
|
||||
- label: I have read and understood the [Getting started guide](https://gameyfin.org/installation/getting-started/)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Gameyfin Version
|
||||
description: What version of Gameyfin are you running?
|
||||
placeholder: e.g. v2.0.0.beta3
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: installation-type
|
||||
attributes:
|
||||
label: Installation Type
|
||||
description: How did you install Gameyfin?
|
||||
options:
|
||||
- Docker
|
||||
- Unraid
|
||||
- Other (please specify in Additional Context)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser with Version
|
||||
description: Which browser are you using?
|
||||
placeholder: e.g. Chrome 120.0.6099.129, Firefox 121.0, Safari 17.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: How to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen
|
||||
placeholder: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: A clear and concise description of what actually happened
|
||||
placeholder: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Application Logs
|
||||
description: Please provide relevant logs from the application. You can usually find these in the logs directory or container logs
|
||||
placeholder: Paste your logs here
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem
|
||||
placeholder: Drag and drop images here or paste them
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here
|
||||
placeholder: Any additional information that might be helpful
|
||||
validations:
|
||||
required: false
|
||||
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a feature
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests!
|
||||
24
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: Feature request
|
||||
description: Request a feature
|
||||
title: 'Feature request'
|
||||
labels: []
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!CAUTION]
|
||||
> **Feature requests should not be submitted as issues!**
|
||||
>
|
||||
> Please use the [discussions](https://github.com/gameyfin/gameyfin/discussions/categories/feature-requests) for feature requests instead.
|
||||
|
||||
- type: input
|
||||
id: acknowledgment
|
||||
attributes:
|
||||
label: I understand
|
||||
description: Type "I will use discussions" to acknowledge
|
||||
placeholder: I will use discussions
|
||||
validations:
|
||||
required: true
|
||||
|
||||
39
.github/actions/docker-build-push/action.yml
vendored
39
.github/actions/docker-build-push/action.yml
vendored
@ -1,42 +1,28 @@
|
||||
name: 'Docker Build and Push'
|
||||
description: 'Builds and pushes Docker images to Docker Hub and GHCR with flexible tagging.'
|
||||
description: 'Builds and pushes Docker images to GHCR with flexible tagging.'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.dockerhub_username }}
|
||||
password: ${{ inputs.dockerhub_token }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ inputs.ghcr_username }}
|
||||
password: ${{ inputs.ghcr_token }}
|
||||
|
||||
- name: Prepare Ubuntu tags
|
||||
id: ubuntu_tags
|
||||
- name: Prepare combined tags (default + ubuntu suffix)
|
||||
id: combined_tags
|
||||
shell: bash
|
||||
run: |
|
||||
TAGS="${{ inputs.tags }}"
|
||||
# Generate both default tags and ubuntu-suffixed tags
|
||||
DEFAULT_TAGS="$TAGS"
|
||||
UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i<NF) printf ","}}')
|
||||
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image (Alpine)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
push: true
|
||||
tags: ${{ inputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
COMBINED_TAGS="$DEFAULT_TAGS,$UBUNTU_TAGS"
|
||||
echo "combined_tags=$COMBINED_TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image (Ubuntu)
|
||||
uses: docker/build-push-action@v5
|
||||
@ -45,17 +31,11 @@ runs:
|
||||
file: docker/Dockerfile.ubuntu
|
||||
platforms: ${{ inputs.platforms }}
|
||||
push: true
|
||||
tags: ${{ steps.ubuntu_tags.outputs.ubuntu_tags }}
|
||||
tags: ${{ steps.combined_tags.outputs.combined_tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha
|
||||
|
||||
inputs:
|
||||
dockerhub_username:
|
||||
required: true
|
||||
description: 'Docker Hub username'
|
||||
dockerhub_token:
|
||||
required: true
|
||||
description: 'Docker Hub token'
|
||||
ghcr_username:
|
||||
required: true
|
||||
description: 'GHCR username'
|
||||
@ -74,3 +54,4 @@ inputs:
|
||||
tags:
|
||||
required: true
|
||||
description: 'Comma-separated list of image tags'
|
||||
|
||||
|
||||
55
.github/workflows/docker-delete-tag-on-merge.yml
vendored
55
.github/workflows/docker-delete-tag-on-merge.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Delete Docker Tag on Merge
|
||||
name: Delete Image Tags on Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@ -9,10 +9,11 @@ jobs:
|
||||
delete-docker-tag:
|
||||
if: startsWith(github.event.pull_request.head.ref, 'fix/') || startsWith(github.event.pull_request.head.ref, 'release/')
|
||||
runs-on: ubuntu-latest
|
||||
name: Cleanup Image Tags from GHCR
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Extract merged branch name and tag
|
||||
- name: Extract tag from branch name
|
||||
id: extract_branch
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
@ -26,50 +27,8 @@ jobs:
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: Delete image tag from Docker Hub
|
||||
- name: Delete tags
|
||||
if: steps.extract_branch.outputs.tag != ''
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
TAG: ${{ steps.extract_branch.outputs.tag }}
|
||||
run: |
|
||||
echo "Deleting Docker tag from Docker Hub: $TAG"
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -u "$DOCKERHUB_USERNAME:$DOCKERHUB_TOKEN" \
|
||||
"https://hub.docker.com/v2/repositories/grimsi/gameyfin/tags/$TAG/")
|
||||
if [ "$RESPONSE" != "204" ]; then
|
||||
echo "Failed to delete Docker Hub tag: $TAG (HTTP $RESPONSE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Delete image tag from GHCR
|
||||
if: steps.extract_branch.outputs.tag != ''
|
||||
env:
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.extract_branch.outputs.tag }}
|
||||
REPO: gameyfin/gameyfin
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
echo "Deleting Docker tag from GHCR: $TAG"
|
||||
# Get the package ID
|
||||
PACKAGE_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/container/$REPO" | jq -r '.id')
|
||||
if [ "$PACKAGE_ID" = "null" ] || [ -z "$PACKAGE_ID" ]; then
|
||||
echo "Failed to get GHCR package ID for $REPO" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Get the version ID for the tag
|
||||
VERSION_ID=$(curl -s -H "Authorization: Bearer $GHCR_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions" | jq -r ".[] | select(.metadata.container.tags[]? == \"$TAG\") | .id")
|
||||
if [ -z "$VERSION_ID" ]; then
|
||||
echo "Failed to find GHCR version for tag: $TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Delete the version
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: Bearer $GHCR_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/container/$REPO/versions/$VERSION_ID")
|
||||
if [ "$RESPONSE" != "204" ]; then
|
||||
echo "Failed to delete GHCR tag: $TAG (HTTP $RESPONSE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
with:
|
||||
tags: ${{ steps.extract_branch.outputs.tag }},${{ steps.extract_branch.outputs.tag }}-ubuntu
|
||||
|
||||
44
.github/workflows/docker-develop.yml
vendored
44
.github/workflows/docker-develop.yml
vendored
@ -1,44 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: 'Docker image tag'
|
||||
required: false
|
||||
default: 'develop'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Run production build
|
||||
env:
|
||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: ./.github/actions/docker-build-push
|
||||
with:
|
||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ghcr_username: ${{ github.actor }}
|
||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64
|
||||
tags: grimsi/gameyfin:${{ inputs.image_tag || 'develop' }},ghcr.io/gameyfin/gameyfin:${{ inputs.image_tag || 'develop' }}
|
||||
43
.github/workflows/docker-fix.yml
vendored
43
.github/workflows/docker-fix.yml
vendored
@ -6,25 +6,56 @@ on:
|
||||
- 'fix/*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
|
||||
- name: Run production build
|
||||
env:
|
||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # always run even if the previous step fails
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
|
||||
- name: Upload build outputs
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-outputs
|
||||
path: |
|
||||
app/build/libs/**
|
||||
plugins/**/build/libs/**/*.jar
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download build outputs
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: build-outputs
|
||||
path: .
|
||||
|
||||
- name: Extract tag from branch name
|
||||
id: extract_tag
|
||||
run: |
|
||||
@ -35,11 +66,9 @@ jobs:
|
||||
- name: Build and push Docker image
|
||||
uses: ./.github/actions/docker-build-push
|
||||
with:
|
||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ghcr_username: ${{ github.actor }}
|
||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64
|
||||
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
|
||||
tags: ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
|
||||
|
||||
80
.github/workflows/docker-preview.yml
vendored
80
.github/workflows/docker-preview.yml
vendored
@ -6,40 +6,94 @@ on:
|
||||
- 'release/*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
outputs:
|
||||
version: ${{ steps.extract_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Extract version from branch name
|
||||
id: extract_version
|
||||
run: |
|
||||
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
|
||||
VERSION="${BRANCH_NAME#release/}-preview"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update version in build.gradle.kts
|
||||
run: |
|
||||
sed -i "s/^version = .*/version = \"${{ steps.extract_version.outputs.version }}\"/" build.gradle.kts
|
||||
|
||||
- name: Update version in app/package.json
|
||||
run: |
|
||||
jq ".version = \"${{ steps.extract_version.outputs.version }}\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
||||
|
||||
- name: Commit version bump (only if changes)
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: 'chore: bump version to v${{ steps.extract_version.outputs.version }}'
|
||||
file_pattern: |
|
||||
build.gradle.kts
|
||||
app/package.json
|
||||
|
||||
- name: Run production build
|
||||
env:
|
||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||
|
||||
- name: Extract tag from branch name
|
||||
id: extract_tag
|
||||
run: |
|
||||
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
|
||||
TAG="${BRANCH_NAME#release/}-preview"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Publish Test Report
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # always run even if the previous step fails
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
|
||||
- name: Upload build outputs
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-outputs
|
||||
path: |
|
||||
app/build/libs/**
|
||||
plugins/**/build/libs/**/*.jar
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download build outputs
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: build-outputs
|
||||
path: .
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: ./.github/actions/docker-build-push
|
||||
with:
|
||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ghcr_username: ${{ github.actor }}
|
||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
platforms: linux/arm64/v8,linux/amd64
|
||||
tags: grimsi/gameyfin:${{ steps.extract_tag.outputs.tag }},ghcr.io/gameyfin/gameyfin:${{ steps.extract_tag.outputs.tag }}
|
||||
tags: ghcr.io/gameyfin/gameyfin:${{ needs.build.outputs.version }}
|
||||
|
||||
35
.github/workflows/image-registry-maintenance.yml
vendored
Normal file
35
.github/workflows/image-registry-maintenance.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: GHCR Image Registry Maintenance
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
older_than:
|
||||
description: 'Only remove images older than (e.g. "1 year", leave empty to remove all untagged images)'
|
||||
required: false
|
||||
dry_run:
|
||||
description: 'Dry run?'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
validate:
|
||||
description: 'Validate all multi-architecture images in the registry after cleanup?'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
delete-untagged-images:
|
||||
runs-on: ubuntu-latest
|
||||
name: Delete Untagged Images from GHCR
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Delete untagged, ghost, and orphaned images
|
||||
uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
with:
|
||||
older-than: ${{ github.event.inputs.older_than }}
|
||||
dry-run: ${{ github.event.inputs.dry_run }}
|
||||
validate: ${{ github.event.inputs.validate }}
|
||||
delete-untagged: true
|
||||
delete-ghost-images: true
|
||||
delete-orphaned-images: true
|
||||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
release_version: ${{ steps.get_version.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -50,34 +50,36 @@ jobs:
|
||||
jq ".version = \"$RELEASE_VERSION\"" app/package.json > app/package.json.tmp && mv app/package.json.tmp app/package.json
|
||||
|
||||
- name: Upload modified files
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: modified-files
|
||||
path: |
|
||||
build.gradle.kts
|
||||
app/package.json
|
||||
|
||||
docker:
|
||||
build:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
@ -86,27 +88,59 @@ jobs:
|
||||
GAMEYFIN_KEYSTORE_PASSWORD: ${{ secrets.GAMEYFIN_KEYSTORE_PASSWORD }}
|
||||
run: ./gradlew clean build -Pvaadin.productionMode=true
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # always run even if the previous step fails
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
|
||||
- name: Upload build outputs
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-outputs
|
||||
path: |
|
||||
app/build/libs/**
|
||||
plugins/**/build/libs/**/*.jar
|
||||
|
||||
docker:
|
||||
needs: [ setup, build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Download build outputs
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: build-outputs
|
||||
path: .
|
||||
|
||||
- name: Generate container image tags
|
||||
id: docker_tags
|
||||
run: |
|
||||
VERSION="${{ needs.setup.outputs.release_version }}"
|
||||
DOCKERHUB_TAGS="grimsi/gameyfin:$VERSION"
|
||||
VERSION='${{ needs.setup.outputs.release_version }}'
|
||||
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:$VERSION"
|
||||
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
MAJOR=${BASH_REMATCH[1]}
|
||||
MINOR=${BASH_REMATCH[2]}
|
||||
PATCH=${BASH_REMATCH[3]}
|
||||
DOCKERHUB_TAGS="grimsi/gameyfin:latest,grimsi/gameyfin:develop,grimsi/gameyfin:$VERSION,grimsi/gameyfin:$MAJOR.$MINOR,grimsi/gameyfin:$MAJOR"
|
||||
GHCR_TAGS="ghcr.io/gameyfin/gameyfin:latest,ghcr.io/gameyfin/gameyfin:develop,ghcr.io/gameyfin/gameyfin:$VERSION,ghcr.io/gameyfin/gameyfin:$MAJOR.$MINOR,ghcr.io/gameyfin/gameyfin:$MAJOR"
|
||||
fi
|
||||
TAGS="$DOCKERHUB_TAGS,$GHCR_TAGS"
|
||||
TAGS="$GHCR_TAGS"
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: ./.github/actions/docker-build-push
|
||||
with:
|
||||
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ghcr_username: ${{ github.actor }}
|
||||
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: .
|
||||
@ -119,20 +153,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: '25'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
@ -150,18 +184,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Commit version bump
|
||||
if: ${{ github.event.inputs.update_version }}
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: 'chore: release v${{ github.event.inputs.version }}'
|
||||
tagging_message: v${{ github.event.inputs.version }}
|
||||
|
||||
44
.github/workflows/sonar.yml
vendored
Normal file
44
.github/workflows/sonar.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Sonar Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened ]
|
||||
|
||||
jobs:
|
||||
sonar:
|
||||
name: Sonar Analysis
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Run tests and generate JaCoCo report
|
||||
run: ./gradlew :app:test :app:jacocoTestReport
|
||||
|
||||
- name: SonarCloud Scan
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: ./gradlew :app:sonar
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -48,9 +48,14 @@ out/
|
||||
/packaged_plugins
|
||||
/logs
|
||||
/templates
|
||||
/docker/docker-compose.yml
|
||||
/app/src/main/bundles/
|
||||
/app/src/main/frontend/**/*.js
|
||||
/app/src/main/frontend/**/*.js.map
|
||||
/app/src/main/frontend/generated/
|
||||
/torrent_dotfiles/
|
||||
**/torrent_dotfiles/
|
||||
*.state.json
|
||||
/plugins/data/
|
||||
/plugins/state/
|
||||
/plugindata/
|
||||
/docker-debug/
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<module name="Gameyfin.app.main" />
|
||||
<option name="SHORTEN_COMMAND_LINE" value="ARGS_FILE" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="org.gameyfin.app.GameyfinApplication" />
|
||||
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development" />
|
||||
<option name="VM_PARAMETERS" value="-Dpf4j.mode=development -Djava.net.preferIPv4Stack=true" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="org.gameyfin.app.*" />
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="scriptParameters" value="-x test" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
17
README.md
17
README.md
@ -4,18 +4,17 @@
|
||||
</a>
|
||||
<h2>Gameyfin</h2>
|
||||
<h4>Manage your video games.</h4>
|
||||
<p>simple / fast / <a href="https://github.com/gameyfin/gameyfin/blob/main/LICENSE.md">FOSS</a></p>
|
||||
<p>simple / fast / <a href="https://gameyfin.org/blog/2025/12/22/why-gameyfin-is-foss/">FOSS</a></p>
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Gameyfins container image will move soon™.
|
||||
> The new image is available under `ghcr.io/gameyfin/gameyfin`.
|
||||
> The old image (`grimsi/gameyfin`) will be removed in the future, so please update your setup accordingly.
|
||||
|
||||
## Overview
|
||||
|
||||
Name and functionality inspired by [Jellyfin](https://jellyfin.org/).
|
||||
|
||||
Gameyfin will turn your disorganized collection of video games into a beautiful, easy-to-navigate library that you can access from any device with a web browser.
|
||||
It will automatically scan your game folders, download metadata and cover images, and present everything in a user-friendly interface.
|
||||
Download your game files directly from the web UI, share your library with friends, and enjoy your games like never before.
|
||||
|
||||
### Documentation
|
||||
|
||||
The documentation and screenshots are available at [gameyfin.org](https://gameyfin.org/).
|
||||
@ -46,3 +45,9 @@ Gameyfin v2 is written in Kotlin and uses the following libraries/frameworks:
|
||||
* Vaadin Hilla & React for the frontend
|
||||
* PF4J for the plugin system
|
||||
* H2 database for persistence
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
|
||||
[](https://www.yourkit.com/)
|
||||
Gameyfin is supported by [YourKit](https://www.yourkit.com/), the makers of [YourKit Java Profiler](https://yourkit.com/java/profiler/), a powerful tool for profiling Java and Kotlin applications.
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
group = "de.grimsi"
|
||||
import org.apache.tools.ant.filters.ReplaceTokens
|
||||
|
||||
group = "org.gameyfin"
|
||||
val appMainClass = "org.gameyfin.app.GameyfinApplicationKt"
|
||||
|
||||
plugins {
|
||||
@ -10,6 +12,8 @@ plugins {
|
||||
kotlin("plugin.jpa")
|
||||
id("com.google.devtools.ksp")
|
||||
application
|
||||
jacoco
|
||||
id("org.sonarqube")
|
||||
}
|
||||
|
||||
application {
|
||||
@ -31,14 +35,19 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-aspectj")
|
||||
implementation("org.springframework.boot:spring-boot-starter-jackson")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
|
||||
implementation("jakarta.validation:jakarta.validation-api:${rootProject.extra["jakartaValidationVersion"]}")
|
||||
|
||||
// Kotlin extensions
|
||||
implementation(kotlin("reflect"))
|
||||
|
||||
// Reactive
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux") {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-reactor-netty")
|
||||
}
|
||||
implementation("org.springframework.boot:spring-boot-starter-jetty")
|
||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
||||
|
||||
@ -46,16 +55,19 @@ dependencies {
|
||||
implementation("com.vaadin:vaadin-core") {
|
||||
exclude("com.vaadin:flow-react")
|
||||
}
|
||||
implementation("com.vaadin:vaadin-spring-boot-starter")
|
||||
implementation("com.vaadin:vaadin-spring-boot-starter") {
|
||||
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
|
||||
}
|
||||
implementation("com.vaadin:hilla-spring-boot-starter")
|
||||
|
||||
// Logging
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:${rootProject.extra["kotlinLoggingVersion"]}")
|
||||
|
||||
// Persistence & I/O
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
|
||||
implementation("org.flywaydb:flyway-core")
|
||||
implementation("commons-io:commons-io:2.18.0")
|
||||
implementation("org.springframework.boot:spring-boot-starter-flyway")
|
||||
implementation("commons-io:commons-io:${rootProject.extra["commonsIoVersion"]}")
|
||||
implementation("com.google.guava:guava:${rootProject.extra["guavaVersion"]}")
|
||||
|
||||
// SSO
|
||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
||||
@ -64,22 +76,31 @@ dependencies {
|
||||
|
||||
// Notifications
|
||||
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||
implementation("ch.digitalfondue.mjml4j:mjml4j:1.0.3")
|
||||
implementation("ch.digitalfondue.mjml4j:mjml4j:${rootProject.extra["mjml4jVersion"]}")
|
||||
|
||||
// Plugins
|
||||
implementation(project(":plugin-api"))
|
||||
|
||||
// Utils
|
||||
implementation("org.apache.tika:tika-core:3.1.0")
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
implementation("org.apache.tika:tika-core:${rootProject.extra["tikaVersion"]}")
|
||||
implementation("me.xdrop:fuzzywuzzy:${rootProject.extra["fuzzywuzzyVersion"]}")
|
||||
implementation("com.vanniktech:blurhash:${rootProject.extra["blurhashVersion"]}")
|
||||
|
||||
// Development
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
developmentOnly("com.vaadin:vaadin-dev")
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
runtimeOnly("com.h2database:h2")
|
||||
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
|
||||
// Testing
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test") {
|
||||
exclude(group = "org.mockito", module = "mockito-core")
|
||||
}
|
||||
testImplementation("io.mockk:mockk:${rootProject.extra["mockkVersion"]}")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("io.projectreactor:reactor-test")
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
@ -91,4 +112,36 @@ dependencyManagement {
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
finalizedBy(tasks.jacocoTestReport)
|
||||
}
|
||||
|
||||
tasks.jacocoTestReport {
|
||||
dependsOn(tasks.test)
|
||||
reports {
|
||||
xml.required = true
|
||||
xml.outputLocation = layout.buildDirectory.file("reports/jacoco/test/jacocoTestReport.xml")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("sonar") {
|
||||
dependsOn(tasks.jacocoTestReport)
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.organization", "gameyfin")
|
||||
property("sonar.projectKey", "gameyfin_gameyfin")
|
||||
property("sonar.projectName", "gameyfin")
|
||||
property("sonar.host.url", "https://sonarcloud.io")
|
||||
property("sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml")
|
||||
property("sonar.coverage.exclusions", "**/*Config.kt,**/org/gameyfin/db/h2/**")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named<ProcessResources>("processResources") {
|
||||
val projectVersion = rootProject.version.toString()
|
||||
filesMatching("application.yml") {
|
||||
filter<ReplaceTokens>("tokens" to mapOf("project.version" to projectVersion))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import {HeroUIPluginConfig} from "@heroui/react";
|
||||
import {compileThemes, themes} from "./src/main/frontend/theming/themes"
|
||||
|
||||
export const HeroUIConfig: HeroUIPluginConfig = {
|
||||
prefix: "gf",
|
||||
themes: compileThemes(themes)
|
||||
};
|
||||
16643
app/package-lock.json
generated
16643
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
344
app/package.json
344
app/package.json
@ -1,116 +1,105 @@
|
||||
{
|
||||
"name": "gameyfin",
|
||||
"version": "2.1.2",
|
||||
"version": "2.3.3",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.9",
|
||||
"@material-tailwind/react": "^2.1.10",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@heroui/react": "^2.8.7",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-stately/data": "^3.12.2",
|
||||
"@react-types/shared": "^3.28.0",
|
||||
"@vaadin/bundles": "24.9.0",
|
||||
"@tailwindcss/vite": "4.1.13",
|
||||
"@vaadin/aura": "25.0.3",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.9.0",
|
||||
"@vaadin/hilla-frontend": "24.9.0",
|
||||
"@vaadin/hilla-lit-form": "24.9.0",
|
||||
"@vaadin/hilla-react-auth": "24.9.0",
|
||||
"@vaadin/hilla-react-crud": "24.9.0",
|
||||
"@vaadin/hilla-react-form": "24.9.0",
|
||||
"@vaadin/hilla-react-i18n": "24.9.0",
|
||||
"@vaadin/hilla-react-signals": "24.9.0",
|
||||
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
||||
"@vaadin/react-components": "24.9.0",
|
||||
"@vaadin/hilla-file-router": "25.0.4",
|
||||
"@vaadin/hilla-frontend": "25.0.4",
|
||||
"@vaadin/hilla-lit-form": "25.0.4",
|
||||
"@vaadin/hilla-react-auth": "25.0.4",
|
||||
"@vaadin/hilla-react-crud": "25.0.4",
|
||||
"@vaadin/hilla-react-form": "25.0.4",
|
||||
"@vaadin/hilla-react-i18n": "25.0.4",
|
||||
"@vaadin/hilla-react-signals": "25.0.4",
|
||||
"@vaadin/react-components": "25.0.3",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
||||
"@vaadin/vaadin-material-styles": "24.9.0",
|
||||
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.3",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.3",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"blurhash": "^2.0.5",
|
||||
"classnames": "^2.5.1",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
"date-fns": "4.1.0",
|
||||
"formik": "^2.4.6",
|
||||
"framer-motion": "^12.5.0",
|
||||
"framer-motion": "^12.23.22",
|
||||
"fzf": "^0.5.2",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"lit": "3.3.0",
|
||||
"lit": "3.3.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"rand-seed": "^2.1.7",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.3",
|
||||
"react-accessible-treeview": "^2.11.1",
|
||||
"react-aria-components": "^1.7.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router": "7.6.1",
|
||||
"react-realtime-chart": "^0.8.1",
|
||||
"react-router": "7.12.0",
|
||||
"react-window": "^2.2.3",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"swiper": "^11.2.6",
|
||||
"valtio": "^2.1.5",
|
||||
"valtio-reactive": "^0.1.2",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@lit-labs/react": "^2.1.3",
|
||||
"@preact/signals-react-transform": "0.5.1",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/node": "^22.4.0",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vaadin/hilla-generator-cli": "24.9.0",
|
||||
"@vaadin/hilla-generator-core": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
||||
"@vaadin/hilla-generator-utils": "24.9.0",
|
||||
"@vitejs/plugin-react": "4.5.0",
|
||||
"@preact/signals-react-transform": "0.6.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vaadin/hilla-generator-cli": "25.0.4",
|
||||
"@vaadin/hilla-generator-core": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-utils": "25.0.4",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"async": "3.2.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"glob": "11.0.2",
|
||||
"magic-string": "0.30.17",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-import": "^16.1.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"magic-string": "0.30.21",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
"rollup-plugin-visualizer": "5.14.0",
|
||||
"rollup-plugin-visualizer": "6.0.5",
|
||||
"strip-css-comments": "5.0.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "4.1.13",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.6",
|
||||
"vite-plugin-checker": "0.9.3",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.1",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"workbox-build": "7.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@react-aria/utils": "^3.28.1",
|
||||
"classnames": "$classnames",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom",
|
||||
"@vaadin/bundles": "$@vaadin/bundles",
|
||||
"@vaadin/common-frontend": "$@vaadin/common-frontend",
|
||||
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
|
||||
"lit": "$lit",
|
||||
"@polymer/polymer": "$@polymer/polymer",
|
||||
"@phosphor-icons/react": "$@phosphor-icons/react",
|
||||
"formik": "$formik",
|
||||
"yup": "$yup",
|
||||
"next-themes": "$next-themes",
|
||||
"@heroui/react": "$@heroui/react",
|
||||
"framer-motion": "$framer-motion",
|
||||
"@material-tailwind/react": "$@material-tailwind/react",
|
||||
"http-status-codes": "$http-status-codes",
|
||||
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
||||
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
||||
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
|
||||
"@vaadin/react-components": "$@vaadin/react-components",
|
||||
@ -128,7 +117,6 @@
|
||||
"date-fns": "$date-fns",
|
||||
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
||||
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
||||
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
|
||||
"@react-types/shared": "$@react-types/shared",
|
||||
"@react-stately/data": "$@react-stately/data",
|
||||
"react-aria-components": "$react-aria-components",
|
||||
@ -140,129 +128,129 @@
|
||||
"react-markdown": "$react-markdown",
|
||||
"remark-breaks": "$remark-breaks",
|
||||
"valtio": "$valtio",
|
||||
"valtio-reactive": "$valtio-reactive",
|
||||
"fzf": "$fzf",
|
||||
"@vaadin/a11y-base": "24.9.0",
|
||||
"@vaadin/accordion": "24.9.0",
|
||||
"@vaadin/app-layout": "24.9.0",
|
||||
"@vaadin/avatar": "24.9.0",
|
||||
"@vaadin/avatar-group": "24.9.0",
|
||||
"@vaadin/button": "24.9.0",
|
||||
"@vaadin/card": "24.9.0",
|
||||
"@vaadin/checkbox": "24.9.0",
|
||||
"@vaadin/checkbox-group": "24.9.0",
|
||||
"@vaadin/combo-box": "24.9.0",
|
||||
"@vaadin/component-base": "24.9.0",
|
||||
"@vaadin/confirm-dialog": "24.9.0",
|
||||
"@vaadin/context-menu": "24.9.0",
|
||||
"@vaadin/custom-field": "24.9.0",
|
||||
"@vaadin/date-picker": "24.9.0",
|
||||
"@vaadin/date-time-picker": "24.9.0",
|
||||
"@vaadin/details": "24.9.0",
|
||||
"@vaadin/dialog": "24.9.0",
|
||||
"@vaadin/email-field": "24.9.0",
|
||||
"@vaadin/field-base": "24.9.0",
|
||||
"@vaadin/field-highlighter": "24.9.0",
|
||||
"@vaadin/form-layout": "24.9.0",
|
||||
"@vaadin/grid": "24.9.0",
|
||||
"@vaadin/horizontal-layout": "24.9.0",
|
||||
"@vaadin/icon": "24.9.0",
|
||||
"@vaadin/icons": "24.9.0",
|
||||
"@vaadin/input-container": "24.9.0",
|
||||
"@vaadin/integer-field": "24.9.0",
|
||||
"@vaadin/item": "24.9.0",
|
||||
"@vaadin/list-box": "24.9.0",
|
||||
"@vaadin/lit-renderer": "24.9.0",
|
||||
"@vaadin/login": "24.9.0",
|
||||
"@vaadin/markdown": "24.9.0",
|
||||
"@vaadin/master-detail-layout": "24.9.0",
|
||||
"@vaadin/menu-bar": "24.9.0",
|
||||
"@vaadin/message-input": "24.9.0",
|
||||
"@vaadin/message-list": "24.9.0",
|
||||
"@vaadin/multi-select-combo-box": "24.9.0",
|
||||
"@vaadin/notification": "24.9.0",
|
||||
"@vaadin/number-field": "24.9.0",
|
||||
"@vaadin/overlay": "24.9.0",
|
||||
"@vaadin/password-field": "24.9.0",
|
||||
"@vaadin/popover": "24.9.0",
|
||||
"@vaadin/progress-bar": "24.9.0",
|
||||
"@vaadin/radio-group": "24.9.0",
|
||||
"@vaadin/scroller": "24.9.0",
|
||||
"@vaadin/select": "24.9.0",
|
||||
"@vaadin/side-nav": "24.9.0",
|
||||
"@vaadin/split-layout": "24.9.0",
|
||||
"@vaadin/tabs": "24.9.0",
|
||||
"@vaadin/tabsheet": "24.9.0",
|
||||
"@vaadin/text-area": "24.9.0",
|
||||
"@vaadin/text-field": "24.9.0",
|
||||
"@vaadin/time-picker": "24.9.0",
|
||||
"@vaadin/tooltip": "24.9.0",
|
||||
"@vaadin/upload": "24.9.0",
|
||||
"@vaadin/router": "2.0.0",
|
||||
"@vaadin/vertical-layout": "24.9.0",
|
||||
"@vaadin/virtual-list": "24.9.0"
|
||||
"@tailwindcss/vite": "$@tailwindcss/vite",
|
||||
"postcss": "$postcss",
|
||||
"postcss-import": "$postcss-import",
|
||||
"next-themes": "$next-themes",
|
||||
"react-realtime-chart": "$react-realtime-chart",
|
||||
"react-window": "$react-window",
|
||||
"blurhash": "$blurhash",
|
||||
"@vaadin/aura": "$@vaadin/aura",
|
||||
"@vaadin/a11y-base": "25.0.3",
|
||||
"@vaadin/accordion": "25.0.3",
|
||||
"@vaadin/app-layout": "25.0.3",
|
||||
"@vaadin/avatar": "25.0.3",
|
||||
"@vaadin/avatar-group": "25.0.3",
|
||||
"@vaadin/button": "25.0.3",
|
||||
"@vaadin/card": "25.0.3",
|
||||
"@vaadin/checkbox": "25.0.3",
|
||||
"@vaadin/checkbox-group": "25.0.3",
|
||||
"@vaadin/combo-box": "25.0.3",
|
||||
"@vaadin/component-base": "25.0.3",
|
||||
"@vaadin/confirm-dialog": "25.0.3",
|
||||
"@vaadin/context-menu": "25.0.3",
|
||||
"@vaadin/custom-field": "25.0.3",
|
||||
"@vaadin/date-picker": "25.0.3",
|
||||
"@vaadin/date-time-picker": "25.0.3",
|
||||
"@vaadin/details": "25.0.3",
|
||||
"@vaadin/dialog": "25.0.3",
|
||||
"@vaadin/email-field": "25.0.3",
|
||||
"@vaadin/field-base": "25.0.3",
|
||||
"@vaadin/field-highlighter": "25.0.3",
|
||||
"@vaadin/form-layout": "25.0.3",
|
||||
"@vaadin/grid": "25.0.3",
|
||||
"@vaadin/horizontal-layout": "25.0.3",
|
||||
"@vaadin/icon": "25.0.3",
|
||||
"@vaadin/icons": "25.0.3",
|
||||
"@vaadin/input-container": "25.0.3",
|
||||
"@vaadin/integer-field": "25.0.3",
|
||||
"@vaadin/item": "25.0.3",
|
||||
"@vaadin/list-box": "25.0.3",
|
||||
"@vaadin/lit-renderer": "25.0.3",
|
||||
"@vaadin/login": "25.0.3",
|
||||
"@vaadin/markdown": "25.0.3",
|
||||
"@vaadin/master-detail-layout": "25.0.3",
|
||||
"@vaadin/menu-bar": "25.0.3",
|
||||
"@vaadin/message-input": "25.0.3",
|
||||
"@vaadin/message-list": "25.0.3",
|
||||
"@vaadin/multi-select-combo-box": "25.0.3",
|
||||
"@vaadin/notification": "25.0.3",
|
||||
"@vaadin/number-field": "25.0.3",
|
||||
"@vaadin/overlay": "25.0.3",
|
||||
"@vaadin/password-field": "25.0.3",
|
||||
"@vaadin/popover": "25.0.3",
|
||||
"@vaadin/progress-bar": "25.0.3",
|
||||
"@vaadin/radio-group": "25.0.3",
|
||||
"@vaadin/scroller": "25.0.3",
|
||||
"@vaadin/select": "25.0.3",
|
||||
"@vaadin/side-nav": "25.0.3",
|
||||
"@vaadin/split-layout": "25.0.3",
|
||||
"@vaadin/tabs": "25.0.3",
|
||||
"@vaadin/tabsheet": "25.0.3",
|
||||
"@vaadin/text-area": "25.0.3",
|
||||
"@vaadin/text-field": "25.0.3",
|
||||
"@vaadin/time-picker": "25.0.3",
|
||||
"@vaadin/tooltip": "25.0.3",
|
||||
"@vaadin/upload": "25.0.3",
|
||||
"@vaadin/router": "2.0.1",
|
||||
"@vaadin/vertical-layout": "25.0.3",
|
||||
"@vaadin/virtual-list": "25.0.3"
|
||||
},
|
||||
"vaadin": {
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@vaadin/bundles": "24.9.0",
|
||||
"@vaadin/aura": "25.0.3",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.9.0",
|
||||
"@vaadin/hilla-frontend": "24.9.0",
|
||||
"@vaadin/hilla-lit-form": "24.9.0",
|
||||
"@vaadin/hilla-react-auth": "24.9.0",
|
||||
"@vaadin/hilla-react-crud": "24.9.0",
|
||||
"@vaadin/hilla-react-form": "24.9.0",
|
||||
"@vaadin/hilla-react-i18n": "24.9.0",
|
||||
"@vaadin/hilla-react-signals": "24.9.0",
|
||||
"@vaadin/polymer-legacy-adapter": "24.9.0",
|
||||
"@vaadin/react-components": "24.9.0",
|
||||
"@vaadin/hilla-file-router": "25.0.4",
|
||||
"@vaadin/hilla-frontend": "25.0.4",
|
||||
"@vaadin/hilla-lit-form": "25.0.4",
|
||||
"@vaadin/hilla-react-auth": "25.0.4",
|
||||
"@vaadin/hilla-react-crud": "25.0.4",
|
||||
"@vaadin/hilla-react-form": "25.0.4",
|
||||
"@vaadin/hilla-react-i18n": "25.0.4",
|
||||
"@vaadin/hilla-react-signals": "25.0.4",
|
||||
"@vaadin/react-components": "25.0.3",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.9.0",
|
||||
"@vaadin/vaadin-material-styles": "24.9.0",
|
||||
"@vaadin/vaadin-themable-mixin": "24.9.0",
|
||||
"@vaadin/vaadin-lumo-styles": "25.0.3",
|
||||
"@vaadin/vaadin-themable-mixin": "25.0.3",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
"lit": "3.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.6.1"
|
||||
"date-fns": "4.1.0",
|
||||
"lit": "3.3.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@preact/signals-react-transform": "0.5.1",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vaadin/hilla-generator-cli": "24.9.0",
|
||||
"@vaadin/hilla-generator-core": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
|
||||
"@vaadin/hilla-generator-utils": "24.9.0",
|
||||
"@vitejs/plugin-react": "4.5.0",
|
||||
"async": "3.2.6",
|
||||
"glob": "11.0.2",
|
||||
"magic-string": "0.30.17",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@preact/signals-react-transform": "0.6.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vaadin/hilla-generator-cli": "25.0.4",
|
||||
"@vaadin/hilla-generator-core": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-client": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-model": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-push": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-signals": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "25.0.4",
|
||||
"@vaadin/hilla-generator-utils": "25.0.4",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"magic-string": "0.30.21",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
"rollup-plugin-visualizer": "5.14.0",
|
||||
"rollup-plugin-visualizer": "6.0.5",
|
||||
"strip-css-comments": "5.0.0",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.6",
|
||||
"vite-plugin-checker": "0.9.3",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.1",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"workbox-build": "7.4.0"
|
||||
},
|
||||
"disableUsageStatistics": true,
|
||||
"hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866"
|
||||
"hash": "d2c583f908a126db3f53ccbc87688b5089107afb58a87159631dc257a3a279ae"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@ -4,7 +4,7 @@ import {HeroUIProvider} from "@heroui/react";
|
||||
import {ThemeProvider as NextThemesProvider} from "next-themes";
|
||||
import {themeNames} from "Frontend/theming/themes";
|
||||
import {AuthProvider, useAuth} from "Frontend/util/auth";
|
||||
import {IconContext, X} from "@phosphor-icons/react";
|
||||
import {IconContext, XIcon} from "@phosphor-icons/react";
|
||||
import client from "Frontend/generated/connect-client.default";
|
||||
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
|
||||
import {initializeLibraryState} from "Frontend/state/LibraryState";
|
||||
@ -16,6 +16,10 @@ import {isAdmin} from "Frontend/util/utils";
|
||||
import {useRouteMetadata} from "Frontend/util/routing";
|
||||
import {useEffect} from "react";
|
||||
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||
import {initializePlatformState} from "Frontend/state/PlatformState";
|
||||
import {initializeDownloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
import {initializeUserState} from "Frontend/state/UserState";
|
||||
import {initializeCollectionState} from "Frontend/state/CollectionState";
|
||||
|
||||
export default function App() {
|
||||
client.middlewares = [ErrorHandlingMiddleware];
|
||||
@ -45,12 +49,16 @@ function ViewWithAuth() {
|
||||
if (auth.state.initializing || auth.state.loading) return;
|
||||
|
||||
initializeLibraryState();
|
||||
initializeGameState();
|
||||
initializeCollectionState();
|
||||
initializePlatformState();
|
||||
initializeGameRequestState();
|
||||
initializePluginState();
|
||||
initializeGameState();
|
||||
|
||||
if (isAdmin(auth)) {
|
||||
initializeScanState();
|
||||
initializeDownloadSessionState();
|
||||
initializeUserState();
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
@ -63,7 +71,7 @@ function ViewWithAuth() {
|
||||
radius: "sm",
|
||||
variant: "flat",
|
||||
hideIcon: true,
|
||||
closeIcon: <X/>,
|
||||
closeIcon: <XIcon/>,
|
||||
classNames: {
|
||||
closeButton: "opacity-100 absolute right-4 top-1/2 -translate-y-1/2",
|
||||
progressTrack: "h-1",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
||||
import {GearFineIcon, QuestionIcon, SignOutIcon, UserIcon} from "@phosphor-icons/react";
|
||||
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
|
||||
import {useNavigate} from "react-router";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
@ -13,23 +13,23 @@ export default function ProfileMenu() {
|
||||
const profileMenuItems = [
|
||||
{
|
||||
label: "My Profile",
|
||||
icon: <User/>,
|
||||
icon: <UserIcon/>,
|
||||
onClick: () => navigate("/settings/profile")
|
||||
},
|
||||
{
|
||||
label: "Administration",
|
||||
icon: <GearFine/>,
|
||||
onClick: () => navigate("/administration/libraries"),
|
||||
icon: <GearFineIcon/>,
|
||||
onClick: () => navigate("/administration/games"),
|
||||
showIf: isAdmin(auth)
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
icon: <Question/>,
|
||||
icon: <QuestionIcon/>,
|
||||
onClick: () => window.open("https://gameyfin.org", "_blank")
|
||||
},
|
||||
{
|
||||
label: "Sign Out",
|
||||
icon: <SignOut/>,
|
||||
icon: <SignOutIcon/>,
|
||||
onClick: auth.logout,
|
||||
color: "primary"
|
||||
},
|
||||
|
||||
@ -4,6 +4,8 @@ import Input from "Frontend/components/general/input/Input";
|
||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||
import SelectInput from "Frontend/components/general/input/SelectInput";
|
||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
import NumberInput from "Frontend/components/general/input/NumberInput";
|
||||
import SliderInput from "Frontend/components/general/input/SliderInput";
|
||||
|
||||
export default function ConfigFormField({configElement, ...props}: any) {
|
||||
function inputElement(configElement: ConfigEntryDto) {
|
||||
@ -27,13 +29,22 @@ export default function ConfigFormField({configElement, ...props}: any) {
|
||||
);
|
||||
case "float":
|
||||
return (
|
||||
<Input label={configElement.description} name={configElement.key} type="number"
|
||||
step="0.1" {...props}/>
|
||||
<NumberInput label={configElement.description} name={configElement.key}
|
||||
step={0.1} {...props}/>
|
||||
);
|
||||
case "int":
|
||||
if (configElement.min != null && configElement.max != null) {
|
||||
return (
|
||||
<SliderInput label={configElement.description} name={configElement.key}
|
||||
min={configElement.min}
|
||||
max={configElement.max}
|
||||
step={configElement.step ?? 1}
|
||||
{...props}/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input label={configElement.description} name={configElement.key} type="number"
|
||||
step="1" {...props}/>
|
||||
<NumberInput label={configElement.description} name={configElement.key}
|
||||
step={1} {...props}/>
|
||||
);
|
||||
case "array":
|
||||
return (
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import * as Yup from "yup";
|
||||
import {Alert, Button, Divider, Tooltip} from "@heroui/react";
|
||||
import {FlaskIcon, SigmaIcon} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
import SessionStatsDto from "Frontend/generated/org/gameyfin/app/core/download/bandwidth/SessionStatsDto";
|
||||
import {DownloadSessionCard} from "Frontend/components/general/cards/DownloadSessionCard";
|
||||
import {humanFileSize} from "Frontend/util/utils";
|
||||
|
||||
function DownloadManagementLayout({getConfig, formik}: any) {
|
||||
const sessions = useSnapshot(downloadSessionState).all;
|
||||
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);
|
||||
@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import * as Yup from 'yup';
|
||||
import "Frontend/util/yup-extensions";
|
||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {ListNumbersIcon, PlusIcon} from "@phosphor-icons/react";
|
||||
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
|
||||
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import LibraryPrioritiesModal from "Frontend/components/general/modals/LibraryPrioritiesModal";
|
||||
import {collectionState} from "Frontend/state/CollectionState";
|
||||
import {CollectionOverviewCard} from "Frontend/components/general/cards/CollectionOverviewCard";
|
||||
import CollectionCreationModal from "Frontend/components/general/modals/CollectionCreationModal";
|
||||
import CollectionPrioritiesModal from "Frontend/components/general/modals/CollectionPrioritiesModal";
|
||||
|
||||
function GameManagementLayout({getConfig, formik}: any) {
|
||||
const libraries = useSnapshot(libraryState);
|
||||
const libraryCreationModal = useDisclosure();
|
||||
const libraryOrderModal = useDisclosure();
|
||||
|
||||
const collections = useSnapshot(collectionState);
|
||||
const collectionCreationModal = useDisclosure();
|
||||
const collectionOrderModal = useDisclosure();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Change library order">
|
||||
<Button isIconOnly variant="flat" onPress={libraryOrderModal.onOpen}>
|
||||
<ListNumbersIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Add new library">
|
||||
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
{libraries.sorted.length > 0 ?
|
||||
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
|
||||
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
|
||||
{libraries.sorted.map((library) =>
|
||||
// @ts-ignore
|
||||
<LibraryOverviewCard library={library} key={library.name}/>
|
||||
)}
|
||||
</div> :
|
||||
<p className="mt-4 text-center text-default-500">No libraries found</p>
|
||||
}
|
||||
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Collections</h2>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Change collection order">
|
||||
<Button isIconOnly variant="flat" onPress={collectionOrderModal.onOpen}>
|
||||
<ListNumbersIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Create new collection">
|
||||
<Button isIconOnly variant="flat" onPress={collectionCreationModal.onOpen}>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
{collections.sorted.length > 0 ?
|
||||
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
|
||||
<div id="collection-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
|
||||
{collections.sorted.map((collection) =>
|
||||
// @ts-ignore
|
||||
<CollectionOverviewCard collection={collection} key={collection.name}/>
|
||||
)}
|
||||
</div> :
|
||||
<p className="mt-4 text-center text-default-500">No collections found</p>
|
||||
}
|
||||
|
||||
<Section title="Scanning"/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
|
||||
<div className="flex flex-row gap-4 items-baseline">
|
||||
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
|
||||
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
|
||||
</div>
|
||||
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||
</div>
|
||||
|
||||
<Section title="Metadata"/>
|
||||
<div className="flex flex-row items-baseline">
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
|
||||
isDisabled={!formik.values.library.metadata.update.enabled}/>
|
||||
</div>
|
||||
|
||||
<LibraryCreationModal
|
||||
isOpen={libraryCreationModal.isOpen}
|
||||
onOpenChange={libraryCreationModal.onOpenChange}
|
||||
/>
|
||||
|
||||
<LibraryPrioritiesModal
|
||||
isOpen={libraryOrderModal.isOpen}
|
||||
onOpenChange={libraryOrderModal.onOpenChange}
|
||||
/>
|
||||
|
||||
<CollectionCreationModal
|
||||
isOpen={collectionCreationModal.isOpen}
|
||||
onOpenChange={collectionCreationModal.onOpenChange}
|
||||
/>
|
||||
|
||||
<CollectionPrioritiesModal
|
||||
isOpen={collectionOrderModal.isOpen}
|
||||
onOpenChange={collectionOrderModal.onOpenChange}/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
library: Yup.object({
|
||||
metadata: Yup.object({
|
||||
update: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
schedule: Yup.string().when("enabled", {
|
||||
is: true,
|
||||
then: (schema) => schema.cron()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
scan: Yup.object({
|
||||
"extract-title-using-regex": Yup.boolean(),
|
||||
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
|
||||
is: true,
|
||||
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
|
||||
}),
|
||||
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const GameManagement = withConfigPage(GameManagementLayout, "Games", validationSchema);
|
||||
@ -20,7 +20,7 @@ function GameRequestManagementLayout({getConfig, formik}: any) {
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<ConfigFormField
|
||||
configElement={getConfig("requests.games.allow-guests-to-request-games")}
|
||||
isDisabled={!formik.values.library["allow-public-access"]}/>
|
||||
isDisabled={!formik.values.security["allow-public-access"]}/>
|
||||
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
import React from "react";
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import * as Yup from 'yup';
|
||||
import "Frontend/util/yup-extensions";
|
||||
import {addToast, Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {Plus} from "@phosphor-icons/react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
|
||||
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
|
||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
|
||||
function LibraryManagementLayout({getConfig, formik}: any) {
|
||||
const libraryCreationModal = useDisclosure();
|
||||
const state = useSnapshot(libraryState);
|
||||
|
||||
async function updateLibrary(library: LibraryUpdateDto) {
|
||||
await LibraryEndpoint.updateLibrary(library);
|
||||
addToast({
|
||||
title: "Library updated",
|
||||
description: `Library ${library.name} has been updated.`,
|
||||
color: "success"
|
||||
})
|
||||
}
|
||||
|
||||
async function removeLibrary(library: LibraryDto) {
|
||||
await LibraryEndpoint.deleteLibrary(library.id);
|
||||
addToast({
|
||||
title: "Library removed",
|
||||
description: `Library ${library.name} has been removed.`,
|
||||
color: "success"
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Section title="Permissions"/>
|
||||
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
|
||||
|
||||
<Section title="Scanning"/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConfigFormField configElement={getConfig("library.scan.enable-filesystem-watcher")} isDisabled/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.scan-empty-directories")}/>
|
||||
<div className="flex flex-row gap-4 items-baseline">
|
||||
<ConfigFormField configElement={getConfig("library.scan.extract-title-using-regex")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.title-extraction-regex")}
|
||||
isDisabled={!formik.values.library.scan["extract-title-using-regex"]}/>
|
||||
</div>
|
||||
<ConfigFormField configElement={getConfig("library.scan.title-match-min-ratio")}/>
|
||||
<ConfigFormField configElement={getConfig("library.scan.game-file-extensions")}/>
|
||||
</div>
|
||||
|
||||
<Section title="Metadata"/>
|
||||
<div className="flex flex-row items-baseline">
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("library.metadata.update.schedule")}
|
||||
isDisabled={!formik.values.library.metadata.update.enabled}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Libraries</h2>
|
||||
<Tooltip content="Add new library">
|
||||
<Button isIconOnly variant="flat" onPress={libraryCreationModal.onOpen}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Divider className="mb-4"/>
|
||||
{state.sorted.length > 0 ?
|
||||
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
|
||||
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
|
||||
{state.sorted.map((library) =>
|
||||
// @ts-ignore
|
||||
<LibraryOverviewCard library={library} updateLibrary={updateLibrary}
|
||||
removeLibrary={removeLibrary} key={library.name}/>
|
||||
)}
|
||||
</div> :
|
||||
<p className="mt-4 text-center text-default-500">No libraries found</p>
|
||||
}
|
||||
|
||||
<LibraryCreationModal
|
||||
// @ts-ignore
|
||||
libraries={state.sorted}
|
||||
isOpen={libraryCreationModal.isOpen}
|
||||
onOpenChange={libraryCreationModal.onOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
library: Yup.object({
|
||||
metadata: Yup.object({
|
||||
update: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
schedule: Yup.string().when("enabled", {
|
||||
is: true,
|
||||
then: (schema) => schema.cron()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
scan: Yup.object({
|
||||
"extract-title-using-regex": Yup.boolean(),
|
||||
"title-extraction-regex": Yup.string().when("extract-title-using-regex", {
|
||||
is: true,
|
||||
then: (schema) => schema.trim().required("Title extraction regex is required when enabled")
|
||||
}),
|
||||
"title-match-min-ratio": Yup.number().min(1, "Must be between 1-100").max(100, "Must be between 1-100")
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const LibraryManagement = withConfigPage(LibraryManagementLayout, "Library Management", validationSchema);
|
||||
@ -4,7 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import * as Yup from 'yup';
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import {addToast, Button, Code, Divider, Tooltip} from "@heroui/react";
|
||||
import {ArrowUDownLeft, SortAscending} from "@phosphor-icons/react";
|
||||
import { ArrowUDownLeftIcon, SortAscendingIcon } from "@phosphor-icons/react";
|
||||
|
||||
function LogManagementLayout({getConfig, formik}: any) {
|
||||
const [logEntries, setLogEntries] = useState<string[]>([]);
|
||||
@ -51,7 +51,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-grow justify-between items-baseline">
|
||||
<div className="flex flex-row grow justify-between items-baseline">
|
||||
<h2 className={"text-xl font-bold mt-8 mb-1"}>Application logs</h2>
|
||||
<div className="flex flex-row gap-1">
|
||||
<Tooltip content="Soft-wrap" placement="bottom">
|
||||
@ -59,7 +59,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
||||
onPress={() => setSoftWrap(!softWrap)}
|
||||
variant={softWrap ? "solid" : "ghost"}
|
||||
>
|
||||
<ArrowUDownLeft/>
|
||||
<ArrowUDownLeftIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Auto-scroll" placement="bottom">
|
||||
@ -67,7 +67,7 @@ function LogManagementLayout({getConfig, formik}: any) {
|
||||
onPress={() => setAutoScroll(!autoScroll)}
|
||||
variant={autoScroll ? "solid" : "ghost"}
|
||||
>
|
||||
<SortAscending/>
|
||||
<SortAscendingIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {addToast, Button, Card, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
||||
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
|
||||
import {PaperPlaneRightIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
import MessageTemplateDto from "Frontend/generated/org/gameyfin/app/messages/templates/MessageTemplateDto";
|
||||
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
||||
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
||||
@ -31,7 +31,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
||||
password: formik.values.messages.providers.email.password
|
||||
}
|
||||
|
||||
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
|
||||
let areCredentialsValid: boolean;
|
||||
|
||||
try {
|
||||
areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
|
||||
} catch (error) {
|
||||
areCredentialsValid = false;
|
||||
}
|
||||
|
||||
if (areCredentialsValid) {
|
||||
addToast({
|
||||
@ -91,7 +97,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
||||
size="sm"
|
||||
onPress={() => openEditor(template)}
|
||||
>
|
||||
<Pencil/>
|
||||
<PencilIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Send test notification">
|
||||
@ -100,7 +106,7 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
||||
onPress={() => openTestNotification(template)}
|
||||
isDisabled={!formik.values.messages.providers.email.enabled}
|
||||
>
|
||||
<PaperPlaneRight/>
|
||||
<PaperPlaneRightIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<p className="text-lg">{template.description}</p>
|
||||
@ -115,13 +121,13 @@ function MessageManagementLayout({getConfig, formik}: any) {
|
||||
<EditTemplateModal
|
||||
isOpen={editorModal.isOpen}
|
||||
onOpenChange={editorModal.onOpenChange}
|
||||
selectedTemplate={selectedTemplate!!}
|
||||
selectedTemplate={selectedTemplate!}
|
||||
/>
|
||||
|
||||
<SendTestNotificationModal
|
||||
isOpen={testNotificationModal.isOpen}
|
||||
onOpenChange={testNotificationModal.onOpenChange}
|
||||
selectedTemplate={selectedTemplate!!}
|
||||
selectedTemplate={selectedTemplate!}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -137,7 +143,6 @@ const validationSchema = Yup.object({
|
||||
.min(0, "Port must be between 0 and 65535")
|
||||
.max(65535, "Port must be between 0 and 65535"),
|
||||
username: Yup.string()
|
||||
.email("Invalid email address")
|
||||
.required("Username is required"),
|
||||
})
|
||||
})
|
||||
|
||||
@ -12,14 +12,14 @@ export default function PluginManagement() {
|
||||
|
||||
return state.isLoaded && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||
<div className="flex flex-row grow justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold">Plugins</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{pluginTypes.map(type =>
|
||||
// @ts-ignore
|
||||
<PluginManagementSection key={type} type={type} plugins={state.pluginsByType[type]}/>
|
||||
<PluginManagementSection key={type} type={type}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import Section from "Frontend/components/general/Section";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import {addToast, Button, Input as NextUiInput, Tooltip} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react";
|
||||
import { ArrowCounterClockwiseIcon, CheckIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import * as Yup from "yup";
|
||||
@ -82,14 +82,14 @@ export default function ProfileManagement() {
|
||||
>
|
||||
{(formik: { values: any; isSubmitting: any; dirty: boolean; }) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||
<div className="flex flex-row grow justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold">My Profile</h2>
|
||||
{auth.state.user?.managedBySso &&
|
||||
<p className="text-warning">Your account is managed externally.</p>}
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{formik.values.newPassword.length > 0 &&
|
||||
<SmallInfoField icon={Info}
|
||||
<SmallInfoField icon={InfoIcon}
|
||||
message="You will be logged out of all current sessions"
|
||||
className="text-default-500"
|
||||
/>
|
||||
@ -100,7 +100,7 @@ export default function ProfileManagement() {
|
||||
isDisabled={!formik.dirty || formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,12 +117,12 @@ export default function ProfileManagement() {
|
||||
color="success">Upload</Button>
|
||||
<Tooltip content="Remove your current avatar">
|
||||
<Button onPress={removeAvatar} isIconOnly color="danger"
|
||||
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
|
||||
isDisabled={auth.state.user?.managedBySso}><TrashIcon/></Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-col grow">
|
||||
<Section title="Personal information"/>
|
||||
<Input name="username" label="Username" type="text" autocomplete="username"
|
||||
isDisabled={auth.state.user?.managedBySso}/>
|
||||
@ -145,14 +145,14 @@ export default function ProfileManagement() {
|
||||
variant="ghost"
|
||||
className="size-14"
|
||||
>
|
||||
<ArrowCounterClockwise size={26}/>
|
||||
<ArrowCounterClockwiseIcon size={26}/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
</div>
|
||||
{!messagesEnabled &&
|
||||
<div className="flex flex-row gap-2 text-warning -mt-5">
|
||||
<Info/>
|
||||
<InfoIcon/>
|
||||
<small>
|
||||
Email services are disabled. Please contact your administrator.
|
||||
</small>
|
||||
|
||||
@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import * as Yup from 'yup';
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {addToast, Button, Checkbox, CheckboxGroup, Tooltip} from "@heroui/react";
|
||||
import {MagicWand, Warning} from "@phosphor-icons/react";
|
||||
import {addToast, Button} from "@heroui/react";
|
||||
import {MagicWandIcon} from "@phosphor-icons/react";
|
||||
|
||||
function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
function SecurityManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
setSaveMessage("Gameyfin must be restarted for the changes to take effect");
|
||||
setSaveMessage("Gameyfin must be restarted for changes in the SSO configuration to take effect");
|
||||
} else {
|
||||
setSaveMessage(null);
|
||||
}
|
||||
@ -43,41 +43,26 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
|
||||
<Section title="Permissions"/>
|
||||
<ConfigFormField configElement={getConfig("security.allow-public-access")}/>
|
||||
|
||||
<Section title="Single Sign-On"/>
|
||||
<div className="flex flex-row items-start gap-8">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-bold mb-4">General configuration</h2>
|
||||
<ConfigFormField className="mb-4"
|
||||
configElement={getConfig("sso.oidc.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<Section title="SSO configuration"/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
|
||||
|
||||
<Section title="SSO user handling"/>
|
||||
<div className="flex flex-row items-baseline mb-4">
|
||||
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
|
||||
value={["auto-register-new-users"]}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Checkbox className="items-baseline" value="auto-register-new-users" isDisabled>
|
||||
Automatically create new users after registration
|
||||
</Checkbox>
|
||||
<Tooltip content={"Currently not configurable (always enabled)"} placement="right">
|
||||
<Warning weight="fill"/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CheckboxGroup>
|
||||
{/*TODO: enable when the issues with unregistered SSO users are sorted
|
||||
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")} isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
*/}
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled ||
|
||||
!formik.values.sso.oidc["auto-register-new-users"]}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
</div>
|
||||
|
||||
<Section title="SSO provider configuration"/>
|
||||
<h2 className="text-xl font-bold mb-4">SSO Provider Configuration</h2>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
|
||||
@ -89,7 +74,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
|
||||
<Button
|
||||
isDisabled={isAutoPopulateDisabled()}
|
||||
onPress={autoPopulate}
|
||||
className="h-14"><MagicWand className="min-w-5"/>Auto-populate</Button>
|
||||
className="h-14"><MagicWandIcon className="min-w-5"/>Auto-populate</Button>
|
||||
</div>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
@ -111,7 +96,6 @@ const validationSchema = Yup.object({
|
||||
sso: Yup.object({
|
||||
oidc: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
"auto-register-new-users": Yup.boolean().required(),
|
||||
"match-existing-users-by": Yup.string().required(),
|
||||
"client-id": Yup.string().when("enabled", ([enabled], schema) =>
|
||||
enabled ? schema.required("Client ID is required") : schema
|
||||
@ -141,4 +125,4 @@ const validationSchema = Yup.object({
|
||||
})
|
||||
});
|
||||
|
||||
export const SsoManagement = withConfigPage(SsoManagementLayout, "Single Sign-On", validationSchema);
|
||||
export const SecurityManagement = withConfigPage(SecurityManagementLayout, "Security", validationSchema);
|
||||
@ -4,8 +4,7 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {Info, UserPlus} from "@phosphor-icons/react";
|
||||
import {UserPlusIcon} from "@phosphor-icons/react";
|
||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||
@ -21,7 +20,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-col grow">
|
||||
|
||||
<Section title="Sign-Ups"/>
|
||||
<div className="flex flex-row">
|
||||
@ -32,13 +31,9 @@ function UserManagementLayout({getConfig, formik}: any) {
|
||||
|
||||
<div className="flex flex-row items-baseline justify-between">
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">Users</h2>
|
||||
{!getConfig("sso.oidc.auto-register-new-users").value &&
|
||||
<SmallInfoField className="mb-4 text-warning" icon={Info}
|
||||
message="Automatic user registration for SSO users is disabled"/>
|
||||
}
|
||||
<Tooltip content="Invite new user">
|
||||
<Button isIconOnly variant="flat" onPress={inviteUserModal.onOpen}>
|
||||
<UserPlus/>
|
||||
<UserPlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@ import {ConfigEndpoint} from "Frontend/generated/endpoints";
|
||||
import ConfigEntryDto from "Frontend/generated/org/gameyfin/app/config/dto/ConfigEntryDto";
|
||||
import {Form, Formik} from "formik";
|
||||
import {Button, Skeleton} from "@heroui/react";
|
||||
import {Check, Info} from "@phosphor-icons/react";
|
||||
import {CheckIcon, InfoIcon} from "@phosphor-icons/react";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {configState, initializeConfigState, NestedConfig} from "Frontend/state/ConfigState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
@ -32,7 +32,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
}
|
||||
|
||||
function getConfig(key: string): ConfigEntryDto | undefined {
|
||||
return state.state[key] as ConfigEntryDto | undefined;
|
||||
return state.state[key];
|
||||
}
|
||||
|
||||
function getChangedValues(initial: NestedConfig, current: NestedConfig): Record<string, any> {
|
||||
@ -92,11 +92,11 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
>
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between">
|
||||
<div className="flex flex-row grow justify-between">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{saveMessage && <SmallInfoField icon={Info}
|
||||
{saveMessage && <SmallInfoField icon={InfoIcon}
|
||||
message={saveMessage}
|
||||
className="text-warning"/>}
|
||||
|
||||
@ -106,7 +106,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
isDisabled={formik.isSubmitting || configSaved || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||
{formik.isSubmitting ? "" : configSaved ? <CheckIcon/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,7 +119,7 @@ export default function withConfigPage(WrappedComponent: React.ComponentType<any
|
||||
)}
|
||||
</Formik> :
|
||||
[...Array(4)].map((_e, i) =>
|
||||
<div className="flex flex-col flex-grow gap-8 mb-12" key={i}>
|
||||
<div className="flex flex-col grow gap-8 mb-12" key={i}>
|
||||
<Skeleton className="h-10 w-full rounded-md"/>
|
||||
<Skeleton className="h-12 flex w-1/3 rounded-md"/>
|
||||
<div className="flex flex-row gap-8">
|
||||
|
||||
46
app/src/main/frontend/components/general/ChipList.tsx
Normal file
46
app/src/main/frontend/components/general/ChipList.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import {Chip, Tooltip} from "@heroui/react";
|
||||
|
||||
interface ChipListProps {
|
||||
items: string[];
|
||||
maxVisible?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
radius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
defaultContent?: string;
|
||||
}
|
||||
|
||||
export default function ChipList({items, maxVisible = 1, size = "sm", radius = "sm", defaultContent}: ChipListProps) {
|
||||
if (items.length === 0) {
|
||||
return defaultContent ? <Chip radius={radius} size={size}>{defaultContent}</Chip> : null;
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(0, maxVisible);
|
||||
const remainingItems = items.slice(maxVisible);
|
||||
const hasMore = remainingItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{visibleItems.map(item => (
|
||||
<Chip key={item} radius={radius} size={size}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
{hasMore && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-1">
|
||||
{remainingItems.map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
placement="right">
|
||||
<Chip radius={radius} size={size}>
|
||||
{maxVisible > 0 && "+"}{remainingItems.length}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,29 +1,4 @@
|
||||
import {
|
||||
Alien,
|
||||
Baseball,
|
||||
Basketball,
|
||||
CastleTurret,
|
||||
DiceFive,
|
||||
GameController,
|
||||
Ghost,
|
||||
IconContext,
|
||||
Joystick,
|
||||
Lego,
|
||||
Medal,
|
||||
PuzzlePiece,
|
||||
Rocket,
|
||||
Skull,
|
||||
SoccerBall,
|
||||
Star,
|
||||
Strategy,
|
||||
Sword,
|
||||
Target,
|
||||
ThumbsUp,
|
||||
TreasureChest,
|
||||
Trophy,
|
||||
User,
|
||||
Volleyball
|
||||
} from "@phosphor-icons/react";
|
||||
import { AlienIcon, BaseballIcon, BasketballIcon, CastleTurretIcon, DiceFiveIcon, GameControllerIcon, GhostIcon, IconContext, JoystickIcon, LegoIcon, MedalIcon, PuzzlePieceIcon, RocketIcon, SkullIcon, SoccerBallIcon, StarIcon, StrategyIcon, SwordIcon, TargetIcon, ThumbsUpIcon, TreasureChestIcon, TrophyIcon, UserIcon, VolleyballIcon } from "@phosphor-icons/react";
|
||||
import React, {useEffect} from "react";
|
||||
|
||||
export default function IconBackgroundPattern() {
|
||||
@ -54,29 +29,29 @@ export default function IconBackgroundPattern() {
|
||||
|
||||
return <div ref={containerRef} className="absolute w-full h-full opacity-50">
|
||||
<IconContext.Provider value={{size: iconSize}}>
|
||||
<GameController className="absolute fill-primary top-[8%] left-[8%] rotate-[350deg]"/>
|
||||
<SoccerBall className="absolute fill-primary top-[48%] left-[96%] rotate-[60deg]"/>
|
||||
<Joystick className="absolute top-[28%] left-[52%] rotate-[90deg]"/>
|
||||
<Strategy className="absolute fill-primary top-[52%] left-[68%] rotate-[30deg]"/>
|
||||
<Sword className="absolute top-[72%] left-[12%] rotate-[60deg]"/>
|
||||
<Alien className="absolute fill-primary top-[12%] left-[88%] rotate-[15deg]"/>
|
||||
<CastleTurret className="absolute top-[6%] left-[38%] rotate-[320deg]"/>
|
||||
<Ghost className="absolute fill-primary top-[38%] left-[6%] rotate-[300deg]"/>
|
||||
<Skull className="absolute top-[82%] left-[28%] rotate-[90deg]"/>
|
||||
<Trophy className="absolute fill-primary top-[12%] left-[62%] rotate-[45deg]"/>
|
||||
<Lego className="absolute top-[32%] left-[18%] rotate-[30deg]"/>
|
||||
<TreasureChest className="absolute top-[68%] left-[48%] rotate-[75deg]"/>
|
||||
<Basketball className="absolute fill-primary top-[22%] left-[37%] rotate-[10deg]"/>
|
||||
<Baseball className="absolute top-[92%] left-[82%] rotate-[340deg]"/>
|
||||
<DiceFive className="absolute top-[62%] left-[22%] rotate-[120deg]"/>
|
||||
<Medal className="absolute fill-primary top-[18%] left-[28%] rotate-[300deg]"/>
|
||||
<PuzzlePiece className="absolute top-[42%] left-[78%] rotate-[45deg]"/>
|
||||
<Rocket className="absolute fill-primary top-[88%] left-[52%] rotate-[15deg]"/>
|
||||
<Star className="absolute top-[28%] left-[72%] rotate-[60deg]"/>
|
||||
<Target className="absolute fill-primary top-[68%] left-[62%] rotate-[330deg]"/>
|
||||
<ThumbsUp className="absolute top-[82%] left-[12%] rotate-[80deg]"/>
|
||||
<User className="absolute fill-primary top-[38%] left-[62%] rotate-[20deg]"/>
|
||||
<Volleyball className="absolute top-[78%] left-[92%] rotate-[100deg]"/>
|
||||
<GameControllerIcon className="absolute fill-primary top-[8%] left-[8%] rotate-350"/>
|
||||
<SoccerBallIcon className="absolute fill-primary top-[48%] left-[96%] rotate-60"/>
|
||||
<JoystickIcon className="absolute top-[28%] left-[52%] rotate-90"/>
|
||||
<StrategyIcon className="absolute fill-primary top-[52%] left-[68%] rotate-30"/>
|
||||
<SwordIcon className="absolute top-[72%] left-[12%] rotate-60"/>
|
||||
<AlienIcon className="absolute fill-primary top-[12%] left-[88%] rotate-15"/>
|
||||
<CastleTurretIcon className="absolute top-[6%] left-[38%] rotate-320"/>
|
||||
<GhostIcon className="absolute fill-primary top-[38%] left-[6%] rotate-300"/>
|
||||
<SkullIcon className="absolute top-[82%] left-[28%] rotate-90"/>
|
||||
<TrophyIcon className="absolute fill-primary top-[12%] left-[62%] rotate-45"/>
|
||||
<LegoIcon className="absolute top-[32%] left-[18%] rotate-30"/>
|
||||
<TreasureChestIcon className="absolute top-[68%] left-[48%] rotate-75"/>
|
||||
<BasketballIcon className="absolute fill-primary top-[22%] left-[37%] rotate-10"/>
|
||||
<BaseballIcon className="absolute top-[92%] left-[82%] rotate-340"/>
|
||||
<DiceFiveIcon className="absolute top-[62%] left-[22%] rotate-120"/>
|
||||
<MedalIcon className="absolute fill-primary top-[18%] left-[28%] rotate-300"/>
|
||||
<PuzzlePieceIcon className="absolute top-[42%] left-[78%] rotate-45"/>
|
||||
<RocketIcon className="absolute fill-primary top-[88%] left-[52%] rotate-15"/>
|
||||
<StarIcon className="absolute top-[28%] left-[72%] rotate-60"/>
|
||||
<TargetIcon className="absolute fill-primary top-[68%] left-[62%] rotate-330"/>
|
||||
<ThumbsUpIcon className="absolute top-[82%] left-[12%] rotate-80"/>
|
||||
<UserIcon className="absolute fill-primary top-[38%] left-[62%] rotate-20"/>
|
||||
<VolleyballIcon className="absolute top-[78%] left-[92%] rotate-100"/>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
}
|
||||
@ -3,7 +3,7 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
|
||||
|
||||
export default function RoleChip({role}: { role: string }) {
|
||||
return (
|
||||
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
|
||||
<Chip key={role} size="sm" radius="sm" className={`text-xs ${roleToColor(role)}`}>
|
||||
{roleToRoleName(role)}
|
||||
</Chip>
|
||||
);
|
||||
|
||||
@ -11,16 +11,15 @@ import {
|
||||
} from "@heroui/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {scanState} from "Frontend/state/ScanState";
|
||||
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {Target, Warning} from "@phosphor-icons/react";
|
||||
import {TargetIcon, WarningIcon} from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function ScanProgressPopover() {
|
||||
const libraries = useSnapshot(libraryState).state;
|
||||
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
|
||||
const scans = useSnapshot(scanState).sortedByStartTime;
|
||||
const scanInProgress = useSnapshot(scanState).isScanning;
|
||||
|
||||
// Add state to track current time and force re-renders
|
||||
@ -45,12 +44,12 @@ export default function ScanProgressPopover() {
|
||||
classNames={{
|
||||
spinnerBars: "bg-foreground-500",
|
||||
}}/> :
|
||||
<Target className="fill-foreground-500"/>
|
||||
<TargetIcon className="fill-foreground-500"/>
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="flex flex-col gap-2 m-2 min-w-96 w-fit">
|
||||
<div className="flex flex-col gap-2 m-2 min-w-md">
|
||||
{scans.length === 0 ?
|
||||
<p className="flex h-12 items-center justify-center text-sm text-default-500">
|
||||
No scans in progress or in history.
|
||||
@ -59,12 +58,12 @@ export default function ScanProgressPopover() {
|
||||
{scans.map((scan, index) =>
|
||||
<div className="flex flex-col" key={scan.scanId}>
|
||||
<div
|
||||
className="flex flex-row justify-between items-center text-default-500 mb-1">
|
||||
className="flex flex-row gap-4 justify-between items-center text-default-500 mb-1">
|
||||
<p>{toTitleCase(scan.type)} scan for library
|
||||
<Link underline="always"
|
||||
color="foreground"
|
||||
size="sm"
|
||||
href={`/administration/libraries/library/${scan.libraryId}`}>
|
||||
href={`/administration/games/library/${scan.libraryId}`}>
|
||||
{libraries[scan.libraryId].name}
|
||||
</Link>
|
||||
</p>
|
||||
@ -103,7 +102,7 @@ export default function ScanProgressPopover() {
|
||||
</p>
|
||||
}
|
||||
{scan.status === LibraryScanStatus.FAILED &&
|
||||
<p className="text-danger flex flex-row gap-1"><Warning weight="fill"/>
|
||||
<p className="text-danger flex flex-row gap-1"><WarningIcon weight="fill"/>
|
||||
Scan failed (check logs for details)
|
||||
</p>
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import {Autocomplete, AutocompleteItem} from "@heroui/react";
|
||||
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {CaretRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useNavigate} from "react-router";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
@ -10,7 +9,7 @@ export default function SearchBar() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.games as GameDto[];
|
||||
const games = state.games;
|
||||
|
||||
return <Autocomplete
|
||||
aria-label="Search for games"
|
||||
@ -41,7 +40,7 @@ export default function SearchBar() {
|
||||
},
|
||||
}}
|
||||
placeholder="Type to search..."
|
||||
startContent={<MagnifyingGlass/>}
|
||||
startContent={<MagnifyingGlassIcon/>}
|
||||
isVirtualized={true}
|
||||
maxListboxHeight={300}
|
||||
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
|
||||
@ -54,7 +53,7 @@ export default function SearchBar() {
|
||||
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
|
||||
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
|
||||
</div>
|
||||
<CaretRight/>
|
||||
<CaretRightIcon/>
|
||||
</div>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import {Button, Card, Tooltip} from "@heroui/react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
|
||||
import {SlidersHorizontalIcon} from "@phosphor-icons/react";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
|
||||
interface CollectionOverviewCardProps {
|
||||
collection: CollectionAdminDto;
|
||||
}
|
||||
|
||||
export function CollectionOverviewCard({collection}: CollectionOverviewCardProps) {
|
||||
const MAX_COVER_COUNT = 5;
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.randomlyOrderedGamesByCollectionId) return;
|
||||
setRandomGames(getRandomGames());
|
||||
}, [state]);
|
||||
|
||||
function getRandomGames() {
|
||||
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
|
||||
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
|
||||
.filter(game => game.cover?.id != null);
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col justify-between w-[353px]">
|
||||
<div className="flex flex-1 justify-center items-center">
|
||||
<div className="flex flex-1 opacity-10 min-h-[100px]">
|
||||
<IconBackgroundPattern/>
|
||||
{randomGames.length > 0 &&
|
||||
<div className="absolute flex flex-row">
|
||||
{randomGames.map((game) => (
|
||||
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="absolute text-2xl font-bold">{collection.name}</p>
|
||||
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate('collection/' + collection.id)}>
|
||||
<SlidersHorizontalIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collection.stats &&
|
||||
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
|
||||
<p>Games</p>
|
||||
<p>Downloads</p>
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{collection.stats.gamesCount}</p>
|
||||
<p className="font-bold">{collection.stats.downloadCount}</p>
|
||||
<ChipList items={collection.stats.gamePlatforms} maxVisible={0}
|
||||
defaultContent={collection.stats.gamesCount > 0 ? "All" : "None"}/>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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,15 @@
|
||||
import {Button, Card, Chip, Tooltip} from "@heroui/react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Button, Card, Tooltip} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import {MagnifyingGlass, MagnifyingGlassPlus, SlidersHorizontal} from "@phosphor-icons/react";
|
||||
import {MagnifyingGlassIcon, MagnifyingGlassPlusIcon, SlidersHorizontalIcon} from "@phosphor-icons/react";
|
||||
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
|
||||
interface LibraryOverviewCardProps {
|
||||
library: LibraryAdminDto;
|
||||
@ -22,7 +22,9 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
const randomGames = getRandomGames();
|
||||
|
||||
function getRandomGames() {
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
|
||||
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id]
|
||||
.filter(game => game.cover?.id != null);
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
@ -39,7 +41,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
{randomGames.length > 0 &&
|
||||
<div className="absolute flex flex-row">
|
||||
{randomGames.map((game) => (
|
||||
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
|
||||
<GameCover game={game} size={100} radius="none" key={game.cover?.id}/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
@ -50,17 +52,17 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<MagnifyingGlassPlus/>
|
||||
<MagnifyingGlassPlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||
<SlidersHorizontal/>
|
||||
<SlidersHorizontalIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -73,7 +75,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||
<Chip size="sm">PC</Chip>
|
||||
<ChipList items={library.platforms} maxVisible={0} defaultContent="All"/>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
CheckCircleIcon,
|
||||
IconContext,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Power,
|
||||
Question,
|
||||
QuestionMark,
|
||||
SealCheck,
|
||||
SealQuestion,
|
||||
SealWarning,
|
||||
SlidersHorizontal,
|
||||
StopCircle,
|
||||
WarningCircle,
|
||||
XCircle
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
PowerIcon,
|
||||
QuestionIcon,
|
||||
QuestionMarkIcon,
|
||||
SealCheckIcon,
|
||||
SealQuestionIcon,
|
||||
SealWarningIcon,
|
||||
SlidersHorizontalIcon,
|
||||
StopCircleIcon,
|
||||
WarningCircleIcon,
|
||||
XCircleIcon
|
||||
} from "@phosphor-icons/react";
|
||||
import PluginState from "Frontend/generated/org/pf4j/PluginState";
|
||||
import React, {ReactNode} from "react";
|
||||
@ -54,17 +54,17 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
function stateToIcon(state: PluginState | undefined): ReactNode {
|
||||
switch (state) {
|
||||
case PluginState.STARTED:
|
||||
return <PlayCircle/>;
|
||||
return <PlayCircleIcon/>;
|
||||
case PluginState.DISABLED:
|
||||
return <PauseCircle/>;
|
||||
return <PauseCircleIcon/>;
|
||||
case PluginState.STOPPED:
|
||||
case PluginState.FAILED:
|
||||
return <StopCircle/>;
|
||||
return <StopCircleIcon/>;
|
||||
case PluginState.UNLOADED:
|
||||
case PluginState.RESOLVED:
|
||||
return <XCircle/>;
|
||||
return <XCircleIcon/>;
|
||||
default:
|
||||
return <QuestionMark/>;
|
||||
return <QuestionMarkIcon/>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,19 +73,19 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
case PluginConfigValidationResultType.VALID:
|
||||
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
||||
<CheckCircle/>
|
||||
<CheckCircleIcon/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
case PluginConfigValidationResultType.INVALID:
|
||||
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="danger">
|
||||
<WarningCircle/>
|
||||
<WarningCircleIcon/>
|
||||
</Chip>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs">
|
||||
<Question/>
|
||||
<QuestionIcon/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
}
|
||||
@ -95,23 +95,23 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
switch (trustLevel) {
|
||||
case PluginTrustLevel.OFFICIAL:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
||||
<SealCheck className="fill-success"/>
|
||||
<SealCheckIcon className="fill-success"/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.BUNDLED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
||||
<SealCheck/>
|
||||
<SealCheckIcon/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.THIRD_PARTY:
|
||||
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
||||
<SealWarning/>
|
||||
<SealWarningIcon/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.UNTRUSTED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
|
||||
<SealWarning className="fill-danger"/>
|
||||
<SealWarningIcon className="fill-danger"/>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
||||
<SealQuestion/>
|
||||
<SealQuestionIcon/>
|
||||
</Tooltip>;
|
||||
}
|
||||
}
|
||||
@ -120,11 +120,11 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
return state === PluginState.DISABLED;
|
||||
}
|
||||
|
||||
function togglePluginEnabled() {
|
||||
async function togglePluginEnabled() {
|
||||
if (isDisabled(plugin.state)) {
|
||||
PluginEndpoint.enablePlugin(plugin.id);
|
||||
await PluginEndpoint.enablePlugin(plugin.id);
|
||||
} else {
|
||||
PluginEndpoint.disablePlugin(plugin.id);
|
||||
await PluginEndpoint.disablePlugin(plugin.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,12 +141,12 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
onPress={() => togglePluginEnabled()}
|
||||
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
|
||||
>
|
||||
<Power/>
|
||||
<PowerIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
|
||||
<SlidersHorizontal/>
|
||||
<SlidersHorizontalIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import {Card, Chip, Image} from "@heroui/react";
|
||||
import React, {useMemo} from "react";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import Rand from "rand-seed";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
|
||||
interface StartPageDisplayCardProps {
|
||||
item: LibraryDto | CollectionDto;
|
||||
}
|
||||
|
||||
export function StartPageDisplayCard({item}: StartPageDisplayCardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isCollection = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is CollectionDto => {
|
||||
return 'description' in libraryOrCollection;
|
||||
};
|
||||
|
||||
const isLibrary = (libraryOrCollection: LibraryDto | CollectionDto): libraryOrCollection is LibraryDto => {
|
||||
return !('description' in libraryOrCollection);
|
||||
};
|
||||
|
||||
const gamesState = useSnapshot(gameState);
|
||||
const randomImageId = useMemo<number | null>(() => getRandomImageId(), [item]);
|
||||
const link = useMemo<string>(() => getLink(), [item]);
|
||||
const type = isCollection(item) ? 'Collection' : 'Library';
|
||||
|
||||
/**
|
||||
* Gets a random cover ID from the games in the specified library or collection.
|
||||
* Since the Random class is seeded with the game ID, the same game and image will always be selected for a given library/collection (unless the games inside change).
|
||||
* @return {number | null} The random cover ID or null if none found.
|
||||
*/
|
||||
function getRandomImageId(): number | null {
|
||||
let games: GameDto[] = [];
|
||||
|
||||
if (isCollection(item)) {
|
||||
games = gamesState.randomlyOrderedGamesByCollectionId[item.id] as GameDto[];
|
||||
} else if (isLibrary(item)) {
|
||||
games = gamesState.randomlyOrderedGamesByLibraryId[item.id] as GameDto[];
|
||||
}
|
||||
|
||||
if (!games || games.length == 0) return null;
|
||||
|
||||
// Find the first game that has at least one screenshot available
|
||||
let game: GameDto | undefined = games.find(game => game.images && game.images.length > 0);
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
const random = new Rand(`${item.id}-${game.id}`);
|
||||
const randomImageIndex = Math.floor(random.next() * game.images!.length);
|
||||
return game.images![randomImageIndex].id;
|
||||
}
|
||||
|
||||
function getLink(): string {
|
||||
if (isCollection(item)) {
|
||||
return `/collection/${item.id}`;
|
||||
} else if (isLibrary(item)) {
|
||||
return `/library/${item.id}`;
|
||||
}
|
||||
return '#';
|
||||
}
|
||||
|
||||
return randomImageId && (
|
||||
<Card isPressable={true}
|
||||
onPress={() => navigate(link)}
|
||||
className="h-48 w-96 relative overflow-hidden scale-95 hover:scale-100 shine transition-all select-none">
|
||||
<Image
|
||||
src={`images/cover/${randomImageId}`}
|
||||
className="absolute inset-0 w-full h-full object-cover brightness-40 z-0"
|
||||
removeWrapper
|
||||
/>
|
||||
<div className="flex flex-col gap-1 relative z-10 items-center justify-center h-full">
|
||||
<h2 className="text-white text-2xl font-bold text-center px-4">
|
||||
{item.name}
|
||||
</h2>
|
||||
<Chip size="sm" radius="sm">{type}</Chip>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
|
||||
import {DotsThreeVertical} from "@phosphor-icons/react";
|
||||
import {DotsThreeVerticalIcon} from "@phosphor-icons/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
||||
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import RoleChip from "Frontend/components/general/RoleChip";
|
||||
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||
@ -112,7 +112,7 @@ export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
|
||||
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light">
|
||||
<DotsThreeVertical/>
|
||||
<DotsThreeVerticalIcon/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import CollectionAdminDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionAdminDto";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import {Card} from "@heroui/react";
|
||||
|
||||
interface CollectionHeaderProps {
|
||||
collection: CollectionAdminDto;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CollectionHeader({collection, className}: CollectionHeaderProps) {
|
||||
const MAX_COVER_COUNT = 5;
|
||||
const state = useSnapshot(gameState);
|
||||
const [randomGames, setRandomGames] = useState<GameDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.randomlyOrderedGamesByCollectionId) return;
|
||||
setRandomGames(getRandomGames());
|
||||
}, [state]);
|
||||
|
||||
function getRandomGames() {
|
||||
if (!state.randomlyOrderedGamesByCollectionId[collection.id]) return [];
|
||||
const games = state.randomlyOrderedGamesByCollectionId[collection.id]
|
||||
.filter(game => game.images && game.images.length > 0);
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
|
||||
<IconBackgroundPattern/>
|
||||
<div className="flex flex-row items-center w-full h-full brightness-50">
|
||||
{randomGames.map((game, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex-none overflow-hidden -ml-[10%]"
|
||||
style={{
|
||||
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
|
||||
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/images/screenshot/${game.images![0].id}`}
|
||||
alt={`Image ${idx}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<h2 className="text-white text-3xl font-bold">{collection.name}</h2>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,122 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import {Grid} from "react-window";
|
||||
import React, {useCallback, useEffect, useRef, useState} from "react";
|
||||
|
||||
interface CoverGridProps {
|
||||
games: GameDto[];
|
||||
}
|
||||
|
||||
interface GridCellProps {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
games: GameDto[];
|
||||
columnCount: number;
|
||||
coverHeight: number;
|
||||
}
|
||||
|
||||
// Constants for grid layout
|
||||
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
|
||||
const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original)
|
||||
const GAP = 16; // gap-4 = 1rem = 16px
|
||||
const ASPECT_RATIO = 12 / 17; // Game cover aspect ratio (width/height)
|
||||
|
||||
export default function CoverGrid({games}: CoverGridProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// Update container width on resize
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
updateDimensions();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate how many columns can fit
|
||||
const columnCount = Math.max(1, Math.floor((containerWidth + GAP) / (MIN_COLUMN_WIDTH + GAP)));
|
||||
|
||||
// Calculate actual column width to distribute space evenly (up to MAX_COLUMN_WIDTH)
|
||||
const actualColumnWidth = Math.min(
|
||||
MAX_COLUMN_WIDTH,
|
||||
Math.floor((containerWidth - (columnCount - 1) * GAP) / columnCount)
|
||||
);
|
||||
|
||||
// Calculate cover height based on width and aspect ratio
|
||||
// GameCover's size prop is the height, so we need to calculate height from width
|
||||
const coverHeight = Math.floor(actualColumnWidth / ASPECT_RATIO);
|
||||
|
||||
// Calculate row count
|
||||
const rowCount = Math.ceil(games.length / columnCount);
|
||||
|
||||
|
||||
// Cell renderer for react-window Grid
|
||||
const Cell = useCallback(({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
games: gamesData,
|
||||
columnCount: colCount,
|
||||
coverHeight: height
|
||||
}: GridCellProps) => {
|
||||
const gameIndex = rowIndex * colCount + columnIndex;
|
||||
|
||||
// Return empty cell if we're past the end of the games array
|
||||
if (gameIndex >= gamesData.length) {
|
||||
return <div style={style}/>;
|
||||
}
|
||||
|
||||
const game = gamesData[gameIndex];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
paddingBottom: GAP,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<GameCover key={game.id} game={game} interactive={true} size={height} lazy={true}/>
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Column width function to handle the last column differently
|
||||
const getColumnWidth = (index: number) => {
|
||||
// Last column doesn't need gap after it
|
||||
if (index === columnCount - 1) {
|
||||
return actualColumnWidth;
|
||||
}
|
||||
return actualColumnWidth + GAP;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
|
||||
{games.map((game) => (
|
||||
<GameCover key={game.id} game={game} interactive={true}/>
|
||||
))}
|
||||
<div ref={containerRef} className="w-full">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{ games: GameDto[], columnCount: number, coverHeight: number }>
|
||||
columnCount={columnCount}
|
||||
columnWidth={getColumnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={coverHeight + GAP}
|
||||
defaultWidth={containerWidth}
|
||||
cellComponent={Cell}
|
||||
cellProps={{games, columnCount, coverHeight}}
|
||||
style={{overflowX: 'hidden'}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,68 +1,169 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import React, {useCallback, useEffect, useRef, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import {useNavigate} from "react-router";
|
||||
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
|
||||
import {Button, Link} from "@heroui/react";
|
||||
import {Grid, GridImperativeAPI} from "react-window";
|
||||
|
||||
interface CoverRowProps {
|
||||
games: GameDto[];
|
||||
title: string;
|
||||
onPressShowMore: () => void;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||
const defaultImageHeight = 300; // default height for the image
|
||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||
const gap = 8; // gap between items in pixels (gap-2 = 0.5rem = 8px)
|
||||
|
||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
export function CoverRow({games, title, link}: CoverRowProps) {
|
||||
const gridRef = useRef<GridImperativeAPI | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(games.length);
|
||||
|
||||
// Update container width on resize
|
||||
useEffect(() => {
|
||||
const calculateVisible = () => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
|
||||
setVisibleCount(maxFit < games.length ? maxFit : games.length);
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisible);
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
calculateVisible(); // initial calculation
|
||||
updateWidth();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [games.length]);
|
||||
}, []);
|
||||
|
||||
const showMore = visibleCount < games.length;
|
||||
// Handle scroll updates - track scroll position from the grid element
|
||||
useEffect(() => {
|
||||
let gridElement: HTMLDivElement | null = null;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (gridElement) {
|
||||
setScrollPosition(gridElement.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure grid is mounted
|
||||
const timer = setTimeout(() => {
|
||||
gridElement = gridRef.current?.element ?? null;
|
||||
if (gridElement) {
|
||||
gridElement.addEventListener('scroll', handleScroll);
|
||||
// Initial scroll position
|
||||
setScrollPosition(gridElement.scrollLeft);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (gridElement) {
|
||||
gridElement.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, [containerWidth, games.length]);
|
||||
|
||||
const totalWidth = games.length * (defaultImageWidth + gap);
|
||||
const maxScroll = Math.max(0, totalWidth - containerWidth);
|
||||
|
||||
const scrollLeft = () => {
|
||||
const gridElement = gridRef.current?.element;
|
||||
if (gridElement) {
|
||||
const itemWidth = defaultImageWidth + gap;
|
||||
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
|
||||
const newPosition = Math.max(0, scrollPosition - scrollAmount);
|
||||
gridElement.scrollTo({
|
||||
left: newPosition,
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
const gridElement = gridRef.current?.element;
|
||||
if (gridElement) {
|
||||
const itemWidth = defaultImageWidth + gap;
|
||||
const scrollAmount = itemWidth * 3; // Scroll exactly 3 items
|
||||
const newPosition = Math.min(maxScroll, scrollPosition + scrollAmount);
|
||||
gridElement.scrollTo({
|
||||
left: newPosition,
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
|
||||
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
|
||||
|
||||
// Define interface for Cell props
|
||||
interface RowCellProps {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: React.CSSProperties;
|
||||
games: GameDto[];
|
||||
}
|
||||
|
||||
// Cell renderer for react-window Grid
|
||||
const Cell = useCallback(({columnIndex, style, games: gamesData}: RowCellProps) => {
|
||||
const game = gamesData[columnIndex];
|
||||
return (
|
||||
<div style={{...style, paddingRight: gap}}>
|
||||
<GameCover key={game.id} game={game} radius="sm" interactive={true}/>
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-4">
|
||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||
<div className="w-full relative">
|
||||
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
|
||||
{games.slice(0, visibleCount).map((game, index) => (
|
||||
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||
))}
|
||||
<div className="flex flex-row justify-between items-baseline mb-4">
|
||||
<Link href={link} className="flex flex-row gap-1 w-fit items-baseline" color="foreground"
|
||||
underline="hover">
|
||||
<p className="text-2xl font-bold">{title}</p>
|
||||
<CaretRightIcon weight="bold" size={16}/>
|
||||
</Link>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={scrollLeft}
|
||||
isDisabled={!canScrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<CaretLeftIcon weight="bold" size={20}/>
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={scrollRight}
|
||||
isDisabled={!canScrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<CaretRightIcon weight="bold" size={20}/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showMore && (
|
||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||
onClick={onPressShowMore}>
|
||||
<div className="absolute h-full w-1/4 right-0 bottom-0
|
||||
bg-gradient-to-r from-transparent to-background
|
||||
transition-all duration-300 ease-in-out hover:opacity-80"/>
|
||||
<div
|
||||
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
|
||||
<p className="text-xl font-semibold">Show more</p>
|
||||
<ArrowRight weight="bold"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="w-full relative overflow-hidden">
|
||||
{containerWidth > 0 && (
|
||||
<Grid<{ games: GameDto[] }>
|
||||
gridRef={gridRef}
|
||||
columnCount={games.length}
|
||||
columnWidth={defaultImageWidth + gap}
|
||||
rowCount={1}
|
||||
rowHeight={defaultImageHeight}
|
||||
defaultHeight={defaultImageHeight}
|
||||
defaultWidth={containerWidth}
|
||||
cellComponent={Cell}
|
||||
cellProps={{games}}
|
||||
className="scrollbar-hide"
|
||||
style={{overflow: 'auto'}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,117 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Image} from "@heroui/react";
|
||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
||||
import {memo, useEffect, useRef, useState} from "react";
|
||||
import {decode} from "blurhash";
|
||||
|
||||
// Cache to track which images have been loaded across component remounts
|
||||
const loadedImagesCache = new Set<number>();
|
||||
|
||||
interface GameCoverProps {
|
||||
game: GameDto;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||
const coverContent = Number.isInteger(game.coverId) ? (
|
||||
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) => {
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy);
|
||||
// Check cache to see if this image has already been loaded
|
||||
const isCached = game.cover ? loadedImagesCache.has(game.cover.id) : false;
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
|
||||
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const prevCoverIdRef = useRef<number | undefined>(game.cover?.id);
|
||||
|
||||
// Reset state when cover ID changes
|
||||
useEffect(() => {
|
||||
const currentCoverId = game.cover?.id;
|
||||
if (prevCoverIdRef.current !== currentCoverId) {
|
||||
prevCoverIdRef.current = currentCoverId;
|
||||
const newIsCached = currentCoverId ? loadedImagesCache.has(currentCoverId) : false;
|
||||
setIsImageLoaded(newIsCached);
|
||||
setBlurhashUrl(undefined);
|
||||
setShouldLoad(!lazy);
|
||||
}
|
||||
}, [game.cover?.id, lazy]);
|
||||
|
||||
// Generate blurhash placeholder image
|
||||
useEffect(() => {
|
||||
if (game.cover?.blurhash && !blurhashUrl) {
|
||||
try {
|
||||
// Decode blurhash to pixel data
|
||||
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
|
||||
|
||||
// Create canvas and draw pixels
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 45;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(32, 45);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Convert canvas to data URL
|
||||
setBlurhashUrl(canvas.toDataURL());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decode blurhash:', e);
|
||||
}
|
||||
}
|
||||
}, [game.cover?.blurhash, blurhashUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazy || shouldLoad) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '200px', // Start loading 200px before the element enters viewport
|
||||
}
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [lazy, shouldLoad]);
|
||||
|
||||
// Preload the real image when shouldLoad becomes true
|
||||
useEffect(() => {
|
||||
if (!shouldLoad || !game.cover || isImageLoaded) return;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `images/cover/${game.cover.id}`;
|
||||
img.onload = () => {
|
||||
loadedImagesCache.add(game.cover!.id);
|
||||
setIsImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
// If image fails to load, we'll just show the fallback
|
||||
setIsImageLoaded(true);
|
||||
};
|
||||
}, [shouldLoad, game.cover, isImageLoaded]);
|
||||
|
||||
const coverContent = game.cover ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}
|
||||
>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-[12/17]"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
className="z-0 object-cover aspect-12/17"
|
||||
src={(shouldLoad || isCached) && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
@ -30,4 +126,15 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false}
|
||||
{coverContent}
|
||||
</a>
|
||||
) : coverContent;
|
||||
}
|
||||
};
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
// Only re-render if the game ID, cover ID, size, radius, interactive, or lazy props change
|
||||
export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => {
|
||||
return prevProps.game.id === nextProps.game.id &&
|
||||
prevProps.game.cover?.id === nextProps.game.cover?.id &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.radius === nextProps.radius &&
|
||||
prevProps.interactive === nextProps.interactive &&
|
||||
prevProps.lazy === nextProps.lazy;
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import "swiper/css/autoplay";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
|
||||
import { CaretLeftIcon, CaretRightIcon, IconContext, PlayIcon } from "@phosphor-icons/react";
|
||||
|
||||
|
||||
interface ImageCarouselProps {
|
||||
@ -61,7 +61,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<div className="w-full flex flex-col gap-2 items-center">
|
||||
<div className="w-full flex flex-row items-center">
|
||||
<IconContext.Provider value={{size: 50}}>
|
||||
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
|
||||
<CaretLeftIcon className="swiper-custom-button-prev cursor-pointer fill-primary"/>
|
||||
<Swiper
|
||||
modules={[Pagination, Navigation, Autoplay]}
|
||||
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
|
||||
@ -90,14 +90,14 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<Image
|
||||
src={e.url}
|
||||
alt={`Game screenshot slide ${index}`}
|
||||
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
|
||||
className={`w-full h-full object-cover aspect-video cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
|
||||
onClick={() => showImagePopup(e.url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
|
||||
className={`w-full h-full aspect-video ${!isActive ? "scale-90" : ""}`}>
|
||||
<ReactPlayer
|
||||
url={e.url}
|
||||
width="100%"
|
||||
@ -105,7 +105,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
light={true}
|
||||
controls={true}
|
||||
playing={isActive}
|
||||
playIcon={<Play weight="fill"/>}
|
||||
playIcon={<PlayIcon weight="fill"/>}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
@ -115,7 +115,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
|
||||
onOpenChange={imagePopup.onOpenChange}/>
|
||||
</Swiper>
|
||||
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||
<CaretRightIcon className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
<div>
|
||||
@ -137,7 +137,7 @@ function ImagePopup({imageUrl, isOpen, onOpenChange}: {
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
|
||||
<ModalContent className="bg-transparent">
|
||||
{(onClose) => (
|
||||
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
|
||||
<div className="flex grow items-center justify-center cursor-zoom-out"
|
||||
onClick={onClose}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import React from "react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
@ -17,7 +16,9 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
|
||||
const randomGames = getRandomGames();
|
||||
|
||||
function getRandomGames() {
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
|
||||
if (!state.randomlyOrderedGamesByLibraryId[library.id]) return [];
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id]
|
||||
.filter(game => game.images && game.images.length > 0);
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
@ -36,7 +37,7 @@ export default function LibraryHeader({library, className}: LibraryHeaderProps)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/images/screenshot/${game.imageIds![0]}`}
|
||||
src={`/images/screenshot/${game.images![0].id}`}
|
||||
alt={`Image ${idx}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {FieldArray, useField} from "formik";
|
||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {KeyboardEvent, useState} from "react";
|
||||
import {Plus} from "@phosphor-icons/react";
|
||||
import {PlusIcon} from "@phosphor-icons/react";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
@ -35,13 +35,23 @@ const ArrayInput = ({label, ...props}) => {
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
{field.value.map((element: any, index: number) => (
|
||||
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
|
||||
<Chip key={index}
|
||||
onClose={() => arrayHelpers.remove(index)}
|
||||
isDisabled={props.isDisabled}
|
||||
>
|
||||
{element}
|
||||
</Chip>
|
||||
))}
|
||||
<Popover placement="bottom" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
|
||||
<Button isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isDisabled={props.isDisabled}
|
||||
>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Input
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
import React, {Key, useEffect, useState} from "react";
|
||||
import {Autocomplete, AutocompleteItem, Chip} from "@heroui/react";
|
||||
import {FieldArray, useField} from "formik";
|
||||
|
||||
type ArrayInputAutocompleteProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: string[];
|
||||
name: string;
|
||||
defaultSelected?: string[];
|
||||
};
|
||||
|
||||
export default function ArrayInputAutocomplete({
|
||||
options,
|
||||
label,
|
||||
placeholder = "Search...",
|
||||
defaultSelected = [],
|
||||
...props
|
||||
}: ArrayInputAutocompleteProps) {
|
||||
const [field, meta, helpers] = useField(props);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Initialize field value if undefined or empty
|
||||
useEffect(() => {
|
||||
if (!field.value) {
|
||||
helpers.setValue(defaultSelected.length > 0 ? defaultSelected : []);
|
||||
} else if (field.value.length === 0 && defaultSelected.length > 0) {
|
||||
helpers.setValue(defaultSelected);
|
||||
}
|
||||
}, [defaultSelected, field.value, helpers]);
|
||||
|
||||
return (
|
||||
<FieldArray name={field.name}
|
||||
render={arrayHelpers => {
|
||||
const selectedValues = field.value || [];
|
||||
const filteredOptions = options.filter(
|
||||
(option) =>
|
||||
option.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!selectedValues.find((selected: string) => selected === option),
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (!selectedValues.find((selected: string) => selected === item)) {
|
||||
arrayHelpers.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
arrayHelpers.remove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
{label && (
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<small>{selectedValues.length} {selectedValues.length === 1 ? "element" : "elements"} selected</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Autocomplete
|
||||
{...props}
|
||||
aria-labelledby="search"
|
||||
shouldCloseOnBlur={false}
|
||||
placeholder={placeholder}
|
||||
inputValue={search}
|
||||
onInputChange={(value) => setSearch(value)}
|
||||
onSelectionChange={(value: Key | null) => {
|
||||
const item = options.find((option) => option === value);
|
||||
if (item) handleSelect(item);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option) => (
|
||||
<AutocompleteItem key={option} data-selected="true">
|
||||
{option}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedValues.map((item: string, index: number) => (
|
||||
<Chip key={index} variant="flat"
|
||||
onClose={() => handleRemove(index)}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && meta.error.trim().length > 0 && (
|
||||
meta.error
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -8,7 +8,7 @@ import {
|
||||
DropdownTrigger,
|
||||
SharedSelection
|
||||
} from "@heroui/react";
|
||||
import {CaretDown} from "@phosphor-icons/react";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||
|
||||
export interface ComboButtonOption {
|
||||
@ -52,7 +52,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
||||
}
|
||||
|
||||
return options[selectedOptionValue] && (
|
||||
<ButtonGroup className="gap-[1px]">
|
||||
<ButtonGroup className="gap-px">
|
||||
<Button color="primary" className="w-52"
|
||||
onPress={options[selectedOptionValue].action}>
|
||||
<div className="flex flex-col items-center">
|
||||
@ -63,7 +63,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly color="primary">
|
||||
<CaretDown/>
|
||||
<CaretDownIcon/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
|
||||
@ -11,7 +11,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
className="min-h-20 flex-grow"
|
||||
className="min-h-20 grow"
|
||||
showMonthAndYearPickers
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import {Button, Code, useDisclosure} from "@heroui/react";
|
||||
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
|
||||
import { ArrowRightIcon, MinusIcon, PlusIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
||||
@ -28,7 +28,7 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
<p className="font-bold">Directories</p>
|
||||
<Button isIconOnly variant="light" size="sm" color="default"
|
||||
onPress={pathPickerModal.onOpen}>
|
||||
<Plus/>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{(field.value || []).map((directory) => (
|
||||
@ -43,8 +43,8 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
/>
|
||||
{directory.externalPath && (
|
||||
<>
|
||||
<div className="flex-shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRight size={20}/>
|
||||
<div className="shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRightIcon size={20}/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@ -62,13 +62,13 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
onPress={() => removeDirectoryMapping(directory)}
|
||||
className="ml-2"
|
||||
>
|
||||
<Minus/>
|
||||
<MinusIcon/>
|
||||
</Button>
|
||||
</Code>
|
||||
))}
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && (
|
||||
<SmallInfoField icon={XCircle} message={meta.error}/>
|
||||
<SmallInfoField icon={XCircleIcon} message={meta.error}/>
|
||||
)}
|
||||
</div>
|
||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
|
||||
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react";
|
||||
import {
|
||||
FileIcon as PhFileIcon,
|
||||
FolderIcon as PhFolderIcon,
|
||||
FolderOpenIcon as PhFolderOpenIcon,
|
||||
IconContext
|
||||
} from "@phosphor-icons/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
|
||||
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
|
||||
@ -72,7 +77,7 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
|
||||
if (subDirectories === undefined) return;
|
||||
|
||||
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
|
||||
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
|
||||
const updatedTree = updateTreeWithNewNodes(fileTree!, element.id, newNodes);
|
||||
|
||||
setFileTree(updatedTree);
|
||||
setFlattenedFileTree(flattenTree(updatedTree));
|
||||
@ -146,9 +151,9 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
|
||||
}
|
||||
|
||||
function FolderIcon({isOpen}: { isOpen: boolean }) {
|
||||
return isOpen ? <FolderOpen/> : <Folder/>;
|
||||
return isOpen ? <PhFolderOpenIcon/> : <PhFolderIcon/>;
|
||||
}
|
||||
|
||||
function FileIcon({fileName}: { fileName: string }) {
|
||||
return <File/>;
|
||||
return <PhFileIcon/>;
|
||||
}
|
||||
@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
@ -14,14 +14,14 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
||||
const gameCoverPickerModal = useDisclosure();
|
||||
|
||||
return (<>
|
||||
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50"
|
||||
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
|
||||
onClick={gameCoverPickerModal.onOpenChange}>
|
||||
{field.value || game.coverId ?
|
||||
{field.value || game.cover?.id ?
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={field.value ? field.value : `images/cover/${game.cover?.id}`}
|
||||
{...props}
|
||||
{...field}
|
||||
radius="none"
|
||||
@ -30,13 +30,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
||||
>
|
||||
<ImageBroken size={46}/>
|
||||
<ImageBrokenIcon size={46}/>
|
||||
<p>No cover image available</p>
|
||||
</div>}
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={46}/>
|
||||
<PencilIcon size={46}/>
|
||||
<p>Edit cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {Image, useDisclosure} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
|
||||
|
||||
|
||||
@ -16,12 +16,12 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
||||
return (<>
|
||||
<div className="relative group size-full cursor-pointer bg-background/50"
|
||||
onClick={gameHeaderPickerModal.onOpenChange}>
|
||||
{field.value || game.headerId ?
|
||||
{field.value || game.header?.id ?
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
src={field.value ? field.value : `images/cover/${game.headerId}`}
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={field.value ? field.value : `images/cover/${game.header?.id}`}
|
||||
{...props}
|
||||
{...field}
|
||||
radius="none"
|
||||
@ -30,13 +30,13 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
||||
>
|
||||
<ImageBroken size={46}/>
|
||||
<ImageBrokenIcon size={46}/>
|
||||
<p>No header image available</p>
|
||||
</div>}
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={46}/>
|
||||
<PencilIcon size={46}/>
|
||||
<p>Edit header image</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useField} from "formik";
|
||||
import {Input as NextUiInput} from "@heroui/react";
|
||||
import {Input as HeroUiInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
@ -7,8 +7,8 @@ const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<NextUiInput
|
||||
className="min-h-20 flex-grow"
|
||||
<HeroUiInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import {useField} from "formik";
|
||||
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiNumberInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
value={field.value}
|
||||
onValueChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberInput;
|
||||
@ -9,7 +9,7 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
const items = values.map((v: string) => ({key: v, label: v}));
|
||||
|
||||
return (
|
||||
<div className="min-h-20 flex-grow">
|
||||
<div className="min-h-20 grow">
|
||||
<Select
|
||||
fullWidth={true}
|
||||
{...field}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import {useField} from "formik";
|
||||
import {Slider as HeroUiSlider} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiSlider
|
||||
className="min-h-20 grow"
|
||||
{...props}
|
||||
value={field.value}
|
||||
onChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SliderInput;
|
||||
@ -8,7 +8,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {Check} from "@phosphor-icons/react";
|
||||
import {CheckIcon} from "@phosphor-icons/react";
|
||||
import {addToast, Button} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {Form, Formik} from "formik";
|
||||
@ -11,6 +11,10 @@ import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMa
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {useNavigate} from "react-router";
|
||||
import * as Yup from "yup";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||
|
||||
interface LibraryManagementDetailsProps {
|
||||
library: LibraryDto;
|
||||
@ -19,6 +23,7 @@ interface LibraryManagementDetailsProps {
|
||||
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [librarySaved, setLibrarySaved] = React.useState(false);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
async function handleSubmit(values: LibraryDto): Promise<void> {
|
||||
const changed = deepDiff(library, values) as LibraryUpdateDto;
|
||||
@ -41,7 +46,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
color: "success"
|
||||
});
|
||||
|
||||
navigate("/administration/libraries");
|
||||
navigate("/administration/games");
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error deleting library",
|
||||
@ -66,7 +71,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
>
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-4">
|
||||
<div className="flex flex-row grow justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Edit library details</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
@ -74,12 +79,16 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
|
||||
{formik.isSubmitting ? "" : librarySaved ? <CheckIcon/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input label="Library name" name="name"/>
|
||||
|
||||
<CheckboxInput label="Display on homepage" name="metadata.displayOnHomepage" className="mb-4"/>
|
||||
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
|
||||
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
|
||||
<Section title="Danger zone"/>
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
|
||||
import {CheckCircleIcon, MagnifyingGlassIcon, PencilIcon, TrashIcon} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
@ -28,6 +28,7 @@ import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
|
||||
import {metadataCompleteness} from "Frontend/util/utils";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
|
||||
interface LibraryManagementGamesProps {
|
||||
library: LibraryDto;
|
||||
@ -37,12 +38,12 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
const rowsPerPage = 25;
|
||||
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameAdminDto[] : [];
|
||||
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] : [];
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
|
||||
|
||||
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0]);
|
||||
const [selectedGame, setSelectedGame] = useState<GameAdminDto>(games[0] as GameAdminDto);
|
||||
const editGameModal = useDisclosure();
|
||||
const matchGameModal = useDisclosure();
|
||||
|
||||
@ -93,7 +94,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
|
||||
function getFilteredGames() {
|
||||
let filteredGames = (games as GameAdminDto[]).filter((game) =>
|
||||
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.metadata.path!.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
@ -101,10 +102,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
|
||||
if (filter === "confirmed") {
|
||||
return filteredGames.filter(g => g.metadata.matchConfirmed);
|
||||
}
|
||||
if (filter === "nonConfirmed") {
|
||||
} else if (filter === "nonConfirmed") {
|
||||
return filteredGames.filter(g => !g.metadata.matchConfirmed);
|
||||
}
|
||||
|
||||
return filteredGames;
|
||||
}
|
||||
|
||||
@ -162,6 +163,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="title" allowsSorting>Game</TableColumn>
|
||||
<TableColumn key="platforms">Platforms</TableColumn>
|
||||
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
||||
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
||||
<TableColumn>Path</TableColumn>
|
||||
@ -176,9 +178,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<Link href={`/game/${item.id}`}
|
||||
color="foreground"
|
||||
className="text-sm"
|
||||
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||
underline="hover">
|
||||
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChipList items={item.platforms} maxVisible={1} defaultContent="Unspecified"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
@ -196,10 +202,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
||||
{item.metadata.matchConfirmed ?
|
||||
<Tooltip content="Unconfirm match">
|
||||
<CheckCircle weight="fill" className="fill-success"/>
|
||||
<CheckCircleIcon weight="fill" className="fill-success"/>
|
||||
</Tooltip> :
|
||||
<Tooltip content="Confirm match">
|
||||
<CheckCircle/>
|
||||
<CheckCircleIcon/>
|
||||
</Tooltip>}
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
@ -207,7 +213,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
editGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Edit metadata">
|
||||
<Pencil/>
|
||||
<PencilIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
@ -215,13 +221,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Match game">
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteGame(item)}>
|
||||
<Tooltip content="Remove from library">
|
||||
<Trash/>
|
||||
<TrashIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
@ -233,7 +239,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<EditGameMetadataModal game={selectedGame}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal path={selectedGame.metadata.path!!}
|
||||
<MatchGameModal path={selectedGame.metadata.path!}
|
||||
libraryId={library.id}
|
||||
replaceGameId={selectedGame.id}
|
||||
initialSearchTerm={selectedGame.title}
|
||||
|
||||
@ -12,35 +12,45 @@ import {
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
|
||||
import {MagnifyingGlassIcon, TrashIcon} from "@phosphor-icons/react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {useMemo, useState} from "react";
|
||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
|
||||
import {fileNameFromPath} from "Frontend/util/utils";
|
||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import IgnoredPathDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathDto";
|
||||
import IgnoredPathSourceTypeDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathSourceTypeDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import {userState} from "Frontend/state/UserState";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface LibraryManagementUnmatchedPathsProps {
|
||||
interface LibraryManagementIgnoredPathsProps {
|
||||
library: LibraryAdminDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
|
||||
export default function LibraryManagementIgnoredPaths({library}: LibraryManagementIgnoredPathsProps) {
|
||||
const plugins = useSnapshot(pluginState).state;
|
||||
const users = useSnapshot(userState).state;
|
||||
|
||||
const matchGameModal = useDisclosure();
|
||||
const [page, setPage] = useState(1);
|
||||
const rowsPerPage = 25;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
|
||||
const [selectedPath, setSelectedPath] = useState(library.ignoredPaths ? library.ignoredPaths[0] : null);
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(getFilteredPaths().length / rowsPerPage);
|
||||
}, [library.unmatchedPaths, searchTerm]);
|
||||
}, [library.ignoredPaths, searchTerm]);
|
||||
|
||||
const filteredPaths = useMemo(() => {
|
||||
return library.unmatchedPaths!
|
||||
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: hashCode(path), path}));
|
||||
return library.ignoredPaths!
|
||||
.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: path.id, path}));
|
||||
}, [library, searchTerm]);
|
||||
|
||||
const sortedPaths = useMemo(() => {
|
||||
@ -48,7 +58,7 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
let cmp: number;
|
||||
switch (sortDescriptor.column) {
|
||||
case "path":
|
||||
cmp = a.path.localeCompare(b.path);
|
||||
cmp = a.path.path.localeCompare(b.path.path);
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
@ -66,22 +76,44 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
return sortedPaths.slice(start, end);
|
||||
}, [page, sortedPaths]);
|
||||
|
||||
async function deleteUnmatchedPath(unmatchedPath: string) {
|
||||
async function deleteIgnoredPath(ignoredPath: IgnoredPathDto) {
|
||||
const libraryUpdateDto: LibraryUpdateDto = {
|
||||
id: library.id,
|
||||
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
|
||||
ignoredPaths: library.ignoredPaths!.filter((path) => path.id !== ignoredPath.id)
|
||||
}
|
||||
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
||||
}
|
||||
|
||||
function getFilteredPaths() {
|
||||
return library.unmatchedPaths!!.filter((path) =>
|
||||
path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return library.ignoredPaths!.filter((path) =>
|
||||
path.path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
function renderSource(ignoredPath: IgnoredPathDto) {
|
||||
if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.USER) {
|
||||
const userId = Number(ignoredPath.source);
|
||||
const user = users[userId];
|
||||
return user ? `Manually added by user (${user.username})` : "Unknown user";
|
||||
} else if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.PLUGIN) {
|
||||
const pluginIds: string[] = JSON.parse(ignoredPath.source)
|
||||
return pluginIds ?
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p>Automatically added by plugins (</p>
|
||||
{pluginIds.map(id => {
|
||||
const p = plugins[id];
|
||||
return p ? <PluginIcon key={id} plugin={p as PluginDto}/>
|
||||
: "Unknown plugin";
|
||||
})}
|
||||
<p>)</p>
|
||||
</div>
|
||||
: "Unknown plugins"
|
||||
}
|
||||
return ignoredPath.source;
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
|
||||
<h1 className="text-2xl font-bold">Manage ignored paths</h1>
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
@ -109,13 +141,17 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="path" allowsSorting>Path</TableColumn>
|
||||
<TableColumn key="source">Source</TableColumn>
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
|
||||
<TableBody emptyContent="This library has no ignored paths." items={pagedPaths}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
<TableCell>
|
||||
{item.path}
|
||||
{item.path.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderSource(item.path)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
@ -124,12 +160,15 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
setSelectedPath(item.path);
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove entry from list">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
||||
onPress={() => deleteIgnoredPath(item.path)}
|
||||
isDisabled={item.path.sourceType !== IgnoredPathSourceTypeDto.USER}
|
||||
>
|
||||
<TrashIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -138,9 +177,9 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{selectedPath && <MatchGameModal path={selectedPath}
|
||||
{selectedPath && <MatchGameModal path={selectedPath.path}
|
||||
libraryId={library.id}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath, false)}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath.path, false)}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
}
|
||||
@ -83,7 +83,7 @@ export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRol
|
||||
placeholder="Select roles"
|
||||
renderValue={(items: SelectedItems<Role>) => {
|
||||
return (
|
||||
<div className="flex flex-grow flex-wrap gap-2">
|
||||
<div className="flex grow flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<RoleChip key={item.key} role={item.textValue as string}/>
|
||||
))}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import {CollectionEndpoint} from "Frontend/generated/endpoints";
|
||||
import CollectionCreateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionCreateDto";
|
||||
import * as Yup from "yup";
|
||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||
|
||||
interface CollectionCreationModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function CollectionCreationModal({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: CollectionCreationModalProps) {
|
||||
|
||||
async function createCollection(collection: CollectionCreateDto) {
|
||||
await CollectionEndpoint.createCollection(collection);
|
||||
|
||||
addToast({
|
||||
title: "New collection created",
|
||||
description: `Collection ${collection.name} created!`,
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
description: ""
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Collection name is required")
|
||||
.max(255, "Collection name must be 255 characters or less")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createCollection(values);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{(formik) =>
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">Create a new collection</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
name="name"
|
||||
label="Collection Name"
|
||||
placeholder="Enter collection name"
|
||||
value={formik.values.name}
|
||||
required
|
||||
/>
|
||||
<TextAreaInput
|
||||
name="description"
|
||||
label="Collection Description"
|
||||
placeholder="Enter collection description"
|
||||
value={formik.values.description}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className="flex flex-row justify-end">
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : "Add"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
}
|
||||
</Formik>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
Select,
|
||||
SelectItem,
|
||||
SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useMemo, useState} from "react";
|
||||
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||
import {CollectionEndpoint} from "Frontend/generated/endpoints";
|
||||
import {MinusIcon, PlusIcon} from "@phosphor-icons/react";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {collectionState} from "Frontend/state/CollectionState";
|
||||
|
||||
interface CollectionGamesTableProps {
|
||||
collectionId: number;
|
||||
}
|
||||
|
||||
export default function CollectionGamesTable({collectionId}: CollectionGamesTableProps) {
|
||||
const gamesState = useSnapshot(gameState);
|
||||
const games = gamesState.games as GameAdminDto[];
|
||||
const librariesState = useSnapshot(libraryState);
|
||||
const libraries = librariesState.state as Record<number, LibraryAdminDto>;
|
||||
const collectionsState = useSnapshot(collectionState);
|
||||
const collection = collectionsState.state[collectionId];
|
||||
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "inCollection" | "notInCollection">("all");
|
||||
|
||||
function libraryName(game: GameAdminDto) {
|
||||
return libraries[game.libraryId]?.name || "Unknown";
|
||||
}
|
||||
|
||||
const gameInCollectionMap = useMemo(() => {
|
||||
const map = new Map<number, boolean>();
|
||||
games.forEach(game => {
|
||||
map.set(game.id, collection.gameIds!.includes(game.id));
|
||||
});
|
||||
return map;
|
||||
}, [games, collection.gameIds]);
|
||||
|
||||
function isGameInCollection(game: GameAdminDto) {
|
||||
return gameInCollectionMap.get(game.id) ?? false;
|
||||
}
|
||||
|
||||
const filteredGames = useMemo(() => {
|
||||
return games
|
||||
.filter((game) => game.title.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.filter(game => {
|
||||
if (filter === "inCollection") {
|
||||
return isGameInCollection(game);
|
||||
} else if (filter === "notInCollection") {
|
||||
return !isGameInCollection(game);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [games, searchTerm, filter, gameInCollectionMap]);
|
||||
|
||||
const sortedGames = useMemo(() => {
|
||||
return filteredGames
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
let cmp: number;
|
||||
switch (sortDescriptor.column) {
|
||||
case "title":
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case "library":
|
||||
cmp = (libraryName(a)).localeCompare(libraryName(b));
|
||||
break;
|
||||
case "dateAdded":
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
}
|
||||
if (sortDescriptor.direction === "descending") {
|
||||
cmp *= -1;
|
||||
}
|
||||
return cmp;
|
||||
})
|
||||
.map(game => ({...game, _inCollection: isGameInCollection(game)}));
|
||||
}, [filteredGames, sortDescriptor, libraries, gameInCollectionMap]);
|
||||
|
||||
async function addGameToCollection(game: GameAdminDto) {
|
||||
await CollectionEndpoint.addGameToCollection(collectionId, game.id);
|
||||
}
|
||||
|
||||
async function removeGameFromCollection(game: GameAdminDto) {
|
||||
await CollectionEndpoint.removeGameFromCollection(collectionId, game.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<Select
|
||||
selectedKeys={[filter]}
|
||||
disallowEmptySelection
|
||||
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
|
||||
className="w-64"
|
||||
>
|
||||
<SelectItem key="all">Show all games</SelectItem>
|
||||
<SelectItem key="inCollection">Show only games in collection</SelectItem>
|
||||
<SelectItem key="notInCollection">Show only games not in collection</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<Table isStriped isHeaderSticky
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
classNames={{
|
||||
base: "h-96"
|
||||
}}>
|
||||
<TableHeader>
|
||||
<TableColumn key="title" allowsSorting>Title</TableColumn>
|
||||
<TableColumn key="library" allowsSorting>Library</TableColumn>
|
||||
<TableColumn key="dateAdded" allowsSorting>Date added</TableColumn>
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="Your filters did not match any games."
|
||||
items={sortedGames}>
|
||||
{(game) => (
|
||||
// Key includes _inCollection to force re-render when that value changes
|
||||
<TableRow key={`${game.id}-${game._inCollection}`}>
|
||||
<TableCell>
|
||||
<Link href={`/game/${game.id}`}
|
||||
color="foreground"
|
||||
className="text-sm"
|
||||
underline="hover">
|
||||
{game.title} ({game.release ? new Date(game.release).getFullYear() : "unknown"})
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/administration/games/library/${game.libraryId}`}
|
||||
color="foreground"
|
||||
className="text-sm"
|
||||
underline="hover">
|
||||
{libraryName(game)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(game.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Add game to collection">
|
||||
<Button isIconOnly size="sm"
|
||||
onPress={() => addGameToCollection(game)}
|
||||
isDisabled={game._inCollection}>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove game from collection">
|
||||
<Button isIconOnly size="sm"
|
||||
onPress={() => removeGameFromCollection(game)}
|
||||
isDisabled={!game._inCollection}>
|
||||
<MinusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import {CollectionEndpoint} from "Frontend/generated/endpoints";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {collectionState} from "Frontend/state/CollectionState";
|
||||
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
|
||||
import CollectionUpdateDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionUpdateDto";
|
||||
import PrioritiesModal from "./PrioritiesModal";
|
||||
|
||||
interface CollectionPrioritiesModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function CollectionPrioritiesModal({isOpen, onOpenChange}: CollectionPrioritiesModalProps) {
|
||||
|
||||
const collections = useSnapshot(collectionState).sorted;
|
||||
|
||||
const updateCollections = async (reorderedCollections: any[]) => {
|
||||
const updateDtos: CollectionUpdateDto[] = reorderedCollections.map((collection, index): CollectionUpdateDto => {
|
||||
return {
|
||||
id: collection.id,
|
||||
metadata: {
|
||||
displayOnHomepage: collection.metadata!.displayOnHomepage,
|
||||
displayOrder: index
|
||||
}
|
||||
};
|
||||
});
|
||||
await CollectionEndpoint.updateCollections(updateDtos);
|
||||
};
|
||||
|
||||
return (
|
||||
<PrioritiesModal
|
||||
title="Edit collection order"
|
||||
subtitle="Collections higher on the list are displayed at the start"
|
||||
items={collections as CollectionDto[]}
|
||||
updateItems={updateCollections}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,8 +11,9 @@ import {
|
||||
} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import React from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||
import GameEnumPropertyValuesDto from "Frontend/generated/org/gameyfin/app/games/dto/GameEnumPropertyValuesDto";
|
||||
import {deepDiff} from "Frontend/util/utils";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||
@ -21,6 +22,9 @@ import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface EditGameMetadataModalProps {
|
||||
game: GameDto;
|
||||
@ -29,7 +33,14 @@ interface EditGameMetadataModalProps {
|
||||
}
|
||||
|
||||
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||
return (
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
const [propertyEnumValues, setPropertyEnumValues] = useState<GameEnumPropertyValuesDto>();
|
||||
|
||||
useEffect(() => {
|
||||
GameEndpoint.getEnumPropertyValues().then(setPropertyEnumValues);
|
||||
}, []);
|
||||
|
||||
return propertyEnumValues && (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
@ -69,6 +80,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
||||
<DatePickerInput key="release" name="release" label="Release"
|
||||
className="w-fit"/>
|
||||
</div>
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
|
||||
name="platforms" label="Platforms"/>
|
||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||
<Accordion variant="splitted"
|
||||
@ -81,14 +94,21 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
||||
title="Additional Metadata">
|
||||
<ArrayInput key="developers" name="developers" label="Developers"/>
|
||||
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
|
||||
<ArrayInput key="genres" name="genres" label="Genres"/>
|
||||
<ArrayInput key="themes" name="themes" label="Themes"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.genres}
|
||||
defaultSelected={game.genres}
|
||||
key="genres" name="genres" label="Genres"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.themes}
|
||||
defaultSelected={game.themes}
|
||||
key="themes" name="themes" label="Themes"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.features}
|
||||
defaultSelected={game.features}
|
||||
key="features" name="features"
|
||||
label="Features"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.perspectives}
|
||||
defaultSelected={game.perspectives}
|
||||
key="perspectives" name="perspectives"
|
||||
label="Perspectives"/>
|
||||
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
|
||||
<ArrayInput key="features" name="features" label="Features"/>
|
||||
<ArrayInput key="perspectives" name="perspectives"
|
||||
label="Perspectives"/>
|
||||
<ArrayInput key="keywords" name="keywords"
|
||||
label="Keywords"/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ModalBody>
|
||||
|
||||
@ -3,11 +3,10 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface GameCoverPickerModalProps {
|
||||
game: GameDto;
|
||||
@ -33,7 +32,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
|
||||
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
|
||||
setSearchResults(validResults);
|
||||
setIsSearching(false);
|
||||
@ -59,7 +58,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
setCoverUrl(coverUrl);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
@ -74,7 +73,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 && !isSearching &&
|
||||
@ -103,17 +102,17 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
>
|
||||
<Image
|
||||
alt={cover.title}
|
||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover aspect-12/17 group-hover:brightness-25"
|
||||
src={cover.url}
|
||||
radius="none"
|
||||
height={216}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
|
||||
<PluginIcon plugin={state[cover.source]} size={32}
|
||||
blurred={false} showTooltip={false}/>
|
||||
<p className="text-s text-center">{cover.title}</p>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -3,11 +3,10 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface GameHeaderPickerModalProps {
|
||||
game: GameDto;
|
||||
@ -33,7 +32,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
|
||||
let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
|
||||
setSearchResults(validResults);
|
||||
setIsSearching(false);
|
||||
@ -59,7 +58,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
setHeaderUrl(headerUrl);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
@ -74,7 +73,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 && !isSearching &&
|
||||
@ -103,16 +102,16 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
>
|
||||
<Image
|
||||
alt={header.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={header.url}
|
||||
radius="none"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-4 items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
|
||||
<PluginIcon plugin={state[header.source]} size={32}
|
||||
blurred={false} showTooltip={false}/>
|
||||
<p className="text-s text-center">{header.title}</p>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import {Form, Formik, FormikErrors} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
|
||||
interface LibraryCreationModalProps {
|
||||
libraries: LibraryDto[];
|
||||
setLibraries: (libraries: LibraryDto[]) => void;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function LibraryCreationModal({
|
||||
libraries,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: LibraryCreationModalProps) {
|
||||
|
||||
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
async function createLibrary(library: LibraryDto) {
|
||||
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
|
||||
async function createLibrary(library: LibraryAdminDto) {
|
||||
await LibraryEndpoint.createLibrary(library, scanAfterCreation);
|
||||
|
||||
addToast({
|
||||
title: "New library created",
|
||||
@ -33,25 +33,30 @@ export default function LibraryCreationModal({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
return (availablePlatforms &&
|
||||
<>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{name: "", directories: []}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createLibrary(values);
|
||||
onClose();
|
||||
}}
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
directories: [],
|
||||
platforms: []
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createLibrary(values);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{(formik) =>
|
||||
<Form>
|
||||
@ -65,6 +70,11 @@ export default function LibraryCreationModal({
|
||||
value={formik.values.name}
|
||||
required
|
||||
/>
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
|
||||
name="platforms"
|
||||
label="Platforms"
|
||||
placeholder="Platform(s) of the games in this library (leave empty for all platforms)"
|
||||
/>
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||
import PrioritiesModal from "./PrioritiesModal";
|
||||
|
||||
interface LibraryPrioritiesModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function LibraryPrioritiesModal({isOpen, onOpenChange}: LibraryPrioritiesModalProps) {
|
||||
|
||||
const libraries = useSnapshot(libraryState).sorted;
|
||||
|
||||
const updateLibraries = async (reorderedLibraries: LibraryDto[]) => {
|
||||
const updateDtos: LibraryUpdateDto[] = reorderedLibraries.map((library, index): LibraryUpdateDto => {
|
||||
return {
|
||||
id: library.id,
|
||||
metadata: {
|
||||
displayOnHomepage: library.metadata!.displayOnHomepage,
|
||||
displayOrder: index
|
||||
}
|
||||
};
|
||||
});
|
||||
await LibraryEndpoint.updateLibraries(updateDtos);
|
||||
};
|
||||
|
||||
return (
|
||||
<PrioritiesModal
|
||||
title="Edit library order"
|
||||
subtitle="Libraries higher on the list are displayed at the start"
|
||||
items={libraries}
|
||||
updateItems={updateLibraries}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -13,13 +13,14 @@ import {
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import PluginIcon from "../plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
|
||||
interface MatchGameModalProps {
|
||||
path: string;
|
||||
@ -44,6 +45,7 @@ export default function MatchGameModal({
|
||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||
|
||||
const state = useSnapshot(pluginState).state;
|
||||
const librariesState = useSnapshot(libraryState).state;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearchTerm);
|
||||
@ -56,7 +58,7 @@ export default function MatchGameModal({
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, (librariesState[libraryId] as LibraryAdminDto).platforms);
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
@ -84,7 +86,7 @@ export default function MatchGameModal({
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -126,7 +128,7 @@ export default function MatchGameModal({
|
||||
<div className="flex flex-row gap-2">
|
||||
{Object.values(item.originalIds).map(
|
||||
originalId => <PluginIcon
|
||||
plugin={state[originalId.pluginId] as PluginDto}/>
|
||||
plugin={state[originalId.pluginId]}/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -141,7 +143,7 @@ export default function MatchGameModal({
|
||||
setIsMatching(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Input as NextInput} from "@heroui/input";
|
||||
import {WarningCircle} from "@phosphor-icons/react";
|
||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
interface PasswordResetModalProps {
|
||||
@ -47,7 +47,7 @@ export default function PasswordResetModal({
|
||||
placeholder="Email"
|
||||
/> :
|
||||
<div className="flex flex-row items-center gap-4 text-warning">
|
||||
<WarningCircle size={40}/>
|
||||
<WarningCircleIcon size={40}/>
|
||||
<p>
|
||||
Password self-service is disabled.<br/>
|
||||
To reset your password please contact your administrator.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import {timeUntil} from "Frontend/util/utils";
|
||||
|
||||
interface PasswordResetTokenModalProps {
|
||||
|
||||
@ -4,7 +4,7 @@ import React, {useEffect, useState} from "react";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import FileTreeView from "Frontend/components/general/input/FileTreeView";
|
||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface PathPickerModalProps {
|
||||
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||
@ -45,7 +45,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<ArrowRightIcon className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
|
||||
@ -6,7 +6,7 @@ import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {ArrowClockwise} from "@phosphor-icons/react";
|
||||
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
|
||||
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
|
||||
|
||||
@ -161,7 +161,7 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
|
||||
}
|
||||
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
|
||||
}}>
|
||||
<ArrowClockwise/>
|
||||
<ArrowClockwiseIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
@ -1,113 +1,39 @@
|
||||
import React from "react";
|
||||
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
|
||||
import {CaretUpDown} from "@phosphor-icons/react";
|
||||
import {useListData} from "@react-stately/data";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PrioritiesModal from "./PrioritiesModal";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface PluginPrioritiesModalProps {
|
||||
plugins: PluginDto[];
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
|
||||
export default function PluginPrioritiesModal({isOpen, onOpenChange, type}: PluginPrioritiesModalProps) {
|
||||
const plugins = useSnapshot(pluginState).sortedByType[type];
|
||||
|
||||
const sortedPlugins = useListData({
|
||||
initialItems: plugins, // Already sorted in parent
|
||||
getKey: (plugin) => plugin.id
|
||||
});
|
||||
const updatePlugins = async (reorderedPlugins: PluginDto[]) => {
|
||||
const prioritiesMap: Record<string, number> = {};
|
||||
const totalPlugins = reorderedPlugins.length;
|
||||
|
||||
let {dragAndDropHooks} = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
|
||||
onReorder(e) {
|
||||
if (e.keys.has(e.target.key)) return;
|
||||
|
||||
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
|
||||
sortedPlugins.moveBefore(e.target.key, e.keys);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
sortedPlugins.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
|
||||
// Recalculate priority based on new position (reversed)
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
const reversedPriority = sortedPlugins.items.length - index;
|
||||
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function generatePrioritiesMap(): Record<string, number> {
|
||||
let map: Record<string, number> = {};
|
||||
const totalPlugins = sortedPlugins.items.length;
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
map[plugin.id] = totalPlugins - index; // Reverse order
|
||||
reorderedPlugins.forEach((plugin, index) => {
|
||||
// Reverse order: first item gets highest priority
|
||||
prioritiesMap[plugin.id] = totalPlugins - index;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function setPluginPriorities(onClose: () => void) {
|
||||
try {
|
||||
const prioritiesMap = generatePrioritiesMap();
|
||||
await PluginEndpoint.setPluginPriorities(prioritiesMap);
|
||||
|
||||
addToast({
|
||||
title: "Plugin order updated",
|
||||
description: "Plugin order has been updated successfully.",
|
||||
color: "success"
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error",
|
||||
description: "An error occurred while updating plugin order.",
|
||||
color: "warning"
|
||||
});
|
||||
}
|
||||
}
|
||||
await PluginEndpoint.setPluginPriorities(prioritiesMap);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<p>Edit plugin order</p>
|
||||
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListBox items={sortedPlugins.items}
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
className="flex flex-col gap-2">
|
||||
{(plugin: PluginDto) => (
|
||||
<ListBoxItem
|
||||
key={plugin.id}
|
||||
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Chip size="sm" color="primary">
|
||||
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
|
||||
</Chip>
|
||||
<p className="font-normal text-small">{plugin.name}</p>
|
||||
</div>
|
||||
<CaretUpDown/>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<PrioritiesModal
|
||||
title="Edit plugin order"
|
||||
subtitle="Plugins higher on the list are preferred"
|
||||
items={plugins}
|
||||
updateItems={updatePlugins}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
|
||||
import {CaretUpDownIcon} from "@phosphor-icons/react";
|
||||
import {useListData} from "@react-stately/data";
|
||||
|
||||
export interface PrioritizableItem {
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PrioritiesModalProps<T extends PrioritizableItem> {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: T[];
|
||||
updateItems: (items: T[]) => Promise<void>;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function PrioritiesModal<T extends PrioritizableItem>({
|
||||
items,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
title,
|
||||
subtitle,
|
||||
updateItems
|
||||
}: PrioritiesModalProps<T>) {
|
||||
|
||||
const sortedItems = useListData<T>({
|
||||
initialItems: items,
|
||||
getKey: (item) => item.id
|
||||
});
|
||||
|
||||
// Track order changes to trigger re-renders
|
||||
const [orderVersion, setOrderVersion] = useState(0);
|
||||
|
||||
// Update sortedItems when items change
|
||||
useEffect(() => {
|
||||
sortedItems.setSelectedKeys(new Set());
|
||||
sortedItems.items.forEach(item => sortedItems.remove(item.id));
|
||||
items.forEach(item => sortedItems.append(item));
|
||||
setOrderVersion(prev => prev + 1);
|
||||
}, [items]);
|
||||
|
||||
let {dragAndDropHooks} = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({'text/plain': sortedItems.getItem(key)!.name})),
|
||||
onReorder(e) {
|
||||
if (e.keys.has(e.target.key)) return;
|
||||
|
||||
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
|
||||
sortedItems.moveBefore(e.target.key, e.keys);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
sortedItems.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
// Trigger re-render after reorder
|
||||
setOrderVersion(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
async function updateItemOrder(onClose: () => void) {
|
||||
try {
|
||||
// Pass the reordered items directly to the update function
|
||||
// The parent component will handle the actual transformation
|
||||
await updateItems(sortedItems.items);
|
||||
|
||||
addToast({
|
||||
title: "Order updated",
|
||||
description: "Item order has been updated successfully.",
|
||||
color: "success"
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error",
|
||||
description: "An error occurred while updating item order.",
|
||||
color: "warning"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<p>{title}</p>
|
||||
<p className="text-small font-normal">{subtitle}</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListBox items={sortedItems.items}
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
className="flex flex-col gap-2"
|
||||
key={orderVersion}>
|
||||
{(item: T) => (
|
||||
<ListBoxItem
|
||||
key={item.id}
|
||||
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Chip size="sm" color="primary">
|
||||
{sortedItems.items.findIndex(p => p.id === item.id) + 1}
|
||||
</Chip>
|
||||
<p className="font-normal text-small">{item.name}</p>
|
||||
</div>
|
||||
<CaretUpDownIcon/>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={() => updateItemOrder(onClose)}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import {
|
||||
addToast,
|
||||
Autocomplete,
|
||||
AutocompleteItem,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
@ -14,7 +16,7 @@ import {
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import PluginIcon from "../plugin/PluginIcon";
|
||||
@ -22,22 +24,29 @@ import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
|
||||
import Platform from "Frontend/generated/org/gameyfin/pluginapi/gamemetadata/Platform";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface RequestGameModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
// TODO: Maybe make this configurable in the admin settings?
|
||||
const DEFAULT_PLATFORM_FOR_NEW_REQUESTS = "PC (Microsoft Windows)";
|
||||
|
||||
export default function RequestGameModal({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: RequestGameModalProps) {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>(DEFAULT_PLATFORM_FOR_NEW_REQUESTS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
||||
|
||||
const plugins = useSnapshot(pluginState).state;
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm("");
|
||||
@ -47,7 +56,9 @@ export default function RequestGameModal({
|
||||
async function requestGame(game: GameSearchResultDto) {
|
||||
const request: GameRequestCreationDto = {
|
||||
title: game.title,
|
||||
release: game.release
|
||||
release: game.release,
|
||||
// Since we can only request for one platform at a time, just pick the first one
|
||||
platform: game.platforms ? game.platforms[0] : DEFAULT_PLATFORM_FOR_NEW_REQUESTS as Platform
|
||||
}
|
||||
|
||||
try {
|
||||
@ -66,7 +77,7 @@ export default function RequestGameModal({
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, [selectedPlatform] as Platform[]);
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
@ -83,6 +94,18 @@ export default function RequestGameModal({
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-xl font-semibold">Request a game</h2>
|
||||
</div>
|
||||
<Autocomplete
|
||||
label="Platform"
|
||||
size="sm"
|
||||
allowsCustomValue={false}
|
||||
selectedKey={selectedPlatform}
|
||||
//@ts-ignore
|
||||
onSelectionChange={(newSelection) => newSelection && setSelectedPlatform(newSelection)}
|
||||
>
|
||||
{Array.from(availablePlatforms).map((platform) => (
|
||||
<AutocompleteItem key={platform}>{platform}</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
@ -93,8 +116,11 @@ export default function RequestGameModal({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<Button isIconOnly
|
||||
color="primary"
|
||||
onPress={search}
|
||||
isLoading={isSearching}>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -151,7 +177,7 @@ export default function RequestGameModal({
|
||||
setIsRequesting(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {Image, Tooltip} from "@heroui/react";
|
||||
import {Plug} from "@phosphor-icons/react";
|
||||
import { PlugIcon } from "@phosphor-icons/react";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface PluginIconProps {
|
||||
@ -18,7 +18,7 @@ export default function PluginIcon({
|
||||
const icon = plugin.hasLogo
|
||||
?
|
||||
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
|
||||
: <Plug size={size} weight="fill"/>;
|
||||
: <PlugIcon size={size} weight="fill"/>;
|
||||
|
||||
return showTooltip
|
||||
? <Tooltip content={plugin.name}>{icon}</Tooltip>
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
import {Button, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {ListNumbers} from "@phosphor-icons/react";
|
||||
import {ListNumbersIcon} from "@phosphor-icons/react";
|
||||
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
|
||||
import React from "react";
|
||||
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
||||
import {camelCaseToTitle} from "Frontend/util/utils";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
|
||||
interface PluginManagementSectionProps {
|
||||
type: string;
|
||||
plugins: PluginDto[];
|
||||
}
|
||||
|
||||
export function PluginManagementSection({type, plugins = []}: PluginManagementSectionProps) {
|
||||
export function PluginManagementSection({type}: PluginManagementSectionProps) {
|
||||
const plugins = useSnapshot(pluginState).sortedByType[type];
|
||||
|
||||
const pluginPrioritiesModal = useDisclosure();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row flex-grow justify-between">
|
||||
<div className="flex flex-row grow justify-between">
|
||||
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
||||
|
||||
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
||||
@ -24,7 +26,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
||||
variant="flat"
|
||||
onPress={pluginPrioritiesModal.onOpen}
|
||||
isDisabled={plugins.length === 0}>
|
||||
<ListNumbers/>
|
||||
<ListNumbersIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -40,10 +42,9 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
||||
</div>}
|
||||
|
||||
<PluginPrioritiesModal
|
||||
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
|
||||
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
|
||||
isOpen={pluginPrioritiesModal.isOpen}
|
||||
onOpenChange={pluginPrioritiesModal.onOpenChange}
|
||||
type={type}
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import {Button, Link, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {Warning} from "@phosphor-icons/react";
|
||||
|
||||
// TODO: Remove this component before the release of version 2.2.0
|
||||
export default function DockerHubDeprecationPopover() {
|
||||
return (
|
||||
<Popover placement="bottom-end" showArrow={true} color="warning">
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly color="warning" variant="flat">
|
||||
<Warning/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="m-4 text-sm leading-relaxed">
|
||||
<h3 className="mb-2 font-bold">Image deprecation notice</h3>
|
||||
<p>
|
||||
Starting with version
|
||||
<code className="font-semibold"> 2.2.0 </code>
|
||||
the image{' '}
|
||||
<Link href="https://hub.docker.com/r/grimsi/gameyfin"
|
||||
isExternal
|
||||
underline="always"
|
||||
size="sm"
|
||||
className="text-warning-contrast">
|
||||
grimsi/gameyfin
|
||||
</Link>
|
||||
{' '}will no longer be published to Docker Hub.
|
||||
</p>
|
||||
<p>
|
||||
Please switch to{' '}
|
||||
<Link href="https://github.com/gameyfin/gameyfin/pkgs/container/gameyfin"
|
||||
isExternal
|
||||
underline="always"
|
||||
size="sm"
|
||||
className="text-warning-contrast">
|
||||
ghcr.io/gameyfin/gameyfin
|
||||
</Link>
|
||||
{' '}if you are currently using the Docker Hub image.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ export default function ThemePreview({theme, isSelected}: {
|
||||
}) {
|
||||
return (
|
||||
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
|
||||
<div className={`flex flex-col flex-grow aspect-square border-2 rounded-large overflow-hidden
|
||||
<div className={`flex flex-col grow aspect-square border-2 rounded-large overflow-hidden
|
||||
${theme.name}-dark
|
||||
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}>
|
||||
<div className="flex-1 bg-primary"/>
|
||||
|
||||
208
app/src/main/frontend/components/wizard/Stepper.tsx
Normal file
208
app/src/main/frontend/components/wizard/Stepper.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import type {ComponentProps} from "react";
|
||||
import React from "react";
|
||||
import type {ButtonProps} from "@heroui/react";
|
||||
import {cn} from "@heroui/react";
|
||||
import {useControlledState} from "@react-stately/utils";
|
||||
import {domAnimation, LazyMotion, m} from "framer-motion"; // reintroduce LazyMotion & domAnimation
|
||||
|
||||
export type StepDescriptor = {
|
||||
title?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export interface StepperProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
steps?: StepDescriptor[];
|
||||
color?: ButtonProps["color"];
|
||||
currentStep?: number;
|
||||
defaultStep?: number;
|
||||
hideProgressBars?: boolean;
|
||||
className?: string;
|
||||
stepClassName?: string;
|
||||
onStepChange?: (stepIndex: number) => void;
|
||||
allowFutureNavigation?: boolean;
|
||||
}
|
||||
|
||||
function CheckIcon(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg {...props} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<m.path
|
||||
animate={{pathLength: 1}}
|
||||
d="M5 13l4 4L19 7"
|
||||
initial={{pathLength: 0}}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transition={{
|
||||
delay: 0.2,
|
||||
type: "tween",
|
||||
ease: "easeOut",
|
||||
duration: 0.3,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Stepper = React.forwardRef<HTMLButtonElement, StepperProps>(
|
||||
(
|
||||
{
|
||||
color = "primary",
|
||||
steps = [],
|
||||
defaultStep = 0,
|
||||
onStepChange,
|
||||
currentStep: currentStepProp,
|
||||
hideProgressBars = false,
|
||||
stepClassName,
|
||||
className,
|
||||
allowFutureNavigation = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [currentStep, setCurrentStep] = useControlledState(currentStepProp, defaultStep, onStepChange);
|
||||
|
||||
const colors = React.useMemo(() => {
|
||||
let userColor;
|
||||
let fgColor;
|
||||
const colorsVars = [
|
||||
"[--active-fg-color:var(--step-fg-color)]",
|
||||
"[--active-border-color:var(--step-color)]",
|
||||
"[--active-color:var(--step-color)]",
|
||||
"[--complete-background-color:var(--step-color)]",
|
||||
"[--complete-border-color:var(--step-color)]",
|
||||
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
|
||||
"[--inactive-color:hsl(var(--heroui-default-300))]"
|
||||
];
|
||||
switch (color) {
|
||||
case "secondary":
|
||||
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
|
||||
break;
|
||||
case "success":
|
||||
userColor = "[--step-color:hsl(var(--heroui-success))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
|
||||
break;
|
||||
case "warning":
|
||||
userColor = "[--step-color:hsl(var(--heroui-warning))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
|
||||
break;
|
||||
case "danger":
|
||||
userColor = "[--step-color:hsl(var(--heroui-error))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
|
||||
break;
|
||||
case "default":
|
||||
userColor = "[--step-color:hsl(var(--heroui-default))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
|
||||
break;
|
||||
case "primary":
|
||||
default:
|
||||
userColor = "[--step-color:hsl(var(--heroui-primary))]";
|
||||
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
|
||||
break;
|
||||
}
|
||||
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
|
||||
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
|
||||
if (!className?.includes("--inactive-bar-color"))
|
||||
colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]");
|
||||
return colorsVars;
|
||||
}, [color, className]);
|
||||
|
||||
// Compute statuses once
|
||||
const statuses = steps.map((_, i) => (
|
||||
currentStep === i ? "active" : currentStep < i ? "inactive" : "complete"
|
||||
));
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}> {/* enable pathLength & variants animations */}
|
||||
<nav aria-label="Progress" className={cn("relative w-full overflow-x-visible py-4", colors, className)}>
|
||||
{/* Circles + connectors row */}
|
||||
<div className="flex w-full items-center">
|
||||
{steps.map((step, idx) => {
|
||||
const status = statuses[idx];
|
||||
const canNavigate = allowFutureNavigation || idx <= currentStep;
|
||||
const isLast = idx === steps.length - 1;
|
||||
return (
|
||||
<div key={idx}
|
||||
className={cn("flex items-center", !isLast && "flex-1")}> {/* flex-1 only if there is a connector after */}
|
||||
<button
|
||||
ref={ref}
|
||||
aria-current={status === "active" ? "step" : undefined}
|
||||
type="button"
|
||||
onClick={() => canNavigate && setCurrentStep(idx)}
|
||||
className={cn(
|
||||
"group relative flex h-[38px] w-[38px] items-center justify-center rounded-full border-medium font-semibold text-large bg-content1 transition-colors duration-300",
|
||||
!canNavigate && "pointer-events-none opacity-60",
|
||||
step.className,
|
||||
stepClassName,
|
||||
status === "inactive" && "text-(--inactive-color) border-(--inactive-border-color)",
|
||||
status === "active" && "text-(--active-color) border-(--active-border-color)",
|
||||
status === "complete" && "border-(--complete-border-color) bg-(--complete-background-color) shadow-lg"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<m.div
|
||||
animate={status}
|
||||
initial={false}
|
||||
variants={{
|
||||
inactive: {scale: 1, opacity: 0.85},
|
||||
active: {scale: 1.04, opacity: 1},
|
||||
complete: {scale: 1, opacity: 1}
|
||||
}}
|
||||
transition={{type: "spring", stiffness: 260, damping: 20}}
|
||||
className="flex items-center justify-center w-full h-full"
|
||||
>
|
||||
{status === "complete" ? (
|
||||
<CheckIcon key={`check-${idx}`}
|
||||
className="h-6 w-6 text-(--active-fg-color)"/>
|
||||
) : step.icon ? (
|
||||
step.icon
|
||||
) : (
|
||||
<span>{idx + 1}</span>
|
||||
)}
|
||||
</m.div>
|
||||
</button>
|
||||
{!isLast && !hideProgressBars && (
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="mx-3 h-0.5 rounded-full bg-(--inactive-bar-color) relative">{/* gap so line does not touch circles */}
|
||||
<m.div
|
||||
className="absolute left-0 top-0 h-full rounded-full bg-(--active-border-color)"
|
||||
animate={{width: idx < currentStep ? '100%' : 0}}
|
||||
transition={{duration: 0.35, ease: 'easeInOut'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Titles row */}
|
||||
<div className={cn("mt-2 grid w-full")}
|
||||
style={{gridTemplateColumns: `repeat(${steps.length}, minmax(0,1fr))`}}>
|
||||
{steps.map((step, idx) => {
|
||||
const status = statuses[idx];
|
||||
return (
|
||||
<div key={idx} className="flex justify-center px-1 text-center">
|
||||
{step.title && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-small lg:text-medium font-medium transition-[color,opacity] duration-300",
|
||||
status === "inactive" ? "text-default-500" : "text-default-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Stepper.displayName = "Stepper";
|
||||
export default Stepper;
|
||||
@ -1,20 +1,22 @@
|
||||
import React, {ReactNode, useState} from "react";
|
||||
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
|
||||
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
|
||||
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon} from "@phosphor-icons/react";
|
||||
import {Button} from "@heroui/react";
|
||||
import {Step, Stepper} from "@material-tailwind/react";
|
||||
import Stepper from "./Stepper";
|
||||
|
||||
const Wizard = ({children, initialValues, onSubmit}: {
|
||||
children: ReactNode,
|
||||
initialValues: any,
|
||||
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
|
||||
}) => {
|
||||
type WizardProps = {
|
||||
children: ReactNode;
|
||||
initialValues: any;
|
||||
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>;
|
||||
};
|
||||
|
||||
const Wizard = ({children, initialValues, onSubmit}: WizardProps) => {
|
||||
const allSteps = React.Children.toArray(children);
|
||||
const [stepNumber, setStepNumber] = useState(0);
|
||||
const steps = React.Children.toArray(children);
|
||||
const [snapshot, setSnapshot] = useState(initialValues);
|
||||
|
||||
const step = steps[stepNumber];
|
||||
const totalSteps = steps.length;
|
||||
const step = allSteps[stepNumber];
|
||||
const totalSteps = allSteps.length;
|
||||
const isFirstStep = stepNumber === 0;
|
||||
const isLastStep = stepNumber === totalSteps - 1;
|
||||
|
||||
@ -28,10 +30,11 @@ const Wizard = ({children, initialValues, onSubmit}: {
|
||||
setStepNumber(Math.max(stepNumber - 1, 0));
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
|
||||
/*// @ts-ignore*/
|
||||
const handleSubmit = async (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => {
|
||||
// per-step custom submit if provided
|
||||
// @ts-ignore
|
||||
if (step.props.onSubmit) {
|
||||
/*// @ts-ignore*/
|
||||
// @ts-ignore
|
||||
await step.props.onSubmit(values, bag);
|
||||
}
|
||||
if (isLastStep) {
|
||||
@ -42,53 +45,44 @@ const Wizard = ({children, initialValues, onSubmit}: {
|
||||
}
|
||||
};
|
||||
|
||||
const stepsMeta = allSteps.map((child: any) => ({
|
||||
title: child.props?.title ?? child.props?.label,
|
||||
icon: child.props?.icon
|
||||
}));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={snapshot}
|
||||
onSubmit={handleSubmit}
|
||||
/*// @ts-ignore*/
|
||||
validationSchema={step.props.validationSchema}
|
||||
// @ts-ignore
|
||||
validationSchema={step.props?.validationSchema}
|
||||
enableReinitialize={false}
|
||||
>
|
||||
{(formik) => (
|
||||
<Form className="flex flex-col h-full">
|
||||
<div className="w-full mb-8">
|
||||
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
|
||||
lineClassName="bg-foreground"
|
||||
placeholder={undefined}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
onResize={undefined}
|
||||
onResizeCapture={undefined}>
|
||||
{steps.map((child, index) => (
|
||||
<Step key={index}
|
||||
className="bg-foreground text-background"
|
||||
activeClassName="bg-primary"
|
||||
completedClassName="bg-primary"
|
||||
placeholder={undefined}
|
||||
onPointerEnterCapture={undefined}
|
||||
onPointerLeaveCapture={undefined}
|
||||
onResize={undefined}
|
||||
onResizeCapture={undefined}>
|
||||
{/*@ts-ignore*/}
|
||||
{child.props.icon}
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
<Stepper
|
||||
steps={stepsMeta}
|
||||
currentStep={stepNumber}
|
||||
onStepChange={(idx) => {
|
||||
// only allow backwards navigation to keep validation flow
|
||||
if (idx <= stepNumber) setStepNumber(idx);
|
||||
}}
|
||||
hideProgressBars={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex grow">
|
||||
{step}
|
||||
</div>
|
||||
<div className="left-8 right-8 absolute bottom-8 -z-1">
|
||||
<div className="flex grow">{step}</div>
|
||||
<div className="left-8 right-8 absolute bottom-8">
|
||||
<div className="flex justify-between">
|
||||
<Button color="primary" onClick={() => previous(formik.values)} isDisabled={isFirstStep}>
|
||||
<ArrowLeft/>
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
type="submit"
|
||||
onPress={() => previous(formik.values)}
|
||||
isDisabled={isFirstStep || formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
|
||||
<ArrowLeftIcon/>
|
||||
</Button>
|
||||
<Button color="primary" isLoading={formik.isSubmitting} type="submit">
|
||||
{formik.isSubmitting ? "" : isLastStep ? <CheckIcon/> : <ArrowRightIcon/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -98,4 +92,4 @@ const Wizard = ({children, initialValues, onSubmit}: {
|
||||
);
|
||||
};
|
||||
|
||||
export default Wizard;
|
||||
export default Wizard;
|
||||
|
||||
10
app/src/main/frontend/heroui.ts
Normal file
10
app/src/main/frontend/heroui.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import {heroui, HeroUIPluginConfig} from "@heroui/react";
|
||||
import {compileThemes, themes} from "./theming/themes"
|
||||
|
||||
export const HeroUIConfig: HeroUIPluginConfig = {
|
||||
// TODO: Prefix disabled until bug in heroui is fixed: https://github.com/heroui-inc/heroui/issues/5403
|
||||
// prefix: "gf",
|
||||
themes: compileThemes(themes)
|
||||
};
|
||||
|
||||
export default heroui(HeroUIConfig);
|
||||
@ -6,6 +6,10 @@ import {router} from './routes';
|
||||
const container = document.getElementById('outlet')!;
|
||||
const root = createRoot(container);
|
||||
|
||||
declare module 'valtio' {
|
||||
function useSnapshot<T extends object>(p: T): T
|
||||
}
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router}/>
|
||||
|
||||
@ -1,44 +1,38 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.gradient-primary {
|
||||
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
||||
}
|
||||
@plugin './heroui.ts';
|
||||
|
||||
.button-secondary {
|
||||
@apply bg-primary-300 text-background/80;
|
||||
}
|
||||
@source "./index.html";
|
||||
@source "./**/*.{js,ts,jsx,tsx}";
|
||||
@source "../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}";
|
||||
|
||||
/* Prevent TailwindCSS from purging the custom themes (since they are not referenced at compile-time) */
|
||||
@source inline("{gameyfin-blue,gameyfin-violet,gameyfin-classic,neutral,slate,red,rose,orange,pink,blue,yellow,violet,colorblind}-{light,dark}");
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-gf-primary: #2332c8;
|
||||
--color-gf-secondary: #6441a5;
|
||||
}
|
||||
|
||||
/* Custom CSS */
|
||||
|
||||
:root {
|
||||
/* Overwrite default Hilla styles (e.g. loading indicator) */
|
||||
--lumo-primary-color: theme(colors.primary);
|
||||
|
||||
/* Overwrite SwiperJS styles */
|
||||
--swiper-navigation-color: theme(colors.primary);
|
||||
--swiper-pagination-color: theme(colors.primary);
|
||||
|
||||
.swiper-pagination-bullet {
|
||||
background-color: theme(colors.primary);
|
||||
}
|
||||
|
||||
/* Custom gridTemplateColumns */
|
||||
@utility grid-cols-300px {
|
||||
grid-template-columns: repeat(auto-fit, 300px);
|
||||
}
|
||||
@utility grid-cols-auto-fill {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
/* List box drag & drop */
|
||||
.react-aria-ListBoxItem {
|
||||
&[data-dragging] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.react-aria-DropIndicator[data-drop-target] {
|
||||
outline: 1px solid theme(colors.primary);
|
||||
/* Re-added custom utilities (Tailwind v4 style) */
|
||||
@utility gradient-primary {
|
||||
@apply bg-gradient-to-br from-primary-400 to-primary-700;
|
||||
}
|
||||
@utility button-secondary {
|
||||
@apply bg-primary-300 text-background/80;
|
||||
}
|
||||
|
||||
/* Shine animation for game covers */
|
||||
.shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -71,4 +65,30 @@
|
||||
100% {
|
||||
left: 125%;
|
||||
}
|
||||
}
|
||||
|
||||
/* List box drag & drop */
|
||||
.react-aria-ListBoxItem[data-dragging] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.react-aria-DropIndicator[data-drop-target] {
|
||||
outline: 1px solid hsl(var(--heroui-primary));
|
||||
}
|
||||
|
||||
/* Root variable overrides */
|
||||
:root {
|
||||
/* Overwrite default Hilla styles (e.g. loading indicator) */
|
||||
--lumo-primary-color: hsl(var(--heroui-primary));
|
||||
|
||||
/* Overwrite SwiperJS styles */
|
||||
--swiper-theme-color: hsl(var(--heroui-primary));
|
||||
--swiper-navigation-color: hsl(var(--heroui-primary));
|
||||
--swiper-pagination-color: hsl(var(--heroui-primary));
|
||||
|
||||
/* Overwrite SwiperJS styles */
|
||||
|
||||
.swiper-pagination-bullet {
|
||||
background-color: hsl(var(--heroui-primary));
|
||||
}
|
||||
}
|
||||
@ -4,10 +4,10 @@ import HomeView from "Frontend/views/HomeView";
|
||||
import SetupView from "Frontend/views/SetupView";
|
||||
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
||||
import App from "Frontend/App";
|
||||
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
|
||||
import {GameManagement} from "Frontend/components/administration/GameManagement";
|
||||
import {UserManagement} from "Frontend/components/administration/UserManagement";
|
||||
import ProfileManagement from "Frontend/components/administration/ProfileManagement";
|
||||
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
||||
import {SecurityManagement} from "Frontend/components/administration/SecurityManagement";
|
||||
import {AdministrationView} from "Frontend/views/AdministrationView";
|
||||
import {ProfileView} from "Frontend/views/ProfileView";
|
||||
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
|
||||
@ -20,12 +20,14 @@ import {SystemManagement} from "Frontend/components/administration/SystemManagem
|
||||
import GameView from "Frontend/views/GameView";
|
||||
import LibraryManagementView from "Frontend/views/LibraryManagementView";
|
||||
import SearchView from "Frontend/views/SearchView";
|
||||
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
|
||||
import LibraryView from "Frontend/views/LibraryView";
|
||||
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
|
||||
import ErrorView from "Frontend/views/ErrorView";
|
||||
import GameRequestView from "Frontend/views/GameRequestView";
|
||||
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
|
||||
import {DownloadManagement} from "Frontend/components/administration/DownloadManagement";
|
||||
import CollectionManagementView from "Frontend/views/CollectionManagementView";
|
||||
import CollectionView from "Frontend/views/CollectionView";
|
||||
|
||||
export const {router, routes} = new RouterConfigurationBuilder()
|
||||
.withReactRoutes([
|
||||
@ -44,11 +46,6 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
element: <SearchView/>,
|
||||
handle: {title: 'Search'}
|
||||
},
|
||||
{
|
||||
path: 'recently-added',
|
||||
element: <RecentlyAddedView/>,
|
||||
handle: {title: 'Recently Added'}
|
||||
},
|
||||
{
|
||||
path: '/requests',
|
||||
element: <GameRequestView/>,
|
||||
@ -58,6 +55,10 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
path: 'library/:libraryId',
|
||||
element: <LibraryView/>
|
||||
},
|
||||
{
|
||||
path: 'collection/:collectionId',
|
||||
element: <CollectionView/>
|
||||
},
|
||||
{
|
||||
path: 'game/:gameId',
|
||||
element: <GameView/>
|
||||
@ -85,29 +86,39 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
handle: {title: 'Administration'},
|
||||
children: [
|
||||
{
|
||||
path: 'libraries',
|
||||
element: <LibraryManagement/>,
|
||||
handle: {title: 'Administration - Libraries'}
|
||||
path: 'games',
|
||||
element: <GameManagement/>,
|
||||
handle: {title: 'Administration - Games'}
|
||||
},
|
||||
{
|
||||
path: 'libraries/library/:libraryId',
|
||||
path: 'games/library/:libraryId',
|
||||
element: <LibraryManagementView/>,
|
||||
handle: {title: 'Administration - Library'}
|
||||
},
|
||||
{
|
||||
path: 'games/collection/:collectionId',
|
||||
element: <CollectionManagementView/>,
|
||||
handle: {title: 'Administration - Collection'}
|
||||
},
|
||||
{
|
||||
path: 'requests',
|
||||
element: <GameRequestManagement/>,
|
||||
handle: {title: 'Administration - Game Requests'}
|
||||
},
|
||||
{
|
||||
path: 'downloads',
|
||||
element: <DownloadManagement/>,
|
||||
handle: {title: 'Administration - Downloads'}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
element: <UserManagement/>,
|
||||
handle: {title: 'Administration - Users'}
|
||||
},
|
||||
{
|
||||
path: 'sso',
|
||||
element: <SsoManagement/>,
|
||||
handle: {title: 'Administration - SSO'}
|
||||
path: 'security',
|
||||
element: <SecurityManagement/>,
|
||||
handle: {title: 'Administration - Security'}
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
|
||||
70
app/src/main/frontend/state/CollectionState.ts
Normal file
70
app/src/main/frontend/state/CollectionState.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {Subscription} from "@vaadin/hilla-frontend";
|
||||
import {proxy} from "valtio/index";
|
||||
import {CollectionEndpoint} from "Frontend/generated/endpoints";
|
||||
import CollectionDto from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionDto";
|
||||
import CollectionEvent from "Frontend/generated/org/gameyfin/app/collections/dto/CollectionEvent";
|
||||
|
||||
type CollectionState = {
|
||||
subscription?: Subscription<CollectionEvent[]>;
|
||||
isLoaded: boolean;
|
||||
state: Record<number, CollectionDto>;
|
||||
collections: CollectionDto[];
|
||||
sorted: CollectionDto[];
|
||||
};
|
||||
|
||||
export const collectionState = proxy<CollectionState>({
|
||||
get isLoaded() {
|
||||
return this.subscription != null;
|
||||
},
|
||||
state: {},
|
||||
get collections() {
|
||||
return Object.values<CollectionDto>(this.state);
|
||||
},
|
||||
get sorted() {
|
||||
return Object.values<CollectionDto>(this.state).sort((a: any, b: any) => {
|
||||
const orderA = a.metadata?.displayOrder ?? -1;
|
||||
const orderB = b.metadata?.displayOrder ?? -1;
|
||||
|
||||
// Handle -1 as "end of list"
|
||||
const effectiveOrderA = orderA === -1 ? Number.MAX_SAFE_INTEGER : orderA;
|
||||
const effectiveOrderB = orderB === -1 ? Number.MAX_SAFE_INTEGER : orderB;
|
||||
|
||||
const orderDiff = effectiveOrderA - effectiveOrderB;
|
||||
if (orderDiff !== 0) {
|
||||
return orderDiff;
|
||||
}
|
||||
|
||||
// Fallback to creation date (newer first)
|
||||
return new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Subscribe to and process state updates from backend **/
|
||||
export async function initializeCollectionState() {
|
||||
if (collectionState.isLoaded) return;
|
||||
|
||||
// Fetch initial collection list
|
||||
const initialEntries = await CollectionEndpoint.getAll();
|
||||
initialEntries.forEach((collection: CollectionDto) => {
|
||||
collectionState.state[collection.id] = collection;
|
||||
});
|
||||
|
||||
// Subscribe to real-time updates
|
||||
collectionState.subscription = CollectionEndpoint.subscribeToCollectionEvents().onNext((collectionEvents: CollectionEvent[]) => {
|
||||
collectionEvents.forEach((collectionEvent: CollectionEvent) => {
|
||||
switch (collectionEvent.type) {
|
||||
case "created":
|
||||
case "updated":
|
||||
//@ts-ignore
|
||||
collectionState.state[collectionEvent.collection.id] = collectionEvent.collection;
|
||||
break;
|
||||
case "deleted":
|
||||
//@ts-ignore
|
||||
delete collectionState.state[collectionEvent.collectionId];
|
||||
break;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user