Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3095b4dfa | ||
|
|
385ca37848 | ||
|
|
ec02d780ea | ||
|
|
ecdb82782f | ||
|
|
ace1d78505 | ||
|
|
87dc8a3f66 | ||
|
|
e8b6db6ed5 | ||
|
|
7aa484b1e4 | ||
|
|
988a2e38f0 | ||
|
|
af359358d5 | ||
|
|
fd3e5c9e5f | ||
|
|
bae28fb94a | ||
|
|
8964086a61 | ||
|
|
e0c2711fac | ||
|
|
27146f2bb0 | ||
|
|
e7e89a4baf | ||
|
|
315807aca2 | ||
|
|
7817a871bf | ||
|
|
fb22c1cee7 | ||
|
|
961a9f0036 | ||
|
|
81f4f3aecb | ||
|
|
f2eabb94c8 | ||
|
|
1ba71dbf15 | ||
|
|
a29cf5f7da | ||
|
|
95bf08c3fb | ||
|
|
cde9df4d81 | ||
|
|
30d3f1600f | ||
|
|
c2d28d1dc8 | ||
|
|
8387805f86 | ||
|
|
c555b0063f | ||
|
|
c741a6122a | ||
|
|
c1c474e2e8 | ||
|
|
9da35dc7ff | ||
|
|
640653bed3 | ||
|
|
93662b802b | ||
|
|
549eff117b | ||
|
|
12dea95ccb | ||
|
|
945188928e | ||
|
|
82b169718f | ||
|
|
becfd85476 | ||
|
|
05cb71f3e3 | ||
|
|
55a929dee8 | ||
|
|
e7d7d20997 | ||
|
|
4d09f9e519 | ||
|
|
103725bc15 | ||
|
|
340a17165c | ||
|
|
f58c3b1081 | ||
|
|
a0623fdeff | ||
|
|
ba16ff1c14 | ||
|
|
788a86331d | ||
|
|
568e8996d4 | ||
|
|
4f232ac245 | ||
|
|
8ce73e9920 | ||
|
|
a45d42bb95 | ||
|
|
1258de0571 | ||
|
|
6f0a651b53 | ||
|
|
8c0f341632 | ||
|
|
b122a48cba | ||
|
|
a7eb3514fc | ||
|
|
54514bb178 | ||
|
|
693f617957 | ||
|
|
a3fa306ac1 | ||
|
|
a7162352ec | ||
|
|
51de0d1a03 | ||
|
|
43f5d9f973 | ||
|
|
a1e1060ae3 | ||
|
|
3063fad0d2 | ||
|
|
4ad434a71c | ||
|
|
8be291d9d2 | ||
|
|
9b07d2ecbc |
No files matched your search
+8
-1
@@ -1,3 +1,10 @@
|
||||
#################### Version
|
||||
# The version of meelo to use (edge, latest, vX.X.X)
|
||||
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
|
||||
# The default value is 'latest'
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
PORT=
|
||||
@@ -36,4 +43,4 @@ API_KEYS=
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/.next
|
||||
key: front
|
||||
restore-keys: front
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -3,7 +3,18 @@ description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
|
||||
shell: sh
|
||||
- run: mkdir ./pip-cache
|
||||
shell: sh
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./pip-cache
|
||||
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
|
||||
restore-keys: matcher-
|
||||
- run: pip3 install -r matcher/requirements.txt
|
||||
shell: sh
|
||||
env:
|
||||
PIP_CACHE_DIR: ./pip-cache
|
||||
@@ -12,4 +12,5 @@ runs:
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: 'scanner/go.mod'
|
||||
cache-dependency-path: scanner/go.sum
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: '1.24.1'
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./server/node_modules
|
||||
./server/dist
|
||||
key: server
|
||||
restore-keys: server
|
||||
key: server-${{ hashFiles('server/yarn.lock') }}
|
||||
restore-keys: server-
|
||||
@@ -0,0 +1,59 @@
|
||||
name: 'Dockerisation'
|
||||
description: 'Build using Docker, and publish if necessary'
|
||||
inputs:
|
||||
docker-username:
|
||||
description: 'Username to publish Docker image under'
|
||||
required: true
|
||||
docker-token:
|
||||
description: 'Token to publish Docker image'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub Token'
|
||||
required: true
|
||||
image-suffix:
|
||||
description: "Suffix to 'meelo-'"
|
||||
required: true
|
||||
publish:
|
||||
description: 'Publish to registry'
|
||||
default: 'false'
|
||||
context:
|
||||
description: 'Directory of the Dockerfile'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-${{ inputs.image-suffix }}
|
||||
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/documentation.yml'
|
||||
- 'assets/**'
|
||||
Build:
|
||||
needs: changes
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v3
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIG_FILE: docs/mkdocs.yml
|
||||
REQUIREMENTS: docs/requirements.txt
|
||||
+10
-52
@@ -28,6 +28,7 @@ jobs:
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -92,70 +93,27 @@ jobs:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./front
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-front
|
||||
ghcr.io/${{ github.repository }}-front
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: front
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
matcher:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
- '.github/actions/cache-matcher/**'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -69,63 +70,21 @@ jobs:
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Typecheck ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Typecheck Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./matcher
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-matcher
|
||||
ghcr.io/${{ github.repository }}-matcher
|
||||
- name: Typecheck and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./matcher
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: matcher
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -86,70 +86,28 @@ jobs:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./scanner
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-scanner
|
||||
ghcr.io/${{ github.repository }}-scanner
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./scanner
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: scanner
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
server:
|
||||
- 'server/**'
|
||||
- '.github/workflows/server.yml'
|
||||
- '.github/actions/cache-server/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -121,70 +122,27 @@ jobs:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./server
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./server
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-server
|
||||
ghcr.io/${{ github.repository }}-server
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: server
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -37,7 +37,7 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
@@ -46,7 +46,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
|
||||
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
|
||||
|
||||
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
|
||||
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
@@ -65,6 +65,7 @@ https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
@@ -72,12 +73,14 @@ More screenshots [here](./assets/examples/)
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
|
||||
|
||||
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
|
||||
|
||||
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
---
|
||||
|
||||
Get ready to make the most out of you music collection!
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
@@ -1,7 +1,7 @@
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/arthi-chaud/meelo-server:latest
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: ghcr.io/arthi-chaud/meelo-scanner:latest
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: ghcr.io/arthi-chaud/meelo-matcher:latest
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
site/
|
||||
@@ -1,45 +0,0 @@
|
||||
site_name: Meelo - User Documentation
|
||||
docs_dir: './src'
|
||||
repo_url: https://github.com/Arthi-chaud/Meelo
|
||||
nav:
|
||||
- Setup:
|
||||
- TLDR: setup/tldr.md
|
||||
- Requirements: setup/requirements.md
|
||||
- Prepare Music Library: setup/prepare-music-library.md
|
||||
- Create Configuration Folder: setup/create-conf-folder.md
|
||||
- Prepare Your Settings: setup/settings.md
|
||||
- Configure Providers: setup/providers.md
|
||||
- Environment Variables: setup/env.md
|
||||
- Docker: setup/docker.md
|
||||
- Start: setup/startup.md
|
||||
- Create First Library: setup/create-library.md
|
||||
- Album Types: album-types.md
|
||||
- Song Types: song-types.md
|
||||
- Featuring Artists: featuring-artists.md
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
scheme: default
|
||||
titles_only: true
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
font:
|
||||
text: Noto Sans
|
||||
logo: ./icon.png
|
||||
favicon: ./favicon.ico
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.tracking
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.indexes
|
||||
- toc.integrate
|
||||
- navigation.top
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
plugins:
|
||||
- search
|
||||
markdown_extensions:
|
||||
- mdx_truly_sane_lists
|
||||
@@ -1,2 +0,0 @@
|
||||
mkdocs-material
|
||||
mdx_truly_sane_lists
|
||||
@@ -1,42 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports this categorization, and the following album types:
|
||||
|
||||
- Studio Recording
|
||||
- What we would usually call *Albums*
|
||||
- Live Recording
|
||||
- Compilation
|
||||
- Single
|
||||
- Soundtrack
|
||||
- Remix Album
|
||||
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
|
||||
- Video Album
|
||||
- Any kind of collection that would only contain video tracks
|
||||
|
||||
## Type recognition
|
||||
|
||||
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
|
||||
|
||||
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
|
||||
|
||||
### Keywords
|
||||
|
||||
| Album Type | Keywords |
|
||||
|---|---|
|
||||
| Studio Album | See note below |
|
||||
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
|
||||
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
|
||||
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
|
||||
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
|
||||
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
|
||||
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
|
||||
|
||||
Notes:
|
||||
|
||||
- Keywords are case-insensitive.
|
||||
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
|
||||
- Any album that would not fall into the other categories will be considered to be a Studio Recording
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/banner-black.png
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/favicon.ico
|
||||
@@ -1,53 +0,0 @@
|
||||
# Meelo's Featuring Artist Management
|
||||
|
||||
Meelo is now able to parse and recognise featuring artists on a song, based on:
|
||||
|
||||
- Its name
|
||||
- Its artist field (from the metadata)
|
||||
|
||||
It does not leverage the album artist.
|
||||
|
||||
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
|
||||
|
||||
Therefore, each featuring artist will be added to the database, and treated like any other artist.
|
||||
|
||||
Parsing works with `()`, `[]` and `-` as delimiters.
|
||||
|
||||
## Examples
|
||||
|
||||
In this section, we list examples that would be successfully parsed by Meelo
|
||||
|
||||
- 'My Song (feat. B)' by 'A'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song (Remix) [feat. B]' by 'A'
|
||||
- Name: 'My Song (Remix)'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A & B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C'
|
||||
- 'My Song - With D' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C', 'D'
|
||||
- 'My Song - Version With Drums' by 'A'
|
||||
- Name: 'My Song - Version With Drums'
|
||||
- Artist: 'A'
|
||||
- Featuring: None
|
||||
- 'My Song' by 'A Vs. B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
|
||||
## Special Case: Artist is already known
|
||||
|
||||
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
|
||||
|
||||
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/icon.png
|
||||
@@ -1,3 +0,0 @@
|
||||

|
||||
|
||||
# Welcome to Meelo's User Documentation
|
||||
@@ -1,7 +0,0 @@
|
||||
# Create a configuration folder
|
||||
|
||||
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
|
||||
|
||||
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
|
||||
|
||||
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Create your first library
|
||||
|
||||
After creating the admin account (from Web App),
|
||||
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
|
||||
|
||||
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
|
||||
|
||||
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
|
||||
@@ -1,5 +0,0 @@
|
||||
# Docker and Docker-compose
|
||||
|
||||
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
|
||||
|
||||
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Environment variables
|
||||
|
||||
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
|
||||
@@ -1,18 +0,0 @@
|
||||
# Prepare your music library
|
||||
|
||||
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
|
||||
|
||||
```tree
|
||||
Library's Folder
|
||||
│
|
||||
Artist 1
|
||||
│ │
|
||||
│ └───Album 1
|
||||
│ │ 01 Track 1.m4a
|
||||
│ │ 02 Track 2.m4a
|
||||
| | cover.jpg
|
||||
│ │ ...
|
||||
│ ...
|
||||
```
|
||||
|
||||
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Providers
|
||||
|
||||
## Enabling Providers
|
||||
|
||||
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
|
||||
|
||||
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
|
||||
|
||||
```json
|
||||
// In settings.json
|
||||
{
|
||||
//... Other properties
|
||||
"providers": {
|
||||
"provider1": {}, // Provider 1 is enabled
|
||||
// Provider 2 was not specified, it is therefore disabled
|
||||
"provider3": {
|
||||
"enabled": true // Provider 3 is enabled
|
||||
},
|
||||
"provider4": {
|
||||
"enabled": false, // Provider 4 is disabled
|
||||
"apiKey": "" // Other property of provider 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a provider is disabled, no call to its service will be done.
|
||||
|
||||
## Providers' properties and features
|
||||
|
||||
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|
||||
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
|
||||
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
|
||||
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
|
||||
| AllMusic | `allmusic` | None | | | x | x | | | | |
|
||||
| MetaCritic | `metacritic` | None | | | x | x | | | | |
|
||||
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
|
||||
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
|
||||
|
||||
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
|
||||
|
||||
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
|
||||
@@ -1,8 +0,0 @@
|
||||
# Requirements
|
||||
|
||||
Meelo is easy to setup! But you'll need the following things before continuing
|
||||
|
||||
- Have you music library ready (see [*this step*](../prepare-music-library))
|
||||
- Know how `Docker` and `docker-compose` work
|
||||
- Know how to create a `.env` file
|
||||
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
|
||||
@@ -1,35 +0,0 @@
|
||||
# Prepare your settings
|
||||
|
||||
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
All fields are **required**
|
||||
|
||||
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
|
||||
- `AlbumArtist`: The artist of the parent album.
|
||||
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
|
||||
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
|
||||
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
|
||||
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
|
||||
- `Year`: The Year of the release of the material
|
||||
- `Disc`: The Index of the disc the track is on.
|
||||
- `Index`: The index of the track in a playlist/on a disc.
|
||||
- `Track`: The name of the track
|
||||
- `Genre`: The genre of the material
|
||||
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
|
||||
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
|
||||
- `metadata`: Configuration of the **metadata extraction** system:
|
||||
- `source`: The **primary metadata source** to use:
|
||||
- `embedded`: Using the embedded tags
|
||||
- `path`: Using the Regular Expression's groups.
|
||||
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
|
||||
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
|
||||
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
|
||||
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
|
||||
- `providers`: Please refer to [*Providers*](../providers)
|
||||
- `compilations`:
|
||||
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
|
||||
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
|
||||
|
||||
*Note*: The provided Regular Expression uses the iTunes file architecture.
|
||||
|
||||
*Note*: Regular Expression must use the Golang Flavour.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Start Meelo
|
||||
|
||||
Once these three files are ready, run the following command from your `Meelo` folder:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Two things can prevent Meelo from starting normally:
|
||||
|
||||
- A bad `settings.json` file
|
||||
- In that case, a message should tell you what went wrong
|
||||
- Connection to database failed
|
||||
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
|
||||
@@ -1,12 +0,0 @@
|
||||
# TLDR
|
||||
|
||||
Don't feel like reading? Here's how to setup Meelo quickly
|
||||
|
||||
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
|
||||
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
|
||||
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
|
||||
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
|
||||
|
||||
Enjoy!
|
||||
@@ -1,31 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
It is possible to filter songs based on their *type*.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports the following song types:
|
||||
|
||||
- Original
|
||||
- Live
|
||||
- Acoustic
|
||||
- Remix
|
||||
- Instrumental
|
||||
- A Cappella
|
||||
- Edit
|
||||
- Of the original version.
|
||||
- If the song is an edit of a remix, it will be categorised as 'Remix'
|
||||
- Clean
|
||||
- Demo
|
||||
- Medley
|
||||
- Non-Music (e.g. Interviews)
|
||||
|
||||
## Type recognition
|
||||
|
||||
Songs' type recognition is solely based on keywords present in their name. For example:
|
||||
|
||||
- 'My Song (Remix)' would be typed as 'Remix'
|
||||
- 'My Song (Live)' would be recognised as a Live rendition
|
||||
- 'My Song (Rough Mix)' would be defined as 'Demo'
|
||||
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
|
||||
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
|
||||
@@ -1,3 +0,0 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #2F2C2B;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import userReducer, {
|
||||
setAccessToken,
|
||||
setUserProfile,
|
||||
unsetAccessToken,
|
||||
} from "../../src/state/userSlice";
|
||||
|
||||
describe("User Slice", () => {
|
||||
let state: ReturnType<typeof userReducer> | undefined = undefined;
|
||||
|
||||
it("Should set the access token", () => {
|
||||
state = userReducer({}, setAccessToken("1234"));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234" });
|
||||
});
|
||||
|
||||
it("Should set the user profile", () => {
|
||||
const user = { id: 1, name: "user", admin: true, enabled: true };
|
||||
state = userReducer(state, setUserProfile(user));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234", user });
|
||||
});
|
||||
|
||||
it("Should unset the access token, and user profile", () => {
|
||||
state = userReducer(state, unsetAccessToken());
|
||||
|
||||
expect(state.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+3
-6
@@ -20,7 +20,6 @@
|
||||
"@mui/utils": "^5.10.6",
|
||||
"@mui/x-data-grid": "^5.17.14",
|
||||
"@mui/x-date-pickers": "^5.0.8",
|
||||
"@reduxjs/toolkit": "^1.8.5",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"change-case": "^5.1.2",
|
||||
@@ -31,9 +30,10 @@
|
||||
"hls.js": "^1.5.11",
|
||||
"i18next": "^23.7.16",
|
||||
"iconsax-react": "^0.0.8",
|
||||
"jotai": "^2.12.1",
|
||||
"material-ui-confirm": "^3.0.7",
|
||||
"mui-react-hook-form-plus": "^1.3.2",
|
||||
"next": "14.2.23",
|
||||
"next": "14.2.25",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -43,13 +43,10 @@
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-loader-spinner": "^5.2.0",
|
||||
"react-query": "^3.39.2",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-use": "^17.5.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sharp": "^0.33.4",
|
||||
"type-fest": "^2.19.0",
|
||||
"type-fest": "^4.33.0",
|
||||
"usehooks-ts": "^2.9.5",
|
||||
"uuid": "^10.0.0",
|
||||
"yup": "^1.0.0"
|
||||
|
||||
+57
-23
@@ -38,6 +38,7 @@ import {
|
||||
import File from "../models/file";
|
||||
import Genre from "../models/genre";
|
||||
import Library from "../models/library";
|
||||
import { Lyrics } from "../models/lyrics";
|
||||
import PaginatedResponse, {
|
||||
type PaginationParameters,
|
||||
} from "../models/pagination";
|
||||
@@ -82,7 +83,8 @@ import {
|
||||
type VideoType,
|
||||
VideoWithRelations,
|
||||
} from "../models/video";
|
||||
import store from "../state/store";
|
||||
import { store } from "../state/store";
|
||||
import { accessTokenAtom } from "../state/user";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import type { SortingParameters } from "../utils/sorting";
|
||||
import type { InfiniteQuery, Query } from "./use-query";
|
||||
@@ -139,7 +141,8 @@ export default class API {
|
||||
([_, value]) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "view",
|
||||
value !== "view" &&
|
||||
(Array.isArray(value) ? value.length > 0 : true),
|
||||
)
|
||||
.map(([key, value]) => `params-${key}-${value}`)
|
||||
: [];
|
||||
@@ -201,6 +204,7 @@ export default class API {
|
||||
service: Service.Scanner,
|
||||
validator: yup.object({
|
||||
current_task: yup.string().required().nullable(),
|
||||
progress: yup.number().required().nullable(),
|
||||
pending_tasks: yup
|
||||
.array(yup.string().required())
|
||||
.required(),
|
||||
@@ -413,12 +417,13 @@ export default class API {
|
||||
static async refreshMetadata(
|
||||
parentResourceType: "library" | "album" | "song" | "release" | "track",
|
||||
resourceSlugOrId: number | string,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
return API.fetch({
|
||||
method: "POST",
|
||||
route: "/refresh",
|
||||
errorMessage: "Metadata Refresh request failed",
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId },
|
||||
otherParameters: { [parentResourceType]: resourceSlugOrId, force },
|
||||
parameters: {},
|
||||
service: Service.Scanner,
|
||||
emptyResponse: true,
|
||||
@@ -432,7 +437,6 @@ export default class API {
|
||||
return API.fetch({
|
||||
route: "/libraries",
|
||||
data: { name: libraryName, path: libraryPath },
|
||||
errorMessage: "Library Creation Failed",
|
||||
parameters: {},
|
||||
method: "POST",
|
||||
validator: Library,
|
||||
@@ -598,7 +602,7 @@ export default class API {
|
||||
*/
|
||||
static getArtists<I extends ArtistInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
album?: Identifier;
|
||||
genre?: Identifier;
|
||||
query?: Identifier;
|
||||
@@ -621,6 +625,7 @@ export default class API {
|
||||
otherParameters: {
|
||||
albumArtistOnly: filter.album ? undefined : "true",
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
ArtistWithRelations(include ?? []),
|
||||
@@ -635,10 +640,10 @@ export default class API {
|
||||
*/
|
||||
static getAlbums<I extends AlbumInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
genre?: Identifier;
|
||||
type?: AlbumType;
|
||||
type?: AlbumType[];
|
||||
related?: Identifier;
|
||||
appearance?: Identifier;
|
||||
query?: Identifier;
|
||||
@@ -659,7 +664,11 @@ export default class API {
|
||||
route: "/albums",
|
||||
errorMessage: "Albums could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
AlbumWithRelations(include ?? []),
|
||||
),
|
||||
@@ -702,8 +711,8 @@ export default class API {
|
||||
*/
|
||||
static getSongs<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
type?: SongType;
|
||||
library?: Identifier[];
|
||||
type?: SongType[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
versionsOf?: Identifier;
|
||||
@@ -727,7 +736,11 @@ export default class API {
|
||||
route: "/songs",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
SongWithRelations(include ?? []),
|
||||
),
|
||||
@@ -737,7 +750,7 @@ export default class API {
|
||||
|
||||
static getSongGroups<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
query?: string;
|
||||
@@ -758,7 +771,10 @@ export default class API {
|
||||
route: "/song-groups",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
SongGroupWithRelations(include ?? []),
|
||||
),
|
||||
@@ -771,13 +787,13 @@ export default class API {
|
||||
*/
|
||||
static getVideos<I extends VideoInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
album?: Identifier;
|
||||
song?: Identifier;
|
||||
group?: Identifier;
|
||||
random?: number;
|
||||
type?: VideoType;
|
||||
type?: VideoType[];
|
||||
query?: string;
|
||||
},
|
||||
sort?: SortingParameters<typeof VideoSortingKeys>,
|
||||
@@ -799,7 +815,11 @@ export default class API {
|
||||
include,
|
||||
sort,
|
||||
},
|
||||
otherParameters: filter,
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
VideoWithRelations(include ?? []),
|
||||
),
|
||||
@@ -978,7 +998,7 @@ export default class API {
|
||||
* @returns A query to a User object
|
||||
*/
|
||||
static getCurrentUserStatus(): Query<User> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
|
||||
return {
|
||||
key: ["user", accessToken ?? ""],
|
||||
@@ -1077,7 +1097,7 @@ export default class API {
|
||||
* @returns an Infinite Query of tracks
|
||||
*/
|
||||
static getTracks<I extends TrackInclude | never = never>(
|
||||
filter: { song?: string | number },
|
||||
filter: { song?: string | number; library?: Identifier[] },
|
||||
sort?: SortingParameters<typeof TrackSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TrackWithRelations<I>> {
|
||||
@@ -1091,7 +1111,10 @@ export default class API {
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: "/tracks",
|
||||
otherParameters: filter,
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
validator: PaginatedResponse(
|
||||
TrackWithRelations(include ?? []),
|
||||
@@ -1156,7 +1179,7 @@ export default class API {
|
||||
* @param slugOrId the id of the song
|
||||
* @returns A query for an array of strings
|
||||
*/
|
||||
static getSongLyrics(slugOrId: string | number): Query<string[] | null> {
|
||||
static getSongLyrics(slugOrId: string | number): Query<Lyrics | null> {
|
||||
return {
|
||||
key: ["song", slugOrId, "lyrics"],
|
||||
exec: () =>
|
||||
@@ -1164,8 +1187,7 @@ export default class API {
|
||||
route: `/songs/${slugOrId}/lyrics`,
|
||||
errorMessage: "Lyrics loading failed",
|
||||
parameters: {},
|
||||
customValidator: async (value) =>
|
||||
(value as { lyrics: string }).lyrics.split("\n"),
|
||||
validator: Lyrics,
|
||||
}).catch(() => null),
|
||||
};
|
||||
}
|
||||
@@ -1292,7 +1314,7 @@ export default class API {
|
||||
emptyResponse,
|
||||
service,
|
||||
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
|
||||
const accessToken = store.getState().user.accessToken;
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
const header = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@@ -1517,4 +1539,16 @@ export default class API {
|
||||
formattedParameters.push(`take=${pageSize}`);
|
||||
return formattedParameters.join("&");
|
||||
}
|
||||
|
||||
private static formatOr(
|
||||
items: (string | number)[] | undefined,
|
||||
): string | undefined {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
return items[0].toString();
|
||||
}
|
||||
return `or:${items.map((i) => i.toString()).join(",")}`;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
/* eslint-disable no-restricted-imports */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type QueryFunctionContext,
|
||||
useInfiniteQuery as useReactInfiniteQuery,
|
||||
@@ -172,8 +173,7 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
const pageSize = API.defaultPageSize;
|
||||
|
||||
return useReactInfiniteQuery({
|
||||
const { data, ...res } = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(query, ...queryParams),
|
||||
getNextPageParam: (
|
||||
lastPage: Page<ReturnType>,
|
||||
@@ -184,6 +184,12 @@ const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
|
||||
return lastPage;
|
||||
},
|
||||
});
|
||||
const [items, setItems] = useState(data?.pages.at(0)?.items);
|
||||
useEffect(() => {
|
||||
setItems(data?.pages.flatMap((p) => p.items));
|
||||
}, [data?.pages]);
|
||||
|
||||
return { ...res, data, items };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import store from "../../state/store";
|
||||
import { unsetAccessToken } from "../../state/userSlice";
|
||||
import { store } from "../../state/store";
|
||||
import { accessTokenAtom } from "../../state/user";
|
||||
import { LogoutIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
@@ -25,5 +25,5 @@ export const LogoutAction: Action = {
|
||||
label: "logout",
|
||||
icon: <LogoutIcon />,
|
||||
href: "/",
|
||||
onClick: () => store.dispatch(unsetAccessToken()),
|
||||
onClick: () => store.set(accessTokenAtom, undefined),
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import toast from "react-hot-toast";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
InfoIcon,
|
||||
LyricsIcon,
|
||||
RelatedTracksIcon,
|
||||
SearchIcon,
|
||||
@@ -37,6 +38,14 @@ export const GoToSongLyricsAction = (
|
||||
icon: <LyricsIcon />,
|
||||
});
|
||||
|
||||
export const GoToSongInfoAction = (
|
||||
songIdentifier: string | number,
|
||||
): Action => ({
|
||||
href: `/songs/${songIdentifier}/info`,
|
||||
label: "songInfo",
|
||||
icon: <InfoIcon />,
|
||||
});
|
||||
|
||||
export const GoToArtistAction = (
|
||||
artistIdentifier: string | number,
|
||||
): Action => ({
|
||||
|
||||
@@ -18,21 +18,33 @@
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { Add, Edit } from "iconsax-react";
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import type { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
|
||||
import type { PlayerActions, TrackState } from "../../contexts/player";
|
||||
import {
|
||||
type MeeloInfiniteQueryFn,
|
||||
type QueryClient,
|
||||
useQueryClient,
|
||||
} from "../../api/use-query";
|
||||
import type Playlist from "../../models/playlist";
|
||||
import type { PlaylistWithRelations } from "../../models/playlist";
|
||||
import {
|
||||
type TrackState,
|
||||
playAfterAtom,
|
||||
playNextAtom,
|
||||
} from "../../state/player";
|
||||
import { store } from "../../state/store";
|
||||
import {
|
||||
AddItemToPlaylistIcon,
|
||||
AddToPlaylistIcon,
|
||||
@@ -43,16 +55,14 @@ import {
|
||||
import Illustration from "../illustration";
|
||||
import InfiniteList from "../infinite/infinite-list";
|
||||
import ListItem from "../list-item/item";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import type Action from "./action";
|
||||
|
||||
export const PlayNextAction = (
|
||||
getTrack: () => PromiseLike<TrackState>,
|
||||
playNext: PlayerActions["playNext"],
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
getTrack().then((track) => {
|
||||
playNext(track);
|
||||
store.set(playNextAtom, track);
|
||||
toast.success(`'${track.track.name}' will play next!`);
|
||||
}),
|
||||
label: "playNext",
|
||||
@@ -61,11 +71,10 @@ export const PlayNextAction = (
|
||||
|
||||
export const PlayAfterAction = (
|
||||
getTrack: () => PromiseLike<TrackState>,
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
): Action => ({
|
||||
onClick: () =>
|
||||
getTrack().then((track) => {
|
||||
playAfter(track);
|
||||
store.set(playAfterAtom, track);
|
||||
toast.success(`'${track.track.name}' will play after!`);
|
||||
}),
|
||||
label: "playAfter",
|
||||
@@ -73,7 +82,7 @@ export const PlayAfterAction = (
|
||||
});
|
||||
|
||||
type CreateOrUpdatePlaylistFormProps = {
|
||||
onSubmit: (newUrl: string) => void;
|
||||
onSubmit: (playlistName: string) => void;
|
||||
onClose: () => void;
|
||||
defaultValue?: string;
|
||||
};
|
||||
@@ -126,15 +135,19 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CreatePlaylistAction = (queryClient: QueryClient): Action => ({
|
||||
export const CreatePlaylistAction = (
|
||||
queryClient: QueryClient,
|
||||
onCreated?: (playlistId: number) => void,
|
||||
): Action => ({
|
||||
label: "new",
|
||||
icon: <Add />,
|
||||
dialog: ({ close }) => {
|
||||
const mutation = useMutation((playlistName: string) => {
|
||||
return API.createPlaylist(playlistName)
|
||||
.then(() => {
|
||||
.then((playlist) => {
|
||||
toast.success("Playlist created!");
|
||||
queryClient.client.invalidateQueries("playlists");
|
||||
onCreated?.(playlist.id);
|
||||
})
|
||||
.catch((error: Error) => toast.error(error.message));
|
||||
});
|
||||
@@ -213,13 +226,37 @@ type SelectPlaylistFormProps = {
|
||||
|
||||
const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const createPlaylistAction = CreatePlaylistAction(queryClient);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const closeModal = () => setOpenModal(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={openModal}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
sx={{ zIndex: 999999 }}
|
||||
>
|
||||
{createPlaylistAction.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
<DialogTitle>Select a playlist</DialogTitle>
|
||||
<DialogContent>
|
||||
<ListItem
|
||||
title={t(createPlaylistAction.label!)}
|
||||
icon={
|
||||
<Illustration
|
||||
illustration={null}
|
||||
fallback={createPlaylistAction.icon}
|
||||
quality="low"
|
||||
/>
|
||||
}
|
||||
onClick={() => setOpenModal(true)}
|
||||
secondTitle={null}
|
||||
/>
|
||||
<Divider sx={{ paddingTop: 1 }} />
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.playlistQuery}
|
||||
render={(item, index) => (
|
||||
<ListItem
|
||||
|
||||
@@ -16,22 +16,76 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Checkbox, Grid } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { ArraySlice } from "type-fest";
|
||||
import API from "../../api/api";
|
||||
import type { Translator } from "../../i18n/i18n";
|
||||
import { MetadataRefreshIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
type APIMethodParams = ArraySlice<
|
||||
Parameters<typeof API.refreshMetadata>,
|
||||
0,
|
||||
-1
|
||||
>;
|
||||
|
||||
const RefreshMetadataActionContent = ({
|
||||
t,
|
||||
params,
|
||||
close,
|
||||
}: { t: Translator; params: APIMethodParams; close: () => void }) => {
|
||||
const [force, setForce] = useState(false);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ padding: 2 }}>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={force}
|
||||
onChange={(_, isChecked) => setForce(isChecked)}
|
||||
/>
|
||||
{t("refreshMetadataForceLabel")}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
API.refreshMetadata(...[...params, force])
|
||||
.then(() =>
|
||||
toast.success(t("refreshMetadataStarted")),
|
||||
)
|
||||
.catch(() =>
|
||||
toast.error(t("refreshMetadataFailed")),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{t("refreshMetadata")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshMetadataAction = (
|
||||
t: Translator,
|
||||
...params: Parameters<typeof API.refreshMetadata>
|
||||
...params: APIMethodParams
|
||||
): Action => ({
|
||||
label: "refreshMetadata",
|
||||
icon: <MetadataRefreshIcon />,
|
||||
onClick: () =>
|
||||
API.refreshMetadata(...params)
|
||||
.then(() => toast.success(t("refreshMetadataStarted")))
|
||||
.catch(() => toast.error(t("refreshMetadataFailed"))),
|
||||
dialog: ({ close }) => (
|
||||
<RefreshMetadataActionContent t={t} params={params} close={close} />
|
||||
),
|
||||
});
|
||||
|
||||
export const RefreshLibraryMetadataAction = (
|
||||
|
||||
@@ -30,7 +30,8 @@ import type Song from "../../models/song";
|
||||
import { SongType } from "../../models/song";
|
||||
import type Video from "../../models/video";
|
||||
import { VideoType } from "../../models/video";
|
||||
import store from "../../state/store";
|
||||
import { store } from "../../state/store";
|
||||
import { userAtom } from "../../state/user";
|
||||
import { EditIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
@@ -78,7 +79,7 @@ const ChangeResourceType = <
|
||||
return {
|
||||
label: label,
|
||||
icon: <EditIcon />,
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
onClick: () =>
|
||||
confirm({
|
||||
title: <br />,
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ShowTrackFileInfoAction = (
|
||||
trackId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "trackInfo",
|
||||
onClick: () => openTrackFileInfoModal(confirm, trackId),
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ShowMasterTrackFileInfoAction = (
|
||||
songId: number,
|
||||
): Action => ({
|
||||
icon: <InfoIcon />,
|
||||
label: "moreInfo",
|
||||
label: "trackInfo",
|
||||
onClick: () =>
|
||||
queryClient
|
||||
.fetchQuery(API.getSongMasterTrack(songId))
|
||||
|
||||
@@ -28,7 +28,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import type { QueryClient } from "../../api/use-query";
|
||||
import store from "../../state/store";
|
||||
import { store } from "../../state/store";
|
||||
import { userAtom } from "../../state/user";
|
||||
import { UpdateIllustrationIcon } from "../icons";
|
||||
import type Action from "./action";
|
||||
|
||||
@@ -107,7 +108,7 @@ const UpdateIllustrationAction = (
|
||||
|
||||
return {
|
||||
label: "changeIllutration",
|
||||
disabled: store.getState().user.user?.admin !== true,
|
||||
disabled: store.get(userAtom)?.admin !== true,
|
||||
icon: <UpdateIllustrationIcon />,
|
||||
dialog: (controls) => (
|
||||
<IllustrationUpdateForm
|
||||
|
||||
@@ -36,19 +36,14 @@ const AdminGrid = <DataType extends Resource>({
|
||||
}: AdminGridProps<DataType>) => {
|
||||
const {
|
||||
data,
|
||||
items,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
} = useInfiniteQuery(infiniteQuery);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const itemsCount = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
.map((page) => page.items.length)
|
||||
.reduce((pageSize, sum) => pageSize + sum, 0) ?? 0,
|
||||
[data?.pages],
|
||||
);
|
||||
const itemsCount = useMemo(() => items?.length, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
hasNextPage && fetchNextPage();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Avatar } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import { ArtistIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
const ArtistAvatar = (props: {
|
||||
@@ -39,6 +40,7 @@ const ArtistAvatar = (props: {
|
||||
imgProps={{ objectFit: "cover" }}
|
||||
illustration={props.illustration}
|
||||
quality={props.quality}
|
||||
fallback={<ArtistIcon />}
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
*/
|
||||
|
||||
import { Button, Divider, Grid } from "@mui/material";
|
||||
import { useAtom } from "jotai";
|
||||
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { setAccessToken } from "../../state/userSlice";
|
||||
import { accessTokenAtom } from "../../state/user";
|
||||
|
||||
/**
|
||||
* Authentication form
|
||||
* On successful authentication, update store with access token
|
||||
*/
|
||||
const AuthenticationForm = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [formType, setFormType] = useState<"login" | "signup">("login");
|
||||
const defaultValues = { username: "", password: "", confirm: "" };
|
||||
const [password, setPassword] = useState(defaultValues.password);
|
||||
const [_, setAccessToken] = useAtom(accessTokenAtom);
|
||||
const { registerState, handleSubmit } = useHookForm({
|
||||
defaultValues,
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const AuthenticationForm = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatch(setAccessToken((await API.login(values)).access_token));
|
||||
setAccessToken((await API.login(values)).access_token);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
|
||||
@@ -19,25 +19,22 @@
|
||||
import { Grid } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useQuery as useReactQuery } from "react-query";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloQuery } from "../../api/use-query";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { setUserProfile } from "../../state/userSlice";
|
||||
import { accessTokenAtom, userAtom } from "../../state/user";
|
||||
import ModalPage from "../modal-page";
|
||||
import ThemedImage from "../themed-image";
|
||||
import AuthenticationForm from "./authentication-form";
|
||||
|
||||
const AuthenticationWall = (props: { children: any }) => {
|
||||
const accessToken = useSelector(
|
||||
(store: RootState) => store.user.accessToken,
|
||||
);
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [_, setUser] = useAtom(userAtom);
|
||||
const status = useReactQuery({
|
||||
...prepareMeeloQuery(API.getCurrentUserStatus),
|
||||
useErrorBoundary: false,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const [authentified, setAuthenticationStatus] = useState(
|
||||
status.data?.id !== undefined,
|
||||
);
|
||||
@@ -49,15 +46,15 @@ const AuthenticationWall = (props: { children: any }) => {
|
||||
if (accessToken && status.data?.id && !status.error) {
|
||||
setAuthenticationStatus(true);
|
||||
}
|
||||
if (status.error || accessToken?.valueOf() === undefined) {
|
||||
if (status.error || accessToken === undefined) {
|
||||
setAuthenticationStatus(false);
|
||||
}
|
||||
}, [accessToken, status, authentified]);
|
||||
useEffect(() => {
|
||||
if (accessToken && status.data && !status.error) {
|
||||
dispatch(setUserProfile(status.data));
|
||||
setUser(status.data);
|
||||
}
|
||||
}, [accessToken, status, dispatch]);
|
||||
}, [accessToken, status]);
|
||||
if (!authentified || !status.data?.id) {
|
||||
return (
|
||||
<ModalPage open>
|
||||
|
||||
@@ -20,11 +20,9 @@ import { useConfirm } from "material-ui-confirm";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { DownloadReleaseAction } from "../actions/download";
|
||||
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
|
||||
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
|
||||
@@ -34,14 +32,16 @@ import { UpdateReleaseIllustrationAction } from "../actions/update-illustration"
|
||||
import { MasterIcon, UpgradeIcon } from "../icons";
|
||||
import ContextualMenu from "./contextual-menu";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "../../state/user";
|
||||
|
||||
type ReleaseContextualMenuProps = {
|
||||
release: ReleaseWithRelations<"album">;
|
||||
};
|
||||
|
||||
const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
|
||||
const userIsAdmin = useSelector(
|
||||
(state: RootState) => state.user.user?.admin === true,
|
||||
);
|
||||
const [user] = useAtom(userAtom);
|
||||
const userIsAdmin = user?.admin === true;
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
@@ -52,17 +52,21 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
onSelect={props.onSelect}
|
||||
actions={[
|
||||
[GoToArtistAction(props.artist.slug)],
|
||||
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
|
||||
songSlug
|
||||
? [
|
||||
GoToSongLyricsAction(songSlug),
|
||||
GoToSongInfoAction(songSlug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(async () => props, playNext),
|
||||
PlayAfterAction(async () => props, playAfter),
|
||||
PlayNextAction(async () => props),
|
||||
PlayAfterAction(async () => props),
|
||||
],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
|
||||
@@ -22,13 +22,13 @@ import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import { DownloadAsyncAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToRelatedTracksAction,
|
||||
GoToReleaseAsyncAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
GoToSongVersionAction,
|
||||
} from "../actions/link";
|
||||
@@ -55,7 +55,6 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
const songSlug = props.song.slug;
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const getMasterTrack = () =>
|
||||
queryClient.fetchQuery(
|
||||
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
|
||||
@@ -80,10 +79,10 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
|
||||
(await getMasterTrack()).release?.slug ?? null,
|
||||
),
|
||||
],
|
||||
[GoToSongLyricsAction(songSlug)],
|
||||
[GoToSongLyricsAction(songSlug), GoToSongInfoAction(songSlug)],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
[AddToPlaylistAction(props.song.id, queryClient)],
|
||||
[
|
||||
|
||||
@@ -16,19 +16,22 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import type { RootState } from "../../state/store";
|
||||
import { userAtom } from "../../state/user";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
|
||||
import {
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import {
|
||||
AddToPlaylistAction,
|
||||
PlayAfterAction,
|
||||
@@ -47,9 +50,8 @@ type TrackContextualMenuProps = {
|
||||
};
|
||||
|
||||
const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
const userIsAdmin = useSelector(
|
||||
(state: RootState) => state.user.user?.admin === true,
|
||||
);
|
||||
const [user] = useAtom(userAtom);
|
||||
const userIsAdmin = user?.admin === true;
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const isMaster = props.track.song?.masterId === props.track.id;
|
||||
@@ -68,7 +70,6 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
const masterMutation = useMutation(async () => {
|
||||
return API.updateSong(props.track.songId!, {
|
||||
masterTrackId: props.track.id,
|
||||
@@ -90,11 +91,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.track.song
|
||||
? [GoToSongLyricsAction(props.track.song.slug)]
|
||||
? [
|
||||
GoToSongLyricsAction(props.track.song.slug),
|
||||
GoToSongInfoAction(props.track.song.slug),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
props.track.songId
|
||||
? [AddToPlaylistAction(props.track.songId, queryClient)]
|
||||
|
||||
@@ -20,13 +20,13 @@ import { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import type Action from "../actions/action";
|
||||
import { DownloadAction } from "../actions/download";
|
||||
import {
|
||||
GoToArtistAction,
|
||||
GoToReleaseAction,
|
||||
GoToSongInfoAction,
|
||||
GoToSongLyricsAction,
|
||||
} from "../actions/link";
|
||||
import {
|
||||
@@ -64,7 +64,6 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
release,
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const { playNext, playAfter } = usePlayerContext();
|
||||
|
||||
return (
|
||||
<ContextualMenu
|
||||
@@ -77,11 +76,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
|
||||
: undefined,
|
||||
].filter((a): a is Action => a !== undefined),
|
||||
props.video.songId
|
||||
? [GoToSongLyricsAction(props.video.songId)]
|
||||
? [
|
||||
GoToSongLyricsAction(props.video.songId),
|
||||
GoToSongInfoAction(props.video.songId),
|
||||
]
|
||||
: [],
|
||||
[
|
||||
PlayNextAction(getPlayNextProps, playNext),
|
||||
PlayAfterAction(getPlayNextProps, playAfter),
|
||||
PlayNextAction(getPlayNextProps),
|
||||
PlayAfterAction(getPlayNextProps),
|
||||
],
|
||||
props.video.songId
|
||||
? [AddToPlaylistAction(props.video.songId, queryClient)]
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloInfiniteQuery } from "../../api/use-query";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import { type LayoutOption, getLayoutParams } from "../../utils/layout";
|
||||
import parseQueryParam from "../../utils/parse-query-param";
|
||||
import { type Order, getOrderParams } from "../../utils/sorting";
|
||||
import type Action from "../actions/action";
|
||||
import Fade from "../fade";
|
||||
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalProps extends object = object,
|
||||
> = {
|
||||
view: LayoutOption;
|
||||
order: Order;
|
||||
sortBy: SortingKeys[number];
|
||||
library: string | null;
|
||||
} & AdditionalProps;
|
||||
|
||||
type ControllerProps<
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
defaultSortingOrder?: Order;
|
||||
defaultLayout: LayoutOption;
|
||||
disableLayoutToggle?: boolean;
|
||||
onChange: (state: OptionState<SortingKeys>) => void;
|
||||
/**
|
||||
* If defined, will push changes as query parameters in router
|
||||
*/
|
||||
router?: NextRouter;
|
||||
disableLibrarySelector?: true;
|
||||
};
|
||||
|
||||
const Controls = <
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
>(
|
||||
props: ControllerProps<SortingKeys, Options, Values>,
|
||||
) => {
|
||||
const _theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
useErrorBoundary: false,
|
||||
onError: () => {
|
||||
toast.error(t("librariesLoadFail"));
|
||||
},
|
||||
});
|
||||
const libraries = useMemo(
|
||||
() => librariesQuery.data?.pages.at(0)?.items ?? null,
|
||||
[librariesQuery.data],
|
||||
);
|
||||
const [optionsState, setOptionState] = useState<OptionState<SortingKeys>>(
|
||||
() => {
|
||||
const libraryQuery = Array.isArray(props.router?.query.library)
|
||||
? props.router?.query.library?.at(0)
|
||||
: props.router?.query.library;
|
||||
const baseOptions: OptionState<SortingKeys> = {
|
||||
view:
|
||||
((props.disableLayoutToggle !== true &&
|
||||
getLayoutParams(props.router?.query.view)) ||
|
||||
undefined) ??
|
||||
props.defaultLayout,
|
||||
sortBy:
|
||||
parseQueryParam(
|
||||
props.router?.query.sortBy,
|
||||
props.sortingKeys,
|
||||
) ??
|
||||
props.defaultSortingKey ??
|
||||
props.sortingKeys[0],
|
||||
order:
|
||||
getOrderParams(props.router?.query.order) ??
|
||||
props.defaultSortingOrder ??
|
||||
"asc",
|
||||
library: props.disableLibrarySelector
|
||||
? null
|
||||
: (libraryQuery ?? null),
|
||||
};
|
||||
|
||||
for (const option of props.options ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
parseQueryParam(
|
||||
props.router?.query[option.name],
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
}
|
||||
for (const option of props.toggles ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
}
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
const [openActionModal, setOpenActionModal] = useState<string | null>(null);
|
||||
const closeModal = () => setOpenActionModal(null);
|
||||
|
||||
/**
|
||||
* To pass the options to parent after initialization
|
||||
*/
|
||||
useEffect(() => {
|
||||
props.onChange(optionsState);
|
||||
}, [optionsState]);
|
||||
|
||||
const updateOptionState = ({
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({ ...optionsState, [name]: value });
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(
|
||||
props.router.asPath.split("?").at(1) ?? "",
|
||||
);
|
||||
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
props.router.push(`${path}?${params.toString()}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (libraries === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.disableLibrarySelector !== true && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name:
|
||||
optionsState.library ??
|
||||
t("allLibraries"),
|
||||
options: [
|
||||
{
|
||||
name: "library",
|
||||
values: [
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].map((library) => library.name),
|
||||
currentValue:
|
||||
[
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].find(
|
||||
({ slug }) =>
|
||||
slug ===
|
||||
optionsState.library,
|
||||
)?.name ?? globalLibrary.name,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={({ name, value }) => {
|
||||
if (value === globalLibrary.name) {
|
||||
updateOptionState({
|
||||
name,
|
||||
value: null,
|
||||
});
|
||||
} else {
|
||||
updateOptionState({
|
||||
name,
|
||||
value:
|
||||
libraries.find(
|
||||
(lib) => lib.name === value,
|
||||
)?.slug ?? null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.disableSorting && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name: `${t("sortBy")} ${t(
|
||||
optionsState.sortBy as TranslationKey,
|
||||
)}`,
|
||||
icon:
|
||||
optionsState.order === "desc" ? (
|
||||
<DescIcon />
|
||||
) : (
|
||||
<AscIcon />
|
||||
),
|
||||
options: [
|
||||
{
|
||||
name: "sortBy",
|
||||
values: props.sortingKeys,
|
||||
currentValue: optionsState.sortBy,
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
values: ["asc", "desc"],
|
||||
currentValue: optionsState.order,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
)}
|
||||
{props.options?.map((option) => {
|
||||
return (
|
||||
<OptionButton
|
||||
key={option.name}
|
||||
optionGroup={{
|
||||
name: option.label ?? option.name,
|
||||
icon: option.icon,
|
||||
options: [
|
||||
{
|
||||
...option,
|
||||
|
||||
currentValue:
|
||||
optionsState[
|
||||
option.name as keyof typeof optionsState
|
||||
] ?? undefined,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.toggles?.map((toggle) => (
|
||||
<Button
|
||||
key={toggle.name}
|
||||
onClick={() => {
|
||||
updateOptionState({
|
||||
name: toggle.name,
|
||||
value:
|
||||
!optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(toggle.label)}
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateOptionState({
|
||||
name: "view",
|
||||
value:
|
||||
optionsState.view === "grid"
|
||||
? "list"
|
||||
: "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{optionsState.view === "grid" ? (
|
||||
<ListIcon />
|
||||
) : (
|
||||
<GridIcon />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.actions?.map((action) => (
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog &&
|
||||
setOpenActionModal(action.label);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
open={openActionModal === action.label}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</Button>
|
||||
)) ?? []}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Divider, ListItemIcon, Menu, MenuItem } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import { CheckIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import type { OptionGroup } from "./option";
|
||||
|
||||
type OptionButtonProps<
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
> = {
|
||||
optionGroup: OptionGroup<Options, OptionsKeys>;
|
||||
onSelect?: (selected: {
|
||||
name: Options[number]["name"];
|
||||
value: OptionsKeys[number][number];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param props an option group
|
||||
* @returns Dropdown Button with selector.
|
||||
*/
|
||||
|
||||
const OptionButton = <
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
>(
|
||||
props: OptionButtonProps<Options, OptionsKeys>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.optionGroup.icon}>
|
||||
{t(props.optionGroup.name as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.optionGroup.options.map((option, index, array) =>
|
||||
option.values
|
||||
.map((value: OptionsKeys[number][number]) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
sx={{ borderRadius: "0" }}
|
||||
selected={option.currentValue === value}
|
||||
onClick={() => {
|
||||
if (props.onSelect) {
|
||||
props.onSelect({
|
||||
name: option.name,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{option.currentValue === value && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
{/* If value is not a Translation Key, will fall through */}
|
||||
{t(value as TranslationKey)}
|
||||
</MenuItem>
|
||||
))
|
||||
.concat(
|
||||
index + 1 !== array.length
|
||||
? [<Divider key={index} />]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionButton;
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
type Option<Values extends readonly string[]> = {
|
||||
name: string;
|
||||
label?: string;
|
||||
icon?: JSX.Element;
|
||||
currentValue?: Values[number];
|
||||
values: Values;
|
||||
};
|
||||
|
||||
export type OptionGroup<
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
> = {
|
||||
name: string;
|
||||
icon?: JSX.Element;
|
||||
options: Options;
|
||||
};
|
||||
|
||||
export default Option;
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Grid } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../i18n/i18n";
|
||||
import type Action from "./actions/action";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
icon: React.ReactNode;
|
||||
text: TranslationKey;
|
||||
actions: Action[];
|
||||
};
|
||||
|
||||
export const EmptyState = (props: EmptyStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
rowSpacing={3}
|
||||
sx={{
|
||||
paddingTop: 3,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "text.disabled",
|
||||
}}
|
||||
>
|
||||
<Grid item>{props.icon}</Grid>
|
||||
<Grid item>{t(props.text)}</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
sx={{ display: "flex", justifyContent: "center" }}
|
||||
>
|
||||
{props.actions?.map((a, i) => (
|
||||
<Grid item key={i}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
href={a.href}
|
||||
onClick={a.onClick}
|
||||
>
|
||||
{t(a.label)}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Box, Button, Skeleton } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import type { ExternalMetadataSource } from "../models/external-metadata";
|
||||
import { ProviderIcon } from "./icons";
|
||||
import Illustration from "./illustration";
|
||||
|
||||
type ExternalMetadataBadgeProps = {
|
||||
@@ -34,6 +35,7 @@ const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
|
||||
<Illustration
|
||||
url={source?.providerIcon ?? undefined}
|
||||
quality="original"
|
||||
fallback={<ProviderIcon />}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import type { AlbumExternalMetadata } from "../../models/external-metadata";
|
||||
import getYear from "../../utils/getYear";
|
||||
import { getYear } from "../../utils/date";
|
||||
import HighlightCard from "./highlight-card";
|
||||
|
||||
type AlbumHighlightCardProps = {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import * as Iconsax from "iconsax-react";
|
||||
|
||||
type IconProps = Iconsax.IconProps;
|
||||
export type IconProps = Iconsax.IconProps;
|
||||
|
||||
export const TranscodeIcon = Iconsax.Polyswarm;
|
||||
export const PlaylistIcon = Iconsax.MusicFilter;
|
||||
@@ -28,6 +28,7 @@ export const AlbumIcon = Iconsax.MusicDashboard;
|
||||
export const ArtistIcon = Iconsax.Profile2User;
|
||||
export const SongIcon = Iconsax.Musicnote;
|
||||
export const PlayerIcon = Iconsax.PlayCircle;
|
||||
export const CompilationIcon = Iconsax.Category;
|
||||
export const TrackIcon = Iconsax.Musicnote;
|
||||
export const LyricsIcon = Iconsax.MessageText;
|
||||
export const VideoIcon = Iconsax.VideoSquare;
|
||||
@@ -36,6 +37,7 @@ export const CloseIcon = Iconsax.CloseCircle;
|
||||
export const EditIcon = Iconsax.Edit2;
|
||||
export const LogoutIcon = Iconsax.Logout;
|
||||
export const DarkMode = Iconsax.Sun1;
|
||||
export const ProviderIcon = Iconsax.Global;
|
||||
export const LightMode = (props: IconProps) => (
|
||||
<Iconsax.Sun1 variant="Bold" {...props} />
|
||||
);
|
||||
@@ -48,7 +50,9 @@ export const BurgerIcon = Iconsax.HambergerMenu;
|
||||
export const AddIcon = Iconsax.AddCircle;
|
||||
export const DeleteIcon = Iconsax.Trash;
|
||||
export const ShuffleIcon = Iconsax.Shuffle;
|
||||
export const InfoIcon = Iconsax.InfoCircle;
|
||||
export const InfoIcon = (props: IconProps) => (
|
||||
<Iconsax.InfoCircle style={{ rotate: "180deg" }} {...props} />
|
||||
);
|
||||
export const AddToPlaylistIcon = Iconsax.MusicSquareAdd;
|
||||
export const AddItemToPlaylistIcon = Iconsax.MusicSquareAdd;
|
||||
export const PlayNextIcon = Iconsax.MusicSquareAdd;
|
||||
@@ -81,5 +85,22 @@ export const PauseIcon = Iconsax.Pause;
|
||||
export const RewindIcon = Iconsax.Previous;
|
||||
export const ForwardIcon = Iconsax.Next;
|
||||
export const FullscreenIcon = Iconsax.Maximize4;
|
||||
export const PlusIcon = Iconsax.Add;
|
||||
export const MinusIcon = Iconsax.Minus;
|
||||
|
||||
export const DoneIcon = Iconsax.TickSquare;
|
||||
|
||||
export const EmptyStateIcon = Iconsax.SearchStatus1;
|
||||
|
||||
// Icons for song type
|
||||
export const SongTypeOriginalIcon = Iconsax.Music;
|
||||
export const SongTypeRemixIcon = Iconsax.Repeat;
|
||||
export const SongTypeLiveIcon = Iconsax.Microphone2;
|
||||
export const SongTypeAcousticIcon = Iconsax.VoiceSquare;
|
||||
export const SongTypeInstrumentalIcon = Iconsax.Musicnote;
|
||||
export const SongTypeEditIcon = Iconsax.Cut;
|
||||
export const SongTypeCleanIcon = Iconsax.TickCircle;
|
||||
export const SongTypeDemoIcon = Iconsax.ReceiptEdit;
|
||||
export const SongTypeAcapellaIcon = Iconsax.VoiceCricle;
|
||||
export const SongTypeMedleyIcon = Iconsax.Layer;
|
||||
export const SongTypeNonMusicIcon = Iconsax.VolumeSlash;
|
||||
@@ -20,14 +20,11 @@ import { Box, IconButton, Skeleton, useTheme } from "@mui/material";
|
||||
import Image, { type ImageProps } from "next/image";
|
||||
import { useState } from "react";
|
||||
import type { RequireExactlyOne } from "type-fest";
|
||||
import blackIllustrationFallback from "../../public/icon-black.png";
|
||||
import whiteIllustrationFallback from "../../public/icon.png";
|
||||
import API from "../api/api";
|
||||
import type IllustrationModel from "../models/illustration";
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import Blurhash from "./blurhash";
|
||||
import Fade from "./fade";
|
||||
import ThemedImage from "./themed-image";
|
||||
|
||||
type ImageQuality = "low" | "medium" | "high" | "original";
|
||||
|
||||
@@ -152,36 +149,49 @@ const Illustration = (props: IllustrationProps) => {
|
||||
}}
|
||||
>
|
||||
{loadingState === "errored" || url === null ? (
|
||||
props.fallback ? (
|
||||
<Box
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
animation={false}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
disabled
|
||||
sx={{ fontSize: "large" }}
|
||||
component="div"
|
||||
>
|
||||
{props.fallback}
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<ThemedImage
|
||||
dark={whiteIllustrationFallback}
|
||||
light={blackIllustrationFallback}
|
||||
fill
|
||||
alt="missing-illustration"
|
||||
loading="eager"
|
||||
style={{
|
||||
padding: "15%",
|
||||
aspectRatio:
|
||||
props.aspectRatio?.toString() ?? "1",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
{props.fallback && (
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
disabled
|
||||
sx={{ fontSize: "large" }}
|
||||
component="div"
|
||||
>
|
||||
{props.fallback}
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Image
|
||||
onError={() => setLoadingState("errored")}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
ListItemIcon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ThemeProvider,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { deepmerge } from "@mui/utils";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../../i18n/i18n";
|
||||
import { ItemSize } from "../../../utils/layout";
|
||||
import type Action from "../../actions/action";
|
||||
import {
|
||||
AscIcon,
|
||||
CheckIcon,
|
||||
DescIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "../../icons";
|
||||
import type { FilterControl } from "./filters/control";
|
||||
import type { LayoutControl } from "./layout";
|
||||
import type { SortControl } from "./sort";
|
||||
|
||||
// Controls should not maintain state regarding options
|
||||
// It should rely on the onUpdate function to update external state
|
||||
export const Controls = <SortingKey extends string>(props: {
|
||||
layout?: LayoutControl;
|
||||
sort?: SortControl<SortingKey>;
|
||||
actions?: Action[][];
|
||||
filters?: FilterControl<any>[]; // Putting string prevent from typechecking if filter is library + song type
|
||||
}) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{props.filters && (
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.filters?.map((filter, idx) => (
|
||||
<FilterMenuButton key={idx} filter={filter} />
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.sort && (
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
<SortMenuButton sort={props.sort} />
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.layout && (
|
||||
<Grid item>
|
||||
<LayoutButtonGroup layout={props.layout} />
|
||||
</Grid>
|
||||
)}
|
||||
{props.actions?.map((actions, idx) => (
|
||||
<Grid item key={idx}>
|
||||
<ActionButtonGroup actions={actions} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterMenuButton = <FilterKeys extends string>({
|
||||
filter,
|
||||
}: { filter: FilterControl<FilterKeys> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={filter.values === undefined ? "loading" : filter.buttonLabel}
|
||||
icon={filter.values && filter.buttonIcon}
|
||||
items={(closeMenu) =>
|
||||
filter.values?.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={
|
||||
filter.multipleChoice
|
||||
? filter.selected?.includes(key)
|
||||
: filter.selected === key
|
||||
}
|
||||
onClick={() => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
key,
|
||||
)
|
||||
? filter.selected.filter((k) => k !== key)
|
||||
: [key, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === key ? null : key,
|
||||
);
|
||||
}
|
||||
// TODO Not close at every select
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{(filter.multipleChoice &&
|
||||
filter.selected.includes(key)) ||
|
||||
filter.selected === key ? (
|
||||
<CheckIcon />
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(filter.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SortMenuButton = <SortingKey extends string>({
|
||||
sort,
|
||||
}: { sort: SortControl<SortingKey> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={sort.buttonLabel}
|
||||
icon={sort.selected.order === "asc" ? <AscIcon /> : <DescIcon />}
|
||||
items={(closeMenu) =>
|
||||
sort.sortingKeys.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={sort.selected.sort === key}
|
||||
onClick={() => {
|
||||
sort?.onUpdate({
|
||||
sort: key,
|
||||
order:
|
||||
sort.selected.sort !== key
|
||||
? "asc"
|
||||
: sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
});
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{sort?.selected.sort === key ? (
|
||||
sort.selected.order === "asc" ? (
|
||||
<AscIcon />
|
||||
) : (
|
||||
<DescIcon />
|
||||
)
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(sort!.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutButtonGroup = ({ layout }: { layout: LayoutControl }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{layout.layout === "grid" &&
|
||||
[
|
||||
[
|
||||
"xs",
|
||||
(currentIndex: number) => currentIndex - 1,
|
||||
MinusIcon,
|
||||
] as const,
|
||||
[
|
||||
"xl",
|
||||
(currentIndex: number) => currentIndex + 1,
|
||||
PlusIcon,
|
||||
] as const,
|
||||
].map(([size, f, Icon]) => (
|
||||
<Button
|
||||
key={`size-button-${size}`}
|
||||
disabled={layout.itemSize === size}
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
layout: layout.layout,
|
||||
itemSize:
|
||||
ItemSize[
|
||||
f(ItemSize.indexOf(layout.itemSize))
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
))}
|
||||
{layout.enableToggle && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
itemSize: layout.itemSize,
|
||||
layout:
|
||||
layout.layout === "grid" ? "list" : "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{layout.layout === "grid" ? <ListIcon /> : <GridIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButtonGroup = ({ actions }: { actions: Action[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [openedDialogIndex, setOpenedDialogIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const closeModal = () => setOpenedDialogIndex(null);
|
||||
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{actions.map((action, idx) => (
|
||||
<>
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog && setOpenedDialogIndex(idx);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Button>
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
key={idx}
|
||||
open={openedDialogIndex === idx}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuButton = (props: {
|
||||
label: TranslationKey;
|
||||
icon: JSX.Element | undefined;
|
||||
items: (closeMenu: () => void) => JSX.Element | JSX.Element[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const extendedTheme = useMemo(
|
||||
() =>
|
||||
deepmerge(theme, {
|
||||
components: {
|
||||
MuiMenuItem: { defaultProps: { sx: { borderRadius: 0 } } },
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
// TODO Remove border radius or menu items
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={extendedTheme}>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.icon}>
|
||||
{t(props.label as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.items(handleMenuClose)}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { parseQueryParam, setQueryParam } from "../../../../utils/query-param";
|
||||
|
||||
export type FilterControl<Key extends string> = {
|
||||
// Gives the translation key from an item to choose from
|
||||
formatItem: (k: Key) => TranslationKey;
|
||||
values: readonly Key[] | undefined;
|
||||
buttonLabel: TranslationKey;
|
||||
buttonIcon: JSX.Element | undefined;
|
||||
} & (
|
||||
| {
|
||||
multipleChoice: true;
|
||||
selected: Key[];
|
||||
onUpdate: (keys: Key[]) => void;
|
||||
}
|
||||
| {
|
||||
multipleChoice: false;
|
||||
selected: Key | null;
|
||||
onUpdate: (key: Key | null) => void;
|
||||
}
|
||||
);
|
||||
|
||||
// Control for filter that accept multiple values
|
||||
export const useFiltersControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey[],
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
// Used to persist Filter in query param, must be unique
|
||||
filterId: string;
|
||||
}) => {
|
||||
// TODO Check layout update does not trigger infinite loop
|
||||
const router = useRouter();
|
||||
const [selectedFilters, setFilterState] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (typeof query === "string") {
|
||||
query = query.trim().split(",");
|
||||
}
|
||||
return query
|
||||
.map((f) => parseQueryParam(f, filterKeys))
|
||||
.filter((f): f is FilterKey => f !== null);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilters),
|
||||
multipleChoice: true,
|
||||
selected: selectedFilters,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam(
|
||||
[[filterId, selected.length ? selected.join(",") : null]],
|
||||
router,
|
||||
);
|
||||
setFilterState(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilters, control] as const;
|
||||
};
|
||||
|
||||
export const useFilterControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey | null,
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
filterId: string;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [selectedFilter, setSelectedFilter] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof query !== "string") {
|
||||
query = query[0];
|
||||
}
|
||||
return parseQueryParam(query, filterKeys);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilter),
|
||||
multipleChoice: false,
|
||||
selected: selectedFilter,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam([[filterId, selected ?? null]], router);
|
||||
setSelectedFilter(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilter, control] as const;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import API from "../../../../api/api";
|
||||
import { prepareMeeloInfiniteQuery } from "../../../../api/use-query";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { useFilterControl, useFiltersControl } from "./control";
|
||||
|
||||
function useLibraryFilterControl(p: { multipleChoices: true }): ReturnType<
|
||||
typeof useFiltersControl<string>
|
||||
>;
|
||||
function useLibraryFilterControl(p: { multipleChoices: false }): ReturnType<
|
||||
typeof useFilterControl<string>
|
||||
>;
|
||||
|
||||
function useLibraryFilterControl(p: { multipleChoices: boolean }): never;
|
||||
function useLibraryFilterControl(p: { multipleChoices: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
useErrorBoundary: false,
|
||||
onError: () => {
|
||||
toast.error(t("librariesLoadFail"));
|
||||
},
|
||||
});
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
const libraryNameBySlug = (s: string) =>
|
||||
libraries!.find((l) => l.slug === s)!.name;
|
||||
|
||||
if (p.multipleChoices) {
|
||||
return useFiltersControl<string>({
|
||||
formatItem: (s) => libraryNameBySlug(s) as TranslationKey,
|
||||
filterKeys: libraries?.map((l) => l.slug),
|
||||
buttonLabel: (selected) => {
|
||||
switch (selected.length) {
|
||||
case 0:
|
||||
return "allLibraries";
|
||||
case 1:
|
||||
return libraryNameBySlug(selected[0]) as TranslationKey;
|
||||
default:
|
||||
return `${selected.length} ${t("libraries")}` as TranslationKey;
|
||||
}
|
||||
},
|
||||
buttonIcon: undefined,
|
||||
filterId: "library",
|
||||
});
|
||||
}
|
||||
|
||||
return useFilterControl<string>({
|
||||
formatItem: (s) => libraryNameBySlug(s) as TranslationKey,
|
||||
filterKeys: libraries?.map((l) => l.slug),
|
||||
buttonLabel: (selected) =>
|
||||
selected
|
||||
? (libraryNameBySlug(selected) as TranslationKey)
|
||||
: "allLibraries",
|
||||
buttonIcon: undefined,
|
||||
filterId: "library",
|
||||
});
|
||||
}
|
||||
|
||||
export { useLibraryFilterControl };
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { useFilterControl, useFiltersControl } from "./control";
|
||||
|
||||
type TypeFilterControlArg<TypeKey extends TranslationKey> = {
|
||||
types: readonly TypeKey[];
|
||||
filterId?: string;
|
||||
};
|
||||
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & {
|
||||
multipleChoices: true;
|
||||
},
|
||||
): ReturnType<typeof useFiltersControl<T>>;
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & {
|
||||
multipleChoices: false;
|
||||
},
|
||||
): ReturnType<typeof useFilterControl<T>>;
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & { multipleChoices: boolean },
|
||||
): never;
|
||||
function useTypeFilterControl<TypeKey extends TranslationKey>(
|
||||
props: TypeFilterControlArg<TypeKey> & {
|
||||
multipleChoices: boolean;
|
||||
},
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (props.multipleChoices) {
|
||||
return useFiltersControl<TypeKey>({
|
||||
formatItem: (t: TypeKey) => t,
|
||||
filterKeys: props.types,
|
||||
buttonLabel: (selected) => {
|
||||
switch (selected.length) {
|
||||
case 0:
|
||||
return "allTypes";
|
||||
case 1:
|
||||
return selected[0];
|
||||
default:
|
||||
return `${selected.length} ${t("types")}` as TranslationKey;
|
||||
}
|
||||
},
|
||||
buttonIcon: undefined,
|
||||
filterId: props.filterId ?? "type",
|
||||
});
|
||||
}
|
||||
|
||||
return useFilterControl<TypeKey>({
|
||||
formatItem: (t: TypeKey) => t,
|
||||
filterKeys: props.types,
|
||||
buttonLabel: (selected) => selected ?? "allTypes",
|
||||
buttonIcon: undefined,
|
||||
filterId: props.filterId ?? "type",
|
||||
});
|
||||
}
|
||||
|
||||
export { useTypeFilterControl };
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type ItemSize,
|
||||
type LayoutOption,
|
||||
LayoutOptions,
|
||||
} from "../../../utils/layout";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
|
||||
export type LayoutControl =
|
||||
| { layout: "list"; itemSize: never; enableToggle: false; onUpdate: never }
|
||||
| {
|
||||
layout: "grid";
|
||||
itemSize: ItemSize;
|
||||
enableToggle: false;
|
||||
onUpdate: (p: { itemSize: ItemSize }) => void;
|
||||
}
|
||||
| {
|
||||
layout: LayoutOption;
|
||||
enableToggle: true;
|
||||
itemSize: ItemSize;
|
||||
onUpdate: (p: {
|
||||
layout: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// Hook to get Layout data to pass to Controls
|
||||
export const useLayoutControl = ({
|
||||
defaultLayout,
|
||||
enableToggle,
|
||||
}: {
|
||||
defaultLayout: LayoutOption;
|
||||
enableToggle: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
const layoutQuery = parseQueryParam(router.query["view"], LayoutOptions);
|
||||
const [layoutState, setLayoutState] = useState<{
|
||||
layout: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
}>(() => ({
|
||||
layout: !enableToggle ? defaultLayout : (layoutQuery ?? defaultLayout),
|
||||
itemSize: "m",
|
||||
}));
|
||||
const control: LayoutControl = {
|
||||
layout: layoutState.layout,
|
||||
enableToggle: enableToggle as true,
|
||||
itemSize: layoutState.itemSize,
|
||||
onUpdate: (p) => {
|
||||
setQueryParam([["view", p.layout]], router);
|
||||
setLayoutState(p);
|
||||
},
|
||||
};
|
||||
return [layoutState, control] as const;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { ParsedUrlQuery } from "node:querystring";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { TranslationKey } from "../../../i18n/i18n";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
import { type Order, Orders } from "../../../utils/sorting";
|
||||
|
||||
export type SortControl<SortingKey extends string> = {
|
||||
formatItem: (k: SortingKey) => TranslationKey;
|
||||
// returns the label of the menu + icon
|
||||
buttonLabel: TranslationKey;
|
||||
sortingKeys: readonly SortingKey[];
|
||||
selected: { sort: SortingKey; order: Order };
|
||||
onUpdate: (p: { sort: SortingKey; order: Order }) => void;
|
||||
};
|
||||
|
||||
// Hook to get Sorting data to pass to Controls
|
||||
export const useSortControl = <SortingKey extends TranslationKey>({
|
||||
sortingKeys,
|
||||
}: {
|
||||
sortingKeys: readonly SortingKey[];
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const orderQuery = getOrderQuery(router);
|
||||
const sortQuery = getSortQuery(router, sortingKeys);
|
||||
// Note: getSortQuery does not return null,
|
||||
// falling back on the first key in the key list
|
||||
const [sortState, setSortState] = useState(() => ({
|
||||
sort: sortQuery,
|
||||
order: orderQuery ?? "asc",
|
||||
}));
|
||||
const control: SortControl<SortingKey> = {
|
||||
sortingKeys: sortingKeys,
|
||||
selected: sortState,
|
||||
buttonLabel: sortState.sort,
|
||||
formatItem: (t) => t,
|
||||
onUpdate: (p) => {
|
||||
setQueryParam(
|
||||
[
|
||||
["sort", p.sort],
|
||||
["order", p.order],
|
||||
],
|
||||
router,
|
||||
);
|
||||
setSortState(p);
|
||||
},
|
||||
};
|
||||
return [sortState, control] as const;
|
||||
};
|
||||
|
||||
export const getOrderQuery = (router: { query: ParsedUrlQuery }) =>
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
parseQueryParam(router.query["order"], Orders);
|
||||
|
||||
export const getSortQuery = <SortKey extends string>(
|
||||
router: { query: ParsedUrlQuery },
|
||||
sortingKeys: readonly SortKey[],
|
||||
) =>
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
parseQueryParam(router.query["sort"], sortingKeys) ?? sortingKeys[0];
|
||||
@@ -16,17 +16,28 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import { type Breakpoint, Grid } from "@mui/material";
|
||||
import type { IllustratedResource } from "../../models/illustration";
|
||||
import type Resource from "../../models/resource";
|
||||
import { useGradientBackground } from "../../utils/gradient-background";
|
||||
import type { ItemSize } from "../../utils/layout";
|
||||
import InfiniteScroll from "./infinite-scroll";
|
||||
|
||||
type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
|
||||
type InfiniteGridProps<T extends Resource> = Omit<
|
||||
Parameters<TypedList<T>>[0],
|
||||
"render"
|
||||
> & { render: (item: T | undefined) => JSX.Element };
|
||||
> & { render: (item: T | undefined) => JSX.Element; itemSize: ItemSize };
|
||||
|
||||
const GridPresets: Record<ItemSize, Partial<Record<Breakpoint, number>>> = {
|
||||
// Note: values of sm == values of md,
|
||||
// because of navbar reducing the actual sufrace of md
|
||||
xs: { xs: 12 / 5, sm: 2, md: 12 / 8, lg: 12 / 10, xl: 1 },
|
||||
s: { xs: 3, sm: 12 / 5, md: 12 / 5, lg: 12 / 8, xl: 12 / 10 },
|
||||
m: { xs: 4, sm: 3, md: 3, lg: 2, xl: 12 / 8 },
|
||||
l: { xs: 6, sm: 4, md: 4, lg: 12 / 5, xl: 12 / 6 },
|
||||
xl: { xs: 12, sm: 6, md: 6, lg: 12 / 4, xl: 12 / 5 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Infinite Scrolling List
|
||||
@@ -49,18 +60,16 @@ const InfiniteGrid = <T extends IllustratedResource>(
|
||||
<>
|
||||
<GradientBackground />
|
||||
<Grid
|
||||
columnSpacing={2}
|
||||
columnSpacing={
|
||||
["xs", "s"].includes(props.itemSize) ? 1.5 : 2
|
||||
}
|
||||
container
|
||||
sx={{ alignItems: "stretch", display: "flex" }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Grid
|
||||
item
|
||||
xs={6}
|
||||
sm={3}
|
||||
// No MD because we get more space when sidebar disappears
|
||||
lg={2}
|
||||
xl={12 / 8}
|
||||
{...GridPresets[props.itemSize]}
|
||||
key={`item-${index}`}
|
||||
>
|
||||
{props.render(item)}
|
||||
|
||||
@@ -27,7 +27,9 @@ type TypedList<T extends Resource> = typeof InfiniteScroll<T>;
|
||||
type InfiniteListProps<T extends Resource> = Omit<
|
||||
Parameters<TypedList<T>>[0],
|
||||
"render"
|
||||
> & { render: (item: T | undefined, index: number) => JSX.Element };
|
||||
> & { render: (item: T | undefined, index: number) => JSX.Element } & Partial<
|
||||
Pick<Parameters<typeof InfiniteScroll>[0], "emptyState">
|
||||
>;
|
||||
|
||||
/**
|
||||
* Similar to InfiniteGrid, but rendered as a list
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
type AlbumWithRelations,
|
||||
} from "../../../models/album";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import AlbumItem from "../../list-item/album-item";
|
||||
import AlbumTile from "../../tile/album-tile";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = { type?: AlbumType };
|
||||
|
||||
const InfiniteAlbumView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
AlbumWithRelations<"artist" | "illustration">,
|
||||
typeof AlbumSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof AlbumTile>, "formatSubtitle"> & {
|
||||
defaultAlbumType: AlbumType | null;
|
||||
},
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState<
|
||||
OptionState<typeof AlbumSortingKeys, AdditionalProps>
|
||||
>({
|
||||
type: props.defaultAlbumType ?? undefined,
|
||||
library: null,
|
||||
order: props.initialSortingOrder ?? "asc",
|
||||
sortBy: props.initialSortingField ?? "name",
|
||||
view: props.defaultLayout ?? "grid",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: t((options?.type as AlbumType) ?? "All"),
|
||||
name: "type",
|
||||
values: ["All", ...AlbumType],
|
||||
currentValue: options?.type ?? undefined,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={AlbumSortingKeys}
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "grid"}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? props.defaultLayout ?? "grid"}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library,
|
||||
view: options?.view ?? props.defaultLayout ?? "grid",
|
||||
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : options?.type,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
AlbumSortingKeys[0],
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteAlbumView;
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { MeeloInfiniteQueryFn } from "../../../api/use-query";
|
||||
import type Resource from "../../../models/resource";
|
||||
import type { LayoutOption } from "../../../utils/layout";
|
||||
import type { Order } from "../../../utils/sorting";
|
||||
import type { OptionState } from "../../controls/controls";
|
||||
|
||||
type InfiniteResourceViewProps<
|
||||
ResourceType extends Resource,
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalQueryParams extends object = object,
|
||||
> = {
|
||||
query: (
|
||||
options: OptionState<SortingKeys> & AdditionalQueryParams,
|
||||
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
|
||||
light?: boolean;
|
||||
onItemClick?: (item: ResourceType) => void;
|
||||
disableSorting?: true;
|
||||
defaultLayout?: LayoutOption;
|
||||
initialSortingOrder?: Order;
|
||||
initialSortingField?: SortingKeys[number];
|
||||
};
|
||||
|
||||
export default InfiniteResourceViewProps;
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { type PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
type SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
// if true, show song groups instead of songs
|
||||
groups?: boolean;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const playSongsAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
SongWithRelations<"artist" | "featuring" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i === 0) {
|
||||
playTrack({
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
} else {
|
||||
playAfter({
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const InfiniteSongView = <
|
||||
T extends SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof SongSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
|
||||
disableShuffle?: boolean;
|
||||
groupsQuery?: (
|
||||
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
|
||||
) => InfiniteQuery<
|
||||
SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const disableGroupingVersions = !props.groupsQuery;
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
const queryClient = useQueryClient();
|
||||
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : (options?.type as SongType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
groups: disableGroupingVersions ? false : (options?.groups ?? false),
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() => props.query(query),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as SongType) ?? "All",
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter((type) => type !== "Unknown"),
|
||||
],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
playAction,
|
||||
...(props.disableShuffle !== true && !options?.groups
|
||||
? [shuffleAction]
|
||||
: []),
|
||||
]}
|
||||
toggles={
|
||||
disableGroupingVersions
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "groups",
|
||||
label: options?.groups
|
||||
? "showAllSongs"
|
||||
: "groupVersions",
|
||||
},
|
||||
]
|
||||
}
|
||||
disableSorting={options?.groups || props.disableSorting}
|
||||
onChange={setOptions}
|
||||
sortingKeys={
|
||||
options?.groups ? SongGroupSortingKeys : SongSortingKeys
|
||||
}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
{options?.groups ? (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.groupsQuery!({
|
||||
...query,
|
||||
sortBy: "name",
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
) : (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteSongView;
|
||||
@@ -25,6 +25,8 @@ import type PaginatedResponse from "../../models/pagination";
|
||||
import type { PaginationParameters } from "../../models/pagination";
|
||||
import type Resource from "../../models/resource";
|
||||
import { generateArray } from "../../utils/gen-list";
|
||||
import { EmptyState, type EmptyStateProps } from "../empty-state";
|
||||
import { EmptyStateIcon } from "../icons";
|
||||
|
||||
export const parentScrollableDivId = "scrollableDiv" as const;
|
||||
|
||||
@@ -41,10 +43,8 @@ type InfiniteScrollProps<T extends Resource> = {
|
||||
* Query to use
|
||||
*/
|
||||
query: MeeloInfiniteQueryFn<T>;
|
||||
/**
|
||||
* Component to display on page fetching (except first)
|
||||
*/
|
||||
loader: () => JSX.Element;
|
||||
|
||||
emptyState?: EmptyStateProps;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,11 +76,17 @@ export type Page<T> = {
|
||||
* @returns a dynamic list component
|
||||
*/
|
||||
const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
const { items, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery(props.query);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items?.length === 0 && (
|
||||
<EmptyState
|
||||
icon={props.emptyState?.icon ?? <EmptyStateIcon />}
|
||||
text={props.emptyState?.text ?? "emptyStateDefault"}
|
||||
actions={props.emptyState?.actions ?? []}
|
||||
/>
|
||||
)}
|
||||
<IScroll.default
|
||||
pageStart={0}
|
||||
loadMore={() => {
|
||||
@@ -96,10 +102,10 @@ const InfiniteScroll = <T extends Resource>(props: InfiniteScrollProps<T>) => {
|
||||
threshold={500}
|
||||
>
|
||||
{props.render(
|
||||
data === undefined
|
||||
items === undefined
|
||||
? generateArray(3)
|
||||
: [
|
||||
...data.pages.flatMap((page) => page.items),
|
||||
...items,
|
||||
...(isFetchingNextPage ? generateArray(3) : []),
|
||||
],
|
||||
)}
|
||||
|
||||
@@ -21,18 +21,21 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MeeloInfiniteQueryFn } from "../../api/use-query";
|
||||
import type { IllustratedResource } from "../../models/illustration";
|
||||
import type { ItemSize } from "../../utils/layout";
|
||||
import type { EmptyStateProps } from "../empty-state";
|
||||
import Fade from "../fade";
|
||||
import { GoBackTopIcon } from "../icons";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import InfiniteGrid from "./infinite-grid";
|
||||
import InfiniteList from "./infinite-list";
|
||||
import { parentScrollableDivId } from "./infinite-scroll";
|
||||
|
||||
export type InfiniteViewProps<ItemType> = {
|
||||
view: "list" | "grid";
|
||||
itemSize: ItemSize;
|
||||
query: MeeloInfiniteQueryFn<ItemType>;
|
||||
renderListItem: (item: ItemType | undefined) => JSX.Element;
|
||||
renderGridItem: (item: ItemType | undefined) => JSX.Element;
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
|
||||
const ScrollToTopButton = () => {
|
||||
@@ -93,7 +96,6 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
<ScrollToTopButton />
|
||||
{props.view.toLowerCase() === "list" ? (
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.query}
|
||||
render={(item, index) => (
|
||||
<Fade in>
|
||||
@@ -106,7 +108,7 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
) : (
|
||||
<InfiniteGrid
|
||||
query={props.query}
|
||||
loader={() => <WideLoadingComponent />}
|
||||
itemSize={props.itemSize}
|
||||
render={(item) => (
|
||||
<Fade in>
|
||||
<Box sx={{ height: "100%" }}>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
type AlbumWithRelations,
|
||||
} from "../../../models/album";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import AlbumItem from "../../list-item/album-item";
|
||||
import AlbumTile from "../../tile/album-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
|
||||
type QueryProps = {
|
||||
types?: AlbumType[];
|
||||
libraries?: string[];
|
||||
} & SortingParameters<typeof AlbumSortingKeys>;
|
||||
|
||||
type AlbumModel = AlbumWithRelations<"artist" | "illustration">;
|
||||
|
||||
type ViewProps = {
|
||||
query: (q: QueryProps) => InfiniteQuery<AlbumModel>;
|
||||
onItemClick?: (song: AlbumModel) => void;
|
||||
disableShuffle?: boolean;
|
||||
disableSort?: boolean;
|
||||
formatSubtitle?: Parameters<typeof AlbumItem>[0]["formatSubtitle"];
|
||||
};
|
||||
|
||||
const InfiniteAlbumView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [typeFilter, typeFilterControl] = useTypeFilterControl({
|
||||
types: AlbumType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: AlbumSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
layout={layoutControl}
|
||||
filters={[libraryFilterControl, typeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
libraries: libraryFilter,
|
||||
types: typeFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteAlbumView;
|
||||
+34
-31
@@ -16,53 +16,56 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
ArtistSortingKeys,
|
||||
type ArtistWithRelations,
|
||||
} from "../../../models/artist";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import ArtistItem from "../../list-item/artist-item";
|
||||
import ArtistTile from "../../tile/artist-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteArtistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
ArtistWithRelations<"illustration">,
|
||||
typeof ArtistSortingKeys
|
||||
>,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof ArtistSortingKeys>>();
|
||||
type QueryProps = { libraries?: string[] } & SortingParameters<
|
||||
typeof ArtistSortingKeys
|
||||
>;
|
||||
type ArtistModel = ArtistWithRelations<"illustration">;
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<ArtistModel>;
|
||||
disableSort?: boolean;
|
||||
onItemClick?: (p: ArtistModel) => void;
|
||||
};
|
||||
|
||||
const InfiniteArtistView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: ArtistSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "list",
|
||||
enableToggle: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
onChange={setOptions}
|
||||
sortingKeys={ArtistSortingKeys}
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
filters={[libraryFilterControl]}
|
||||
layout={layoutControl}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library ?? null,
|
||||
view: options?.view ?? props.defaultLayout ?? "list",
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
libraries: libraryFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
+29
-34
@@ -16,57 +16,52 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "../../../api/use-query";
|
||||
import { type InfiniteQuery, useQueryClient } from "../../../api/use-query";
|
||||
import {
|
||||
PlaylistSortingKeys,
|
||||
type PlaylistWithRelations,
|
||||
} from "../../../models/playlist";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { EmptyStateProps } from "../../empty-state";
|
||||
import PlaylistItem from "../../list-item/playlist-item";
|
||||
import PlaylistTile from "../../tile/playlist-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfinitePlaylistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
PlaylistWithRelations<"illustration">,
|
||||
typeof PlaylistSortingKeys
|
||||
>,
|
||||
) => {
|
||||
type QueryProps = SortingParameters<typeof PlaylistSortingKeys>;
|
||||
type PlaylistModel = PlaylistWithRelations<"illustration">;
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<PlaylistModel>;
|
||||
onItemClick?: (p: PlaylistModel) => void;
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
const InfinitePlaylistView = (props: ViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof PlaylistSortingKeys>>();
|
||||
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: PlaylistSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
actions={[CreatePlaylistAction(queryClient)]}
|
||||
onChange={setOptions}
|
||||
disableLibrarySelector
|
||||
sortingKeys={PlaylistSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
sort={sortControl}
|
||||
layout={layoutControl}
|
||||
actions={[[CreatePlaylistAction(queryClient)]]}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
emptyState={props.emptyState}
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: null,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
view: "grid",
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import type { SongGroupWithRelations } from "../../../models/song-group";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import type Action from "../../actions/action";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
|
||||
type SongModel = SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type SongQueryProps = {
|
||||
libraries?: string[];
|
||||
types?: SongType[];
|
||||
random?: number;
|
||||
} & SortingParameters<typeof SongSortingKeys>;
|
||||
|
||||
type SongGroupModel = SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type SongGroupQueryProps = {
|
||||
libraries?: string[];
|
||||
type?: SongType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const playSongsAction = (
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<SongModel>,
|
||||
) => {
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
} else {
|
||||
store.set(playAfterAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const shuffleActionBase = {
|
||||
label: "shuffle" as const,
|
||||
icon: <ShuffleIcon />,
|
||||
};
|
||||
|
||||
const playActionBase = {
|
||||
label: "playAll" as const,
|
||||
icon: <PlayIcon />,
|
||||
};
|
||||
|
||||
type SongViewProps = {
|
||||
query: (q: SongQueryProps) => InfiniteQuery<SongModel>;
|
||||
onItemClick?: (song: SongModel) => void;
|
||||
disableShuffle?: boolean;
|
||||
disableSort?: boolean;
|
||||
subtitles?: Parameters<typeof SongItem>[0]["subtitles"];
|
||||
additionalActions?: Action[];
|
||||
};
|
||||
|
||||
export const InfiniteSongView = (props: SongViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const shuffleAction = {
|
||||
...shuffleActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const playAction = {
|
||||
...playActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
/// state
|
||||
const [libraries, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [types, songTypeFilterControl] = useTypeFilterControl({
|
||||
types: SongType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: SongSortingKeys,
|
||||
});
|
||||
const query: SongQueryProps = {
|
||||
libraries: libraries,
|
||||
types: types,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
filters={[libraryFilterControl, songTypeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
actions={[
|
||||
[
|
||||
playAction,
|
||||
...(props.disableShuffle ? [] : [shuffleAction]),
|
||||
],
|
||||
...(props.additionalActions
|
||||
? [props.additionalActions]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query(query);
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SongGroupViewProps = {
|
||||
query: (q: SongGroupQueryProps) => InfiniteQuery<SongGroupModel>;
|
||||
onItemClick?: (song: SongGroupModel) => void;
|
||||
subtitles?: Parameters<typeof SongGroupItem>[0]["subtitles"];
|
||||
additionalActions?: Action[];
|
||||
};
|
||||
|
||||
export const InfiniteSongGroupView = (props: SongGroupViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const playAction = {
|
||||
...playActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
/// state
|
||||
const [libraries, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [type, songTypeFilterControl] = useTypeFilterControl({
|
||||
types: SongType,
|
||||
multipleChoices: false,
|
||||
});
|
||||
const query: SongGroupQueryProps = {
|
||||
libraries: libraries,
|
||||
type: type ?? undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
filters={[libraryFilterControl, songTypeFilterControl]}
|
||||
actions={[
|
||||
[playAction],
|
||||
...(props.additionalActions
|
||||
? [props.additionalActions]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query(query);
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type HybridSongViewProps = {
|
||||
song: SongViewProps;
|
||||
// If not specified, there will be no toggle button
|
||||
songGroup?: SongGroupViewProps;
|
||||
};
|
||||
|
||||
// Infinite song View with a toggle for song groups
|
||||
export const HybridInfiniteSongView = (props: HybridSongViewProps) => {
|
||||
const router = useRouter();
|
||||
const [useSongGroup, setUseSongGroup] = useState(
|
||||
parseQueryParam(router.query.groups, ["true", "false"]) === "true",
|
||||
);
|
||||
const toggleSongGroupAction: Action = {
|
||||
label: useSongGroup ? "showAllSongs" : "groupVersions",
|
||||
onClick: () => {
|
||||
setUseSongGroup((prev) => {
|
||||
setQueryParam(
|
||||
[
|
||||
["groups", (!prev).toString()],
|
||||
["type", null],
|
||||
["library", null],
|
||||
["order", null],
|
||||
["sort", null],
|
||||
],
|
||||
router,
|
||||
);
|
||||
return !prev;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (useSongGroup && props.songGroup) {
|
||||
return (
|
||||
<InfiniteSongGroupView
|
||||
{...props.songGroup}
|
||||
additionalActions={[
|
||||
...(props.songGroup.additionalActions ?? []),
|
||||
toggleSongGroupAction,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteSongView
|
||||
{...props.song}
|
||||
additionalActions={[
|
||||
...(props.songGroup ? [toggleSongGroupAction] : []),
|
||||
...(props.song.additionalActions ?? []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+33
-27
@@ -16,46 +16,52 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
TrackSortingKeys,
|
||||
type TrackWithRelations,
|
||||
} from "../../../models/track";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import TrackItem from "../../list-item/track-item";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteTrackView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
TrackWithRelations<"video" | "song" | "release" | "illustration">,
|
||||
typeof TrackSortingKeys
|
||||
>,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof TrackSortingKeys>>();
|
||||
type QueryProps = {
|
||||
libraries?: string[];
|
||||
} & SortingParameters<typeof TrackSortingKeys>;
|
||||
|
||||
type TrackModel = TrackWithRelations<
|
||||
"video" | "release" | "song" | "illustration"
|
||||
>;
|
||||
|
||||
type ViewProps = {
|
||||
query: (q: QueryProps) => InfiniteQuery<TrackModel>;
|
||||
onItemClick?: (track: TrackModel) => void;
|
||||
disableSort?: boolean;
|
||||
};
|
||||
|
||||
const InfiniteTrackView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: TrackSortingKeys,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
onChange={setOptions}
|
||||
sortingKeys={TrackSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
<Controls filters={[libraryFilterControl]} sort={sortControl} />
|
||||
|
||||
<InfiniteView
|
||||
view={options?.view ?? "list"}
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library ?? null,
|
||||
view: "list",
|
||||
sortBy: options?.sortBy ?? "name",
|
||||
order: options?.order ?? "asc",
|
||||
libraries: libraryFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
+91
-102
@@ -16,150 +16,108 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { type PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
VideoSortingKeys,
|
||||
VideoType,
|
||||
type VideoWithRelations,
|
||||
} from "../../../models/video";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import VideoItem from "../../list-item/video-item";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const playVideosAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
VideoWithRelations<"artist" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const videos = res.pages
|
||||
.flatMap(({ items }) => items)
|
||||
.map((video) => ({
|
||||
...video,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
}));
|
||||
let i = 0;
|
||||
for (const video of videos) {
|
||||
if (i === 0) {
|
||||
playTrack(video);
|
||||
} else {
|
||||
playAfter(video);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: VideoType;
|
||||
type QueryProps = {
|
||||
types?: VideoType[];
|
||||
libraries?: string[];
|
||||
random?: number;
|
||||
} & SortingParameters<typeof VideoSortingKeys>;
|
||||
|
||||
type VideoModel = VideoWithRelations<"artist" | "illustration" | "master">;
|
||||
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<VideoModel>;
|
||||
onItemClick?: (video: VideoModel) => void;
|
||||
disableSort?: boolean;
|
||||
subtitle: Parameters<typeof VideoTile>[0]["subtitle"];
|
||||
};
|
||||
|
||||
const InfiniteVideoView = <
|
||||
T extends VideoWithRelations<"artist" | "illustration" | "master">,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof VideoSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const InfiniteVideoView = (props: ViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
|
||||
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [typeFilter, typeFilterControl] = useTypeFilterControl({
|
||||
types: VideoType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: VideoSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : (options?.type as VideoType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
|
||||
libraries: libraryFilter,
|
||||
types: typeFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
};
|
||||
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() => props.query(query),
|
||||
);
|
||||
playVideosAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
playVideosAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as VideoType) ?? "All",
|
||||
name: "type",
|
||||
values: ["All", ...VideoType],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={VideoSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={"grid"}
|
||||
actions={[playAction, shuffleAction]}
|
||||
filters={[libraryFilterControl, typeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
layout={layoutControl}
|
||||
actions={[[playAction, shuffleAction]]}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? "grid"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() => props.query(query)}
|
||||
renderListItem={(item) => (
|
||||
<VideoItem
|
||||
video={item}
|
||||
// TODO Subtitle
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
@@ -175,4 +133,35 @@ const InfiniteVideoView = <
|
||||
);
|
||||
};
|
||||
|
||||
const playVideosAction = (
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
VideoWithRelations<"artist" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const videos = res.pages
|
||||
.flatMap(({ items }) => items)
|
||||
.map((video) => ({
|
||||
...video,
|
||||
track: {
|
||||
...video.master,
|
||||
illustration: video.illustration,
|
||||
},
|
||||
}));
|
||||
let i = 0;
|
||||
for (const video of videos) {
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, video);
|
||||
} else {
|
||||
store.set(playAfterAtom, video);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default InfiniteVideoView;
|
||||
@@ -77,6 +77,8 @@ const LibraryForm = (props: LibraryFormProps) => {
|
||||
textFieldProps={{
|
||||
fullWidth: true,
|
||||
label: t("pathOfLibrary"),
|
||||
helperText:
|
||||
"Path should be relative to the 'DATA_DIR' variable. Use './' if the library path is 'DATA_DIR' ",
|
||||
}}
|
||||
gridProps={{}}
|
||||
rules={{
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AlbumWithRelations } from "../../models/album";
|
||||
import AlbumContextualMenu from "../contextual-menu/album-contextual-menu";
|
||||
import { AlbumIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
|
||||
@@ -43,6 +44,7 @@ const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
|
||||
<Illustration
|
||||
illustration={album?.illustration}
|
||||
quality="low"
|
||||
fallback={<AlbumIcon />}
|
||||
/>
|
||||
}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
import { Grid } from "@mui/material";
|
||||
import { Star } from "iconsax-react";
|
||||
import type { ReleaseWithRelations } from "../../models/release";
|
||||
import getYear from "../../utils/getYear";
|
||||
import { getYear } from "../../utils/date";
|
||||
import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
|
||||
import { AlbumIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "./item";
|
||||
|
||||
@@ -39,6 +40,7 @@ const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
|
||||
<Illustration
|
||||
illustration={release.illustration}
|
||||
quality="low"
|
||||
fallback={<AlbumIcon />}
|
||||
/>
|
||||
}
|
||||
href={`/releases/${release.slug}`}
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { SongWithRelations } from "../../models/song";
|
||||
import type { SongGroupWithRelations } from "../../models/song-group";
|
||||
import { playTrackAtom } from "../../state/player";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import SongContextualMenu from "../contextual-menu/song-contextual-menu";
|
||||
import { SongIcon } from "../icons";
|
||||
@@ -89,7 +90,7 @@ const SongItem = <
|
||||
onClick,
|
||||
}: SongItemProps<T>) => {
|
||||
const artist = song?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
const [subtitle, setSubtitle] = useState<string | null | undefined>(
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
*/
|
||||
|
||||
import { Grid } from "@mui/material";
|
||||
import { useSetAtom } from "jotai";
|
||||
import API from "../../api/api";
|
||||
import { useQueryClient } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import { playTrackAtom } from "../../state/player";
|
||||
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
|
||||
import { MasterIcon, TrackIcon } from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
@@ -40,9 +41,9 @@ type TrackItemProps = {
|
||||
*/
|
||||
const TrackItem = ({ track, onClick }: TrackItemProps) => {
|
||||
const release = track?.release;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const isMaster = track ? track.song?.masterId === track.id : false;
|
||||
const queryClient = useQueryClient();
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { VideoWithRelations } from "../../models/video";
|
||||
import { playTrackAtom } from "../../state/player";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
|
||||
import { VideoIcon } from "../icons";
|
||||
@@ -46,7 +47,7 @@ const VideoItem = <T extends VideoType>({
|
||||
onClick,
|
||||
}: VideoItemProps<T>) => {
|
||||
const artist = video?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
const [subtitle, setSubtitle] = useState<string | null | undefined>(
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bars } from "react-loader-spinner";
|
||||
import Fade from "../fade";
|
||||
|
||||
/**
|
||||
* Base loading component
|
||||
* @returns
|
||||
*/
|
||||
const LoadingComponent = () => {
|
||||
const theme = useTheme();
|
||||
const [displayLoad, setDisplay] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeId = setTimeout(() => setDisplay(true), 2000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeId);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Fade in={displayLoad} timeout={500} mountOnEnter unmountOnExit>
|
||||
<Box>
|
||||
<Bars
|
||||
height="40"
|
||||
width="40"
|
||||
color={theme.palette.text.primary}
|
||||
ariaLabel="bars-loading"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading component that take the whole width and center the loading animation
|
||||
* @returns
|
||||
*/
|
||||
type WideLoadingComponentProps = {
|
||||
verticalPadding?: number;
|
||||
};
|
||||
const WideLoadingComponent = (props: WideLoadingComponentProps) => (
|
||||
<Box
|
||||
width="100%"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
paddingY={props.verticalPadding ?? 10}
|
||||
>
|
||||
<LoadingComponent />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default LoadingComponent;
|
||||
export { WideLoadingComponent };
|
||||
@@ -17,22 +17,23 @@
|
||||
*/
|
||||
|
||||
import { Box, Skeleton, Typography } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import { EmptyState } from "./empty-state";
|
||||
import Fade from "./fade";
|
||||
import { LyricsIcon } from "./icons";
|
||||
|
||||
type LyricsProps = {
|
||||
lyrics: string[] | null | undefined;
|
||||
songName: string | undefined;
|
||||
};
|
||||
const LyricsBox = (props: LyricsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (props.lyrics === null) {
|
||||
return (
|
||||
<Typography sx={{ fontStyle: "italic" }}>
|
||||
{t("noLyricsFound")}
|
||||
</Typography>
|
||||
<EmptyState
|
||||
actions={[]}
|
||||
text={"emptyStateLyrics"}
|
||||
icon={<LyricsIcon />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -20,14 +20,16 @@ import { Box, Button, Grid } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { type ComponentProps, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UseInfiniteQueryResult, UseQueryResult } from "react-query";
|
||||
import type { UseQueryResult } from "react-query";
|
||||
import type { useInfiniteQuery } from "../api/use-query";
|
||||
import type { TranslationKey } from "../i18n/i18n";
|
||||
import type { AlbumWithRelations } from "../models/album";
|
||||
import type Artist from "../models/artist";
|
||||
import type Resource from "../models/resource";
|
||||
import type { SongWithRelations } from "../models/song";
|
||||
import type { VideoWithRelations } from "../models/video";
|
||||
import { getYear } from "../utils/date";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import getYear from "../utils/getYear";
|
||||
import Fade from "./fade";
|
||||
import { MoreIcon } from "./icons";
|
||||
import SectionHeader from "./section-header";
|
||||
@@ -38,13 +40,16 @@ import VideoTile from "./tile/video-tile";
|
||||
|
||||
export const SectionPadding = 4;
|
||||
|
||||
export type PageSectionProps<T> = {
|
||||
export type PageSectionProps<
|
||||
T extends Resource,
|
||||
QT extends typeof useInfiniteQuery<T, []> = typeof useInfiniteQuery<T, []>,
|
||||
> = {
|
||||
title: TranslationKey;
|
||||
artist: UseQueryResult<Artist>;
|
||||
seeMoreHref?: string;
|
||||
maxItemCount: number;
|
||||
child: (items: T[] | undefined) => JSX.Element;
|
||||
query?: UseInfiniteQueryResult<{ items: T[] }>;
|
||||
query?: ReturnType<QT>;
|
||||
items?: T[] | undefined;
|
||||
minimizePadding?: true;
|
||||
};
|
||||
@@ -84,7 +89,7 @@ export const SongGridPageSection = (
|
||||
);
|
||||
};
|
||||
|
||||
export const ListPageSection = <T,>(
|
||||
export const ListPageSection = <T extends Resource>(
|
||||
props: Omit<PageSectionProps<T>, "child"> & {
|
||||
builder: (item: T | undefined, index: number) => JSX.Element;
|
||||
},
|
||||
@@ -171,13 +176,12 @@ export const VideoListPageSection = (
|
||||
);
|
||||
};
|
||||
|
||||
const PageSection = <T,>(props: PageSectionProps<T>) => {
|
||||
const PageSection = <T extends Resource>(props: PageSectionProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const data = props.query?.data || props.items;
|
||||
const firstPage = props.query?.data?.pages.at(0)?.items ?? props.items;
|
||||
const data = props.query?.items || props.items;
|
||||
return (
|
||||
<>
|
||||
{firstPage?.length !== 0 && (
|
||||
{data?.length !== 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
heading={
|
||||
@@ -187,8 +191,7 @@ const PageSection = <T,>(props: PageSectionProps<T>) => {
|
||||
props.seeMoreHref ? (
|
||||
<Fade
|
||||
in={
|
||||
(firstPage?.length ?? 0) >
|
||||
props.maxItemCount
|
||||
(data?.length ?? 0) > props.maxItemCount
|
||||
}
|
||||
>
|
||||
<Link href={props.seeMoreHref}>
|
||||
@@ -208,7 +211,7 @@ const PageSection = <T,>(props: PageSectionProps<T>) => {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{props.child(firstPage)}
|
||||
{props.child(data)}
|
||||
<Box
|
||||
sx={{
|
||||
paddingBottom: props.minimizePadding
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { IconButton, Typography } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import API from "../../../api/api";
|
||||
import type Artist from "../../../models/artist";
|
||||
import type { TrackWithRelations } from "../../../models/track";
|
||||
import formatDuration from "../../../utils/formatDuration";
|
||||
import { ForwardIcon, PauseIcon, PlayIcon, RewindIcon } from "../../icons";
|
||||
import type PlayerSlider from "./slider";
|
||||
|
||||
export const parentSongQuery = (id: number) =>
|
||||
API.getSong(id, ["artist", "lyrics", "featuring"]);
|
||||
|
||||
type PlayerButtonControlsProps = {
|
||||
playing: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
onSkipTrack: () => void;
|
||||
onRewind: () => void;
|
||||
};
|
||||
|
||||
export type PlayerControlsProps = ComponentProps<typeof PlayerSlider> &
|
||||
PlayerButtonControlsProps & {
|
||||
isTranscoding: boolean;
|
||||
expanded: boolean;
|
||||
onExpand: (expand: boolean) => void;
|
||||
artist?: Artist;
|
||||
track?: TrackWithRelations<"illustration">;
|
||||
};
|
||||
|
||||
export const playerTextStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
export type ControlButtonProps = {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const ControlButton = (props: ControlButtonProps) => (
|
||||
<IconButton onClick={props.onClick} color="inherit">
|
||||
{props.icon}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
export const PlayButton = (props: {
|
||||
isPlaying: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}) => (
|
||||
<ControlButton
|
||||
icon={props.isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
onClick={props.isPlaying ? props.onPause : props.onPlay}
|
||||
/>
|
||||
);
|
||||
|
||||
export const SkipButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<ForwardIcon />} />
|
||||
);
|
||||
export const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
|
||||
export const DurationComponent = ({ time }: { time?: number }) => (
|
||||
<Typography>{formatDuration(time)}</Typography>
|
||||
);
|
||||
+246
-310
@@ -19,8 +19,8 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Container,
|
||||
Dialog,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
@@ -29,214 +29,51 @@ import {
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type LegacyRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { type LegacyRef, useCallback, useState } from "react";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import API from "../../api/api";
|
||||
import { useQuery } from "../../api/use-query";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import ReleaseTrackContextualMenu from "../contextual-menu/release-track-contextual-menu";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../../api/api";
|
||||
import { useQuery, useQueryClient } from "../../../api/use-query";
|
||||
import {
|
||||
cursorAtom,
|
||||
playlistAtom,
|
||||
removeTrackAtom,
|
||||
reorderAtom,
|
||||
skipTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import formatArtists from "../../../utils/formatArtists";
|
||||
import formatDuration from "../../../utils/formatDuration";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import ReleaseTrackContextualMenu from "../../contextual-menu/release-track-contextual-menu";
|
||||
import {
|
||||
CloseIcon,
|
||||
ContextualMenuIcon,
|
||||
DeleteIcon,
|
||||
DragHandleIcon,
|
||||
ForwardIcon,
|
||||
FullscreenIcon,
|
||||
LyricsIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlayerIcon,
|
||||
PlaylistIcon,
|
||||
RewindIcon,
|
||||
TrackIcon,
|
||||
} from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "../list-item/item";
|
||||
import LyricsBox from "../lyrics";
|
||||
import PlayerSlider from "./controls/slider";
|
||||
} from "../../icons";
|
||||
import Illustration from "../../illustration";
|
||||
import ListItem from "../../list-item/item";
|
||||
import {
|
||||
PlayButton,
|
||||
type PlayerControlsProps,
|
||||
PreviousButton,
|
||||
SkipButton,
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
} from "./common";
|
||||
import { LyricsComponent } from "./lyrics";
|
||||
import PlayerSlider from "./slider";
|
||||
|
||||
const parentSongQuery = (id: number) =>
|
||||
API.getSong(id, ["artist", "lyrics", "featuring"]);
|
||||
|
||||
type PlayerButtonControlsProps = {
|
||||
playing: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
onSkipTrack: () => void;
|
||||
onRewind: () => void;
|
||||
};
|
||||
|
||||
type PlayerControlsProps = ComponentProps<typeof PlayerSlider> &
|
||||
PlayerButtonControlsProps & {
|
||||
isTranscoding: boolean;
|
||||
expanded: boolean;
|
||||
onExpand: (expand: boolean) => void;
|
||||
artist?: Artist;
|
||||
track?: TrackWithRelations<"illustration">;
|
||||
};
|
||||
|
||||
const playerTextStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
type ControlButtonProps = {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const ControlButton = (props: ControlButtonProps) => (
|
||||
<IconButton onClick={props.onClick} color="inherit">
|
||||
{props.icon}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const PlayButton = (props: {
|
||||
isPlaying: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}) => (
|
||||
<ControlButton
|
||||
icon={props.isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
onClick={props.isPlaying ? props.onPause : props.onPlay}
|
||||
/>
|
||||
);
|
||||
const SkipButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<ForwardIcon />} />
|
||||
);
|
||||
const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => props.onExpand(true)}
|
||||
disableTouchRipple
|
||||
sx={{ width: "100%", height: "100%", padding: 0, margin: 0 }}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Grid item sx={{ minWidth: "60px" }}>
|
||||
{props.track ? (
|
||||
<Illustration
|
||||
illustration={props.track?.illustration ?? null}
|
||||
quality="low"
|
||||
fallback={<TrackIcon />}
|
||||
imgProps={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
marginX: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TrackIcon />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "space-evenly",
|
||||
marginLeft: { xs: 0, sm: 1 },
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ fontWeight: "bold", ...playerTextStyle }}
|
||||
>
|
||||
{props.track?.name ?? <br />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
...playerTextStyle,
|
||||
fontSize: "medium",
|
||||
}}
|
||||
>
|
||||
{props.artist ? (
|
||||
formatArtists(
|
||||
props.artist,
|
||||
parentSong.data?.featuring,
|
||||
)
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs={4}
|
||||
sm={3}
|
||||
md={2}
|
||||
flexWrap="nowrap"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grid item xs>
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpandedPlayerControls = (
|
||||
export const ExpandedPlayerControls = (
|
||||
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -244,10 +81,34 @@ const ExpandedPlayerControls = (
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
|
||||
const queryClient = useQueryClient();
|
||||
const [playlist] = useAtom(playlistAtom);
|
||||
const [cursor] = useAtom(cursorAtom);
|
||||
const [playlistModalIsOpen, openPlaylistModal] = useState(false);
|
||||
const skipTrack = useSetAtom(skipTrackAtom);
|
||||
const removeTrack = useSetAtom(removeTrackAtom);
|
||||
const reorder = useSetAtom(reorderAtom);
|
||||
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
|
||||
"player",
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const saveAsPlaylistAction = CreatePlaylistAction(
|
||||
queryClient,
|
||||
async (playlistId) => {
|
||||
try {
|
||||
for (const s of playlist) {
|
||||
if (s.track.songId) {
|
||||
await API.addSongToPlaylist(s.track.songId, playlistId);
|
||||
}
|
||||
}
|
||||
router.push(`/playlists/${playlistId}`);
|
||||
} catch {
|
||||
toast.error(t("errorOccured"));
|
||||
}
|
||||
},
|
||||
);
|
||||
const requestFullscreen = useCallback(() => {
|
||||
const el: any = document.getElementById("videoPlayer");
|
||||
|
||||
@@ -308,13 +169,16 @@ const ExpandedPlayerControls = (
|
||||
variant="h6"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{props.track.name ?? <Skeleton />}
|
||||
{props.track.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{props.artist.name ?? <Skeleton />}
|
||||
{formatArtists(
|
||||
props.artist,
|
||||
parentSong.data?.featuring,
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
@@ -327,7 +191,7 @@ const ExpandedPlayerControls = (
|
||||
</IconButton>
|
||||
</Box>
|
||||
{selectedTab !== "player" && (
|
||||
<Divider sx={{ margin: 1 }} variant="middle" />
|
||||
<Divider sx={{ margin: 1, marginBottom: 0 }} variant="middle" />
|
||||
)}
|
||||
|
||||
<Grid
|
||||
@@ -561,122 +425,196 @@ const ExpandedPlayerControls = (
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
>
|
||||
{parentSong.data && (
|
||||
<LyricsBox
|
||||
lyrics={
|
||||
parentSong.data.lyrics?.content.split("\n") ??
|
||||
null
|
||||
}
|
||||
songName={props.track?.name}
|
||||
/>
|
||||
)}
|
||||
<LyricsComponent
|
||||
lyrics={parentSong.data?.lyrics}
|
||||
songName={props.track?.name}
|
||||
progress={props.progress}
|
||||
setProgress={props.onSlide}
|
||||
playerIsExpanded={props.expanded}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{selectedTab === "playlist" && (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflowY: "scroll",
|
||||
alignSelf: "center",
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
>
|
||||
<DragDropContext
|
||||
onDragEnd={(result) => {
|
||||
if (result.destination) {
|
||||
reorder({
|
||||
from: result.source.index + cursor + 1,
|
||||
to: result.destination.index + cursor + 1,
|
||||
});
|
||||
}
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflowY: "scroll",
|
||||
alignSelf: "center",
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{playlist
|
||||
.slice(cursor + 1)
|
||||
.map((playlistItem, index) => (
|
||||
<>
|
||||
<Draggable
|
||||
draggableId={index.toString()}
|
||||
key={index}
|
||||
index={index}
|
||||
>
|
||||
{(providedChild) => (
|
||||
<div
|
||||
ref={
|
||||
providedChild.innerRef
|
||||
}
|
||||
{...providedChild.draggableProps}
|
||||
style={
|
||||
providedChild
|
||||
.draggableProps
|
||||
.style
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
title={
|
||||
playlistItem
|
||||
.track
|
||||
.name
|
||||
<DragDropContext
|
||||
onDragEnd={(result) => {
|
||||
if (result.destination) {
|
||||
reorder({
|
||||
from: result.source.index + cursor + 1,
|
||||
to:
|
||||
result.destination.index +
|
||||
cursor +
|
||||
1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId="droppable">
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{playlist
|
||||
.slice(cursor + 1)
|
||||
.map((playlistItem, index) => (
|
||||
<>
|
||||
<Draggable
|
||||
draggableId={index.toString()}
|
||||
key={index}
|
||||
index={index}
|
||||
>
|
||||
{(providedChild) => (
|
||||
<div
|
||||
ref={
|
||||
providedChild.innerRef
|
||||
}
|
||||
secondTitle={
|
||||
playlistItem
|
||||
.artist
|
||||
.name
|
||||
{...providedChild.draggableProps}
|
||||
style={
|
||||
providedChild
|
||||
.draggableProps
|
||||
.style
|
||||
}
|
||||
icon={
|
||||
<Box
|
||||
{...providedChild.dragHandleProps}
|
||||
>
|
||||
<DragHandleIcon />
|
||||
</Box>
|
||||
}
|
||||
trailing={
|
||||
<Typography color="text.disabled">
|
||||
{formatDuration(
|
||||
playlistItem
|
||||
.track
|
||||
.duration,
|
||||
)}
|
||||
</Typography>
|
||||
}
|
||||
onClick={() => {
|
||||
let toSkip =
|
||||
index +
|
||||
1;
|
||||
|
||||
while (
|
||||
toSkip >
|
||||
0
|
||||
) {
|
||||
skipTrack();
|
||||
toSkip--;
|
||||
>
|
||||
<ListItem
|
||||
title={
|
||||
playlistItem
|
||||
.track
|
||||
.name
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
<Divider
|
||||
key={`divider-${index}`}
|
||||
variant="middle"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Box>
|
||||
secondTitle={
|
||||
playlistItem
|
||||
.artist
|
||||
.name
|
||||
}
|
||||
icon={
|
||||
<Box
|
||||
{...providedChild.dragHandleProps}
|
||||
>
|
||||
<DragHandleIcon />
|
||||
</Box>
|
||||
}
|
||||
trailing={
|
||||
<Grid
|
||||
container
|
||||
wrap="nowrap"
|
||||
columnSpacing={
|
||||
1
|
||||
}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display:
|
||||
"flex",
|
||||
alignItems:
|
||||
"center",
|
||||
}}
|
||||
>
|
||||
<Typography color="text.disabled">
|
||||
{formatDuration(
|
||||
playlistItem
|
||||
.track
|
||||
.duration,
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
>
|
||||
<IconButton
|
||||
size={
|
||||
"small"
|
||||
}
|
||||
onClick={() =>
|
||||
removeTrack(
|
||||
cursor +
|
||||
1 +
|
||||
index,
|
||||
)
|
||||
}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
onClick={() => {
|
||||
let toSkip =
|
||||
index +
|
||||
1;
|
||||
|
||||
while (
|
||||
toSkip >
|
||||
0
|
||||
) {
|
||||
skipTrack();
|
||||
toSkip--;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
<Divider
|
||||
key={`divider-${index}`}
|
||||
variant="middle"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Box>
|
||||
|
||||
{playlist.length > 0 && (
|
||||
<Button
|
||||
sx={{
|
||||
width: "100%",
|
||||
alignSelf: "center",
|
||||
marginTop: 1,
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
props.onExpand(false);
|
||||
openPlaylistModal(true);
|
||||
}}
|
||||
>
|
||||
Save queue as playlist
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Divider variant="middle" sx={{ margin: 1, marginBottom: 2 }} />
|
||||
|
||||
<Dialog
|
||||
open={playlistModalIsOpen}
|
||||
onClose={() => openPlaylistModal(false)}
|
||||
fullWidth
|
||||
>
|
||||
{saveAsPlaylistAction.dialog!({
|
||||
close: () => openPlaylistModal(false),
|
||||
})}
|
||||
</Dialog>
|
||||
<Divider
|
||||
variant="middle"
|
||||
sx={{
|
||||
margin: 1,
|
||||
marginBottom: 2,
|
||||
marginTop: selectedTab === "lyrics" ? 0 : undefined,
|
||||
}}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
@@ -714,5 +652,3 @@ const ExpandedPlayerControls = (
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { MinimizedPlayerControls, ExpandedPlayerControls };
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { type MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import type { Lyrics, SyncedLyric } from "../../../models/lyrics";
|
||||
import LyricsBox from "../../lyrics";
|
||||
|
||||
export const LyricsComponent = ({
|
||||
lyrics,
|
||||
songName,
|
||||
progress,
|
||||
setProgress,
|
||||
playerIsExpanded,
|
||||
}: {
|
||||
lyrics: Lyrics | null | undefined;
|
||||
songName?: string;
|
||||
progress?: MutableRefObject<number | null>;
|
||||
setProgress: (newProgress: number) => void;
|
||||
playerIsExpanded: boolean;
|
||||
}) => {
|
||||
if (lyrics?.synced && progress) {
|
||||
return (
|
||||
<SyncedLyricsComponent
|
||||
syncedLyrics={lyrics.synced}
|
||||
progress={progress}
|
||||
setProgress={setProgress}
|
||||
playerIsExpanded={playerIsExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
marginLeft: 2,
|
||||
paddingRight: 3,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
{
|
||||
<LyricsBox
|
||||
lyrics={lyrics?.plain.split("\n") ?? null}
|
||||
songName={songName}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type SyncedLyricWithNext = SyncedLyric & { next: number | null };
|
||||
|
||||
const SyncedLyricsComponent = ({
|
||||
syncedLyrics,
|
||||
progress,
|
||||
setProgress,
|
||||
playerIsExpanded,
|
||||
}: {
|
||||
syncedLyrics: NonNullable<Lyrics["synced"]>;
|
||||
progress: MutableRefObject<number | null>;
|
||||
setProgress: (newProgress: number) => void;
|
||||
playerIsExpanded: boolean;
|
||||
}) => {
|
||||
const [syncedLyricsWithNext, setLyricsWithNext] =
|
||||
useState<SyncedLyricWithNext[]>();
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState<number>(-1);
|
||||
const intervalRef = useRef<NodeJS.Timer>();
|
||||
|
||||
// Do this async-ly, to avoid lag
|
||||
useEffect(() => {
|
||||
const sortedLyrics = syncedLyrics.toSorted(
|
||||
(la, lb) => la.timestamp - lb.timestamp,
|
||||
);
|
||||
setLyricsWithNext(
|
||||
sortedLyrics.map((entry, index) => {
|
||||
return {
|
||||
...entry,
|
||||
next: sortedLyrics.at(index + 1)?.timestamp ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!playerIsExpanded) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
return;
|
||||
}
|
||||
intervalRef.current = setInterval(() => {
|
||||
const progressValue = progress.current;
|
||||
if (progressValue === null || syncedLyricsWithNext === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentLyric = syncedLyricsWithNext.at(currentLyricIndex);
|
||||
if (
|
||||
currentLyric &&
|
||||
currentLyric.timestamp <= progressValue &&
|
||||
(currentLyric.next ? progressValue < currentLyric.next : true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLyricIndex = syncedLyricsWithNext?.findIndex(
|
||||
(entry) =>
|
||||
entry.timestamp <= progressValue &&
|
||||
(entry.next ? progressValue < entry.next : true),
|
||||
);
|
||||
if (nextLyricIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`lyric-${nextLyricIndex}`)?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
setCurrentLyricIndex(nextLyricIndex);
|
||||
}, 100);
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [playerIsExpanded, syncedLyricsWithNext]);
|
||||
return (
|
||||
<Box sx={{ height: "100%", width: "100%", overflow: "hidden" }}>
|
||||
<Stack spacing={4}>
|
||||
{syncedLyricsWithNext?.map((entry, i) => (
|
||||
<Typography
|
||||
id={`lyric-${i}`}
|
||||
key={`lyric-${i}`}
|
||||
variant={i === currentLyricIndex ? "h4" : "h6"}
|
||||
component={"div"}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
transition:
|
||||
"font-size .2s ease-in; font-weight .2s ease-in",
|
||||
filter:
|
||||
i === currentLyricIndex
|
||||
? undefined
|
||||
: "blur(1px)",
|
||||
fontWeight:
|
||||
i === currentLyricIndex ? "bold" : undefined,
|
||||
}}
|
||||
onClick={() => setProgress(entry.timestamp)}
|
||||
>
|
||||
{entry.content || <br />}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, ButtonBase, Grid, Typography } from "@mui/material";
|
||||
import { useQuery } from "../../../api/use-query";
|
||||
import formatArtists from "../../../utils/formatArtists";
|
||||
import { TrackIcon } from "../../icons";
|
||||
import Illustration from "../../illustration";
|
||||
import {
|
||||
PlayButton,
|
||||
type PlayerControlsProps,
|
||||
SkipButton,
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
} from "./common";
|
||||
|
||||
export const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => props.onExpand(true)}
|
||||
disableTouchRipple
|
||||
sx={{ width: "100%", height: "100%", padding: 0, margin: 0 }}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Grid item sx={{ minWidth: "60px" }}>
|
||||
{props.track ? (
|
||||
<Illustration
|
||||
illustration={props.track?.illustration ?? null}
|
||||
quality="low"
|
||||
fallback={<TrackIcon />}
|
||||
imgProps={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
marginX: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TrackIcon />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "space-evenly",
|
||||
marginLeft: { xs: 0, sm: 1 },
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ fontWeight: "bold", ...playerTextStyle }}
|
||||
>
|
||||
{props.track?.name ?? <br />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
...playerTextStyle,
|
||||
fontSize: "medium",
|
||||
}}
|
||||
>
|
||||
{props.artist ? (
|
||||
formatArtists(
|
||||
props.artist,
|
||||
parentSong.data?.featuring,
|
||||
)
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs={4}
|
||||
sm={3}
|
||||
md={2}
|
||||
flexWrap="nowrap"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grid item xs>
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { Grid, Slider } from "@mui/material";
|
||||
import { type MutableRefObject, useEffect, useState } from "react";
|
||||
import DurationComponent from "./duration";
|
||||
import { DurationComponent } from "./common";
|
||||
|
||||
type PlayerSliderProps = {
|
||||
onSlide: (newProgress: number) => void;
|
||||
|
||||
@@ -25,27 +25,35 @@ import {
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Hls from "hls.js";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { type LegacyRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useReadLocalStorage } from "usehooks-ts";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import API from "../../api/api";
|
||||
import { useKeyboardBinding } from "../../contexts/keybindings";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import type { RootState } from "../../state/store";
|
||||
import {
|
||||
cursorAtom,
|
||||
playPreviousTrackAtom,
|
||||
playTracksAtom,
|
||||
playlistAtom,
|
||||
skipTrackAtom,
|
||||
} from "../../state/player";
|
||||
import { userAtom } from "../../state/user";
|
||||
import { DrawerBreakpoint } from "../scaffold/scaffold";
|
||||
import { ExpandedPlayerControls, MinimizedPlayerControls } from "./controls";
|
||||
import { ExpandedPlayerControls } from "./controls/expanded";
|
||||
import { MinimizedPlayerControls } from "./controls/minimized";
|
||||
|
||||
const Player = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const userIsAuthentified = useSelector(
|
||||
(state: RootState) => state.user.user !== undefined,
|
||||
);
|
||||
const { playPreviousTrack, playTracks, skipTrack, cursor, playlist } =
|
||||
usePlayerContext();
|
||||
const [user] = useAtom(userAtom);
|
||||
const playPreviousTrack = useSetAtom(playPreviousTrackAtom);
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const skipTrack = useSetAtom(skipTrackAtom);
|
||||
const [cursor] = useAtom(cursorAtom);
|
||||
const [playlist] = useAtom(playlistAtom);
|
||||
const currentTrack = useMemo(() => playlist[cursor], [cursor, playlist]);
|
||||
const player = useRef<HTMLAudioElement | HTMLVideoElement>();
|
||||
const audioPlayer = useRef<HTMLAudioElement>(
|
||||
@@ -219,11 +227,11 @@ const Player = () => {
|
||||
}
|
||||
}, [useTranscoding]);
|
||||
useEffect(() => {
|
||||
if (!userIsAuthentified) {
|
||||
if (!user) {
|
||||
pause();
|
||||
playTracks({ tracks: [] });
|
||||
}
|
||||
}, [userIsAuthentified]);
|
||||
}, [user]);
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "p",
|
||||
@@ -330,6 +338,10 @@ const Player = () => {
|
||||
setDuration(undefined);
|
||||
setPlaying(false);
|
||||
}
|
||||
return () => {
|
||||
player?.current?.pause();
|
||||
navigator.mediaSession.metadata = null;
|
||||
};
|
||||
}, [currentTrack]);
|
||||
const playerBgColor = useMemo(() => {
|
||||
const themePaperColor = `rgba(${theme.vars.palette.background.defaultChannel} / 0.75)`;
|
||||
|
||||
@@ -28,25 +28,31 @@ import {
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Audio } from "react-loader-spinner";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
import { type TrackState, usePlayerContext } from "../contexts/player";
|
||||
import type Artist from "../models/artist";
|
||||
import type Release from "../models/release";
|
||||
import type { ReleaseWithRelations } from "../models/release";
|
||||
import type { SongWithRelations } from "../models/song";
|
||||
import type { TrackWithRelations } from "../models/track";
|
||||
import type Tracklist from "../models/tracklist";
|
||||
import type { VideoWithRelations } from "../models/video";
|
||||
import {
|
||||
type TrackState,
|
||||
cursorAtom,
|
||||
playTracksAtom,
|
||||
playlistAtom,
|
||||
} from "../state/player";
|
||||
import formatArtists from "../utils/formatArtists";
|
||||
import formatDuration from "../utils/formatDuration";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import ReleaseTrackContextualMenu from "./contextual-menu/release-track-contextual-menu";
|
||||
import { ContextualMenuIcon, VideoIcon } from "./icons";
|
||||
import { ContextualMenuIcon, PlayIcon, VideoIcon } from "./icons";
|
||||
|
||||
type TrackType = TrackWithRelations<"illustration"> &
|
||||
RequireAtLeastOne<{
|
||||
@@ -56,7 +62,7 @@ type TrackType = TrackWithRelations<"illustration"> &
|
||||
type ReleaseTracklistProps = {
|
||||
mainArtist: Artist | undefined | null;
|
||||
tracklist: Tracklist<TrackType> | undefined;
|
||||
release: Release | undefined;
|
||||
release: ReleaseWithRelations<"discs"> | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,14 +76,17 @@ const ReleaseTrackList = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const PlayingIcon = () => (
|
||||
<Audio
|
||||
height="25"
|
||||
width="20"
|
||||
color={theme.palette.text.disabled}
|
||||
ariaLabel="bars-loading"
|
||||
<PlayIcon
|
||||
style={{
|
||||
fill: theme.palette.text.disabled,
|
||||
color: "transparent",
|
||||
marginLeft: -4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { playTracks, playlist, cursor } = usePlayerContext();
|
||||
const [playlist] = useAtom(playlistAtom);
|
||||
const [cursor] = useAtom(cursorAtom);
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const currentlyPlayingTrack = useMemo(
|
||||
() => playlist[cursor] as TrackState | undefined,
|
||||
[playlist, cursor],
|
||||
@@ -95,6 +104,19 @@ const ReleaseTrackList = ({
|
||||
}
|
||||
return formatArtists(song.artist, song.featuring);
|
||||
};
|
||||
// Note, at this point disc index is a string /shrug
|
||||
const formatDisc = (discIndex: string) => {
|
||||
const disc = release?.discs.find((d) =>
|
||||
d.index === null
|
||||
? discIndex === "?"
|
||||
: d.index.toString() === discIndex,
|
||||
);
|
||||
const base = `${t("disc")} ${discIndex}`;
|
||||
if (disc?.name) {
|
||||
return `${base} — ${disc.name}`;
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -111,7 +133,7 @@ const ReleaseTrackList = ({
|
||||
subheader={
|
||||
discs.length !== 1 && (
|
||||
<ListSubheader disableSticky>
|
||||
{`${t("disc")} ${disc[0]}`}
|
||||
{formatDisc(disc[0])}
|
||||
</ListSubheader>
|
||||
)
|
||||
}
|
||||
@@ -206,12 +228,21 @@ const ReleaseTrackList = ({
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
currentTrack ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
alignItems="center"
|
||||
>
|
||||
{currentTrack.name}
|
||||
</Stack>
|
||||
) : (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
component: "div",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, Link, Skeleton } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CommonExternalMetadata } from "../models/external-metadata";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
|
||||
type Props = {
|
||||
externalMetadata: CommonExternalMetadata | undefined;
|
||||
};
|
||||
|
||||
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const maxLine = 5;
|
||||
const smallBoxStyle = {
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: maxLine,
|
||||
lineClamp: maxLine,
|
||||
WebkitBoxOrient: "vertical",
|
||||
} as const;
|
||||
const bigBoxStyle = {};
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
|
||||
{externalMetadata?.description ||
|
||||
(externalMetadata?.description === undefined &&
|
||||
generateArray(5).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
)))}
|
||||
|
||||
{externalMetadata?.description && (
|
||||
<Link
|
||||
href="#description"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{t(isExpanded ? "showLess" : "showMore")}
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceDescriptionExpandable;
|
||||
+16
-17
@@ -16,25 +16,24 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
import LoadingComponent from "./loading";
|
||||
/**
|
||||
* Component that take the whole page for a loading animation
|
||||
* It should be at the root of the component tree as its content position's will be `fixed`
|
||||
*/
|
||||
const LoadingPage = () => {
|
||||
import { Box, Skeleton } from "@mui/material";
|
||||
import type { CommonExternalMetadata } from "../models/external-metadata";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
|
||||
type Props = {
|
||||
externalMetadata: CommonExternalMetadata | undefined;
|
||||
};
|
||||
|
||||
const ResourceDescription = ({ externalMetadata }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100vh"
|
||||
position="fixed"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<LoadingComponent />
|
||||
<Box id="description">
|
||||
{externalMetadata?.description ||
|
||||
(externalMetadata?.description === undefined &&
|
||||
generateArray(5).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
)))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingPage;
|
||||
export default ResourceDescription;
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user