mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Integrate branch 'master' into feature/langugae_filters
This commit is contained in:
commit
92edf34f0c
719 changed files with 45157 additions and 21485 deletions
|
@ -5,7 +5,6 @@
|
||||||
"not": [
|
"not": [
|
||||||
"./dist/libraries/pdf.worker.js",
|
"./dist/libraries/pdf.worker.js",
|
||||||
"./dist/libraries/worker-bundle.js",
|
"./dist/libraries/worker-bundle.js",
|
||||||
"./dist/libraries/wasm-gen/libarchive.js",
|
|
||||||
"./dist/serviceworker.js"
|
"./dist/serviceworker.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
coverage
|
||||||
dist
|
dist
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
90
.eslintrc.js
90
.eslintrc.js
|
@ -4,10 +4,10 @@ module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
'@stylistic',
|
||||||
'@typescript-eslint',
|
'@typescript-eslint',
|
||||||
'react',
|
'react',
|
||||||
'import',
|
'import',
|
||||||
'eslint-comments',
|
|
||||||
'sonarjs'
|
'sonarjs'
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
|
@ -20,23 +20,14 @@ module.exports = {
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:import/errors',
|
'plugin:import/errors',
|
||||||
'plugin:eslint-comments/recommended',
|
'plugin:@eslint-community/eslint-comments/recommended',
|
||||||
'plugin:compat/recommended',
|
'plugin:compat/recommended',
|
||||||
'plugin:sonarjs/recommended'
|
'plugin:sonarjs/recommended'
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'array-callback-return': ['error', { 'checkForEach': true }],
|
'array-callback-return': ['error', { 'checkForEach': true }],
|
||||||
'block-spacing': ['error'],
|
|
||||||
'brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
|
||||||
'comma-dangle': ['error', 'never'],
|
|
||||||
'comma-spacing': ['error'],
|
|
||||||
'curly': ['error', 'multi-line', 'consistent'],
|
'curly': ['error', 'multi-line', 'consistent'],
|
||||||
'default-case-last': ['error'],
|
'default-case-last': ['error'],
|
||||||
'eol-last': ['error'],
|
|
||||||
'indent': ['error', 4, { 'SwitchCase': 1 }],
|
|
||||||
'jsx-quotes': ['error', 'prefer-single'],
|
|
||||||
'keyword-spacing': ['error'],
|
|
||||||
'max-statements-per-line': ['error'],
|
|
||||||
'max-params': ['error', 7],
|
'max-params': ['error', 7],
|
||||||
'new-cap': [
|
'new-cap': [
|
||||||
'error',
|
'error',
|
||||||
|
@ -48,10 +39,7 @@ module.exports = {
|
||||||
'no-duplicate-imports': ['error'],
|
'no-duplicate-imports': ['error'],
|
||||||
'no-empty-function': ['error'],
|
'no-empty-function': ['error'],
|
||||||
'no-extend-native': ['error'],
|
'no-extend-native': ['error'],
|
||||||
'no-floating-decimal': ['error'],
|
|
||||||
'no-lonely-if': ['error'],
|
'no-lonely-if': ['error'],
|
||||||
'no-multi-spaces': ['error'],
|
|
||||||
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
|
||||||
'no-nested-ternary': ['error'],
|
'no-nested-ternary': ['error'],
|
||||||
'no-redeclare': ['off'],
|
'no-redeclare': ['off'],
|
||||||
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||||
|
@ -62,7 +50,6 @@ module.exports = {
|
||||||
'no-shadow': ['off'],
|
'no-shadow': ['off'],
|
||||||
'@typescript-eslint/no-shadow': ['error'],
|
'@typescript-eslint/no-shadow': ['error'],
|
||||||
'no-throw-literal': ['error'],
|
'no-throw-literal': ['error'],
|
||||||
'no-trailing-spaces': ['error'],
|
|
||||||
'no-undef-init': ['error'],
|
'no-undef-init': ['error'],
|
||||||
'no-unneeded-ternary': ['error'],
|
'no-unneeded-ternary': ['error'],
|
||||||
'no-unused-expressions': ['off'],
|
'no-unused-expressions': ['off'],
|
||||||
|
@ -74,18 +61,11 @@ module.exports = {
|
||||||
'no-var': ['error'],
|
'no-var': ['error'],
|
||||||
'no-void': ['error', { 'allowAsStatement': true }],
|
'no-void': ['error', { 'allowAsStatement': true }],
|
||||||
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
|
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
|
||||||
'object-curly-spacing': ['error', 'always'],
|
|
||||||
'one-var': ['error', 'never'],
|
'one-var': ['error', 'never'],
|
||||||
'operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
|
||||||
'padded-blocks': ['error', 'never'],
|
|
||||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||||
|
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
|
||||||
'@typescript-eslint/prefer-for-of': ['error'],
|
'@typescript-eslint/prefer-for-of': ['error'],
|
||||||
'@typescript-eslint/prefer-optional-chain': ['error'],
|
|
||||||
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
|
||||||
'radix': ['error'],
|
'radix': ['error'],
|
||||||
'@typescript-eslint/semi': ['error'],
|
|
||||||
'space-before-blocks': ['error'],
|
|
||||||
'space-infix-ops': 'error',
|
|
||||||
'yoda': 'error',
|
'yoda': 'error',
|
||||||
|
|
||||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||||
|
@ -97,7 +77,28 @@ module.exports = {
|
||||||
'sonarjs/no-inverted-boolean-check': ['error'],
|
'sonarjs/no-inverted-boolean-check': ['error'],
|
||||||
// TODO: Enable the following rules and fix issues
|
// TODO: Enable the following rules and fix issues
|
||||||
'sonarjs/cognitive-complexity': ['off'],
|
'sonarjs/cognitive-complexity': ['off'],
|
||||||
'sonarjs/no-duplicate-string': ['off']
|
'sonarjs/no-duplicate-string': ['off'],
|
||||||
|
|
||||||
|
'@stylistic/block-spacing': ['error'],
|
||||||
|
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||||
|
'@stylistic/comma-dangle': ['error', 'never'],
|
||||||
|
'@stylistic/comma-spacing': ['error'],
|
||||||
|
'@stylistic/eol-last': ['error'],
|
||||||
|
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||||
|
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||||
|
'@stylistic/keyword-spacing': ['error'],
|
||||||
|
'@stylistic/max-statements-per-line': ['error'],
|
||||||
|
'@stylistic/no-floating-decimal': ['error'],
|
||||||
|
'@stylistic/no-multi-spaces': ['error'],
|
||||||
|
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||||
|
'@stylistic/no-trailing-spaces': ['error'],
|
||||||
|
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||||
|
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||||
|
'@stylistic/padded-blocks': ['error', 'never'],
|
||||||
|
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||||
|
'@stylistic/semi': ['error'],
|
||||||
|
'@stylistic/space-before-blocks': ['error'],
|
||||||
|
'@stylistic/space-infix-ops': ['error']
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
|
@ -263,6 +264,7 @@ module.exports = {
|
||||||
'UserParentalControlPage': 'writable',
|
'UserParentalControlPage': 'writable',
|
||||||
'Windows': 'readonly',
|
'Windows': 'readonly',
|
||||||
// Build time definitions
|
// Build time definitions
|
||||||
|
__COMMIT_SHA__: 'readonly',
|
||||||
__JF_BUILD_VERSION__: 'readonly',
|
__JF_BUILD_VERSION__: 'readonly',
|
||||||
__PACKAGE_JSON_NAME__: 'readonly',
|
__PACKAGE_JSON_NAME__: 'readonly',
|
||||||
__PACKAGE_JSON_VERSION__: 'readonly',
|
__PACKAGE_JSON_VERSION__: 'readonly',
|
||||||
|
@ -270,6 +272,44 @@ module.exports = {
|
||||||
__WEBPACK_SERVE__: 'readonly'
|
__WEBPACK_SERVE__: 'readonly'
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'default',
|
||||||
|
format: [ 'camelCase', 'PascalCase' ],
|
||||||
|
leadingUnderscore: 'allow'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'variable',
|
||||||
|
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
|
||||||
|
leadingUnderscore: 'allowSingleOrDouble',
|
||||||
|
trailingUnderscore: 'allowSingleOrDouble'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'typeLike',
|
||||||
|
format: [ 'PascalCase' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'enumMember',
|
||||||
|
format: [ 'PascalCase', 'UPPER_CASE' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||||
|
format: [ 'camelCase', 'PascalCase' ],
|
||||||
|
leadingUnderscore: 'allowSingleOrDouble',
|
||||||
|
trailingUnderscore: 'allowSingleOrDouble'
|
||||||
|
},
|
||||||
|
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
|
||||||
|
// and api_key parameter
|
||||||
|
{
|
||||||
|
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||||
|
format: null,
|
||||||
|
filter: {
|
||||||
|
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
|
||||||
|
match: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -282,7 +322,7 @@ module.exports = {
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:import/typescript',
|
'plugin:import/typescript',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:eslint-comments/recommended',
|
'plugin:@eslint-community/eslint-comments/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:jsx-a11y/recommended'
|
'plugin:jsx-a11y/recommended'
|
||||||
|
|
23
.github/renovate.json
vendored
23
.github/renovate.json
vendored
|
@ -1,4 +1,25 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["github>jellyfin/.github//renovate-presets/nodejs", ":semanticCommitsDisabled"]
|
"extends": [
|
||||||
|
"github>jellyfin/.github//renovate-presets/nodejs",
|
||||||
|
":dependencyDashboard"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": [ "@jellyfin/sdk" ],
|
||||||
|
"followTag": "unstable",
|
||||||
|
"minimumReleaseAge": null,
|
||||||
|
"prPriority": 5,
|
||||||
|
"schedule": [ "after 7:00 am" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": [ "dompurify" ],
|
||||||
|
"matchUpdateTypes": [ "major" ],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": [ "hls.js" ],
|
||||||
|
"prPriority": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
15
.github/workflows/__automation.yml
vendored
Normal file
15
.github/workflows/__automation.yml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
name: Automation 🎛️
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
conflicts:
|
||||||
|
name: Merge conflict labeling 🏷️
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
|
with:
|
||||||
|
dirtyLabel: 'merge conflict'
|
||||||
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
40
.github/workflows/__codeql.yml
vendored
Normal file
40
.github/workflows/__codeql.yml
vendored
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
name: GitHub CodeQL 🔬
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
commit:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze ${{ matrix.language }} 🔬
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language:
|
||||||
|
- javascript-typescript
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository ⬇️
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.commit }}
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: Initialize CodeQL 🛠️
|
||||||
|
uses: github/codeql-action/init@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||||
|
with:
|
||||||
|
queries: security-and-quality
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
|
- name: Autobuild 📦
|
||||||
|
uses: github/codeql-action/autobuild@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis 🧪
|
||||||
|
uses: github/codeql-action/analyze@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||||
|
with:
|
||||||
|
category: '/language:${{matrix.language}}'
|
59
.github/workflows/__deploy.yml
vendored
Normal file
59
.github/workflows/__deploy.yml
vendored
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
name: Deploy 🏗️
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
comment:
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
artifact_name:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cf-pages:
|
||||||
|
name: CloudFlare Pages 📃
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: ${{ inputs.branch == 'master' && 'Production' || 'Preview' }}
|
||||||
|
url: ${{ steps.cf.outputs.deployment-url }}
|
||||||
|
outputs:
|
||||||
|
url: ${{ steps.cf.outputs.deployment-url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download workflow artifact ⬇️
|
||||||
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.artifact_name }}
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Publish to Cloudflare Pages 📃
|
||||||
|
uses: cloudflare/wrangler-action@7a5f8bbdfeedcde38e6777a50fe685f89259d4ca # v3.13.1
|
||||||
|
id: cf
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
command: pages deploy dist --project-name=jellyfin-web --branch=${{ inputs.branch }}
|
||||||
|
|
||||||
|
compose-comment:
|
||||||
|
name: Compose and push comment 📝
|
||||||
|
# Always run so the comment is composed for the workflow summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: ./.github/workflows/__job_messages.yml
|
||||||
|
secrets: inherit
|
||||||
|
needs:
|
||||||
|
- cf-pages
|
||||||
|
|
||||||
|
with:
|
||||||
|
branch: ${{ inputs.branch }}
|
||||||
|
commit: ${{ inputs.commit }}
|
||||||
|
preview_url: ${{ needs.cf-pages.outputs.url }}
|
||||||
|
in_progress: false
|
||||||
|
comment: ${{ inputs.comment }}
|
|
@ -1,4 +1,4 @@
|
||||||
name: Job messages
|
name: Job messages ⚙️
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
@ -12,32 +12,26 @@ on:
|
||||||
preview_url:
|
preview_url:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
build_workflow_run_id:
|
|
||||||
required: false
|
|
||||||
type: number
|
|
||||||
commenting_workflow_run_id:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
in_progress:
|
in_progress:
|
||||||
required: true
|
required: true
|
||||||
type: boolean
|
type: boolean
|
||||||
outputs:
|
comment:
|
||||||
msg:
|
required: false
|
||||||
description: The composed message
|
type: boolean
|
||||||
value: ${{ jobs.msg.outputs.msg }}
|
|
||||||
marker:
|
marker:
|
||||||
description: Hidden marker to detect PR comments composed by the bot
|
description: Hidden marker to detect PR comments composed by the bot
|
||||||
value: "CFPages-deployment"
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "CFPages-deployment"
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
msg:
|
cf_pages_msg:
|
||||||
name: Deployment status
|
name: CloudFlare Pages deployment 📃🚀
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
msg: ${{ env.msg }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Compose message
|
- name: Compose message 📃
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
id: compose
|
id: compose
|
||||||
env:
|
env:
|
||||||
|
@ -45,8 +39,7 @@ jobs:
|
||||||
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
|
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
|
||||||
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
|
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
|
||||||
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
|
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
|
||||||
BUILD_WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.build_workflow_run_id) || '' }}
|
WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', github.repository, github.run_id) || '' }}
|
||||||
COMMENTING_WORKFLOW_RUN: ${{ format('**[View bot logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.commenting_workflow_run_id) }}
|
|
||||||
# EOF is needed for multiline environment variables in a GitHub Actions context
|
# EOF is needed for multiline environment variables in a GitHub Actions context
|
||||||
run: |
|
run: |
|
||||||
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
|
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
|
||||||
|
@ -57,9 +50,16 @@ jobs:
|
||||||
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "$BUILD_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
echo "$WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "$COMMENTING_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
|
||||||
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
|
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
|
||||||
echo "msg<<EOF" >> $GITHUB_ENV
|
echo "msg<<EOF" >> $GITHUB_ENV
|
||||||
echo "$COMPOSED_MSG" >> $GITHUB_ENV
|
echo "$COMPOSED_MSG" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Push comment to Pull Request 🔼
|
||||||
|
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
|
||||||
|
if: ${{ inputs.comment && steps.compose.conclusion == 'success' }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
message: ${{ env.msg }}
|
||||||
|
comment-tag: ${{ inputs.marker }}
|
45
.github/workflows/__package.yml
vendored
Normal file
45
.github/workflows/__package.yml
vendored
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
name: Packaging 📦
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
commit:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-build-prod:
|
||||||
|
name: Run production build 🏗️
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.commit || github.sha }}
|
||||||
|
|
||||||
|
- name: Setup node environment
|
||||||
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci --no-audit
|
||||||
|
|
||||||
|
- name: Run a production build
|
||||||
|
env:
|
||||||
|
JELLYFIN_VERSION: ${{ inputs.commit || github.sha }}
|
||||||
|
run: npm run build:production
|
||||||
|
|
||||||
|
- name: Update config.json for testing
|
||||||
|
run: |
|
||||||
|
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
|
||||||
|
mv dist/config.tmp.json dist/config.json
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||||
|
with:
|
||||||
|
name: frontend
|
||||||
|
path: dist
|
61
.github/workflows/__quality_checks.yml
vendored
Normal file
61
.github/workflows/__quality_checks.yml
vendored
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
name: Quality checks 👌🧪
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
commit:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
name: Vulnerable dependencies 🔎
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.commit }}
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: Scan
|
||||||
|
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
||||||
|
with:
|
||||||
|
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||||
|
## TODO: Remove when necessary
|
||||||
|
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
|
||||||
|
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
quality:
|
||||||
|
name: Run ${{ matrix.command }} 🕵️♂️
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
command:
|
||||||
|
- build:es-check
|
||||||
|
- lint
|
||||||
|
- stylelint
|
||||||
|
- build:check
|
||||||
|
- test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout ⬇️
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.commit }}
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: Setup node environment ⚙️
|
||||||
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Install dependencies 📦
|
||||||
|
run: npm ci --no-audit
|
||||||
|
|
||||||
|
- name: Run ${{ matrix.command }} ⚙️
|
||||||
|
run: npm run ${{ matrix.command }}
|
21
.github/workflows/automation.yml
vendored
21
.github/workflows/automation.yml
vendored
|
@ -1,21 +0,0 @@
|
||||||
name: 'Automation'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
triage:
|
|
||||||
name: 'Merge conflict labeling'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
|
||||||
steps:
|
|
||||||
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
|
|
||||||
with:
|
|
||||||
dirtyLabel: 'merge conflict'
|
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
|
||||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
|
72
.github/workflows/build.yml
vendored
72
.github/workflows/build.yml
vendored
|
@ -1,72 +0,0 @@
|
||||||
name: Build
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-build-prod:
|
|
||||||
name: Run production build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run a production build
|
|
||||||
env:
|
|
||||||
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
run: npm run build:production
|
|
||||||
|
|
||||||
- name: Update config.json for testing
|
|
||||||
run: |
|
|
||||||
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
|
|
||||||
mv dist/config.tmp.json dist/config.json
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
|
||||||
with:
|
|
||||||
name: jellyfin-web__prod
|
|
||||||
path: |
|
|
||||||
dist
|
|
||||||
|
|
||||||
pr_context:
|
|
||||||
name: Save PR context as artifact
|
|
||||||
if: ${{ always() && !cancelled() && github.event_name == 'pull_request' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- run-build-prod
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Save PR context
|
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
|
||||||
PR_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
echo $PR_NUMBER > PR_number
|
|
||||||
echo $PR_SHA > PR_sha
|
|
||||||
|
|
||||||
- name: Upload PR number as artifact
|
|
||||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
|
||||||
with:
|
|
||||||
name: PR_context
|
|
||||||
path: |
|
|
||||||
PR_number
|
|
||||||
PR_sha
|
|
34
.github/workflows/codeql.yml
vendored
34
.github/workflows/codeql.yml
vendored
|
@ -1,34 +0,0 @@
|
||||||
name: CodeQL
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
schedule:
|
|
||||||
- cron: '30 7 * * 6'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
codeql:
|
|
||||||
name: Run CodeQL
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
|
||||||
with:
|
|
||||||
languages: javascript
|
|
||||||
queries: +security-extended
|
|
||||||
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6
|
|
36
.github/workflows/commands.yml
vendored
36
.github/workflows/commands.yml
vendored
|
@ -1,36 +0,0 @@
|
||||||
name: Commands
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types:
|
|
||||||
- created
|
|
||||||
- edited
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rebase:
|
|
||||||
name: Rebase
|
|
||||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Notify as seen
|
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
reactions: '+1'
|
|
||||||
- name: Checkout the latest code
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Automatic Rebase
|
|
||||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
- name: Comment on failure
|
|
||||||
if: failure()
|
|
||||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
body: |
|
|
||||||
I'm sorry @${{ github.event.comment.user.login }}, I'm afraid I can't do that.
|
|
38
.github/workflows/pr-suggestions.yml
vendored
38
.github/workflows/pr-suggestions.yml
vendored
|
@ -1,38 +0,0 @@
|
||||||
name: PR suggestions
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.id || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
types:
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-eslint:
|
|
||||||
name: Run eslint suggestions
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run eslint
|
|
||||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
|
||||||
uses: CatChen/eslint-suggestion-action@8fb7db4e235f7af9fc434349a124034b681d99a3 # v3.1.3
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
97
.github/workflows/publish.yml
vendored
97
.github/workflows/publish.yml
vendored
|
@ -1,97 +0,0 @@
|
||||||
name: Publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- Build
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
deployments: write
|
|
||||||
|
|
||||||
name: Deploy to Cloudflare Pages
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
url: ${{ steps.cf.outputs.url }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download workflow artifact
|
|
||||||
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
|
|
||||||
with:
|
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
|
||||||
name: jellyfin-web__prod
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
id: cf
|
|
||||||
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # 1
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
projectName: jellyfin-web
|
|
||||||
branch: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
directory: dist
|
|
||||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
pr-context:
|
|
||||||
name: PR context
|
|
||||||
if: ${{ always() && github.event.workflow_run.event == 'pull_request' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
commit: ${{ env.pr_sha }}
|
|
||||||
pr_number: ${{ env.pr_number }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Get PR context
|
|
||||||
uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2
|
|
||||||
id: pr_context
|
|
||||||
with:
|
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
|
||||||
name: PR_context
|
|
||||||
|
|
||||||
- name: Set PR context environment variables
|
|
||||||
if: ${{ steps.pr_context.conclusion == 'success' }}
|
|
||||||
run: |
|
|
||||||
echo "pr_number=$(cat PR_number)" >> $GITHUB_ENV
|
|
||||||
echo "pr_sha=$(cat PR_sha)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
compose-comment:
|
|
||||||
name: Compose comment
|
|
||||||
if: ${{ always() }}
|
|
||||||
uses: ./.github/workflows/job-messages.yml
|
|
||||||
needs:
|
|
||||||
- publish
|
|
||||||
- pr-context
|
|
||||||
|
|
||||||
with:
|
|
||||||
branch: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
commit: ${{ needs.pr-context.outputs.commit != '' && needs.pr-context.outputs.commit || github.event.workflow_run.head_sha }}
|
|
||||||
preview_url: ${{ needs.publish.outputs.url }}
|
|
||||||
build_workflow_run_id: ${{ github.event.workflow_run.id }}
|
|
||||||
commenting_workflow_run_id: ${{ github.run_id }}
|
|
||||||
in_progress: false
|
|
||||||
|
|
||||||
comment-status:
|
|
||||||
name: Create comment status
|
|
||||||
if: |
|
|
||||||
always() &&
|
|
||||||
github.event.workflow_run.event == 'pull_request' &&
|
|
||||||
needs.pr-context.outputs.pr_number != ''
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- compose-comment
|
|
||||||
- pr-context
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Update job summary in PR comment
|
|
||||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
|
||||||
with:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
message: ${{ needs.compose-comment.outputs.msg }}
|
|
||||||
pr_number: ${{ needs.pr-context.outputs.pr_number }}
|
|
||||||
comment_tag: ${{ needs.compose-comment.outputs.marker }}
|
|
||||||
mode: recreate
|
|
100
.github/workflows/pull_request.yml
vendored
Normal file
100
.github/workflows/pull_request.yml
vendored
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
name: Pull Request 📥
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- release*
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push-comment:
|
||||||
|
name: Create comments ✍️
|
||||||
|
if: ${{ always() && !cancelled() && github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
uses: ./.github/workflows/__job_messages.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
commit: ${{ github.event.pull_request.head.sha }}
|
||||||
|
in_progress: true
|
||||||
|
comment: true
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build 🏗️
|
||||||
|
if: ${{ always() && !cancelled() }}
|
||||||
|
uses: ./.github/workflows/__package.yml
|
||||||
|
with:
|
||||||
|
commit: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
automation:
|
||||||
|
name: Automation 🎛️
|
||||||
|
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
uses: ./.github/workflows/__automation.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
quality_checks:
|
||||||
|
name: Quality checks 👌🧪
|
||||||
|
if: ${{ always() && !cancelled() }}
|
||||||
|
uses: ./.github/workflows/__quality_checks.yml
|
||||||
|
permissions: {}
|
||||||
|
with:
|
||||||
|
commit: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
codeql:
|
||||||
|
name: GitHub CodeQL 🔬
|
||||||
|
if: ${{ always() && !cancelled() }}
|
||||||
|
uses: ./.github/workflows/__codeql.yml
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
with:
|
||||||
|
commit: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy 🚀
|
||||||
|
uses: ./.github/workflows/__deploy.yml
|
||||||
|
if: ${{ always() && !cancelled() && needs.build.result == 'success' && github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
needs:
|
||||||
|
- push-comment
|
||||||
|
- build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
# If the PR is from the master branch of a fork, append the fork's name to the branch name
|
||||||
|
branch: ${{ github.event.pull_request.head.repo.full_name != github.repository && github.event.pull_request.head.ref == 'master' && format('{0}/{1}', github.event.pull_request.head.repo.full_name, github.event.pull_request.head.ref) || github.event.pull_request.head.ref }}
|
||||||
|
comment: true
|
||||||
|
commit: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
run-eslint:
|
||||||
|
name: Run eslint suggestions
|
||||||
|
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Setup node environment
|
||||||
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci --no-audit
|
||||||
|
|
||||||
|
- name: Run eslint
|
||||||
|
uses: CatChen/eslint-suggestion-action@9c12109c4943f26f0676b71c9c10e456748872cf # v4.1.7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
58
.github/workflows/push.yml
vendored
Normal file
58
.github/workflows/push.yml
vendored
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
name: Push & Release 🌍
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- release*
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
automation:
|
||||||
|
name: Automation 🎛️
|
||||||
|
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
uses: ./.github/workflows/__automation.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
main:
|
||||||
|
name: 'Unstable release 🚀⚠️'
|
||||||
|
uses: ./.github/workflows/__package.yml
|
||||||
|
with:
|
||||||
|
commit: ${{ github.sha }}
|
||||||
|
|
||||||
|
quality_checks:
|
||||||
|
name: Quality checks 👌🧪
|
||||||
|
if: ${{ always() && !cancelled() }}
|
||||||
|
uses: ./.github/workflows/__quality_checks.yml
|
||||||
|
permissions: {}
|
||||||
|
with:
|
||||||
|
commit: ${{ github.sha }}
|
||||||
|
|
||||||
|
codeql:
|
||||||
|
name: GitHub CodeQL 🔬
|
||||||
|
uses: ./.github/workflows/__codeql.yml
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
with:
|
||||||
|
commit: ${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy 🚀
|
||||||
|
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||||
|
uses: ./.github/workflows/__deploy.yml
|
||||||
|
needs:
|
||||||
|
- main
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
branch: ${{ github.ref_name }}
|
||||||
|
comment: false
|
123
.github/workflows/quality.yml
vendored
123
.github/workflows/quality.yml
vendored
|
@ -1,123 +0,0 @@
|
||||||
name: Quality checks
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master, release* ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-escheck:
|
|
||||||
name: Run es-check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run a production build
|
|
||||||
run: npm run build:production
|
|
||||||
|
|
||||||
- name: Run es-check
|
|
||||||
run: npm run escheck
|
|
||||||
|
|
||||||
run-eslint:
|
|
||||||
name: Run eslint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run eslint
|
|
||||||
run: npx eslint --quiet "."
|
|
||||||
|
|
||||||
run-stylelint:
|
|
||||||
name: Run stylelint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Set up stylelint matcher
|
|
||||||
uses: xt0rted/stylelint-problem-matcher@34db1b874c0452909f0696aedef70b723870a583 # tag=v1
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run stylelint
|
|
||||||
run: npm run stylelint
|
|
||||||
|
|
||||||
run-tsc:
|
|
||||||
name: Run TypeScript build check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run tsc
|
|
||||||
run: npm run build:check
|
|
||||||
|
|
||||||
run-test:
|
|
||||||
name: Run tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
|
|
||||||
- name: Setup node environment
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: npm ci --no-audit
|
|
||||||
|
|
||||||
- name: Run test suite
|
|
||||||
run: npm run test
|
|
|
@ -1,10 +1,9 @@
|
||||||
name: Stale Check
|
name: Scheduled tasks 🕑
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 1 * * *'
|
- cron: '30 1 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
@ -15,7 +14,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
operations-per-run: 75
|
operations-per-run: 75
|
||||||
|
@ -37,7 +36,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
operations-per-run: 75
|
operations-per-run: 75
|
52
.github/workflows/update-sdk.yml
vendored
52
.github/workflows/update-sdk.yml
vendored
|
@ -1,52 +0,0 @@
|
||||||
name: Update the Jellyfin SDK
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 7 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: unstable-sdk-pr
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out Git repository
|
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
|
||||||
with:
|
|
||||||
ref: master
|
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
check-latest: true
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install latest unstable SDK
|
|
||||||
run: |
|
|
||||||
npm i --save @jellyfin/sdk@unstable
|
|
||||||
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
|
||||||
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Open a pull request
|
|
||||||
uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # v6.0.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
|
||||||
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
|
||||||
committer: jellyfin-bot <team@jellyfin.org>
|
|
||||||
author: jellyfin-bot <team@jellyfin.org>
|
|
||||||
branch: update-jf-sdk
|
|
||||||
delete-branch: true
|
|
||||||
title: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
|
||||||
body: |
|
|
||||||
**Changes**
|
|
||||||
Updates to the latest unstable @jellyfin/sdk build
|
|
||||||
labels: |
|
|
||||||
dependencies
|
|
||||||
npm
|
|
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -3,6 +3,9 @@ dist
|
||||||
web
|
web
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
coverage
|
||||||
|
|
||||||
# config
|
# config
|
||||||
config.json
|
config.json
|
||||||
|
|
||||||
|
@ -10,12 +13,6 @@ config.json
|
||||||
.idea
|
.idea
|
||||||
.vs
|
.vs
|
||||||
|
|
||||||
# log
|
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# vim
|
# vim
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# build artifacts
|
|
||||||
fedora/jellyfin-web-*.src.rpm
|
|
||||||
fedora/jellyfin-web-*.tar.gz
|
|
||||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
20
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@stylistic/stylelint-plugin",
|
||||||
"stylelint-no-browser-hacks/lib"
|
"stylelint-no-browser-hacks/lib"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
@ -10,20 +11,20 @@
|
||||||
],
|
],
|
||||||
"ignore": ["after-comment"]
|
"ignore": ["after-comment"]
|
||||||
} ],
|
} ],
|
||||||
"at-rule-name-case": "lower",
|
"@stylistic/at-rule-name-case": "lower",
|
||||||
"at-rule-name-space-after": "always-single-line",
|
"@stylistic/at-rule-name-space-after": "always-single-line",
|
||||||
"at-rule-no-unknown": true,
|
"at-rule-no-unknown": true,
|
||||||
"at-rule-no-vendor-prefix": true,
|
"at-rule-no-vendor-prefix": true,
|
||||||
"at-rule-semicolon-newline-after": "always",
|
"@stylistic/at-rule-semicolon-newline-after": "always",
|
||||||
"block-closing-brace-empty-line-before": "never",
|
"@stylistic/block-closing-brace-empty-line-before": "never",
|
||||||
"block-closing-brace-newline-after": "always",
|
"@stylistic/block-closing-brace-newline-after": "always",
|
||||||
"block-closing-brace-newline-before": "always-multi-line",
|
"@stylistic/block-closing-brace-newline-before": "always-multi-line",
|
||||||
"block-closing-brace-space-before": "always-single-line",
|
"@stylistic/block-closing-brace-space-before": "always-single-line",
|
||||||
"block-no-empty": true,
|
"block-no-empty": true,
|
||||||
"block-opening-brace-newline-after": "always-multi-line",
|
"@stylistic/block-opening-brace-newline-after": "always-multi-line",
|
||||||
"block-opening-brace-space-after": "always-single-line",
|
"@stylistic/block-opening-brace-space-after": "always-single-line",
|
||||||
"block-opening-brace-space-before": "always",
|
"@stylistic/block-opening-brace-space-before": "always",
|
||||||
"color-hex-case": "lower",
|
"@stylistic/color-hex-case": "lower",
|
||||||
"color-hex-length": "short",
|
"color-hex-length": "short",
|
||||||
"color-no-invalid-hex": true,
|
"color-no-invalid-hex": true,
|
||||||
"comment-empty-line-before": [ "always", {
|
"comment-empty-line-before": [ "always", {
|
||||||
|
@ -42,8 +43,8 @@
|
||||||
"inside-single-line-block"
|
"inside-single-line-block"
|
||||||
]
|
]
|
||||||
} ],
|
} ],
|
||||||
"declaration-bang-space-after": "never",
|
"@stylistic/declaration-bang-space-after": "never",
|
||||||
"declaration-bang-space-before": "always",
|
"@stylistic/declaration-bang-space-before": "always",
|
||||||
"declaration-block-no-duplicate-properties": [
|
"declaration-block-no-duplicate-properties": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
|
@ -51,52 +52,52 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"declaration-block-no-shorthand-property-overrides": true,
|
"declaration-block-no-shorthand-property-overrides": true,
|
||||||
"declaration-block-semicolon-newline-after": "always-multi-line",
|
"@stylistic/declaration-block-semicolon-newline-after": "always-multi-line",
|
||||||
"declaration-block-semicolon-space-after": "always-single-line",
|
"@stylistic/declaration-block-semicolon-space-after": "always-single-line",
|
||||||
"declaration-block-semicolon-space-before": "never",
|
"@stylistic/declaration-block-semicolon-space-before": "never",
|
||||||
"declaration-block-single-line-max-declarations": 1,
|
"declaration-block-single-line-max-declarations": 1,
|
||||||
"declaration-block-trailing-semicolon": "always",
|
"@stylistic/declaration-block-trailing-semicolon": "always",
|
||||||
"declaration-colon-newline-after": "always-multi-line",
|
"@stylistic/declaration-colon-newline-after": "always-multi-line",
|
||||||
"declaration-colon-space-after": "always-single-line",
|
"@stylistic/declaration-colon-space-after": "always-single-line",
|
||||||
"declaration-colon-space-before": "never",
|
"@stylistic/declaration-colon-space-before": "never",
|
||||||
"font-family-no-duplicate-names": true,
|
"font-family-no-duplicate-names": true,
|
||||||
"function-calc-no-unspaced-operator": true,
|
"function-calc-no-unspaced-operator": true,
|
||||||
"function-comma-newline-after": "always-multi-line",
|
"@stylistic/function-comma-newline-after": "always-multi-line",
|
||||||
"function-comma-space-after": "always-single-line",
|
"@stylistic/function-comma-space-after": "always-single-line",
|
||||||
"function-comma-space-before": "never",
|
"@stylistic/function-comma-space-before": "never",
|
||||||
"function-linear-gradient-no-nonstandard-direction": true,
|
"function-linear-gradient-no-nonstandard-direction": true,
|
||||||
"function-max-empty-lines": 0,
|
"@stylistic/function-max-empty-lines": 0,
|
||||||
"function-name-case": "lower",
|
"function-name-case": "lower",
|
||||||
"function-parentheses-newline-inside": "always-multi-line",
|
"@stylistic/function-parentheses-newline-inside": "always-multi-line",
|
||||||
"function-parentheses-space-inside": "never-single-line",
|
"@stylistic/function-parentheses-space-inside": "never-single-line",
|
||||||
"function-whitespace-after": "always",
|
"@stylistic/function-whitespace-after": "always",
|
||||||
"indentation": 4,
|
"@stylistic/indentation": 4,
|
||||||
"keyframe-declaration-no-important": true,
|
"keyframe-declaration-no-important": true,
|
||||||
"length-zero-no-unit": true,
|
"length-zero-no-unit": true,
|
||||||
"max-empty-lines": 1,
|
"@stylistic/max-empty-lines": 1,
|
||||||
"media-feature-colon-space-after": "always",
|
"@stylistic/media-feature-colon-space-after": "always",
|
||||||
"media-feature-colon-space-before": "never",
|
"@stylistic/media-feature-colon-space-before": "never",
|
||||||
"media-feature-name-case": "lower",
|
"@stylistic/media-feature-name-case": "lower",
|
||||||
"media-feature-name-no-unknown": true,
|
"media-feature-name-no-unknown": true,
|
||||||
"media-feature-name-no-vendor-prefix": true,
|
"media-feature-name-no-vendor-prefix": true,
|
||||||
"media-feature-parentheses-space-inside": "never",
|
"@stylistic/media-feature-parentheses-space-inside": "never",
|
||||||
"media-feature-range-operator-space-after": "always",
|
"@stylistic/media-feature-range-operator-space-after": "always",
|
||||||
"media-feature-range-operator-space-before": "always",
|
"@stylistic/media-feature-range-operator-space-before": "always",
|
||||||
"media-query-list-comma-newline-after": "always-multi-line",
|
"@stylistic/media-query-list-comma-newline-after": "always-multi-line",
|
||||||
"media-query-list-comma-space-after": "always-single-line",
|
"@stylistic/media-query-list-comma-space-after": "always-single-line",
|
||||||
"media-query-list-comma-space-before": "never",
|
"@stylistic/media-query-list-comma-space-before": "never",
|
||||||
"no-descending-specificity": true,
|
"no-descending-specificity": true,
|
||||||
"no-duplicate-at-import-rules": true,
|
"no-duplicate-at-import-rules": true,
|
||||||
"no-duplicate-selectors": true,
|
"no-duplicate-selectors": true,
|
||||||
"no-empty-source": true,
|
"no-empty-source": true,
|
||||||
"no-eol-whitespace": true,
|
"@stylistic/no-eol-whitespace": true,
|
||||||
"no-extra-semicolons": true,
|
"@stylistic/no-extra-semicolons": true,
|
||||||
"no-invalid-double-slash-comments": true,
|
"no-invalid-double-slash-comments": true,
|
||||||
"no-missing-end-of-source-newline": true,
|
"@stylistic/no-missing-end-of-source-newline": true,
|
||||||
"number-leading-zero": "always",
|
"@stylistic/number-leading-zero": "always",
|
||||||
"number-no-trailing-zeros": true,
|
"@stylistic/number-no-trailing-zeros": true,
|
||||||
"plugin/no-browser-hacks": true,
|
"plugin/no-browser-hacks": true,
|
||||||
"property-case": "lower",
|
"@stylistic/property-case": "lower",
|
||||||
"property-no-unknown": [
|
"property-no-unknown": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
|
@ -110,20 +111,20 @@
|
||||||
"except": ["first-nested"],
|
"except": ["first-nested"],
|
||||||
"ignore": ["after-comment"]
|
"ignore": ["after-comment"]
|
||||||
} ],
|
} ],
|
||||||
"selector-attribute-brackets-space-inside": "never",
|
"@stylistic/selector-attribute-brackets-space-inside": "never",
|
||||||
"selector-attribute-operator-space-after": "never",
|
"@stylistic/selector-attribute-operator-space-after": "never",
|
||||||
"selector-attribute-operator-space-before": "never",
|
"@stylistic/selector-attribute-operator-space-before": "never",
|
||||||
"selector-combinator-space-after": "always",
|
"@stylistic/selector-combinator-space-after": "always",
|
||||||
"selector-combinator-space-before": "always",
|
"@stylistic/selector-combinator-space-before": "always",
|
||||||
"selector-descendant-combinator-no-non-space": true,
|
"@stylistic/selector-descendant-combinator-no-non-space": true,
|
||||||
"selector-list-comma-newline-after": "always",
|
"@stylistic/selector-list-comma-newline-after": "always",
|
||||||
"selector-list-comma-space-before": "never",
|
"@stylistic/selector-list-comma-space-before": "never",
|
||||||
"selector-max-empty-lines": 0,
|
"@stylistic/selector-max-empty-lines": 0,
|
||||||
"selector-no-vendor-prefix": true,
|
"selector-no-vendor-prefix": true,
|
||||||
"selector-pseudo-class-case": "lower",
|
"@stylistic/selector-pseudo-class-case": "lower",
|
||||||
"selector-pseudo-class-no-unknown": true,
|
"selector-pseudo-class-no-unknown": true,
|
||||||
"selector-pseudo-class-parentheses-space-inside": "never",
|
"@stylistic/selector-pseudo-class-parentheses-space-inside": "never",
|
||||||
"selector-pseudo-element-case": "lower",
|
"@stylistic/selector-pseudo-element-case": "lower",
|
||||||
"selector-pseudo-element-colon-notation": "double",
|
"selector-pseudo-element-colon-notation": "double",
|
||||||
"selector-pseudo-element-no-unknown": [
|
"selector-pseudo-element-no-unknown": [
|
||||||
true,
|
true,
|
||||||
|
@ -136,13 +137,13 @@
|
||||||
"selector-type-case": "lower",
|
"selector-type-case": "lower",
|
||||||
"selector-type-no-unknown": true,
|
"selector-type-no-unknown": true,
|
||||||
"string-no-newline": true,
|
"string-no-newline": true,
|
||||||
"unit-case": "lower",
|
"@stylistic/unit-case": "lower",
|
||||||
"unit-no-unknown": true,
|
"unit-no-unknown": true,
|
||||||
"value-no-vendor-prefix": true,
|
"value-no-vendor-prefix": true,
|
||||||
"value-list-comma-newline-after": "always-multi-line",
|
"@stylistic/value-list-comma-newline-after": "always-multi-line",
|
||||||
"value-list-comma-space-after": "always-single-line",
|
"@stylistic/value-list-comma-space-after": "always-single-line",
|
||||||
"value-list-comma-space-before": "never",
|
"@stylistic/value-list-comma-space-before": "never",
|
||||||
"value-list-max-empty-lines": 0
|
"@stylistic/value-list-max-empty-lines": 0
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -79,9 +79,21 @@
|
||||||
- [Kevin Tan (Valius)](https://github.com/valius)
|
- [Kevin Tan (Valius)](https://github.com/valius)
|
||||||
- [Rasmus Krämer](https://github.com/rasmuslos)
|
- [Rasmus Krämer](https://github.com/rasmuslos)
|
||||||
- [ntarelix](https://github.com/ntarelix)
|
- [ntarelix](https://github.com/ntarelix)
|
||||||
|
- [btopherjohnson](https://github.com/btopherjohnson)
|
||||||
- [András Maróy](https://github.com/andrasmaroy)
|
- [András Maróy](https://github.com/andrasmaroy)
|
||||||
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
|
- [Chris-Codes-It](https://github.com/Chris-Codes-It)
|
||||||
- [Vedant](https://github.com/viktory36)
|
- [Vedant](https://github.com/viktory36)
|
||||||
|
- [GeorgeH005](https://github.com/GeorgeH005)
|
||||||
|
- [JPUC1143](https://github.com/Jpuc1143)
|
||||||
|
- [David Angel](https://github.com/davidangel)
|
||||||
|
- [Pithaya](https://github.com/Pithaya)
|
||||||
|
- [Peter Santos](https://github.com/prsantos-com)
|
||||||
|
- [Chaitanya Shahare](https://github.com/Chaitanya-Shahare)
|
||||||
|
- [Venkat Karasani](https://github.com/venkat-karasani)
|
||||||
|
- [Connor Smith](https://github.com/ConnorS1110)
|
||||||
|
- [iFraan](https://github.com/iFraan)
|
||||||
|
- [Ali](https://github.com/bu3alwa)
|
||||||
|
- [K. Kyle Puchkov](https://github.com/kepper104)
|
||||||
|
|
||||||
## Emby Contributors
|
## Emby Contributors
|
||||||
|
|
||||||
|
|
21
README.md
21
README.md
|
@ -73,6 +73,10 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> We are in the process of refactoring to a [new structure](https://forum.jellyfin.org/t-proposed-update-to-the-structure-of-jellyfin-web) based on [Bulletproof React](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md) architecture guidelines.
|
||||||
|
> Most new code should be organized under the appropriate app directory unless it is common/shared.
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
└── src
|
└── src
|
||||||
|
@ -82,19 +86,24 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||||
│ └── stable # Classic (stable) app layout and routes
|
│ └── stable # Classic (stable) app layout and routes
|
||||||
├── assets # Static assets
|
├── assets # Static assets
|
||||||
├── components # Higher order visual components and React components
|
├── components # Higher order visual components and React components
|
||||||
├── controllers # Legacy page views and controllers 🧹
|
├── constants # Common constant values
|
||||||
├── elements # Basic webcomponents and React wrappers 🧹
|
├── controllers # Legacy page views and controllers 🧹 ❌
|
||||||
|
├── elements # Basic webcomponents and React equivalents 🧹
|
||||||
├── hooks # Custom React hooks
|
├── hooks # Custom React hooks
|
||||||
├── legacy # Polyfills for legacy browsers
|
├── lib # Reusable libraries
|
||||||
├── libraries # Third party libraries 🧹
|
│ ├── globalize # Custom localization library
|
||||||
|
│ ├── legacy # Polyfills for legacy browsers
|
||||||
|
│ ├── navdrawer # Navigation drawer library for classic layout
|
||||||
|
│ └── scroller # Content scrolling library
|
||||||
├── plugins # Client plugins
|
├── plugins # Client plugins
|
||||||
├── scripts # Random assortment of visual components and utilities 🐉
|
├── scripts # Random assortment of visual components and utilities 🐉 ❌
|
||||||
├── strings # Translation files
|
├── strings # Translation files (only commit changes to en-us.json)
|
||||||
├── styles # Common app Sass stylesheets
|
├── styles # Common app Sass stylesheets
|
||||||
├── themes # CSS themes
|
├── themes # CSS themes
|
||||||
├── types # Common TypeScript interfaces/types
|
├── types # Common TypeScript interfaces/types
|
||||||
└── utils # Utility functions
|
└── utils # Utility functions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- ❌ — Deprecated, do **not** create new files here
|
||||||
- 🧹 — Needs cleanup
|
- 🧹 — Needs cleanup
|
||||||
- 🐉 — Serious mess (Here be dragons)
|
- 🐉 — Serious mess (Here be dragons)
|
||||||
|
|
|
@ -15,8 +15,5 @@ module.exports = {
|
||||||
'@babel/preset-react'
|
'@babel/preset-react'
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
'@babel/plugin-proposal-class-properties',
|
|
||||||
'@babel/plugin-proposal-private-methods',
|
|
||||||
'babel-plugin-dynamic-import-polyfill'
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
23626
package-lock.json
generated
23626
package-lock.json
generated
File diff suppressed because it is too large
Load diff
163
package.json
163
package.json
|
@ -1,131 +1,135 @@
|
||||||
{
|
{
|
||||||
"name": "jellyfin-web",
|
"name": "jellyfin-web",
|
||||||
"version": "10.9.0",
|
"version": "10.11.0",
|
||||||
"description": "Web interface for Jellyfin",
|
"description": "Web interface for Jellyfin",
|
||||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||||
"license": "GPL-2.0-or-later",
|
"license": "GPL-2.0-or-later",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.23.7",
|
"@babel/core": "7.25.8",
|
||||||
"@babel/plugin-proposal-class-properties": "7.18.6",
|
"@babel/plugin-transform-modules-umd": "7.25.7",
|
||||||
"@babel/plugin-proposal-private-methods": "7.18.6",
|
"@babel/preset-env": "7.25.8",
|
||||||
"@babel/plugin-transform-modules-umd": "7.23.3",
|
"@babel/preset-react": "7.25.7",
|
||||||
"@babel/preset-env": "7.23.8",
|
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
|
||||||
"@babel/preset-react": "7.23.3",
|
"@stylistic/eslint-plugin": "2.12.1",
|
||||||
|
"@stylistic/stylelint-plugin": "3.1.1",
|
||||||
|
"@types/dompurify": "3.0.5",
|
||||||
"@types/escape-html": "1.0.4",
|
"@types/escape-html": "1.0.4",
|
||||||
"@types/loadable__component": "5.13.9",
|
"@types/loadable__component": "5.13.9",
|
||||||
"@types/lodash-es": "4.17.12",
|
"@types/lodash-es": "4.17.12",
|
||||||
"@types/markdown-it": "13.0.7",
|
"@types/markdown-it": "14.1.2",
|
||||||
"@types/react": "17.0.75",
|
"@types/react": "18.3.11",
|
||||||
"@types/react-dom": "17.0.25",
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@types/react-lazy-load-image-component": "1.6.4",
|
||||||
"@types/sortablejs": "1.15.8",
|
"@types/sortablejs": "1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
"@typescript-eslint/eslint-plugin": "8.17.0",
|
||||||
"@typescript-eslint/parser": "5.62.0",
|
"@typescript-eslint/parser": "8.17.0",
|
||||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||||
"autoprefixer": "10.4.17",
|
"@vitest/coverage-v8": "2.1.8",
|
||||||
"babel-loader": "9.1.3",
|
"autoprefixer": "10.4.20",
|
||||||
"babel-plugin-dynamic-import-polyfill": "1.0.0",
|
"babel-loader": "9.2.1",
|
||||||
"clean-webpack-plugin": "4.0.0",
|
"clean-webpack-plugin": "4.0.0",
|
||||||
"confusing-browser-globals": "1.0.11",
|
"confusing-browser-globals": "1.0.11",
|
||||||
"copy-webpack-plugin": "12.0.2",
|
"copy-webpack-plugin": "12.0.2",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"css-loader": "6.9.1",
|
"css-loader": "7.1.2",
|
||||||
"cssnano": "6.0.5",
|
"cssnano": "7.0.6",
|
||||||
"es-check": "7.1.1",
|
"es-check": "7.2.1",
|
||||||
"eslint": "8.56.0",
|
"eslint": "8.57.1",
|
||||||
"eslint-plugin-compat": "4.2.0",
|
"eslint-plugin-compat": "4.2.0",
|
||||||
"eslint-plugin-eslint-comments": "3.2.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-import": "2.29.1",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-jsx-a11y": "6.8.0",
|
"eslint-plugin-react": "7.37.3",
|
||||||
"eslint-plugin-react": "7.33.2",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-sonarjs": "0.25.1",
|
||||||
"eslint-plugin-sonarjs": "0.23.0",
|
"expose-loader": "5.0.0",
|
||||||
"expose-loader": "4.1.0",
|
|
||||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||||
"html-loader": "4.2.0",
|
"html-loader": "5.1.0",
|
||||||
"html-webpack-plugin": "5.6.0",
|
"html-webpack-plugin": "5.6.0",
|
||||||
"jsdom": "23.2.0",
|
"jsdom": "25.0.1",
|
||||||
"mini-css-extract-plugin": "2.7.7",
|
"mini-css-extract-plugin": "2.9.1",
|
||||||
"postcss": "8.4.33",
|
"postcss": "8.4.49",
|
||||||
"postcss-loader": "7.3.4",
|
"postcss-loader": "8.1.1",
|
||||||
"postcss-preset-env": "9.3.0",
|
"postcss-preset-env": "10.1.3",
|
||||||
"postcss-scss": "4.0.9",
|
"postcss-scss": "4.0.9",
|
||||||
"sass": "1.70.0",
|
"sass": "1.83.1",
|
||||||
"sass-loader": "13.3.3",
|
"sass-loader": "16.0.2",
|
||||||
"source-map-loader": "4.0.2",
|
"source-map-loader": "5.0.0",
|
||||||
"speed-measure-webpack-plugin": "1.5.0",
|
"speed-measure-webpack-plugin": "1.5.0",
|
||||||
"style-loader": "3.3.4",
|
"style-loader": "4.0.0",
|
||||||
"stylelint": "15.11.0",
|
"stylelint": "16.12.0",
|
||||||
"stylelint-config-rational-order": "0.1.2",
|
"stylelint-config-rational-order": "0.1.2",
|
||||||
"stylelint-no-browser-hacks": "1.2.1",
|
"stylelint-no-browser-hacks": "1.3.0",
|
||||||
"stylelint-order": "6.0.4",
|
"stylelint-order": "6.0.4",
|
||||||
"stylelint-scss": "5.3.2",
|
"stylelint-scss": "6.10.0",
|
||||||
"ts-loader": "9.5.1",
|
"ts-loader": "9.5.1",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.6.3",
|
||||||
"vitest": "1.3.0",
|
"vitest": "2.1.8",
|
||||||
"webpack": "5.89.0",
|
"webpack": "5.95.0",
|
||||||
"webpack-bundle-analyzer": "4.10.1",
|
"webpack-bundle-analyzer": "4.10.2",
|
||||||
"webpack-cli": "5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "4.15.1",
|
"webpack-dev-server": "5.1.0",
|
||||||
"webpack-merge": "5.10.0",
|
"webpack-merge": "6.0.1",
|
||||||
"worker-loader": "3.0.8"
|
"worker-loader": "3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "11.11.4",
|
"@emotion/react": "11.13.3",
|
||||||
"@emotion/styled": "11.11.0",
|
"@emotion/styled": "11.13.0",
|
||||||
"@fontsource/noto-sans": "5.0.18",
|
"@fontsource/noto-sans": "5.1.1",
|
||||||
"@fontsource/noto-sans-hk": "5.0.17",
|
"@fontsource/noto-sans-hk": "5.1.1",
|
||||||
"@fontsource/noto-sans-jp": "5.0.17",
|
"@fontsource/noto-sans-jp": "5.1.1",
|
||||||
"@fontsource/noto-sans-kr": "5.0.17",
|
"@fontsource/noto-sans-kr": "5.1.1",
|
||||||
"@fontsource/noto-sans-sc": "5.0.17",
|
"@fontsource/noto-sans-sc": "5.1.1",
|
||||||
"@fontsource/noto-sans-tc": "5.0.17",
|
"@fontsource/noto-sans-tc": "5.1.1",
|
||||||
"@jellyfin/sdk": "0.0.0-unstable.202403180216",
|
"@jellyfin/libass-wasm": "4.2.3",
|
||||||
"@loadable/component": "5.16.3",
|
"@jellyfin/sdk": "0.0.0-unstable.202501180501",
|
||||||
"@mui/icons-material": "5.15.11",
|
"@mui/icons-material": "5.16.7",
|
||||||
"@mui/material": "5.15.11",
|
"@mui/material": "5.16.7",
|
||||||
"@mui/x-data-grid": "6.19.5",
|
"@mui/x-date-pickers": "7.20.0",
|
||||||
"@react-hook/resize-observer": "1.2.6",
|
"@react-hook/resize-observer": "2.0.2",
|
||||||
"@tanstack/react-query": "4.36.1",
|
"@tanstack/react-query": "5.62.16",
|
||||||
"@tanstack/react-query-devtools": "4.36.1",
|
"@tanstack/react-query-devtools": "5.62.16",
|
||||||
"@types/react-lazy-load-image-component": "1.6.3",
|
|
||||||
"abortcontroller-polyfill": "1.7.5",
|
"abortcontroller-polyfill": "1.7.5",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"core-js": "3.35.1",
|
"core-js": "3.38.1",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"dompurify": "3.0.1",
|
"dompurify": "2.5.7",
|
||||||
"epubjs": "0.3.93",
|
"epubjs": "0.3.93",
|
||||||
"escape-html": "1.0.3",
|
"escape-html": "1.0.3",
|
||||||
"event-target-polyfill": "github:ThaUnknown/event-target-polyfill",
|
|
||||||
"fast-text-encoding": "1.0.6",
|
"fast-text-encoding": "1.0.6",
|
||||||
"flv.js": "1.6.2",
|
"flv.js": "1.6.2",
|
||||||
"headroom.js": "0.12.0",
|
"headroom.js": "0.12.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"hls.js": "1.5.7",
|
"hls.js": "1.5.18",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
"jassub": "1.7.15",
|
|
||||||
"jellyfin-apiclient": "1.11.0",
|
"jellyfin-apiclient": "1.11.0",
|
||||||
"jquery": "3.7.1",
|
"jquery": "3.7.1",
|
||||||
"jstree": "3.3.16",
|
"jstree": "3.3.17",
|
||||||
"libarchive.js": "1.3.0",
|
"libarchive.js": "2.0.2",
|
||||||
|
"libpgs": "0.8.1",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"markdown-it": "14.0.0",
|
"markdown-it": "14.1.0",
|
||||||
"material-design-icons-iconfont": "6.7.0",
|
"material-design-icons-iconfont": "6.7.0",
|
||||||
|
"material-react-table": "2.13.3",
|
||||||
"native-promise-only": "0.8.1",
|
"native-promise-only": "0.8.1",
|
||||||
"pdfjs-dist": "3.11.174",
|
"pdfjs-dist": "3.11.174",
|
||||||
"react": "17.0.2",
|
"react": "18.3.1",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.3.1",
|
||||||
"react-lazy-load-image-component": "1.6.0",
|
"react-lazy-load-image-component": "1.6.2",
|
||||||
"react-router-dom": "6.21.3",
|
"react-router-dom": "6.27.0",
|
||||||
"resize-observer-polyfill": "1.5.1",
|
"resize-observer-polyfill": "1.5.1",
|
||||||
"screenfull": "6.0.2",
|
"screenfull": "6.0.2",
|
||||||
"sortablejs": "1.15.2",
|
"sortablejs": "1.15.3",
|
||||||
"swiper": "11.0.5",
|
"swiper": "11.2.1",
|
||||||
"usehooks-ts": "2.14.0",
|
"usehooks-ts": "3.1.0",
|
||||||
"webcomponents.js": "0.7.24",
|
"webcomponents.js": "0.7.24",
|
||||||
"whatwg-fetch": "3.6.20"
|
"whatwg-fetch": "3.6.20"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"sass-embedded": "1.83.1"
|
||||||
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 2 Firefox versions",
|
"last 2 Firefox versions",
|
||||||
"last 2 Chrome versions",
|
"last 2 Chrome versions",
|
||||||
|
@ -146,9 +150,10 @@
|
||||||
"start": "npm run serve",
|
"start": "npm run serve",
|
||||||
"serve": "webpack serve --config webpack.dev.js",
|
"serve": "webpack serve --config webpack.dev.js",
|
||||||
"build:analyze": "cross-env NODE_ENV=\"production\" webpack --config webpack.analyze.js",
|
"build:analyze": "cross-env NODE_ENV=\"production\" webpack --config webpack.analyze.js",
|
||||||
"build:development": "cross-env NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.dev.js",
|
"build:development": "webpack --config webpack.dev.js",
|
||||||
"build:production": "cross-env NODE_ENV=\"production\" NODE_OPTIONS=\"--max_old_space_size=6144\" webpack --config webpack.prod.js",
|
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
|
||||||
"build:check": "tsc --noEmit",
|
"build:check": "tsc --noEmit",
|
||||||
|
"build:es-check": "npm run build:production && npm run escheck",
|
||||||
"escheck": "es-check",
|
"escheck": "es-check",
|
||||||
"lint": "eslint \"./\"",
|
"lint": "eslint \"./\"",
|
||||||
"test": "vitest --watch=false --config vite.config.ts",
|
"test": "vitest --watch=false --config vite.config.ts",
|
||||||
|
|
|
@ -1,37 +1,31 @@
|
||||||
import loadable from '@loadable/component';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import { History } from '@remix-run/router';
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { ApiProvider } from 'hooks/useApi';
|
import { ApiProvider } from 'hooks/useApi';
|
||||||
|
import { UserSettingsProvider } from 'hooks/useUserSettings';
|
||||||
import { WebConfigProvider } from 'hooks/useWebConfig';
|
import { WebConfigProvider } from 'hooks/useWebConfig';
|
||||||
import theme from 'themes/theme';
|
import browser from 'scripts/browser';
|
||||||
import { queryClient } from 'utils/query/queryClient';
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
const StableAppRouter = loadable(() => import('./apps/stable/AppRouter'));
|
import RootAppRouter from 'RootAppRouter';
|
||||||
const RootAppRouter = loadable(() => import('./RootAppRouter'));
|
|
||||||
|
|
||||||
const RootApp = ({ history }: Readonly<{ history: History }>) => {
|
const useReactQueryDevtools = window.Proxy // '@tanstack/query-devtools' requires 'Proxy', which cannot be polyfilled for legacy browsers
|
||||||
const layoutMode = localStorage.getItem('layout');
|
&& !browser.tv; // Don't use devtools on the TV as the navigation is weird
|
||||||
const isExperimentalLayout = layoutMode === 'experimental';
|
|
||||||
|
|
||||||
return (
|
const RootApp = () => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
|
<UserSettingsProvider>
|
||||||
<WebConfigProvider>
|
<WebConfigProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<RootAppRouter />
|
||||||
{isExperimentalLayout ?
|
|
||||||
<RootAppRouter history={history} /> :
|
|
||||||
<StableAppRouter history={history} />
|
|
||||||
}
|
|
||||||
</ThemeProvider>
|
|
||||||
</WebConfigProvider>
|
</WebConfigProvider>
|
||||||
|
</UserSettingsProvider>
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
|
{useReactQueryDevtools && (
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
)}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default RootApp;
|
export default RootApp;
|
||||||
|
|
|
@ -1,31 +1,41 @@
|
||||||
|
|
||||||
import { History } from '@remix-run/router';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
createHashRouter,
|
createHashRouter,
|
||||||
Outlet
|
Outlet,
|
||||||
|
useLocation
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DASHBOARD_APP_PATHS, DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
||||||
import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
|
import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
|
||||||
|
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
|
||||||
import AppHeader from 'components/AppHeader';
|
import AppHeader from 'components/AppHeader';
|
||||||
import Backdrop from 'components/Backdrop';
|
import Backdrop from 'components/Backdrop';
|
||||||
import { useLegacyRouterSync } from 'hooks/useLegacyRouterSync';
|
import BangRedirect from 'components/router/BangRedirect';
|
||||||
import { DASHBOARD_APP_ROUTES } from 'apps/dashboard/routes/routes';
|
import { createRouterHistory } from 'components/router/routerHistory';
|
||||||
|
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||||
|
|
||||||
|
const layoutMode = localStorage.getItem('layout');
|
||||||
|
const isExperimentalLayout = layoutMode === 'experimental';
|
||||||
|
|
||||||
const router = createHashRouter([
|
const router = createHashRouter([
|
||||||
{
|
{
|
||||||
element: <RootAppLayout />,
|
element: <RootAppLayout />,
|
||||||
children: [
|
children: [
|
||||||
...EXPERIMENTAL_APP_ROUTES,
|
...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
|
||||||
...DASHBOARD_APP_ROUTES
|
...DASHBOARD_APP_ROUTES,
|
||||||
|
{
|
||||||
|
path: '!/*',
|
||||||
|
Component: BangRedirect
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default function RootAppRouter({ history }: Readonly<{ history: History}>) {
|
export const history = createRouterHistory(router);
|
||||||
useLegacyRouterSync({ router, history });
|
|
||||||
|
|
||||||
|
export default function RootAppRouter() {
|
||||||
return <RouterProvider router={router} />;
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,12 +44,16 @@ export default function RootAppRouter({ history }: Readonly<{ history: History}>
|
||||||
* NOTE: The app will crash if these get removed from the DOM.
|
* NOTE: The app will crash if these get removed from the DOM.
|
||||||
*/
|
*/
|
||||||
function RootAppLayout() {
|
function RootAppLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isNewLayoutPath = Object.values(DASHBOARD_APP_PATHS)
|
||||||
|
.some(path => location.pathname.startsWith(`/${path}`));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserThemeProvider>
|
||||||
<Backdrop />
|
<Backdrop />
|
||||||
<AppHeader isHidden />
|
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</UserThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
7
src/apiclient.d.ts
vendored
7
src/apiclient.d.ts
vendored
|
@ -76,6 +76,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
accessToken(): string;
|
accessToken(): string;
|
||||||
addMediaPath(virtualFolderName: string, mediaPath: string, networkSharePath: string, refreshLibrary?: boolean): Promise<void>;
|
addMediaPath(virtualFolderName: string, mediaPath: string, networkSharePath: string, refreshLibrary?: boolean): Promise<void>;
|
||||||
addVirtualFolder(name: string, type?: string, refreshLibrary?: boolean, libraryOptions?: any): Promise<void>;
|
addVirtualFolder(name: string, type?: string, refreshLibrary?: boolean, libraryOptions?: any): Promise<void>;
|
||||||
|
ajax(request: any): Promise<any>;
|
||||||
appName(): string;
|
appName(): string;
|
||||||
appVersion(): string;
|
appVersion(): string;
|
||||||
authenticateUserByName(name: string, password: string): Promise<AuthenticationResult>;
|
authenticateUserByName(name: string, password: string): Promise<AuthenticationResult>;
|
||||||
|
@ -181,7 +182,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
getPluginConfiguration(id: string): Promise<any>;
|
getPluginConfiguration(id: string): Promise<any>;
|
||||||
getPublicSystemInfo(): Promise<PublicSystemInfo>;
|
getPublicSystemInfo(): Promise<PublicSystemInfo>;
|
||||||
getPublicUsers(): Promise<UserDto[]>;
|
getPublicUsers(): Promise<UserDto[]>;
|
||||||
getQuickConnect(verb: string): Promise<void|boolean|number|QuickConnectResult|QuickConnectState>;
|
getQuickConnect(verb: string): Promise<void | boolean | number | QuickConnectResult | QuickConnectState>;
|
||||||
getReadySyncItems(deviceId: string): Promise<any>;
|
getReadySyncItems(deviceId: string): Promise<any>;
|
||||||
getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>;
|
getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>;
|
||||||
getRegistrationInfo(feature: string): Promise<any>;
|
getRegistrationInfo(feature: string): Promise<any>;
|
||||||
|
@ -307,7 +308,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
class AppStore {
|
class AppStore {
|
||||||
constructor();
|
constructor();
|
||||||
|
|
||||||
getItem(name: string): string|null;
|
getItem(name: string): string | null;
|
||||||
removeItem(name: string): void;
|
removeItem(name: string): void;
|
||||||
setItem(name: string, value: string): void;
|
setItem(name: string, value: string): void;
|
||||||
}
|
}
|
||||||
|
@ -328,7 +329,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
connectToServer(server: any, options?: any): Promise<any>;
|
connectToServer(server: any, options?: any): Promise<any>;
|
||||||
connectToServers(servers: any[], options?: any): Promise<any>;
|
connectToServers(servers: any[], options?: any): Promise<any>;
|
||||||
deleteServer(serverId: string): Promise<void>;
|
deleteServer(serverId: string): Promise<void>;
|
||||||
getApiClient(item: BaseItemDto|string): ApiClient;
|
getApiClient(item: BaseItemDto | string): ApiClient;
|
||||||
getApiClients(): ApiClient[];
|
getApiClients(): ApiClient[];
|
||||||
getAvailableServers(): any[];
|
getAvailableServers(): any[];
|
||||||
getOrCreateApiClient(serverId: string): ApiClient;
|
getOrCreateApiClient(serverId: string): ApiClient;
|
||||||
|
|
|
@ -2,7 +2,9 @@ import AppBar from '@mui/material/AppBar';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { type Theme } from '@mui/material/styles';
|
import { type Theme } from '@mui/material/styles';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import React, { FC, useCallback, useState } from 'react';
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
import React, { FC, StrictMode, useCallback, useEffect, useState } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import AppBody from 'components/AppBody';
|
import AppBody from 'components/AppBody';
|
||||||
|
@ -10,33 +12,45 @@ import AppToolbar from 'components/toolbar/AppToolbar';
|
||||||
import ElevationScroll from 'components/ElevationScroll';
|
import ElevationScroll from 'components/ElevationScroll';
|
||||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||||
import { useApi } from 'hooks/useApi';
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { useLocale } from 'hooks/useLocale';
|
||||||
|
|
||||||
|
import AppTabs from './components/AppTabs';
|
||||||
import AppDrawer from './components/drawer/AppDrawer';
|
import AppDrawer from './components/drawer/AppDrawer';
|
||||||
|
import HelpButton from './components/toolbar/HelpButton';
|
||||||
|
import { DASHBOARD_APP_PATHS } from './routes/routes';
|
||||||
|
|
||||||
import './AppOverrides.scss';
|
import './AppOverrides.scss';
|
||||||
|
|
||||||
interface AppLayoutProps {
|
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
|
||||||
drawerlessPaths: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppLayout: FC<AppLayoutProps> = ({
|
export const Component: FC = () => {
|
||||||
drawerlessPaths
|
|
||||||
}) => {
|
|
||||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useApi();
|
const { user } = useApi();
|
||||||
|
const { dateFnsLocale } = useLocale();
|
||||||
|
|
||||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||||
const isDrawerAvailable = Boolean(user)
|
const isDrawerAvailable = Boolean(user)
|
||||||
&& !drawerlessPaths.some(path => location.pathname.startsWith(`/${path}`));
|
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
|
||||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||||
|
|
||||||
const onToggleDrawer = useCallback(() => {
|
const onToggleDrawer = useCallback(() => {
|
||||||
setIsDrawerActive(!isDrawerActive);
|
setIsDrawerActive(!isDrawerActive);
|
||||||
}, [ isDrawerActive, setIsDrawerActive ]);
|
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||||
|
|
||||||
|
// Update body class
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('dashboardDocument');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('dashboardDocument');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<StrictMode>
|
||||||
<ElevationScroll elevate={false}>
|
<ElevationScroll elevate={false}>
|
||||||
<AppBar
|
<AppBar
|
||||||
position='fixed'
|
position='fixed'
|
||||||
|
@ -55,7 +69,12 @@ const AppLayout: FC<AppLayoutProps> = ({
|
||||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||||
isDrawerOpen={isDrawerOpen}
|
isDrawerOpen={isDrawerOpen}
|
||||||
onDrawerButtonClick={onToggleDrawer}
|
onDrawerButtonClick={onToggleDrawer}
|
||||||
/>
|
buttons={
|
||||||
|
<HelpButton />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||||
|
</AppToolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
</ElevationScroll>
|
</ElevationScroll>
|
||||||
|
|
||||||
|
@ -68,6 +87,7 @@ const AppLayout: FC<AppLayoutProps> = ({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
</StrictMode>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component='main'
|
component='main'
|
||||||
|
@ -81,7 +101,6 @@ const AppLayout: FC<AppLayoutProps> = ({
|
||||||
</AppBody>
|
</AppBody>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AppLayout;
|
|
||||||
|
|
|
@ -5,10 +5,14 @@ $mui-bp-md: 900px;
|
||||||
$mui-bp-lg: 1200px;
|
$mui-bp-lg: 1200px;
|
||||||
$mui-bp-xl: 1536px;
|
$mui-bp-xl: 1536px;
|
||||||
|
|
||||||
|
$drawer-width: 240px;
|
||||||
|
|
||||||
// Fix dashboard pages layout to work with drawer
|
// Fix dashboard pages layout to work with drawer
|
||||||
.dashboardDocument {
|
.dashboardDocument {
|
||||||
.mainAnimatedPage {
|
.mainAnimatedPage:not(.metadataEditorPage) {
|
||||||
position: relative;
|
@media all and (min-width: $mui-bp-md) {
|
||||||
|
left: $drawer-width;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.skinBody {
|
.skinBody {
|
||||||
|
@ -16,7 +20,19 @@ $mui-bp-xl: 1536px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix the padding of dashboard pages
|
// Fix the padding of dashboard pages
|
||||||
.content-primary.content-primary {
|
.content-primary {
|
||||||
|
padding-top: 3.25rem;
|
||||||
|
}
|
||||||
|
// Tabbed pages
|
||||||
|
.withTabs .content-primary {
|
||||||
|
padding-top: 6.5rem;
|
||||||
|
|
||||||
|
@media all and (min-width: $mui-bp-lg) {
|
||||||
|
padding-top: 3.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataEditorPage {
|
||||||
padding-top: 3.25rem !important;
|
padding-top: 3.25rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
96
src/apps/dashboard/components/AppTabs.tsx
Normal file
96
src/apps/dashboard/components/AppTabs.tsx
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { Theme } from '@mui/material/styles';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { EventType } from 'types/eventType';
|
||||||
|
import Events, { type Event } from 'utils/events';
|
||||||
|
|
||||||
|
interface AppTabsParams {
|
||||||
|
isDrawerOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabDefinition {
|
||||||
|
href: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
|
||||||
|
|
||||||
|
const AppTabs: FC<AppTabsParams> = ({
|
||||||
|
isDrawerOpen
|
||||||
|
}) => {
|
||||||
|
const documentRef = useRef<Document>(document);
|
||||||
|
const [ activeIndex, setActiveIndex ] = useState(0);
|
||||||
|
const [ tabs, setTabs ] = useState<TabDefinition[]>();
|
||||||
|
|
||||||
|
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||||
|
|
||||||
|
const onTabsUpdate = useCallback((
|
||||||
|
_e: Event,
|
||||||
|
_newView?: string,
|
||||||
|
newIndex: number | undefined = 0,
|
||||||
|
newTabs?: TabDefinition[]
|
||||||
|
) => {
|
||||||
|
setActiveIndex(newIndex);
|
||||||
|
|
||||||
|
if (!isEqual(tabs, newTabs)) {
|
||||||
|
setTabs(newTabs);
|
||||||
|
}
|
||||||
|
}, [ tabs ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const doc = documentRef.current;
|
||||||
|
|
||||||
|
if (doc) Events.on(doc, EventType.SET_TABS, onTabsUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (doc) Events.off(doc, EventType.SET_TABS, onTabsUpdate);
|
||||||
|
};
|
||||||
|
}, [ onTabsUpdate ]);
|
||||||
|
|
||||||
|
// HACK: Force resizing to workaround upstream bug with tab resizing
|
||||||
|
// https://github.com/mui/material-ui/issues/24011
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
}, [ isDrawerOpen ]);
|
||||||
|
|
||||||
|
if (!tabs?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeIndex}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: {
|
||||||
|
xs: 0,
|
||||||
|
lg: 'unset'
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
xs: 100,
|
||||||
|
lg: 'unset'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant={isBigScreen ? 'standard' : 'scrollable'}
|
||||||
|
centered={isBigScreen}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
tabs.map(({ href, name }, index) => (
|
||||||
|
<Tab
|
||||||
|
key={`tab-${name}`}
|
||||||
|
label={name}
|
||||||
|
data-tab-index={`${index}`}
|
||||||
|
component={Link}
|
||||||
|
to={href}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppTabs;
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { type RefAttributes } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { GridActionsCellItem, type GridActionsCellItemProps } from '@mui/x-data-grid';
|
|
||||||
|
|
||||||
type GridActionsCellLinkProps = { to: string } & GridActionsCellItemProps & RefAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link component to use in mui's data-grid action column due to a current bug with passing props to custom link components.
|
|
||||||
* @see https://github.com/mui/mui-x/issues/4654
|
|
||||||
*/
|
|
||||||
const GridActionsCellLink = ({ to, ...props }: GridActionsCellLinkProps) => (
|
|
||||||
<Link to={to}>
|
|
||||||
<GridActionsCellItem {...props} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default GridActionsCellLink;
|
|
|
@ -29,8 +29,8 @@ const AppDrawer: FC<ResponsiveDrawerProps> = ({
|
||||||
<ServerDrawerSection />
|
<ServerDrawerSection />
|
||||||
<DevicesDrawerSection />
|
<DevicesDrawerSection />
|
||||||
<LiveTvDrawerSection />
|
<LiveTvDrawerSection />
|
||||||
<AdvancedDrawerSection />
|
|
||||||
<PluginDrawerSection />
|
<PluginDrawerSection />
|
||||||
|
<AdvancedDrawerSection />
|
||||||
</ResponsiveDrawer>
|
</ResponsiveDrawer>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,18 @@
|
||||||
import Article from '@mui/icons-material/Article';
|
import Article from '@mui/icons-material/Article';
|
||||||
import EditNotifications from '@mui/icons-material/EditNotifications';
|
|
||||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
|
||||||
import Extension from '@mui/icons-material/Extension';
|
|
||||||
import Lan from '@mui/icons-material/Lan';
|
import Lan from '@mui/icons-material/Lan';
|
||||||
import Schedule from '@mui/icons-material/Schedule';
|
import Schedule from '@mui/icons-material/Schedule';
|
||||||
import VpnKey from '@mui/icons-material/VpnKey';
|
import VpnKey from '@mui/icons-material/VpnKey';
|
||||||
import Collapse from '@mui/material/Collapse';
|
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
const PLUGIN_PATHS = [
|
|
||||||
'/dashboard/plugins',
|
|
||||||
'/dashboard/plugins/catalog',
|
|
||||||
'/dashboard/plugins/repositories',
|
|
||||||
'/dashboard/plugins/add',
|
|
||||||
'/configurationpage'
|
|
||||||
];
|
|
||||||
|
|
||||||
const AdvancedDrawerSection = () => {
|
const AdvancedDrawerSection = () => {
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
aria-labelledby='advanced-subheader'
|
aria-labelledby='advanced-subheader'
|
||||||
|
@ -64,36 +46,6 @@ const AdvancedDrawerSection = () => {
|
||||||
<ListItemText primary={globalize.translate('TabLogs')} />
|
<ListItemText primary={globalize.translate('TabLogs')} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemLink to='/dashboard/notifications'>
|
|
||||||
<ListItemIcon>
|
|
||||||
<EditNotifications />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={globalize.translate('Notifications')} />
|
|
||||||
</ListItemLink>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemLink to='/dashboard/plugins' selected={false}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Extension />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={globalize.translate('TabPlugins')} />
|
|
||||||
{isPluginSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
|
||||||
</ListItemLink>
|
|
||||||
</ListItem>
|
|
||||||
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
|
|
||||||
<List component='div' disablePadding>
|
|
||||||
<ListItemLink to='/dashboard/plugins' sx={{ pl: 4 }}>
|
|
||||||
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
|
|
||||||
</ListItemLink>
|
|
||||||
<ListItemLink to='/dashboard/plugins/catalog' sx={{ pl: 4 }}>
|
|
||||||
<ListItemText inset primary={globalize.translate('TabCatalog')} />
|
|
||||||
</ListItemLink>
|
|
||||||
<ListItemLink to='/dashboard/plugins/repositories' sx={{ pl: 4 }}>
|
|
||||||
<ListItemText inset primary={globalize.translate('TabRepositories')} />
|
|
||||||
</ListItemLink>
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
<ListItem disablePadding>
|
<ListItem disablePadding>
|
||||||
<ListItemLink to='/dashboard/tasks'>
|
<ListItemLink to='/dashboard/tasks'>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Devices, Analytics, Input } from '@mui/icons-material';
|
import { Devices, Analytics } from '@mui/icons-material';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
const DevicesDrawerSection = () => {
|
const DevicesDrawerSection = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -35,14 +35,6 @@ const DevicesDrawerSection = () => {
|
||||||
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemLink to='/dashboard/dlna'>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Input />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={'DLNA'} />
|
|
||||||
</ListItemLink>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
const LiveTvDrawerSection = () => {
|
const LiveTvDrawerSection = () => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,41 +1,26 @@
|
||||||
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client';
|
import Extension from '@mui/icons-material/Extension';
|
||||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
import Folder from '@mui/icons-material/Folder';
|
||||||
import { Folder } from '@mui/icons-material';
|
import Public from '@mui/icons-material/Public';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import { useApi } from 'hooks/useApi';
|
import globalize from 'lib/globalize';
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
import Dashboard from 'utils/dashboard';
|
import Dashboard from 'utils/dashboard';
|
||||||
|
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||||
|
|
||||||
const PluginDrawerSection = () => {
|
const PluginDrawerSection = () => {
|
||||||
const { api } = useApi();
|
const {
|
||||||
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
|
data: pagesInfo,
|
||||||
|
error
|
||||||
|
} = useConfigurationPages({ enableInMainMenu: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPluginPages = async () => {
|
if (error) console.error('[PluginDrawerSection] unable to fetch plugin config pages', error);
|
||||||
if (!api) return;
|
}, [ error ]);
|
||||||
|
|
||||||
const pagesResponse = await getDashboardApi(api)
|
|
||||||
.getConfigurationPages({ enableInMainMenu: true });
|
|
||||||
|
|
||||||
setPagesInfo(pagesResponse.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPluginPages()
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[PluginDrawerSection] unable to fetch plugin config pages', err);
|
|
||||||
});
|
|
||||||
}, [ api ]);
|
|
||||||
|
|
||||||
if (!api || pagesInfo.length < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
|
@ -46,19 +31,39 @@ const PluginDrawerSection = () => {
|
||||||
</ListSubheader>
|
</ListSubheader>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{
|
<ListItemLink
|
||||||
pagesInfo.map(pageInfo => (
|
to='/dashboard/plugins'
|
||||||
<ListItem key={pageInfo.PluginId} disablePadding>
|
includePaths={[ '/configurationpage' ]}
|
||||||
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}>
|
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Extension />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabMyPlugins')} />
|
||||||
|
</ListItemLink>
|
||||||
|
|
||||||
|
<ListItemLink
|
||||||
|
to='/dashboard/plugins/catalog'
|
||||||
|
includePaths={[ '/dashboard/plugins/repositories' ]}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Public />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabCatalog')} />
|
||||||
|
</ListItemLink>
|
||||||
|
|
||||||
|
{pagesInfo?.map(pageInfo => (
|
||||||
|
<ListItemLink
|
||||||
|
key={pageInfo.PluginId}
|
||||||
|
to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}
|
||||||
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{/* TODO: Support different icons? */}
|
{/* TODO: Support different icons? */}
|
||||||
<Folder />
|
<Folder />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={pageInfo.DisplayName} />
|
<ListItemText primary={pageInfo.DisplayName} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
))}
|
||||||
))
|
|
||||||
}
|
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||||
|
import Palette from '@mui/icons-material/Palette';
|
||||||
import Collapse from '@mui/material/Collapse';
|
import Collapse from '@mui/material/Collapse';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton/ListItemButton';
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import ListSubheader from '@mui/material/ListSubheader';
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import React from 'react';
|
import React, { type MouseEvent, useCallback, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import ListItemLink from 'components/ListItemLink';
|
import ListItemLink from 'components/ListItemLink';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
const LIBRARY_PATHS = [
|
const LIBRARY_PATHS = [
|
||||||
'/dashboard/libraries',
|
'/dashboard/libraries',
|
||||||
|
@ -28,8 +30,20 @@ const PLAYBACK_PATHS = [
|
||||||
const ServerDrawerSection = () => {
|
const ServerDrawerSection = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
|
const [ isLibrarySectionOpen, setIsLibrarySectionOpen ] = useState(LIBRARY_PATHS.includes(location.pathname));
|
||||||
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
|
const [ isPlaybackSectionOpen, setIsPlaybackSectionOpen ] = useState(PLAYBACK_PATHS.includes(location.pathname));
|
||||||
|
|
||||||
|
const onLibrarySectionClick = useCallback((e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsLibrarySectionOpen(isOpen => !isOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPlaybackSectionClick = useCallback((e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsPlaybackSectionOpen(isOpen => !isOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
|
@ -56,6 +70,12 @@ const ServerDrawerSection = () => {
|
||||||
<ListItemText primary={globalize.translate('General')} />
|
<ListItemText primary={globalize.translate('General')} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItemLink to='/dashboard/branding'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Palette />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderBranding')} />
|
||||||
|
</ListItemLink>
|
||||||
<ListItem disablePadding>
|
<ListItem disablePadding>
|
||||||
<ListItemLink to='/dashboard/users'>
|
<ListItemLink to='/dashboard/users'>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
|
@ -65,13 +85,13 @@ const ServerDrawerSection = () => {
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem disablePadding>
|
<ListItem disablePadding>
|
||||||
<ListItemLink to='/dashboard/libraries' selected={false}>
|
<ListItemButton onClick={onLibrarySectionClick}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<LibraryAdd />
|
<LibraryAdd />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
||||||
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
</ListItemLink>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
||||||
<List component='div' disablePadding>
|
<List component='div' disablePadding>
|
||||||
|
@ -82,7 +102,7 @@ const ServerDrawerSection = () => {
|
||||||
<ListItemText inset primary={globalize.translate('Display')} />
|
<ListItemText inset primary={globalize.translate('Display')} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
|
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
|
||||||
<ListItemText inset primary={globalize.translate('Metadata')} />
|
<ListItemText inset primary={globalize.translate('LabelMetadata')} />
|
||||||
</ListItemLink>
|
</ListItemLink>
|
||||||
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
|
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
|
||||||
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
||||||
|
@ -90,13 +110,13 @@ const ServerDrawerSection = () => {
|
||||||
</List>
|
</List>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
<ListItem disablePadding>
|
<ListItem disablePadding>
|
||||||
<ListItemLink to='/dashboard/playback/transcoding' selected={false}>
|
<ListItemButton onClick={onPlaybackSectionClick}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<PlayCircle />
|
<PlayCircle />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
||||||
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
</ListItemLink>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
||||||
<List component='div' disablePadding>
|
<List component='div' disablePadding>
|
||||||
|
|
36
src/apps/dashboard/components/toolbar/HelpButton.tsx
Normal file
36
src/apps/dashboard/components/toolbar/HelpButton.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import HelpOutline from '@mui/icons-material/HelpOutline';
|
||||||
|
import IconButton from '@mui/material/IconButton/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip/Tooltip';
|
||||||
|
import React from 'react';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { HelpLinks } from 'apps/dashboard/constants/helpLinks';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
|
const HelpButton = () => (
|
||||||
|
<Routes>
|
||||||
|
{
|
||||||
|
HelpLinks.map(({ paths, url }) => paths.map(path => (
|
||||||
|
<Route
|
||||||
|
key={[url, path].join('-')}
|
||||||
|
path={path}
|
||||||
|
element={
|
||||||
|
<Tooltip title={globalize.translate('Help')}>
|
||||||
|
<IconButton
|
||||||
|
href={url}
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
target='_blank'
|
||||||
|
size='large'
|
||||||
|
color='inherit'
|
||||||
|
>
|
||||||
|
<HelpOutline />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))).flat()
|
||||||
|
}
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default HelpButton;
|
54
src/apps/dashboard/constants/helpLinks.ts
Normal file
54
src/apps/dashboard/constants/helpLinks.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
export const HelpLinks = [
|
||||||
|
{
|
||||||
|
paths: ['/dashboard/devices'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/devices'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/libraries'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/libraries'
|
||||||
|
}, {
|
||||||
|
paths: [
|
||||||
|
'/dashboard/livetv',
|
||||||
|
'/dashboard/livetv/tuner',
|
||||||
|
'/dashboard/recordings'
|
||||||
|
],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/live-tv/'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/livetv/guide'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/live-tv/setup-guide#adding-guide-data'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/networking'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/networking/'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/playback/transcoding'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/transcoding'
|
||||||
|
}, {
|
||||||
|
paths: [
|
||||||
|
'/dashboard/plugins',
|
||||||
|
'/dashboard/plugins/catalog'
|
||||||
|
],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/plugins/'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/plugins/repositories'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/plugins/#repositories'
|
||||||
|
}, {
|
||||||
|
paths: [
|
||||||
|
'/dashboard/branding',
|
||||||
|
'/dashboard/settings'
|
||||||
|
],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/settings'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/tasks'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/tasks'
|
||||||
|
}, {
|
||||||
|
paths: ['/dashboard/users'],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/users/adding-managing-users'
|
||||||
|
}, {
|
||||||
|
paths: [
|
||||||
|
'/dashboard/users/access',
|
||||||
|
'/dashboard/users/parentalcontrol',
|
||||||
|
'/dashboard/users/password',
|
||||||
|
'/dashboard/users/profile'
|
||||||
|
],
|
||||||
|
url: 'https://jellyfin.org/docs/general/server/users/'
|
||||||
|
}
|
||||||
|
];
|
36
src/apps/dashboard/features/activity/api/useLogEntries.ts
Normal file
36
src/apps/dashboard/features/activity/api/useLogEntries.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import type { ActivityLogApiGetLogEntriesRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import type { Api } from '@jellyfin/sdk';
|
||||||
|
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const fetchLogEntries = async (
|
||||||
|
api?: Api,
|
||||||
|
requestParams?: ActivityLogApiGetLogEntriesRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn('[fetchLogEntries] No API instance available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getActivityLogApi(api).getLogEntries(requestParams, {
|
||||||
|
signal: options?.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogEntries = (
|
||||||
|
requestParams: ActivityLogApiGetLogEntriesRequest
|
||||||
|
) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['ActivityLogEntries', requestParams],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
fetchLogEntries(api, requestParams, { signal }),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import IconButton from '@mui/material/IconButton/IconButton';
|
||||||
|
import PermMedia from '@mui/icons-material/PermMedia';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
|
const ActionsCell: FC<ActivityLogEntryCell> = ({ row }) => (
|
||||||
|
row.original.ItemId ? (
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
title={globalize.translate('LabelMediaDetails')}
|
||||||
|
component={Link}
|
||||||
|
to={`/details?id=${row.original.ItemId}`}
|
||||||
|
>
|
||||||
|
<PermMedia fontSize='inherit' />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ActionsCell;
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
|
||||||
|
import { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
|
||||||
|
import LogLevelChip from './LogLevelChip';
|
||||||
|
|
||||||
|
const LogLevelCell: FC<ActivityLogEntryCell> = ({ cell }) => {
|
||||||
|
const level = cell.getValue<LogLevel | undefined>();
|
||||||
|
return level ? (
|
||||||
|
<LogLevelChip level={level} />
|
||||||
|
) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogLevelCell;
|
|
@ -2,7 +2,7 @@ import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
const LogLevelChip = ({ level }: { level: LogLevel }) => {
|
const LogLevelChip = ({ level }: { level: LogLevel }) => {
|
||||||
let color: 'info' | 'warning' | 'error' | undefined;
|
let color: 'info' | 'warning' | 'error' | undefined;
|
|
@ -1,12 +1,14 @@
|
||||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
|
||||||
import Info from '@mui/icons-material/Info';
|
import Info from '@mui/icons-material/Info';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import React, { FC, useCallback, useState } from 'react';
|
import React, { type FC, useCallback, useState } from 'react';
|
||||||
|
|
||||||
const OverviewCell: FC<ActivityLogEntry> = ({ Overview, ShortOverview }) => {
|
import type { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
|
||||||
|
|
||||||
|
const OverviewCell: FC<ActivityLogEntryCell> = ({ row }) => {
|
||||||
|
const { ShortOverview, Overview } = row.original;
|
||||||
const displayValue = ShortOverview ?? Overview;
|
const displayValue = ShortOverview ?? Overview;
|
||||||
const [ open, setOpen ] = useState(false);
|
const [ open, setOpen ] = useState(false);
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||||
|
import IconButton from '@mui/material/IconButton/IconButton';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import UserAvatar from 'components/UserAvatar';
|
||||||
|
|
||||||
|
interface UserAvatarButtonProps {
|
||||||
|
user?: UserDto
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
|
||||||
|
user?.Id ? (
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
color='inherit'
|
||||||
|
sx={{ padding: 0 }}
|
||||||
|
title={user.Name || undefined}
|
||||||
|
component={Link}
|
||||||
|
to={`/dashboard/users/profile?userId=${user.Id}`}
|
||||||
|
>
|
||||||
|
<UserAvatar user={user} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export default UserAvatarButton;
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||||
|
import type { MRT_Cell, MRT_Row } from 'material-react-table';
|
||||||
|
|
||||||
|
export interface ActivityLogEntryCell {
|
||||||
|
cell: MRT_Cell<ActivityLogEntry>
|
||||||
|
row: MRT_Row<ActivityLogEntry>
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Api } from '@jellyfin/sdk';
|
||||||
|
import { getBrandingApi } from '@jellyfin/sdk/lib/utils/api/branding-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
export const QUERY_KEY = 'BrandingOptions';
|
||||||
|
|
||||||
|
const fetchBrandingOptions = async (
|
||||||
|
api?: Api,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
if (!api) {
|
||||||
|
console.error('[fetchBrandingOptions] no Api instance provided');
|
||||||
|
throw new Error('No Api instance provided to fetchBrandingOptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBrandingApi(api)
|
||||||
|
.getBrandingOptions(options)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBrandingOptionsQuery = (
|
||||||
|
api?: Api
|
||||||
|
) => queryOptions({
|
||||||
|
queryKey: [ QUERY_KEY ],
|
||||||
|
queryFn: ({ signal }) => fetchBrandingOptions(api, { signal }),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBrandingOptions = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useQuery(getBrandingOptionsQuery(api));
|
||||||
|
};
|
27
src/apps/dashboard/features/keys/api/useApiKeys.ts
Normal file
27
src/apps/dashboard/features/keys/api/useApiKeys.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Api } from '@jellyfin/sdk';
|
||||||
|
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
export const QUERY_KEY = 'ApiKeys';
|
||||||
|
|
||||||
|
const fetchApiKeys = async (api?: Api) => {
|
||||||
|
if (!api) {
|
||||||
|
console.error('[useApiKeys] Failed to create Api instance');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getApiKeyApi(api).getKeys();
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useApiKeys = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ QUERY_KEY ],
|
||||||
|
queryFn: () => fetchApiKeys(api),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
};
|
23
src/apps/dashboard/features/keys/api/useCreateKey.ts
Normal file
23
src/apps/dashboard/features/keys/api/useCreateKey.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ApiKeyApiCreateKeyRequest } from '@jellyfin/sdk/lib/generated-client/api/api-key-api';
|
||||||
|
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
import { QUERY_KEY } from './useApiKeys';
|
||||||
|
|
||||||
|
export const useCreateKey = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: ApiKeyApiCreateKeyRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getApiKeyApi(api!)
|
||||||
|
.createKey(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QUERY_KEY ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
23
src/apps/dashboard/features/keys/api/useRevokeKey.ts
Normal file
23
src/apps/dashboard/features/keys/api/useRevokeKey.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ApiKeyApiRevokeKeyRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
import { QUERY_KEY } from './useApiKeys';
|
||||||
|
|
||||||
|
export const useRevokeKey = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: ApiKeyApiRevokeKeyRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getApiKeyApi(api!)
|
||||||
|
.revokeKey(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QUERY_KEY ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
26
src/apps/dashboard/features/logs/api/useServerLogs.ts
Normal file
26
src/apps/dashboard/features/logs/api/useServerLogs.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { Api } from '@jellyfin/sdk';
|
||||||
|
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
const fetchServerLogs = async (api?: Api, options?: AxiosRequestConfig) => {
|
||||||
|
if (!api) {
|
||||||
|
console.error('[useServerLogs] No API instance available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getSystemApi(api).getServerLogs(options);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useServerLogs = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ 'ServerLogs' ],
|
||||||
|
queryFn: ({ signal }) => fetchServerLogs(api, { signal }),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
};
|
53
src/apps/dashboard/features/logs/components/LogItemList.tsx
Normal file
53
src/apps/dashboard/features/logs/components/LogItemList.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import React, { FunctionComponent } from 'react';
|
||||||
|
import type { LogFile } from '@jellyfin/sdk/lib/generated-client/models/log-file';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import datetime from 'scripts/datetime';
|
||||||
|
|
||||||
|
type LogItemProps = {
|
||||||
|
logs: LogFile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const LogItemList: FunctionComponent<LogItemProps> = ({ logs }: LogItemProps) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
|
||||||
|
const getLogFileUrl = (logFile: LogFile) => {
|
||||||
|
if (!api) return '';
|
||||||
|
|
||||||
|
return api.getUri('/System/Logs/Log', {
|
||||||
|
name: logFile.Name,
|
||||||
|
api_key: api.accessToken
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDate = (logFile: LogFile) => {
|
||||||
|
const date = datetime.parseISO8601Date(logFile.DateModified, true);
|
||||||
|
return datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List sx={{ bgcolor: 'background.paper' }}>
|
||||||
|
{logs.map(log => {
|
||||||
|
return (
|
||||||
|
<ListItem key={log.Name} disablePadding>
|
||||||
|
<ListItemButton href={getLogFileUrl(log)} target='_blank'>
|
||||||
|
<ListItemText
|
||||||
|
primary={log.Name}
|
||||||
|
primaryTypographyProps={{ variant: 'h3' }}
|
||||||
|
secondary={getDate(log)}
|
||||||
|
secondaryTypographyProps={{ variant: 'body1' }}
|
||||||
|
/>
|
||||||
|
<OpenInNewIcon />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogItemList;
|
21
src/apps/dashboard/features/plugins/api/configurationPage.ts
Normal file
21
src/apps/dashboard/features/plugins/api/configurationPage.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||||
|
|
||||||
|
export const findBestConfigurationPage = (
|
||||||
|
configurationPages: ConfigurationPageInfo[],
|
||||||
|
pluginId: string
|
||||||
|
) => {
|
||||||
|
// Find candidates matching the plugin id
|
||||||
|
const candidates = configurationPages.filter(c => c.PluginId === pluginId);
|
||||||
|
|
||||||
|
// If none are found, return undefined
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
// If only one is found, return it
|
||||||
|
if (candidates.length === 1) return candidates[0];
|
||||||
|
|
||||||
|
// Prefer the first candidate with the EnableInMainMenu flag for consistency
|
||||||
|
const menuCandidate = candidates.find(c => !!c.EnableInMainMenu);
|
||||||
|
if (menuCandidate) return menuCandidate;
|
||||||
|
|
||||||
|
// Fallback to the first match
|
||||||
|
return candidates[0];
|
||||||
|
};
|
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||||
|
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HACK: The Plugins API is returning garbage data in some cases,
|
||||||
|
* so we need to try to find the "best" match if multiple exist.
|
||||||
|
*/
|
||||||
|
export const findBestPluginInfo = (
|
||||||
|
pluginId: string,
|
||||||
|
plugins?: PluginInfo[]
|
||||||
|
) => {
|
||||||
|
if (!plugins) return;
|
||||||
|
// Find all plugin entries with a matching ID
|
||||||
|
const matches = plugins.filter(p => p.Id === pluginId);
|
||||||
|
// Get the first match (or undefined if none)
|
||||||
|
const firstMatch = matches?.[0];
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|
||||||
|
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|
||||||
|
|| firstMatch; // Fallback to the first match
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstMatch;
|
||||||
|
};
|
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export enum QueryKey {
|
||||||
|
ConfigurationPages = 'ConfigurationPages',
|
||||||
|
PackageInfo = 'PackageInfo',
|
||||||
|
Plugins = 'Plugins'
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Api } from '@jellyfin/sdk';
|
||||||
|
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
|
||||||
|
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
const fetchConfigurationPages = async (
|
||||||
|
api?: Api,
|
||||||
|
params?: DashboardApiGetConfigurationPagesRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn('[fetchConfigurationPages] No API instance available');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getDashboardApi(api)
|
||||||
|
.getConfigurationPages(params, options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigurationPagesQuery = (
|
||||||
|
api?: Api,
|
||||||
|
params?: DashboardApiGetConfigurationPagesRequest
|
||||||
|
) => queryOptions({
|
||||||
|
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||||
|
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useConfigurationPages = (
|
||||||
|
params?: DashboardApiGetConfigurationPagesRequest
|
||||||
|
) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useQuery(getConfigurationPagesQuery(api, params));
|
||||||
|
};
|
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||||
|
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
export const useDisablePlugin = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getPluginsApi(api!)
|
||||||
|
.disablePlugin(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.Plugins ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||||
|
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
export const useEnablePlugin = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getPluginsApi(api!)
|
||||||
|
.enablePlugin(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.Plugins ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||||
|
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
export const useInstallPackage = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getPackageApi(api!)
|
||||||
|
.installPackage(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.ConfigurationPages ]
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.Plugins ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { Api } from '@jellyfin/sdk';
|
||||||
|
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||||
|
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
const fetchPackageInfo = async (
|
||||||
|
api?: Api,
|
||||||
|
params?: PackageApiGetPackageInfoRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn('[fetchPackageInfo] No API instance available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params) {
|
||||||
|
console.warn('[fetchPackageInfo] Missing request params');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getPackageApi(api)
|
||||||
|
.getPackageInfo(params, options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPackageInfoQuery = (
|
||||||
|
api?: Api,
|
||||||
|
params?: PackageApiGetPackageInfoRequest
|
||||||
|
) => queryOptions({
|
||||||
|
// Don't retry since requests for plugins not available in repos fail
|
||||||
|
retry: false,
|
||||||
|
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||||
|
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||||
|
enabled: !!api && !!params?.name
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePackageInfo = (
|
||||||
|
params?: PackageApiGetPackageInfoRequest
|
||||||
|
) => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useQuery(getPackageInfoQuery(api, params));
|
||||||
|
};
|
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Api } from '@jellyfin/sdk';
|
||||||
|
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
const fetchPlugins = async (
|
||||||
|
api?: Api,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn('[fetchPlugins] No API instance available');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getPluginsApi(api)
|
||||||
|
.getPlugins(options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPluginsQuery = (
|
||||||
|
api?: Api
|
||||||
|
) => queryOptions({
|
||||||
|
queryKey: [ QueryKey.Plugins ],
|
||||||
|
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||||
|
enabled: !!api
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePlugins = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useQuery(getPluginsQuery(api));
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||||
|
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
|
||||||
|
import { QueryKey } from './queryKey';
|
||||||
|
|
||||||
|
export const useUninstallPlugin = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
getPluginsApi(api!)
|
||||||
|
.uninstallPluginByVersion(params)
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.Plugins ]
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QueryKey.ConfigurationPages ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,94 @@
|
||||||
|
import Link from '@mui/material/Link/Link';
|
||||||
|
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
|
||||||
|
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||||
|
import Table from '@mui/material/Table/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer/TableContainer';
|
||||||
|
import TableRow from '@mui/material/TableRow/TableRow';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
|
import type { PluginDetails } from '../types/PluginDetails';
|
||||||
|
|
||||||
|
interface PluginDetailsTableProps extends PaperProps {
|
||||||
|
isPluginLoading: boolean
|
||||||
|
isRepositoryLoading: boolean
|
||||||
|
pluginDetails?: PluginDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||||
|
isPluginLoading,
|
||||||
|
isRepositoryLoading,
|
||||||
|
pluginDetails,
|
||||||
|
...paperProps
|
||||||
|
}) => (
|
||||||
|
<TableContainer component={Paper} {...paperProps}>
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant='head'>
|
||||||
|
{globalize.translate('LabelStatus')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{
|
||||||
|
(isPluginLoading && <Skeleton />)
|
||||||
|
|| pluginDetails?.status
|
||||||
|
|| globalize.translate('LabelNotInstalled')
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant='head'>
|
||||||
|
{globalize.translate('LabelVersion')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{
|
||||||
|
(isPluginLoading && <Skeleton />)
|
||||||
|
|| pluginDetails?.version?.version
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant='head'>
|
||||||
|
{globalize.translate('LabelDeveloper')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{
|
||||||
|
(isRepositoryLoading && <Skeleton />)
|
||||||
|
|| pluginDetails?.owner
|
||||||
|
|| globalize.translate('Unknown')
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
<TableCell variant='head'>
|
||||||
|
{globalize.translate('LabelRepository')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{
|
||||||
|
(isRepositoryLoading && <Skeleton />)
|
||||||
|
|| (pluginDetails?.version?.repositoryUrl && (
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={pluginDetails.version.repositoryUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{pluginDetails.version.repositoryName}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
|| globalize.translate('Unknown')
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PluginDetailsTable;
|
|
@ -0,0 +1,34 @@
|
||||||
|
import Paper from '@mui/material/Paper/Paper';
|
||||||
|
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
|
||||||
|
interface PluginImageProps {
|
||||||
|
isLoading: boolean
|
||||||
|
alt?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginImage: FC<PluginImageProps> = ({
|
||||||
|
isLoading,
|
||||||
|
alt,
|
||||||
|
url
|
||||||
|
}) => (
|
||||||
|
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
|
||||||
|
{isLoading && (
|
||||||
|
<Skeleton
|
||||||
|
variant='rectangular'
|
||||||
|
width='100%'
|
||||||
|
height='100%'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{url && (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
width='100%'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PluginImage;
|
|
@ -0,0 +1,67 @@
|
||||||
|
import Download from '@mui/icons-material/Download';
|
||||||
|
import DownloadDone from '@mui/icons-material/DownloadDone';
|
||||||
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
|
import Accordion from '@mui/material/Accordion/Accordion';
|
||||||
|
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
|
||||||
|
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
|
||||||
|
import Button from '@mui/material/Button/Button';
|
||||||
|
import Stack from '@mui/material/Stack/Stack';
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
|
||||||
|
import MarkdownBox from 'components/MarkdownBox';
|
||||||
|
import { getDisplayDateTime } from 'scripts/datetime';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
|
import type { PluginDetails } from '../types/PluginDetails';
|
||||||
|
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
|
||||||
|
interface PluginRevisionsProps {
|
||||||
|
pluginDetails?: PluginDetails,
|
||||||
|
onInstall: (version?: VersionInfo) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginRevisions: FC<PluginRevisionsProps> = ({
|
||||||
|
pluginDetails,
|
||||||
|
onInstall
|
||||||
|
}) => (
|
||||||
|
pluginDetails?.versions?.map(version => (
|
||||||
|
<Accordion key={version.checksum}>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMore />}
|
||||||
|
>
|
||||||
|
{version.version}
|
||||||
|
{version.timestamp && (<>
|
||||||
|
—
|
||||||
|
{getDisplayDateTime(version.timestamp)}
|
||||||
|
</>)}
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<MarkdownBox
|
||||||
|
fallback={globalize.translate('LabelNoChangelog')}
|
||||||
|
markdown={version.changelog}
|
||||||
|
/>
|
||||||
|
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
startIcon={<DownloadDone />}
|
||||||
|
variant='outlined'
|
||||||
|
>
|
||||||
|
{globalize.translate('LabelInstalled')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
startIcon={<Download />}
|
||||||
|
variant='outlined'
|
||||||
|
onClick={onInstall(version)}
|
||||||
|
>
|
||||||
|
{globalize.translate('HeaderInstall')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PluginRevisions;
|
|
@ -0,0 +1,15 @@
|
||||||
|
/** A mapping of category names used by the plugin repository to translation keys. */
|
||||||
|
export const CATEGORY_LABELS = {
|
||||||
|
Administration: 'HeaderAdmin',
|
||||||
|
Anime: 'Anime',
|
||||||
|
Authentication: 'LabelAuthProvider', // Legacy
|
||||||
|
Books: 'Books',
|
||||||
|
Channel: 'Channels', // Unused?
|
||||||
|
General: 'General',
|
||||||
|
LiveTV: 'LiveTV',
|
||||||
|
Metadata: 'LabelMetadata', // Legacy
|
||||||
|
MoviesAndShows: 'MoviesAndShows',
|
||||||
|
Music: 'TabMusic',
|
||||||
|
Subtitles: 'Subtitles',
|
||||||
|
Other: 'Other'
|
||||||
|
};
|
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
|
||||||
|
export interface PluginDetails {
|
||||||
|
canUninstall: boolean
|
||||||
|
description?: string
|
||||||
|
id: string
|
||||||
|
imageUrl?: string
|
||||||
|
isEnabled: boolean
|
||||||
|
name?: string
|
||||||
|
owner?: string
|
||||||
|
configurationPage?: ConfigurationPageInfo
|
||||||
|
status?: PluginStatus
|
||||||
|
version?: VersionInfo
|
||||||
|
versions: VersionInfo[]
|
||||||
|
}
|
|
@ -1,14 +1,17 @@
|
||||||
import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
import type { AsyncRoute } from 'components/router/AsyncRoute';
|
||||||
|
import { AppType } from 'constants/appType';
|
||||||
|
|
||||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
{ path: 'activity', type: AppType.Dashboard },
|
||||||
{ path: 'dlna', type: AsyncRouteType.Dashboard },
|
{ path: 'branding', type: AppType.Dashboard },
|
||||||
{ path: 'notifications', type: AsyncRouteType.Dashboard },
|
{ path: 'keys', type: AppType.Dashboard },
|
||||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
{ path: 'logs', type: AppType.Dashboard },
|
||||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||||
{ path: 'users/add', type: AsyncRouteType.Dashboard },
|
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
|
||||||
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
|
{ path: 'users', type: AppType.Dashboard },
|
||||||
{ path: 'users/password', type: AsyncRouteType.Dashboard },
|
{ path: 'users/access', type: AppType.Dashboard },
|
||||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
|
{ path: 'users/add', type: AppType.Dashboard },
|
||||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
|
{ path: 'users/parentalcontrol', type: AppType.Dashboard },
|
||||||
|
{ path: 'users/password', type: AppType.Dashboard },
|
||||||
|
{ path: 'users/profile', type: AppType.Dashboard }
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,147 +1,151 @@
|
||||||
import type { LegacyRoute } from 'components/router/LegacyRoute';
|
import type { LegacyRoute } from 'components/router/LegacyRoute';
|
||||||
|
import { AppType } from 'constants/appType';
|
||||||
|
|
||||||
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/dashboard',
|
controller: 'dashboard/dashboard',
|
||||||
view: 'dashboard/dashboard.html'
|
view: 'dashboard/dashboard.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/general',
|
controller: 'dashboard/general',
|
||||||
view: 'dashboard/general.html'
|
view: 'dashboard/general.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'networking',
|
path: 'networking',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/networking',
|
controller: 'dashboard/networking',
|
||||||
view: 'dashboard/networking.html'
|
view: 'dashboard/networking.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'devices',
|
path: 'devices',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/devices/devices',
|
controller: 'dashboard/devices/devices',
|
||||||
view: 'dashboard/devices/devices.html'
|
view: 'dashboard/devices/devices.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'devices/edit',
|
path: 'devices/edit',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/devices/device',
|
controller: 'dashboard/devices/device',
|
||||||
view: 'dashboard/devices/device.html'
|
view: 'dashboard/devices/device.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'plugins/add',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/plugins/add/index',
|
|
||||||
view: 'dashboard/plugins/add/index.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'libraries',
|
path: 'libraries',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/library',
|
controller: 'dashboard/library',
|
||||||
view: 'dashboard/library.html'
|
view: 'dashboard/library.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'libraries/display',
|
path: 'libraries/display',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/librarydisplay',
|
controller: 'dashboard/librarydisplay',
|
||||||
view: 'dashboard/librarydisplay.html'
|
view: 'dashboard/librarydisplay.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'playback/transcoding',
|
path: 'playback/transcoding',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/encodingsettings',
|
controller: 'dashboard/encodingsettings',
|
||||||
view: 'dashboard/encodingsettings.html'
|
view: 'dashboard/encodingsettings.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'logs',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/logs',
|
|
||||||
view: 'dashboard/logs.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'libraries/metadata',
|
path: 'libraries/metadata',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/metadataImages',
|
controller: 'dashboard/metadataImages',
|
||||||
view: 'dashboard/metadataimages.html'
|
view: 'dashboard/metadataimages.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'libraries/nfo',
|
path: 'libraries/nfo',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/metadatanfo',
|
controller: 'dashboard/metadatanfo',
|
||||||
view: 'dashboard/metadatanfo.html'
|
view: 'dashboard/metadatanfo.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'playback/resume',
|
path: 'playback/resume',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/playback',
|
controller: 'dashboard/playback',
|
||||||
view: 'dashboard/playback.html'
|
view: 'dashboard/playback.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'plugins/catalog',
|
path: 'plugins/catalog',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/plugins/available/index',
|
controller: 'dashboard/plugins/available/index',
|
||||||
view: 'dashboard/plugins/available/index.html'
|
view: 'dashboard/plugins/available/index.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'plugins/repositories',
|
path: 'plugins/repositories',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/plugins/repositories/index',
|
controller: 'dashboard/plugins/repositories/index',
|
||||||
view: 'dashboard/plugins/repositories/index.html'
|
view: 'dashboard/plugins/repositories/index.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'livetv/guide',
|
path: 'livetv/guide',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'livetvguideprovider',
|
controller: 'livetvguideprovider',
|
||||||
view: 'livetvguideprovider.html'
|
view: 'livetvguideprovider.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'recordings',
|
path: 'recordings',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'livetvsettings',
|
controller: 'livetvsettings',
|
||||||
view: 'livetvsettings.html'
|
view: 'livetvsettings.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'livetv',
|
path: 'livetv',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'livetvstatus',
|
controller: 'livetvstatus',
|
||||||
view: 'livetvstatus.html'
|
view: 'livetvstatus.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'livetv/tuner',
|
path: 'livetv/tuner',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'livetvtuner',
|
controller: 'livetvtuner',
|
||||||
view: 'livetvtuner.html'
|
view: 'livetvtuner.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'plugins',
|
path: 'plugins',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/plugins/installed/index',
|
controller: 'dashboard/plugins/installed/index',
|
||||||
view: 'dashboard/plugins/installed/index.html'
|
view: 'dashboard/plugins/installed/index.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'tasks/edit',
|
path: 'tasks/edit',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/scheduledtasks/scheduledtask',
|
controller: 'dashboard/scheduledtasks/scheduledtask',
|
||||||
view: 'dashboard/scheduledtasks/scheduledtask.html'
|
view: 'dashboard/scheduledtasks/scheduledtask.html'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
||||||
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'keys',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/apikeys',
|
|
||||||
view: 'dashboard/apikeys.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'playback/streaming',
|
path: 'playback/streaming',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
appType: AppType.Dashboard,
|
||||||
view: 'dashboard/streaming.html',
|
view: 'dashboard/streaming.html',
|
||||||
controller: 'dashboard/streaming'
|
controller: 'dashboard/streaming'
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
import type { Redirect } from 'components/router/Redirect';
|
|
||||||
|
|
||||||
export const REDIRECTS: Redirect[] = [
|
|
||||||
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
|
|
||||||
{ from: 'apikeys.html', to: '/dashboard/keys' },
|
|
||||||
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
|
|
||||||
{ from: 'dashboard.html', to: '/dashboard' },
|
|
||||||
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
|
||||||
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
|
||||||
{ from: 'devices.html', to: '/dashboard/devices' },
|
|
||||||
{ from: 'dlnaprofile.html', to: '/dashboard/dlna' },
|
|
||||||
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna' },
|
|
||||||
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
|
|
||||||
{ from: 'edititemmetadata.html', to: '/metadata' },
|
|
||||||
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
|
|
||||||
{ from: 'installedplugins.html', to: '/dashboard/plugins' },
|
|
||||||
{ from: 'library.html', to: '/dashboard/libraries' },
|
|
||||||
{ from: 'librarydisplay.html', to: '/dashboard/libraries/display' },
|
|
||||||
{ from: 'livetvguideprovider.html', to: '/dashboard/livetv/guide' },
|
|
||||||
{ from: 'livetvsettings.html', to: '/dashboard/recordings' },
|
|
||||||
{ from: 'livetvstatus.html', to: '/dashboard/livetv' },
|
|
||||||
{ from: 'livetvtuner.html', to: '/dashboard/livetv/tuner' },
|
|
||||||
{ from: 'log.html', to: '/dashboard/logs' },
|
|
||||||
{ from: 'metadataimages.html', to: '/dashboard/libraries/metadata' },
|
|
||||||
{ from: 'metadatanfo.html', to: '/dashboard/libraries/nfo' },
|
|
||||||
{ from: 'networking.html', to: '/dashboard/networking' },
|
|
||||||
{ from: 'notificationsettings.html', to: '/dashboard/notifications' },
|
|
||||||
{ from: 'playbackconfiguration.html', to: '/dashboard/playback/resume' },
|
|
||||||
{ from: 'repositories.html', to: '/dashboard/plugins/repositories' },
|
|
||||||
{ from: 'scheduledtask.html', to: '/dashboard/tasks/edit' },
|
|
||||||
{ from: 'scheduledtasks.html', to: '/dashboard/tasks' },
|
|
||||||
{ from: 'serveractivity.html', to: '/dashboard/activity' },
|
|
||||||
{ from: 'streamingsettings.html', to: '/dashboard/playback/streaming' },
|
|
||||||
{ from: 'useredit.html', to: '/dashboard/users/profile' },
|
|
||||||
{ from: 'userlibraryaccess.html', to: '/dashboard/users/access' },
|
|
||||||
{ from: 'usernew.html', to: '/dashboard/users/add' },
|
|
||||||
{ from: 'userparentalcontrol.html', to: '/dashboard/users/parentalcontrol' },
|
|
||||||
{ from: 'userpassword.html', to: '/dashboard/users/password' },
|
|
||||||
{ from: 'userprofiles.html', to: '/dashboard/users' }
|
|
||||||
];
|
|
|
@ -1,273 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
|
|
||||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
|
||||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
|
||||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
|
||||||
import PermMedia from '@mui/icons-material/PermMedia';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
|
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Page from 'components/Page';
|
|
||||||
import UserAvatar from 'components/UserAvatar';
|
|
||||||
import { useApi } from 'hooks/useApi';
|
|
||||||
import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'scripts/datetime';
|
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
import { toBoolean } from 'utils/string';
|
|
||||||
|
|
||||||
import LogLevelChip from '../components/activityTable/LogLevelChip';
|
|
||||||
import OverviewCell from '../components/activityTable/OverviewCell';
|
|
||||||
import GridActionsCellLink from '../components/dataGrid/GridActionsCellLink';
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
|
||||||
const VIEW_PARAM = 'useractivity';
|
|
||||||
|
|
||||||
const enum ActivityView {
|
|
||||||
All,
|
|
||||||
User,
|
|
||||||
System
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActivityView = (param: string | null) => {
|
|
||||||
if (param === null) return ActivityView.All;
|
|
||||||
if (toBoolean(param)) return ActivityView.User;
|
|
||||||
return ActivityView.System;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRowId = (row: ActivityLogEntry) => row.Id ?? -1;
|
|
||||||
|
|
||||||
const Activity = () => {
|
|
||||||
const { api } = useApi();
|
|
||||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
|
||||||
|
|
||||||
const [ activityView, setActivityView ] = useState(
|
|
||||||
getActivityView(searchParams.get(VIEW_PARAM)));
|
|
||||||
const [ isLoading, setIsLoading ] = useState(true);
|
|
||||||
const [ paginationModel, setPaginationModel ] = useState({
|
|
||||||
page: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE
|
|
||||||
});
|
|
||||||
const [ rowCount, setRowCount ] = useState(0);
|
|
||||||
const [ rows, setRows ] = useState<ActivityLogEntry[]>([]);
|
|
||||||
const [ users, setUsers ] = useState<Record<string, UserDto>>({});
|
|
||||||
|
|
||||||
const userColDef: GridColDef[] = activityView !== ActivityView.System ? [
|
|
||||||
{
|
|
||||||
field: 'User',
|
|
||||||
headerName: globalize.translate('LabelUser'),
|
|
||||||
width: 60,
|
|
||||||
valueGetter: ({ row }) => users[row.UserId]?.Name,
|
|
||||||
renderCell: ({ row }) => (
|
|
||||||
<IconButton
|
|
||||||
size='large'
|
|
||||||
color='inherit'
|
|
||||||
sx={{ padding: 0 }}
|
|
||||||
title={users[row.UserId]?.Name ?? undefined}
|
|
||||||
component={Link}
|
|
||||||
to={`/dashboard/users/profile?userId=${row.UserId}`}
|
|
||||||
>
|
|
||||||
<UserAvatar user={users[row.UserId]} />
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
|
||||||
{
|
|
||||||
field: 'Date',
|
|
||||||
headerName: globalize.translate('LabelDate'),
|
|
||||||
width: 90,
|
|
||||||
type: 'date',
|
|
||||||
valueGetter: ({ value }) => parseISO8601Date(value),
|
|
||||||
valueFormatter: ({ value }) => toLocaleDateString(value)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'Time',
|
|
||||||
headerName: globalize.translate('LabelTime'),
|
|
||||||
width: 100,
|
|
||||||
type: 'dateTime',
|
|
||||||
valueGetter: ({ row }) => parseISO8601Date(row.Date),
|
|
||||||
valueFormatter: ({ value }) => toLocaleTimeString(value)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'Severity',
|
|
||||||
headerName: globalize.translate('LabelLevel'),
|
|
||||||
width: 110,
|
|
||||||
renderCell: ({ value }) => (
|
|
||||||
value ? (
|
|
||||||
<LogLevelChip level={value} />
|
|
||||||
) : undefined
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...userColDef,
|
|
||||||
{
|
|
||||||
field: 'Name',
|
|
||||||
headerName: globalize.translate('LabelName'),
|
|
||||||
width: 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'Overview',
|
|
||||||
headerName: globalize.translate('LabelOverview'),
|
|
||||||
width: 200,
|
|
||||||
valueGetter: ({ row }) => row.ShortOverview ?? row.Overview,
|
|
||||||
renderCell: ({ row }) => (
|
|
||||||
<OverviewCell {...row} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'Type',
|
|
||||||
headerName: globalize.translate('LabelType'),
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions',
|
|
||||||
getActions: ({ row }) => {
|
|
||||||
const actions = [];
|
|
||||||
|
|
||||||
if (row.ItemId) {
|
|
||||||
actions.push(
|
|
||||||
<GridActionsCellLink
|
|
||||||
size='large'
|
|
||||||
icon={<PermMedia />}
|
|
||||||
label={globalize.translate('LabelMediaDetails')}
|
|
||||||
title={globalize.translate('LabelMediaDetails')}
|
|
||||||
to={`/details?id=${row.ItemId}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const onViewChange = useCallback((_e, newView: ActivityView | null) => {
|
|
||||||
if (newView !== null) {
|
|
||||||
setActivityView(newView);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (api) {
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
const { data } = await getUserApi(api).getUsers();
|
|
||||||
const usersById: Record<string, UserDto> = {};
|
|
||||||
data.forEach(user => {
|
|
||||||
if (user.Id) {
|
|
||||||
usersById[user.Id] = user;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setUsers(usersById);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUsers()
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[activity] failed to fetch users', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [ api ]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (api) {
|
|
||||||
const fetchActivity = async () => {
|
|
||||||
const params: {
|
|
||||||
startIndex: number,
|
|
||||||
limit: number,
|
|
||||||
hasUserId?: boolean
|
|
||||||
} = {
|
|
||||||
startIndex: paginationModel.page * paginationModel.pageSize,
|
|
||||||
limit: paginationModel.pageSize
|
|
||||||
};
|
|
||||||
if (activityView !== ActivityView.All) {
|
|
||||||
params.hasUserId = activityView === ActivityView.User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await getActivityLogApi(api)
|
|
||||||
.getLogEntries(params);
|
|
||||||
|
|
||||||
setRowCount(data.TotalRecordCount ?? 0);
|
|
||||||
setRows(data.Items ?? []);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
fetchActivity()
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[activity] failed to fetch activity log entries', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [ activityView, api, paginationModel ]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM));
|
|
||||||
if (currentViewParam !== activityView) {
|
|
||||||
if (activityView === ActivityView.All) {
|
|
||||||
searchParams.delete(VIEW_PARAM);
|
|
||||||
} else {
|
|
||||||
searchParams.set(VIEW_PARAM, `${activityView === ActivityView.User}`);
|
|
||||||
}
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
}
|
|
||||||
}, [ activityView, searchParams, setSearchParams ]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page
|
|
||||||
id='serverActivityPage'
|
|
||||||
title={globalize.translate('HeaderActivity')}
|
|
||||||
className='mainAnimatedPage type-interior'
|
|
||||||
>
|
|
||||||
<div className='content-primary'>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
marginY: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<Typography variant='h2'>
|
|
||||||
{globalize.translate('HeaderActivity')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={activityView}
|
|
||||||
onChange={onViewChange}
|
|
||||||
exclusive
|
|
||||||
>
|
|
||||||
<ToggleButton value={ActivityView.All}>
|
|
||||||
{globalize.translate('All')}
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ActivityView.User}>
|
|
||||||
{globalize.translate('LabelUser')}
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton value={ActivityView.System}>
|
|
||||||
{globalize.translate('LabelSystem')}
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
<DataGrid
|
|
||||||
columns={columns}
|
|
||||||
rows={rows}
|
|
||||||
pageSizeOptions={[ 10, 25, 50, 100 ]}
|
|
||||||
paginationMode='server'
|
|
||||||
paginationModel={paginationModel}
|
|
||||||
onPaginationModelChange={setPaginationModel}
|
|
||||||
rowCount={rowCount}
|
|
||||||
getRowId={getRowId}
|
|
||||||
loading={isLoading}
|
|
||||||
sx={{
|
|
||||||
minHeight: 500
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Activity;
|
|
260
src/apps/dashboard/routes/activity/index.tsx
Normal file
260
src/apps/dashboard/routes/activity/index.tsx
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||||
|
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||||
|
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||||
|
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
|
||||||
|
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
|
||||||
|
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
|
||||||
|
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
|
||||||
|
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import { useUsers } from 'hooks/useUsers';
|
||||||
|
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import { toBoolean } from 'utils/string';
|
||||||
|
|
||||||
|
type UsersRecords = Record<string, UserDto>;
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
const VIEW_PARAM = 'useractivity';
|
||||||
|
|
||||||
|
const enum ActivityView {
|
||||||
|
All = 'All',
|
||||||
|
User = 'User',
|
||||||
|
System = 'System'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActivityView = (param: string | null) => {
|
||||||
|
if (param === null) return ActivityView.All;
|
||||||
|
if (toBoolean(param)) return ActivityView.User;
|
||||||
|
return ActivityView.System;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserCell = (users: UsersRecords) => function UserCell({ row }: ActivityLogEntryCell) {
|
||||||
|
return (
|
||||||
|
<UserAvatarButton user={row.original.UserId && users[row.original.UserId] || undefined} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Activity = () => {
|
||||||
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
|
|
||||||
|
const [ activityView, setActivityView ] = useState(
|
||||||
|
getActivityView(searchParams.get(VIEW_PARAM)));
|
||||||
|
|
||||||
|
const [ pagination, setPagination ] = useState({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: usersData, isLoading: isUsersLoading } = useUsers();
|
||||||
|
|
||||||
|
const users: UsersRecords = useMemo(() => {
|
||||||
|
if (!usersData) return {};
|
||||||
|
|
||||||
|
return usersData.reduce<UsersRecords>((acc, user) => {
|
||||||
|
const userId = user.Id;
|
||||||
|
if (!userId) return acc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[userId]: user
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
}, [ usersData ]);
|
||||||
|
|
||||||
|
const userNames = useMemo(() => {
|
||||||
|
const names: string[] = [];
|
||||||
|
usersData?.forEach(user => {
|
||||||
|
if (user.Name) names.push(user.Name);
|
||||||
|
});
|
||||||
|
return names;
|
||||||
|
}, [ usersData ]);
|
||||||
|
|
||||||
|
const UserCell = getUserCell(users);
|
||||||
|
|
||||||
|
const activityParams = useMemo(() => ({
|
||||||
|
startIndex: pagination.pageIndex * pagination.pageSize,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
|
||||||
|
}), [activityView, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
|
const { data: logEntries, isLoading: isLogEntriesLoading } = useLogEntries(activityParams);
|
||||||
|
|
||||||
|
const isLoading = isUsersLoading || isLogEntriesLoading;
|
||||||
|
|
||||||
|
const userColumn: MRT_ColumnDef<ActivityLogEntry>[] = useMemo(() =>
|
||||||
|
(activityView === ActivityView.System) ? [] : [{
|
||||||
|
id: 'User',
|
||||||
|
accessorFn: row => row.UserId && users[row.UserId]?.Name,
|
||||||
|
header: globalize.translate('LabelUser'),
|
||||||
|
size: 75,
|
||||||
|
Cell: UserCell,
|
||||||
|
enableResizing: false,
|
||||||
|
muiTableBodyCellProps: {
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
filterVariant: 'multi-select',
|
||||||
|
filterSelectOptions: userNames
|
||||||
|
}], [ activityView, userNames, users, UserCell ]);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'Date',
|
||||||
|
accessorFn: row => parseISO8601Date(row.Date),
|
||||||
|
header: globalize.translate('LabelTime'),
|
||||||
|
size: 160,
|
||||||
|
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
|
||||||
|
filterVariant: 'datetime-range'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'Severity',
|
||||||
|
header: globalize.translate('LabelLevel'),
|
||||||
|
size: 90,
|
||||||
|
Cell: LogLevelCell,
|
||||||
|
enableResizing: false,
|
||||||
|
muiTableBodyCellProps: {
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
filterVariant: 'multi-select',
|
||||||
|
filterSelectOptions: Object.values(LogLevel).map(level => globalize.translate(`LogLevel.${level}`))
|
||||||
|
},
|
||||||
|
...userColumn,
|
||||||
|
{
|
||||||
|
accessorKey: 'Name',
|
||||||
|
header: globalize.translate('LabelName'),
|
||||||
|
size: 270
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Overview',
|
||||||
|
accessorFn: row => row.ShortOverview || row.Overview,
|
||||||
|
header: globalize.translate('LabelOverview'),
|
||||||
|
size: 170,
|
||||||
|
Cell: OverviewCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'Type',
|
||||||
|
header: globalize.translate('LabelType'),
|
||||||
|
size: 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Actions',
|
||||||
|
accessorFn: row => row.ItemId,
|
||||||
|
header: '',
|
||||||
|
size: 60,
|
||||||
|
Cell: ActionsCell,
|
||||||
|
enableColumnActions: false,
|
||||||
|
enableColumnFilter: false,
|
||||||
|
enableResizing: false,
|
||||||
|
enableSorting: false
|
||||||
|
}
|
||||||
|
], [ userColumn ]);
|
||||||
|
|
||||||
|
const onViewChange = useCallback((_e: React.MouseEvent<HTMLElement, MouseEvent>, newView: ActivityView | null) => {
|
||||||
|
if (newView !== null) {
|
||||||
|
setActivityView(newView);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM));
|
||||||
|
if (currentViewParam !== activityView) {
|
||||||
|
if (activityView === ActivityView.All) {
|
||||||
|
searchParams.delete(VIEW_PARAM);
|
||||||
|
} else {
|
||||||
|
searchParams.set(VIEW_PARAM, `${activityView === ActivityView.User}`);
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
}, [ activityView, searchParams, setSearchParams ]);
|
||||||
|
|
||||||
|
const table = useMaterialReactTable({
|
||||||
|
columns,
|
||||||
|
data: logEntries?.Items || [],
|
||||||
|
|
||||||
|
// Enable custom features
|
||||||
|
enableColumnPinning: true,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
|
||||||
|
// Sticky header/footer
|
||||||
|
enableStickyFooter: true,
|
||||||
|
enableStickyHeader: true,
|
||||||
|
muiTableContainerProps: {
|
||||||
|
sx: {
|
||||||
|
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// State
|
||||||
|
initialState: {
|
||||||
|
density: 'compact'
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isLoading,
|
||||||
|
pagination
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server pagination
|
||||||
|
manualPagination: true,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
rowCount: logEntries?.TotalRecordCount || 0,
|
||||||
|
|
||||||
|
// Custom toolbar contents
|
||||||
|
renderTopToolbarCustomActions: () => (
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size='small'
|
||||||
|
value={activityView}
|
||||||
|
onChange={onViewChange}
|
||||||
|
exclusive
|
||||||
|
>
|
||||||
|
<ToggleButton value={ActivityView.All}>
|
||||||
|
{globalize.translate('All')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={ActivityView.User}>
|
||||||
|
{globalize.translate('LabelUser')}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={ActivityView.System}>
|
||||||
|
{globalize.translate('LabelSystem')}
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
id='serverActivityPage'
|
||||||
|
title={globalize.translate('HeaderActivity')}
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className='content-primary'
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginBottom: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant='h2'>
|
||||||
|
{globalize.translate('HeaderActivity')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<MaterialReactTable table={table} />
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Activity;
|
173
src/apps/dashboard/routes/branding/index.tsx
Normal file
173
src/apps/dashboard/routes/branding/index.tsx
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
import type { BrandingOptions } from '@jellyfin/sdk/lib/generated-client/models/branding-options';
|
||||||
|
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
|
||||||
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import ServerConnections from 'components/ServerConnections';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import { queryClient } from 'utils/query/queryClient';
|
||||||
|
import { ActionData } from 'types/actionData';
|
||||||
|
|
||||||
|
const BRANDING_CONFIG_KEY = 'branding';
|
||||||
|
const BrandingOption = {
|
||||||
|
CustomCss: 'CustomCss',
|
||||||
|
LoginDisclaimer: 'LoginDisclaimer',
|
||||||
|
SplashscreenEnabled: 'SplashscreenEnabled'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const api = ServerConnections.getCurrentApi();
|
||||||
|
if (!api) throw new Error('No Api instance available');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const data = Object.fromEntries(formData);
|
||||||
|
|
||||||
|
const brandingOptions: BrandingOptions = {
|
||||||
|
CustomCss: data.CustomCss?.toString(),
|
||||||
|
LoginDisclaimer: data.LoginDisclaimer?.toString(),
|
||||||
|
SplashscreenEnabled: data.SplashscreenEnabled?.toString() === 'on'
|
||||||
|
};
|
||||||
|
|
||||||
|
await getConfigurationApi(api)
|
||||||
|
.updateNamedConfiguration({
|
||||||
|
key: BRANDING_CONFIG_KEY,
|
||||||
|
body: JSON.stringify(brandingOptions)
|
||||||
|
});
|
||||||
|
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [ QUERY_KEY ]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSaved: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader = () => {
|
||||||
|
return queryClient.ensureQueryData(
|
||||||
|
getBrandingOptionsQuery(ServerConnections.getCurrentApi()));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Component = () => {
|
||||||
|
const actionData = useActionData() as ActionData | undefined;
|
||||||
|
const [ isSubmitting, setIsSubmitting ] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: defaultBrandingOptions,
|
||||||
|
isPending
|
||||||
|
} = useBrandingOptions();
|
||||||
|
const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, [ actionData ]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(() => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSplashscreenEnabled = useCallback((_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
|
||||||
|
setBrandingOptions({
|
||||||
|
...brandingOptions,
|
||||||
|
[BrandingOption.SplashscreenEnabled]: isEnabled
|
||||||
|
});
|
||||||
|
}, [ brandingOptions ]);
|
||||||
|
|
||||||
|
const setBrandingOption = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||||
|
if (Object.keys(BrandingOption).includes(event.target.name)) {
|
||||||
|
setBrandingOptions({
|
||||||
|
...brandingOptions,
|
||||||
|
[event.target.name]: event.target.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ brandingOptions ]);
|
||||||
|
|
||||||
|
if (isPending) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
id='brandingPage'
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Box className='content-primary'>
|
||||||
|
<Form
|
||||||
|
method='POST'
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant='h1'>
|
||||||
|
{globalize.translate('HeaderBranding')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!isSubmitting && actionData?.isSaved && (
|
||||||
|
<Alert severity='success'>
|
||||||
|
{globalize.translate('SettingsSaved')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
name={BrandingOption.SplashscreenEnabled}
|
||||||
|
checked={brandingOptions?.SplashscreenEnabled}
|
||||||
|
onChange={setSplashscreenEnabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={globalize.translate('EnableSplashScreen')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
maxRows={5}
|
||||||
|
InputProps={{
|
||||||
|
className: 'textarea-mono'
|
||||||
|
}}
|
||||||
|
name={BrandingOption.LoginDisclaimer}
|
||||||
|
label={globalize.translate('LabelLoginDisclaimer')}
|
||||||
|
helperText={globalize.translate('LabelLoginDisclaimerHelp')}
|
||||||
|
value={brandingOptions?.LoginDisclaimer}
|
||||||
|
onChange={setBrandingOption}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
maxRows={20}
|
||||||
|
InputProps={{
|
||||||
|
className: 'textarea-mono'
|
||||||
|
}}
|
||||||
|
name={BrandingOption.CustomCss}
|
||||||
|
label={globalize.translate('LabelCustomCss')}
|
||||||
|
helperText={globalize.translate('LabelCustomCssHelp')}
|
||||||
|
value={brandingOptions?.CustomCss}
|
||||||
|
onChange={setBrandingOption}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
size='large'
|
||||||
|
>
|
||||||
|
{globalize.translate('Save')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Component.displayName = 'BrandingPage';
|
|
@ -1,33 +0,0 @@
|
||||||
import Alert from '@mui/material/Alert/Alert';
|
|
||||||
import Box from '@mui/material/Box/Box';
|
|
||||||
import Button from '@mui/material/Button/Button';
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Page from 'components/Page';
|
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
|
|
||||||
const DlnaPage = () => (
|
|
||||||
<Page
|
|
||||||
id='dlnaSettingsPage'
|
|
||||||
title='DLNA'
|
|
||||||
className='mainAnimatedPage type-interior'
|
|
||||||
>
|
|
||||||
<div className='content-primary'>
|
|
||||||
<h2>DLNA</h2>
|
|
||||||
<Alert severity='info'>
|
|
||||||
<Box sx={{ marginBottom: 2 }}>
|
|
||||||
{globalize.translate('DlnaMovedMessage')}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to='/dashboard/plugins/add?name=DLNA&guid=33eba9cd7da14720967fdd7dae7b74a1'
|
|
||||||
>
|
|
||||||
{globalize.translate('GetThePlugin')}
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DlnaPage;
|
|
167
src/apps/dashboard/routes/keys/index.tsx
Normal file
167
src/apps/dashboard/routes/keys/index.tsx
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info';
|
||||||
|
import confirm from 'components/confirm/confirm';
|
||||||
|
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
|
||||||
|
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
|
||||||
|
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||||
|
import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
|
||||||
|
const ApiKeys = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
const { data: keys, isLoading } = useApiKeys();
|
||||||
|
const revokeKey = useRevokeKey();
|
||||||
|
const createKey = useCreateKey();
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'ApiKey',
|
||||||
|
accessorKey: 'AccessToken',
|
||||||
|
header: globalize.translate('HeaderApiKey'),
|
||||||
|
size: 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AppName',
|
||||||
|
accessorKey: 'AppName',
|
||||||
|
header: globalize.translate('HeaderApp')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DateIssued',
|
||||||
|
accessorFn: item => parseISO8601Date(item.DateCreated),
|
||||||
|
Cell: ({ cell }) => toLocaleDateString(cell.getValue<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
|
||||||
|
header: globalize.translate('HeaderDateIssued'),
|
||||||
|
filterVariant: 'datetime-range'
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const table = useMaterialReactTable({
|
||||||
|
columns,
|
||||||
|
data: keys?.Items || [],
|
||||||
|
|
||||||
|
state: {
|
||||||
|
isLoading
|
||||||
|
},
|
||||||
|
|
||||||
|
rowCount: keys?.TotalRecordCount || 0,
|
||||||
|
|
||||||
|
enableColumnPinning: true,
|
||||||
|
enableColumnResizing: true,
|
||||||
|
|
||||||
|
enableStickyFooter: true,
|
||||||
|
enableStickyHeader: true,
|
||||||
|
muiTableContainerProps: {
|
||||||
|
sx: {
|
||||||
|
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enable (delete) row actions
|
||||||
|
enableRowActions: true,
|
||||||
|
positionActionsColumn: 'last',
|
||||||
|
displayColumnDefOptions: {
|
||||||
|
'mrt-row-actions': {
|
||||||
|
header: '',
|
||||||
|
size: 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTopToolbarCustomActions: () => (
|
||||||
|
<Button onClick={showNewKeyPopup}>
|
||||||
|
<AddIcon />
|
||||||
|
{globalize.translate('HeaderNewApiKey')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
|
||||||
|
renderRowActions: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<Tooltip title={globalize.translate('ButtonRevoke')}>
|
||||||
|
<IconButton
|
||||||
|
color='error'
|
||||||
|
// eslint-disable-next-line react/jsx-no-bind
|
||||||
|
onClick={() => row.original?.AccessToken && onRevokeKey(row.original.AccessToken)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRevokeKey = useCallback((accessToken: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () {
|
||||||
|
revokeKey.mutate({
|
||||||
|
key: accessToken
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[apikeys] failed to show confirmation dialog', err);
|
||||||
|
});
|
||||||
|
}, [api, revokeKey]);
|
||||||
|
|
||||||
|
const showNewKeyPopup = useCallback(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||||
|
prompt({
|
||||||
|
title: globalize.translate('HeaderNewApiKey'),
|
||||||
|
label: globalize.translate('LabelAppName'),
|
||||||
|
description: globalize.translate('LabelAppNameExample')
|
||||||
|
}).then((value) => {
|
||||||
|
createKey.mutate({
|
||||||
|
app: value
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// popup closed
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[apikeys] failed to load api key popup', err);
|
||||||
|
});
|
||||||
|
}, [api, createKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
id='apiKeysPage'
|
||||||
|
title={globalize.translate('HeaderApiKeys')}
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className='content-primary'
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginBottom: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant='h2'>
|
||||||
|
{globalize.translate('HeaderApiKeys')}
|
||||||
|
</Typography>
|
||||||
|
<Typography>{globalize.translate('HeaderApiKeysHelp')}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<MaterialReactTable table={table} />
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeys;
|
139
src/apps/dashboard/routes/logs/index.tsx
Normal file
139
src/apps/dashboard/routes/logs/index.tsx
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||||
|
import Loading from 'components/loading/LoadingComponent';
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
|
||||||
|
import ServerConnections from 'components/ServerConnections';
|
||||||
|
import { useServerLogs } from 'apps/dashboard/features/logs/api/useServerLogs';
|
||||||
|
import { useConfiguration } from 'hooks/useConfiguration';
|
||||||
|
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||||
|
import { ActionData } from 'types/actionData';
|
||||||
|
import LogItemList from 'apps/dashboard/features/logs/components/LogItemList';
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const api = ServerConnections.getCurrentApi();
|
||||||
|
if (!api) throw new Error('No Api instance available');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||||
|
|
||||||
|
const enableWarningMessage = formData.get('EnableWarningMessage');
|
||||||
|
config.EnableSlowResponseWarning = enableWarningMessage === 'on';
|
||||||
|
|
||||||
|
const responseTime = formData.get('SlowResponseTime');
|
||||||
|
if (responseTime) {
|
||||||
|
config.SlowResponseThresholdMs = parseInt(responseTime.toString(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
await getConfigurationApi(api)
|
||||||
|
.updateConfiguration({ serverConfiguration: config });
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSaved: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const Logs = () => {
|
||||||
|
const actionData = useActionData() as ActionData | undefined;
|
||||||
|
const [ isSubmitting, setIsSubmitting ] = useState(false);
|
||||||
|
|
||||||
|
const { isPending: isLogEntriesPending, data: logs } = useServerLogs();
|
||||||
|
const { isPending: isConfigurationPending, data: defaultConfiguration } = useConfiguration();
|
||||||
|
const [ loading, setLoading ] = useState(true);
|
||||||
|
const [ configuration, setConfiguration ] = useState<ServerConfiguration>( {} );
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfigurationPending && defaultConfiguration) {
|
||||||
|
setConfiguration(defaultConfiguration);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isConfigurationPending, defaultConfiguration]);
|
||||||
|
|
||||||
|
const setLogWarningMessage = useCallback((_: ChangeEvent<HTMLInputElement>, checked: boolean) => {
|
||||||
|
setConfiguration({
|
||||||
|
...configuration,
|
||||||
|
EnableSlowResponseWarning: checked
|
||||||
|
});
|
||||||
|
}, [configuration]);
|
||||||
|
|
||||||
|
const onResponseTimeChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setConfiguration({
|
||||||
|
...configuration,
|
||||||
|
SlowResponseThresholdMs: parseInt(event.target.value, 10)
|
||||||
|
});
|
||||||
|
}, [configuration]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(() => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLogEntriesPending || isConfigurationPending || loading || !logs) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
id='logPage'
|
||||||
|
title={globalize.translate('TabLogs')}
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Box className='content-primary'>
|
||||||
|
<Form method='POST' onSubmit={onSubmit}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant='h1'>
|
||||||
|
{globalize.translate('TabLogs')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isSubmitting && actionData?.isSaved && (
|
||||||
|
<Alert severity='success'>
|
||||||
|
{globalize.translate('SettingsSaved')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={configuration?.EnableSlowResponseWarning}
|
||||||
|
onChange={setLogWarningMessage}
|
||||||
|
name={'EnableWarningMessage'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={globalize.translate('LabelSlowResponseEnabled')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type='number'
|
||||||
|
name={'SlowResponseTime'}
|
||||||
|
label={globalize.translate('LabelSlowResponseTime')}
|
||||||
|
value={configuration?.SlowResponseThresholdMs}
|
||||||
|
disabled={!configuration?.EnableSlowResponseWarning}
|
||||||
|
onChange={onResponseTimeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
size='large'
|
||||||
|
>
|
||||||
|
{globalize.translate('Save')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
<Box className='serverLogs readOnlyContent' sx={{ mt: 3 }}>
|
||||||
|
<LogItemList logs={logs} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logs;
|
|
@ -1,34 +0,0 @@
|
||||||
import Alert from '@mui/material/Alert/Alert';
|
|
||||||
import Box from '@mui/material/Box/Box';
|
|
||||||
import Button from '@mui/material/Button/Button';
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Page from 'components/Page';
|
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
|
|
||||||
const NotificationsPage = () => (
|
|
||||||
<Page
|
|
||||||
id='notificationSettingPage'
|
|
||||||
title={globalize.translate('Notifications')}
|
|
||||||
className='mainAnimatedPage type-interior'
|
|
||||||
>
|
|
||||||
<div className='content-primary'>
|
|
||||||
<h2>{globalize.translate('Notifications')}</h2>
|
|
||||||
|
|
||||||
<Alert severity='info'>
|
|
||||||
<Box sx={{ marginBottom: 2 }}>
|
|
||||||
{globalize.translate('NotificationsMovedMessage')}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to='/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
|
||||||
>
|
|
||||||
{globalize.translate('GetThePlugin')}
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default NotificationsPage;
|
|
|
@ -1,14 +1,15 @@
|
||||||
import type { ProcessPriorityClass, ServerConfiguration, TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client';
|
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||||
import React, { type FunctionComponent, useCallback, useEffect, useRef } from 'react';
|
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
|
||||||
|
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
|
||||||
|
import React, { type FC, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../lib/globalize';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import ButtonElement from '../../../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
import SelectElement from '../../../../elements/SelectElement';
|
import SelectElement from '../../../../elements/SelectElement';
|
||||||
import InputElement from '../../../../elements/InputElement';
|
import InputElement from '../../../../elements/InputElement';
|
||||||
import LinkTrickplayAcceleration from '../../../../components/dashboard/playback/trickplay/LinkTrickplayAcceleration';
|
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import toast from '../../../../components/toast/toast';
|
import toast from '../../../../components/toast/toast';
|
||||||
import ServerConnections from '../../../../components/ServerConnections';
|
import ServerConnections from '../../../../components/ServerConnections';
|
||||||
|
@ -18,10 +19,10 @@ function onSaveComplete() {
|
||||||
toast(globalize.translate('SettingsSaved'));
|
toast(globalize.translate('SettingsSaved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackTrickplay: FunctionComponent = () => {
|
const PlaybackTrickplay: FC = () => {
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const loadConfig = useCallback((config) => {
|
const loadConfig = useCallback((config: ServerConfiguration) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
const options = config.TrickplayOptions;
|
const options = config.TrickplayOptions;
|
||||||
|
|
||||||
|
@ -30,16 +31,18 @@ const PlaybackTrickplay: FunctionComponent = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options.EnableHwAcceleration;
|
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
|
||||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = options.ScanBehavior;
|
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
|
||||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = options.ProcessPriority;
|
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
|
||||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options.Interval;
|
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
|
||||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options.WidthResolutions.join(',');
|
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
|
||||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options.TileWidth;
|
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
|
||||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options.TileHeight;
|
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
|
||||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options.Qscale;
|
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
|
||||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options.JpegQuality;
|
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
|
||||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options.ProcessThreads;
|
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
|
||||||
|
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
|
||||||
|
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
|
||||||
|
|
||||||
loading.hide();
|
loading.hide();
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -76,6 +79,8 @@ const PlaybackTrickplay: FunctionComponent = () => {
|
||||||
|
|
||||||
const options = config.TrickplayOptions;
|
const options = config.TrickplayOptions;
|
||||||
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
||||||
|
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
|
||||||
|
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
|
||||||
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
||||||
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
||||||
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
||||||
|
@ -139,12 +144,12 @@ const PlaybackTrickplay: FunctionComponent = () => {
|
||||||
<Page
|
<Page
|
||||||
id='trickplayConfigurationPage'
|
id='trickplayConfigurationPage'
|
||||||
className='mainAnimatedPage type-interior playbackConfigurationPage'
|
className='mainAnimatedPage type-interior playbackConfigurationPage'
|
||||||
|
title={globalize.translate('Trickplay')}
|
||||||
>
|
>
|
||||||
<div ref={element} className='content-primary'>
|
<div ref={element} className='content-primary'>
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={globalize.translate('Trickplay')}
|
title={globalize.translate('Trickplay')}
|
||||||
isLinkVisible={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -154,12 +159,27 @@ const PlaybackTrickplay: FunctionComponent = () => {
|
||||||
className='chkEnableHwAcceleration'
|
className='chkEnableHwAcceleration'
|
||||||
title='LabelTrickplayAccel'
|
title='LabelTrickplayAccel'
|
||||||
/>
|
/>
|
||||||
<div className='fieldDescription checkboxFieldDescription'>
|
</div>
|
||||||
<LinkTrickplayAcceleration
|
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||||
title='LabelTrickplayAccelHelp'
|
<CheckBoxElement
|
||||||
href='#/dashboard/playback/transcoding'
|
className='chkEnableHwEncoding'
|
||||||
className='button-link'
|
title='LabelTrickplayAccelEncoding'
|
||||||
/>
|
/>
|
||||||
|
<div className='fieldDescription checkboxFieldDescription'>
|
||||||
|
<div className='fieldDescription'>
|
||||||
|
{globalize.translate('LabelTrickplayAccelEncodingHelp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||||
|
<CheckBoxElement
|
||||||
|
className='chkEnableKeyFrameOnlyExtraction'
|
||||||
|
title='LabelTrickplayKeyFrameOnlyExtraction'
|
||||||
|
/>
|
||||||
|
<div className='fieldDescription checkboxFieldDescription'>
|
||||||
|
<div className='fieldDescription'>
|
||||||
|
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
442
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
442
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
|
@ -0,0 +1,442 @@
|
||||||
|
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||||
|
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
|
||||||
|
import Alert from '@mui/material/Alert/Alert';
|
||||||
|
import Button from '@mui/material/Button/Button';
|
||||||
|
import Container from '@mui/material/Container/Container';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
|
||||||
|
import FormGroup from '@mui/material/FormGroup/FormGroup';
|
||||||
|
import Grid from '@mui/material/Grid/Grid';
|
||||||
|
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||||
|
import Stack from '@mui/material/Stack/Stack';
|
||||||
|
import Switch from '@mui/material/Switch/Switch';
|
||||||
|
import Typography from '@mui/material/Typography/Typography';
|
||||||
|
import Delete from '@mui/icons-material/Delete';
|
||||||
|
import Download from '@mui/icons-material/Download';
|
||||||
|
import Settings from '@mui/icons-material/Settings';
|
||||||
|
import React, { type FC, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||||
|
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
|
||||||
|
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||||
|
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
|
||||||
|
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
|
||||||
|
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
|
||||||
|
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
|
||||||
|
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||||
|
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
|
||||||
|
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
|
||||||
|
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
|
||||||
|
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
|
||||||
|
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
|
||||||
|
|
||||||
|
import ConfirmDialog from 'components/ConfirmDialog';
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import { getPluginUrl } from 'utils/dashboard';
|
||||||
|
|
||||||
|
interface AlertMessage {
|
||||||
|
severity?: 'success' | 'info' | 'warning' | 'error'
|
||||||
|
messageKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugins from this url will be trusted and not prompt for confirmation when installing
|
||||||
|
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
|
||||||
|
|
||||||
|
const PluginPage: FC = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
const { pluginId } = useParams();
|
||||||
|
const [ searchParams ] = useSearchParams();
|
||||||
|
const disablePlugin = useDisablePlugin();
|
||||||
|
const enablePlugin = useEnablePlugin();
|
||||||
|
const installPlugin = useInstallPackage();
|
||||||
|
const uninstallPlugin = useUninstallPlugin();
|
||||||
|
|
||||||
|
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||||
|
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||||
|
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||||
|
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||||
|
|
||||||
|
const pluginName = searchParams.get('name') ?? undefined;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: configurationPages,
|
||||||
|
isError: isConfigurationPagesError,
|
||||||
|
isLoading: isConfigurationPagesLoading
|
||||||
|
} = useConfigurationPages();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: packageInfo,
|
||||||
|
isError: isPackageInfoError,
|
||||||
|
isLoading: isPackageInfoLoading
|
||||||
|
} = usePackageInfo(pluginName ? {
|
||||||
|
name: pluginName,
|
||||||
|
assemblyGuid: pluginId
|
||||||
|
} : undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: plugins,
|
||||||
|
isLoading: isPluginsLoading,
|
||||||
|
isError: isPluginsError
|
||||||
|
} = usePlugins();
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading;
|
||||||
|
|
||||||
|
const pluginDetails = useMemo<PluginDetails | undefined>(() => {
|
||||||
|
if (pluginId && !isPluginsLoading) {
|
||||||
|
const pluginInfo = findBestPluginInfo(pluginId, plugins);
|
||||||
|
|
||||||
|
let version;
|
||||||
|
if (pluginInfo) {
|
||||||
|
// Find the installed version
|
||||||
|
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
|
||||||
|
version = repoVersion || {
|
||||||
|
version: pluginInfo.Version,
|
||||||
|
VersionNumber: pluginInfo.Version
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Use the latest version
|
||||||
|
version = packageInfo?.versions?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageUrl;
|
||||||
|
if (pluginInfo?.HasImage) {
|
||||||
|
imageUrl = api?.getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUninstall: !!pluginInfo?.CanUninstall,
|
||||||
|
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||||
|
id: pluginId,
|
||||||
|
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
|
||||||
|
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
|
||||||
|
?? pluginInfo?.Status !== PluginStatus.Disabled,
|
||||||
|
name: pluginName || pluginInfo?.Name || packageInfo?.name,
|
||||||
|
owner: packageInfo?.owner,
|
||||||
|
status: pluginInfo?.Status,
|
||||||
|
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
|
||||||
|
version,
|
||||||
|
versions: packageInfo?.versions || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
configurationPages,
|
||||||
|
isEnabledOverride,
|
||||||
|
isPluginsLoading,
|
||||||
|
packageInfo?.description,
|
||||||
|
packageInfo?.imageUrl,
|
||||||
|
packageInfo?.name,
|
||||||
|
packageInfo?.overview,
|
||||||
|
packageInfo?.owner,
|
||||||
|
packageInfo?.versions,
|
||||||
|
pluginId,
|
||||||
|
pluginName,
|
||||||
|
plugins
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alertMessages = useMemo(() => {
|
||||||
|
const alerts: AlertMessage[] = [];
|
||||||
|
|
||||||
|
if (disablePlugin.isError) {
|
||||||
|
alerts.push({ messageKey: 'PluginDisableError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enablePlugin.isError) {
|
||||||
|
alerts.push({ messageKey: 'PluginEnableError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installPlugin.isSuccess) {
|
||||||
|
alerts.push({
|
||||||
|
severity: 'success',
|
||||||
|
messageKey: 'MessagePluginInstalled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installPlugin.isError) {
|
||||||
|
alerts.push({ messageKey: 'MessagePluginInstallError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uninstallPlugin.isError) {
|
||||||
|
alerts.push({ messageKey: 'PluginUninstallError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConfigurationPagesError) {
|
||||||
|
alerts.push({ messageKey: 'PluginLoadConfigError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPackageInfoError) {
|
||||||
|
alerts.push({
|
||||||
|
severity: 'warning',
|
||||||
|
messageKey: 'PluginLoadRepoError'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPluginsError) {
|
||||||
|
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}, [
|
||||||
|
disablePlugin.isError,
|
||||||
|
enablePlugin.isError,
|
||||||
|
installPlugin.isError,
|
||||||
|
installPlugin.isSuccess,
|
||||||
|
isConfigurationPagesError,
|
||||||
|
isPackageInfoError,
|
||||||
|
isPluginsError,
|
||||||
|
uninstallPlugin.isError
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Enable/disable the plugin */
|
||||||
|
const toggleEnabled = useCallback(() => {
|
||||||
|
if (!pluginDetails?.version?.version) return;
|
||||||
|
|
||||||
|
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
|
||||||
|
|
||||||
|
if (pluginDetails.isEnabled) {
|
||||||
|
disablePlugin.mutate({
|
||||||
|
pluginId: pluginDetails.id,
|
||||||
|
version: pluginDetails.version.version
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEnabledOverride(false);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
installPlugin.reset();
|
||||||
|
enablePlugin.reset();
|
||||||
|
uninstallPlugin.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
enablePlugin.mutate({
|
||||||
|
pluginId: pluginDetails.id,
|
||||||
|
version: pluginDetails.version.version
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEnabledOverride(true);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
installPlugin.reset();
|
||||||
|
disablePlugin.reset();
|
||||||
|
uninstallPlugin.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||||
|
|
||||||
|
/** Install the plugin or prompt for confirmation if untrusted */
|
||||||
|
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
|
||||||
|
if (!pluginDetails?.name) return;
|
||||||
|
const installVersion = version || pluginDetails.version;
|
||||||
|
if (!installVersion) return;
|
||||||
|
|
||||||
|
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
|
||||||
|
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
|
||||||
|
setPendingInstallVersion(installVersion);
|
||||||
|
setIsInstallConfirmOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[PluginPage] installing plugin', installVersion);
|
||||||
|
|
||||||
|
installPlugin.mutate({
|
||||||
|
name: pluginDetails.name,
|
||||||
|
assemblyGuid: pluginDetails.id,
|
||||||
|
version: installVersion.version,
|
||||||
|
repositoryUrl: installVersion.repositoryUrl
|
||||||
|
}, {
|
||||||
|
onSettled: () => {
|
||||||
|
setPendingInstallVersion(undefined);
|
||||||
|
disablePlugin.reset();
|
||||||
|
enablePlugin.reset();
|
||||||
|
uninstallPlugin.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||||
|
|
||||||
|
/** Confirm and install the plugin */
|
||||||
|
const onConfirmInstall = useCallback(() => {
|
||||||
|
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
|
||||||
|
setIsInstallConfirmOpen(false);
|
||||||
|
onInstall(pendingInstallVersion, true)();
|
||||||
|
}, [ onInstall, pendingInstallVersion ]);
|
||||||
|
|
||||||
|
/** Close the install confirmation dialog */
|
||||||
|
const onCloseInstallConfirmDialog = useCallback(() => {
|
||||||
|
setPendingInstallVersion(undefined);
|
||||||
|
setIsInstallConfirmOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Show the uninstall confirmation dialog */
|
||||||
|
const onConfirmUninstall = useCallback(() => {
|
||||||
|
setIsUninstallConfirmOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Uninstall the plugin */
|
||||||
|
const onUninstall = useCallback(() => {
|
||||||
|
if (!pluginDetails?.version?.version) return;
|
||||||
|
|
||||||
|
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
|
||||||
|
|
||||||
|
setIsUninstallConfirmOpen(false);
|
||||||
|
|
||||||
|
uninstallPlugin.mutate({
|
||||||
|
pluginId: pluginDetails.id,
|
||||||
|
version: pluginDetails.version.version
|
||||||
|
}, {
|
||||||
|
onSettled: () => {
|
||||||
|
disablePlugin.reset();
|
||||||
|
enablePlugin.reset();
|
||||||
|
installPlugin.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||||
|
|
||||||
|
/** Close the uninstall confirmation dialog */
|
||||||
|
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||||
|
setIsUninstallConfirmOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
id='addPluginPage'
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<Container className='content-primary'>
|
||||||
|
|
||||||
|
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||||
|
<Alert key={messageKey} severity={severity}>
|
||||||
|
{globalize.translate(messageKey)}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
|
<Grid item xs={12} lg={8}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant='h1'>
|
||||||
|
{pluginDetails?.name || pluginName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography sx={{ maxWidth: '80ch' }}>
|
||||||
|
{isLoading && !pluginDetails?.description ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : (
|
||||||
|
pluginDetails?.description
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||||
|
<PluginImage
|
||||||
|
isLoading={isLoading}
|
||||||
|
alt={pluginDetails?.name}
|
||||||
|
url={pluginDetails?.imageUrl}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||||
|
{!!pluginDetails?.versions.length && (
|
||||||
|
<>
|
||||||
|
<Typography variant='h3' sx={{ marginBottom: 2 }}>
|
||||||
|
{globalize.translate('HeaderRevisionHistory')}
|
||||||
|
</Typography>
|
||||||
|
<PluginRevisions
|
||||||
|
pluginDetails={pluginDetails}
|
||||||
|
onInstall={onInstall}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} lg={4}>
|
||||||
|
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
|
||||||
|
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
|
||||||
|
{!isLoading && !pluginDetails?.status && (
|
||||||
|
<>
|
||||||
|
<Alert severity='info'>
|
||||||
|
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<Download />}
|
||||||
|
onClick={onInstall()}
|
||||||
|
>
|
||||||
|
{globalize.translate('HeaderInstall')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && pluginDetails?.canUninstall && (
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={pluginDetails.isEnabled}
|
||||||
|
onChange={toggleEnabled}
|
||||||
|
disabled={pluginDetails.status === PluginStatus.Restart}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={globalize.translate('LabelEnablePlugin')}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && pluginDetails?.configurationPage?.Name && (
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/${getPluginUrl(pluginDetails.configurationPage.Name)}`}
|
||||||
|
startIcon={<Settings />}
|
||||||
|
>
|
||||||
|
{globalize.translate('Settings')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && pluginDetails?.canUninstall && (
|
||||||
|
<Button
|
||||||
|
color='error'
|
||||||
|
startIcon={<Delete />}
|
||||||
|
onClick={onConfirmUninstall}
|
||||||
|
>
|
||||||
|
{globalize.translate('ButtonUninstall')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<PluginDetailsTable
|
||||||
|
isPluginLoading={isPluginsLoading}
|
||||||
|
isRepositoryLoading={isPackageInfoLoading}
|
||||||
|
pluginDetails={pluginDetails}
|
||||||
|
sx={{ flexBasis: '50%' }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={isInstallConfirmOpen}
|
||||||
|
title={globalize.translate('HeaderConfirmPluginInstallation')}
|
||||||
|
text={globalize.translate('MessagePluginInstallDisclaimer')}
|
||||||
|
onCancel={onCloseInstallConfirmDialog}
|
||||||
|
onConfirm={onConfirmInstall}
|
||||||
|
confirmButtonText={globalize.translate('HeaderInstall')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={isUninstallConfirmOpen}
|
||||||
|
title={globalize.translate('HeaderUninstallPlugin')}
|
||||||
|
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
|
||||||
|
onCancel={onCloseUninstallConfirmDialog}
|
||||||
|
onConfirm={onUninstall}
|
||||||
|
confirmButtonColor='error'
|
||||||
|
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginPage;
|
|
@ -1,12 +1,13 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RouteObject } from 'react-router-dom';
|
import { RouteObject } from 'react-router-dom';
|
||||||
import AppLayout from '../AppLayout';
|
|
||||||
import ConnectionRequired from 'components/ConnectionRequired';
|
import ConnectionRequired from 'components/ConnectionRequired';
|
||||||
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
|
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
|
||||||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||||
import { LEGACY_ADMIN_ROUTES } from './_legacyRoutes';
|
import { LEGACY_ADMIN_ROUTES } from './_legacyRoutes';
|
||||||
import ServerContentPage from 'components/ServerContentPage';
|
import ServerContentPage from 'components/ServerContentPage';
|
||||||
|
import ErrorBoundary from 'components/router/ErrorBoundary';
|
||||||
|
|
||||||
export const DASHBOARD_APP_PATHS = {
|
export const DASHBOARD_APP_PATHS = {
|
||||||
Dashboard: 'dashboard',
|
Dashboard: 'dashboard',
|
||||||
|
@ -19,14 +20,15 @@ export const DASHBOARD_APP_ROUTES: RouteObject[] = [
|
||||||
element: <ConnectionRequired isAdminRequired />,
|
element: <ConnectionRequired isAdminRequired />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <AppLayout drawerlessPaths={[ DASHBOARD_APP_PATHS.MetadataManager ]} />,
|
lazy: () => import('../AppLayout'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: DASHBOARD_APP_PATHS.Dashboard,
|
path: DASHBOARD_APP_PATHS.Dashboard,
|
||||||
children: [
|
children: [
|
||||||
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
|
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
|
||||||
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
|
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
|
||||||
]
|
],
|
||||||
|
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
|
||||||
},
|
},
|
||||||
|
|
||||||
/* NOTE: The metadata editor might deserve a dedicated app in the future */
|
/* NOTE: The metadata editor might deserve a dedicated app in the future */
|
||||||
|
|
|
@ -1,30 +1,33 @@
|
||||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { BaseItemDto, DeviceInfoDto, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import libraryMenu from '../../../../scripts/libraryMenu';
|
import globalize from '../../../../lib/globalize';
|
||||||
import globalize from '../../../../scripts/globalize';
|
|
||||||
import toast from '../../../../components/toast/toast';
|
import toast from '../../../../components/toast/toast';
|
||||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
import ButtonElement from '../../../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
import { getParameterByName } from '../../../../utils/url';
|
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type ItemsArr = {
|
type ItemsArr = {
|
||||||
Name?: string;
|
Name?: string | null;
|
||||||
Id?: string;
|
Id?: string | null;
|
||||||
AppName?: string;
|
AppName?: string | null;
|
||||||
|
CustomName?: string | null;
|
||||||
checkedAttribute?: string
|
checkedAttribute?: string
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserLibraryAccess: FunctionComponent = () => {
|
const UserLibraryAccess = () => {
|
||||||
|
const [ searchParams ] = useSearchParams();
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ userName, setUserName ] = useState('');
|
||||||
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
||||||
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
||||||
const [devicesItems, setDevicesItems] = useState<ItemsArr[]>([]);
|
const [devicesItems, setDevicesItems] = useState<ItemsArr[]>([]);
|
||||||
|
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||||
|
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
@ -33,18 +36,18 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
select.dispatchEvent(evt);
|
select.dispatchEvent(evt);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMediaFolders = useCallback((user, mediaFolders) => {
|
const loadMediaFolders = useCallback((user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsArr: ItemsArr[] = [];
|
const itemsArr: ItemsArr[] = [];
|
||||||
|
|
||||||
for (const folder of mediaFolders) {
|
for (const folder of mediaFolders) {
|
||||||
const isChecked = user.Policy.EnableAllFolders || user.Policy.EnabledFolders.indexOf(folder.Id) != -1;
|
const isChecked = user.Policy?.EnableAllFolders || user.Policy?.EnabledFolders?.indexOf(folder.Id || '') != -1;
|
||||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
itemsArr.push({
|
||||||
Id: folder.Id,
|
Id: folder.Id,
|
||||||
|
@ -56,22 +59,22 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
setMediaFoldersItems(itemsArr);
|
setMediaFoldersItems(itemsArr);
|
||||||
|
|
||||||
const chkEnableAllFolders = page.querySelector('.chkEnableAllFolders') as HTMLInputElement;
|
const chkEnableAllFolders = page.querySelector('.chkEnableAllFolders') as HTMLInputElement;
|
||||||
chkEnableAllFolders.checked = user.Policy.EnableAllFolders;
|
chkEnableAllFolders.checked = Boolean(user.Policy?.EnableAllFolders);
|
||||||
triggerChange(chkEnableAllFolders);
|
triggerChange(chkEnableAllFolders);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChannels = useCallback((user, channels) => {
|
const loadChannels = useCallback((user: UserDto, channels: BaseItemDto[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsArr: ItemsArr[] = [];
|
const itemsArr: ItemsArr[] = [];
|
||||||
|
|
||||||
for (const folder of channels) {
|
for (const folder of channels) {
|
||||||
const isChecked = user.Policy.EnableAllChannels || user.Policy.EnabledChannels.indexOf(folder.Id) != -1;
|
const isChecked = user.Policy?.EnableAllChannels || user.Policy?.EnabledChannels?.indexOf(folder.Id || '') != -1;
|
||||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
itemsArr.push({
|
||||||
Id: folder.Id,
|
Id: folder.Id,
|
||||||
|
@ -89,27 +92,28 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const chkEnableAllChannels = page.querySelector('.chkEnableAllChannels') as HTMLInputElement;
|
const chkEnableAllChannels = page.querySelector('.chkEnableAllChannels') as HTMLInputElement;
|
||||||
chkEnableAllChannels.checked = user.Policy.EnableAllChannels;
|
chkEnableAllChannels.checked = Boolean(user.Policy?.EnableAllChannels);
|
||||||
triggerChange(chkEnableAllChannels);
|
triggerChange(chkEnableAllChannels);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback((user, devices) => {
|
const loadDevices = useCallback((user: UserDto, devices: DeviceInfoDto[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsArr: ItemsArr[] = [];
|
const itemsArr: ItemsArr[] = [];
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
const isChecked = user.Policy.EnableAllDevices || user.Policy.EnabledDevices.indexOf(device.Id) != -1;
|
const isChecked = user.Policy?.EnableAllDevices || user.Policy?.EnabledDevices?.indexOf(device.Id || '') != -1;
|
||||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
itemsArr.push({
|
||||||
Id: device.Id,
|
Id: device.Id,
|
||||||
Name: device.Name,
|
Name: device.Name,
|
||||||
AppName: device.AppName,
|
AppName: device.AppName,
|
||||||
|
CustomName: device.CustomName,
|
||||||
checkedAttribute: checkedAttribute
|
checkedAttribute: checkedAttribute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -117,19 +121,19 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
setDevicesItems(itemsArr);
|
setDevicesItems(itemsArr);
|
||||||
|
|
||||||
const chkEnableAllDevices = page.querySelector('.chkEnableAllDevices') as HTMLInputElement;
|
const chkEnableAllDevices = page.querySelector('.chkEnableAllDevices') as HTMLInputElement;
|
||||||
chkEnableAllDevices.checked = user.Policy.EnableAllDevices;
|
chkEnableAllDevices.checked = Boolean(user.Policy?.EnableAllDevices);
|
||||||
triggerChange(chkEnableAllDevices);
|
triggerChange(chkEnableAllDevices);
|
||||||
|
|
||||||
if (user.Policy.IsAdministrator) {
|
if (user.Policy?.IsAdministrator) {
|
||||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.add('hide');
|
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.add('hide');
|
||||||
} else {
|
} else {
|
||||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.remove('hide');
|
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.remove('hide');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUser = useCallback((user, mediaFolders, channels, devices) => {
|
const loadUser = useCallback((user: UserDto, mediaFolders: BaseItemDto[], channels: BaseItemDto[], devices: DeviceInfoDto[]) => {
|
||||||
setUserName(user.Name);
|
setUserName(user.Name || '');
|
||||||
libraryMenu.setTitle(user.Name);
|
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||||
loadChannels(user, channels);
|
loadChannels(user, channels);
|
||||||
loadMediaFolders(user, mediaFolders);
|
loadMediaFolders(user, mediaFolders);
|
||||||
loadDevices(user, devices);
|
loadDevices(user, devices);
|
||||||
|
@ -138,7 +142,6 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
loading.show();
|
loading.show();
|
||||||
const userId = getParameterByName('userId');
|
|
||||||
const promise1 = userId ? window.ApiClient.getUser(userId) : Promise.resolve({ Configuration: {} });
|
const promise1 = userId ? window.ApiClient.getUser(userId) : Promise.resolve({ Configuration: {} });
|
||||||
const promise2 = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
const promise2 = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||||
IsHidden: false
|
IsHidden: false
|
||||||
|
@ -150,21 +153,25 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[userlibraryaccess] failed to load data', err);
|
console.error('[userlibraryaccess] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadUser]);
|
}, [loadUser, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userlibraryaccess] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
const onSubmit = (e: Event) => {
|
const onSubmit = (e: Event) => {
|
||||||
|
if (!userId) {
|
||||||
|
console.error('[userlibraryaccess] missing user id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.show();
|
loading.show();
|
||||||
const userId = getParameterByName('userId');
|
|
||||||
window.ApiClient.getUser(userId).then(function (result) {
|
window.ApiClient.getUser(userId).then(function (result) {
|
||||||
saveUser(result);
|
saveUser(result);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
@ -240,7 +247,6 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={userName}
|
title={userName}
|
||||||
url='https://jellyfin.org/docs/general/server/users/'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionTabs activeTab='userlibraryaccess'/>
|
<SectionTabs activeTab='userlibraryaccess'/>
|
||||||
|
@ -302,7 +308,7 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
key={Item.Id}
|
key={Item.Id}
|
||||||
className='chkDevice'
|
className='chkDevice'
|
||||||
itemId={Item.Id}
|
itemId={Item.Id}
|
||||||
itemName={Item.Name}
|
itemName={Item.CustomName || Item.Name}
|
||||||
itemAppName={Item.AppName}
|
itemAppName={Item.AppName}
|
||||||
itemCheckedAttribute={Item.checkedAttribute}
|
itemCheckedAttribute={Item.checkedAttribute}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
import Dashboard from '../../../../utils/dashboard';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../lib/globalize';
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import toast from '../../../../components/toast/toast';
|
import toast from '../../../../components/toast/toast';
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
|
@ -11,22 +12,22 @@ import AccessContainer from '../../../../components/dashboard/users/AccessContai
|
||||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type userInput = {
|
type UserInput = {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
Password?: string;
|
Password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ItemsArr = {
|
type ItemsArr = {
|
||||||
Name?: string;
|
Name?: string | null;
|
||||||
Id?: string;
|
Id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserNew: FunctionComponent = () => {
|
const UserNew = () => {
|
||||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getItemsResult = (items: ItemsArr[]) => {
|
const getItemsResult = (items: BaseItemDto[]) => {
|
||||||
return items.map(item =>
|
return items.map(item =>
|
||||||
({
|
({
|
||||||
Id: item.Id,
|
Id: item.Id,
|
||||||
|
@ -35,7 +36,7 @@ const UserNew: FunctionComponent = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMediaFolders = useCallback((result) => {
|
const loadMediaFolders = useCallback((result: BaseItemDto[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
|
@ -53,7 +54,7 @@ const UserNew: FunctionComponent = () => {
|
||||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked = false;
|
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChannels = useCallback((result) => {
|
const loadChannels = useCallback((result: BaseItemDto[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
|
@ -109,7 +110,7 @@ const UserNew: FunctionComponent = () => {
|
||||||
loadUser();
|
loadUser();
|
||||||
|
|
||||||
const saveUser = () => {
|
const saveUser = () => {
|
||||||
const userInput: userInput = {};
|
const userInput: UserInput = {};
|
||||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
|
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
|
||||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||||
window.ApiClient.createUser(userInput).then(function (user) {
|
window.ApiClient.createUser(userInput).then(function (user) {
|
||||||
|
@ -187,7 +188,6 @@ const UserNew: FunctionComponent = () => {
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={globalize.translate('HeaderAddUser')}
|
title={globalize.translate('HeaderAddUser')}
|
||||||
url='https://jellyfin.org/docs/general/server/users/'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FunctionComponent, useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
import Dashboard from '../../../../utils/dashboard';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../lib/globalize';
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import dom from '../../../../scripts/dom';
|
import dom from '../../../../scripts/dom';
|
||||||
import confirm from '../../../../components/confirm/confirm';
|
import confirm from '../../../../components/confirm/confirm';
|
||||||
|
@ -21,7 +21,7 @@ type MenuEntry = {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserProfiles: FunctionComponent = () => {
|
const UserProfiles = () => {
|
||||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||||
|
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
@ -159,6 +159,7 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
<Page
|
<Page
|
||||||
id='userProfilesPage'
|
id='userProfilesPage'
|
||||||
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
|
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
|
||||||
|
title={globalize.translate('HeaderUsers')}
|
||||||
>
|
>
|
||||||
<div ref={element} className='content-primary'>
|
<div ref={element} className='content-primary'>
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
|
@ -169,7 +170,6 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
btnClassName='fab submit sectionTitleButton'
|
btnClassName='fab submit sectionTitleButton'
|
||||||
btnTitle='ButtonAddUser'
|
btnTitle='ButtonAddUser'
|
||||||
btnIcon='add'
|
btnIcon='add'
|
||||||
url='https://jellyfin.org/docs/general/server/users/adding-managing-users'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,38 +1,79 @@
|
||||||
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { UnratedItem } from '@jellyfin/sdk/lib/generated-client/models/unrated-item';
|
||||||
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
|
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
|
||||||
import escapeHTML from 'escape-html';
|
import escapeHTML from 'escape-html';
|
||||||
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../lib/globalize';
|
||||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
|
||||||
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
||||||
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList';
|
import TagList from '../../../../components/dashboard/users/TagList';
|
||||||
import ButtonElement from '../../../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import toast from '../../../../components/toast/toast';
|
import toast from '../../../../components/toast/toast';
|
||||||
import { getParameterByName } from '../../../../utils/url';
|
|
||||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
import SelectElement from '../../../../elements/SelectElement';
|
import SelectElement from '../../../../elements/SelectElement';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
|
import prompt from '../../../../components/prompt/prompt';
|
||||||
|
import ServerConnections from 'components/ServerConnections';
|
||||||
|
|
||||||
type UnratedItem = {
|
type NamedItem = {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: UnratedItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnratedNamedItem = NamedItem & {
|
||||||
checkedAttribute: string
|
checkedAttribute: string
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserParentalControl: FunctionComponent = () => {
|
function handleSaveUser(
|
||||||
|
page: HTMLDivElement,
|
||||||
|
getSchedulesFromPage: () => AccessSchedule[],
|
||||||
|
getAllowedTagsFromPage: () => string[],
|
||||||
|
getBlockedTagsFromPage: () => string[],
|
||||||
|
onSaveComplete: () => void
|
||||||
|
) {
|
||||||
|
return (user: UserDto) => {
|
||||||
|
const userId = user.Id;
|
||||||
|
const userPolicy = user.Policy;
|
||||||
|
if (!userId || !userPolicy) {
|
||||||
|
throw new Error('Unexpected null user id or policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||||
|
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||||
|
userPolicy.BlockUnratedItems = Array.prototype.filter
|
||||||
|
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
|
||||||
|
.map(i => i.getAttribute('data-itemtype'));
|
||||||
|
userPolicy.AccessSchedules = getSchedulesFromPage();
|
||||||
|
userPolicy.AllowedTags = getAllowedTagsFromPage();
|
||||||
|
userPolicy.BlockedTags = getBlockedTagsFromPage();
|
||||||
|
ServerConnections.getCurrentApiClientAsync()
|
||||||
|
.then(apiClient => apiClient.updateUserPolicy(userId, userPolicy))
|
||||||
|
.then(() => onSaveComplete())
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[userparentalcontrol] failed to update user policy', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserParentalControl = () => {
|
||||||
|
const [ searchParams ] = useSearchParams();
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ userName, setUserName ] = useState('');
|
||||||
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
|
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
|
||||||
const [ unratedItems, setUnratedItems ] = useState<UnratedItem[]>([]);
|
const [ unratedItems, setUnratedItems ] = useState<UnratedNamedItem[]>([]);
|
||||||
|
const [ maxParentalRating, setMaxParentalRating ] = useState<string>();
|
||||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||||
const [ blockedTags, setBlockedTags ] = useState([]);
|
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||||
|
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||||
|
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||||
|
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const populateRatings = useCallback((allParentalRatings) => {
|
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
|
||||||
let rating;
|
let rating;
|
||||||
const ratings: ParentalRating[] = [];
|
const ratings: ParentalRating[] = [];
|
||||||
|
|
||||||
|
@ -57,142 +98,96 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
setParentalRatings(ratings);
|
setParentalRatings(ratings);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUnratedItems = useCallback((user) => {
|
const loadUnratedItems = useCallback((user: UserDto) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [{
|
const items: NamedItem[] = [{
|
||||||
name: globalize.translate('Books'),
|
name: globalize.translate('Books'),
|
||||||
value: 'Book'
|
value: UnratedItem.Book
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('Channels'),
|
name: globalize.translate('Channels'),
|
||||||
value: 'ChannelContent'
|
value: UnratedItem.ChannelContent
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('LiveTV'),
|
name: globalize.translate('LiveTV'),
|
||||||
value: 'LiveTvChannel'
|
value: UnratedItem.LiveTvChannel
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('Movies'),
|
name: globalize.translate('Movies'),
|
||||||
value: 'Movie'
|
value: UnratedItem.Movie
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('Music'),
|
name: globalize.translate('Music'),
|
||||||
value: 'Music'
|
value: UnratedItem.Music
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('Trailers'),
|
name: globalize.translate('Trailers'),
|
||||||
value: 'Trailer'
|
value: UnratedItem.Trailer
|
||||||
}, {
|
}, {
|
||||||
name: globalize.translate('Shows'),
|
name: globalize.translate('Shows'),
|
||||||
value: 'Series'
|
value: UnratedItem.Series
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const itemsArr: UnratedItem[] = [];
|
const unratedNamedItem: UnratedNamedItem[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const isChecked = user.Policy.BlockUnratedItems.indexOf(item.value) != -1;
|
const isChecked = user.Policy?.BlockUnratedItems?.indexOf(item.value) != -1;
|
||||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
unratedNamedItem.push({
|
||||||
value: item.value,
|
value: item.value,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
checkedAttribute: checkedAttribute
|
checkedAttribute: checkedAttribute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnratedItems(itemsArr);
|
setUnratedItems(unratedNamedItem);
|
||||||
|
|
||||||
const blockUnratedItems = page.querySelector('.blockUnratedItems') as HTMLDivElement;
|
const blockUnratedItems = page.querySelector('.blockUnratedItems') as HTMLDivElement;
|
||||||
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
|
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadBlockedTags = useCallback((tags) => {
|
const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlockedTags(tags);
|
setUserName(user.Name || '');
|
||||||
|
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||||
const blockedTagsElem = page.querySelector('.blockedTags') as HTMLDivElement;
|
|
||||||
|
|
||||||
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
|
||||||
btnDeleteTag.addEventListener('click', function () {
|
|
||||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
|
||||||
const newTags = tags.filter(function (t: string) {
|
|
||||||
return t != tag;
|
|
||||||
});
|
|
||||||
loadBlockedTags(newTags);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderAccessSchedule = useCallback((schedules) => {
|
|
||||||
const page = element.current;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
console.error('Unexpected null reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAccessSchedules(schedules);
|
|
||||||
|
|
||||||
const accessScheduleList = page.querySelector('.accessScheduleList') as HTMLDivElement;
|
|
||||||
|
|
||||||
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
|
|
||||||
btnDelete.addEventListener('click', function () {
|
|
||||||
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
|
|
||||||
schedules.splice(index, 1);
|
|
||||||
const newindex = schedules.filter(function (i: number) {
|
|
||||||
return i != index;
|
|
||||||
});
|
|
||||||
renderAccessSchedule(newindex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadUser = useCallback((user, allParentalRatings) => {
|
|
||||||
const page = element.current;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
console.error('Unexpected null reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserName(user.Name);
|
|
||||||
LibraryMenu.setTitle(user.Name);
|
|
||||||
loadUnratedItems(user);
|
loadUnratedItems(user);
|
||||||
|
|
||||||
loadBlockedTags(user.Policy.BlockedTags);
|
setAllowedTags(user.Policy?.AllowedTags || []);
|
||||||
|
setBlockedTags(user.Policy?.BlockedTags || []);
|
||||||
populateRatings(allParentalRatings);
|
populateRatings(allParentalRatings);
|
||||||
|
|
||||||
let ratingValue = '';
|
let ratingValue = '';
|
||||||
|
allParentalRatings.forEach(rating => {
|
||||||
if (user.Policy.MaxParentalRating != null) {
|
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
|
||||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
ratingValue = `${rating.Value}`;
|
||||||
const rating = allParentalRatings[i];
|
|
||||||
|
|
||||||
if (user.Policy.MaxParentalRating >= rating.Value) {
|
|
||||||
ratingValue = rating.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = ratingValue;
|
setMaxParentalRating(ratingValue);
|
||||||
|
|
||||||
if (user.Policy.IsAdministrator) {
|
if (user.Policy?.IsAdministrator) {
|
||||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||||
} else {
|
} else {
|
||||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
|
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
|
||||||
}
|
}
|
||||||
renderAccessSchedule(user.Policy.AccessSchedules || []);
|
setAccessSchedules(user.Policy?.AccessSchedules || []);
|
||||||
loading.hide();
|
loading.hide();
|
||||||
}, [loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
|
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
|
if (!userId) {
|
||||||
|
console.error('[userparentalcontrol.loadData] missing user id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.show();
|
loading.show();
|
||||||
const userId = getParameterByName('userId');
|
|
||||||
const promise1 = window.ApiClient.getUser(userId);
|
const promise1 = window.ApiClient.getUser(userId);
|
||||||
const promise2 = window.ApiClient.getParentalRatings();
|
const promise2 = window.ApiClient.getParentalRatings();
|
||||||
Promise.all([promise1, promise2]).then(function (responses) {
|
Promise.all([promise1, promise2]).then(function (responses) {
|
||||||
|
@ -200,44 +195,18 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[userparentalcontrol] failed to load data', err);
|
console.error('[userparentalcontrol] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadUser]);
|
}, [loadUser, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
const onSaveComplete = () => {
|
|
||||||
loading.hide();
|
|
||||||
toast(globalize.translate('SettingsSaved'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUser = (user: UserDto) => {
|
|
||||||
if (!user.Id || !user.Policy) {
|
|
||||||
throw new Error('Unexpected null user id or policy');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
|
||||||
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
|
||||||
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
|
|
||||||
return i.checked;
|
|
||||||
}).map(function (i) {
|
|
||||||
return i.getAttribute('data-itemtype');
|
|
||||||
});
|
|
||||||
user.Policy.AccessSchedules = getSchedulesFromPage();
|
|
||||||
user.Policy.BlockedTags = getBlockedTagsFromPage();
|
|
||||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
|
||||||
onSaveComplete();
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[userparentalcontrol] failed to update user policy', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
|
const showSchedulePopup = (schedule: AccessSchedule, index: number) => {
|
||||||
schedule = schedule || {};
|
schedule = schedule || {};
|
||||||
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
|
import('../../../../components/accessSchedule/accessSchedule').then(({ default: accessschedule }) => {
|
||||||
|
@ -251,7 +220,7 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
schedules[index] = updatedSchedule;
|
schedules[index] = updatedSchedule;
|
||||||
renderAccessSchedule(schedules);
|
setAccessSchedules(schedules);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// access schedule closed
|
// access schedule closed
|
||||||
});
|
});
|
||||||
|
@ -270,6 +239,27 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
}) as AccessSchedule[];
|
}) as AccessSchedule[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAllowedTagsFromPage = () => {
|
||||||
|
return Array.prototype.map.call(page.querySelectorAll('.allowedTag'), function (elem) {
|
||||||
|
return elem.getAttribute('data-tag');
|
||||||
|
}) as string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAllowedTagPopup = () => {
|
||||||
|
prompt({
|
||||||
|
label: globalize.translate('LabelTag')
|
||||||
|
}).then(function (value) {
|
||||||
|
const tags = getAllowedTagsFromPage();
|
||||||
|
|
||||||
|
if (tags.indexOf(value) == -1) {
|
||||||
|
tags.push(value);
|
||||||
|
setAllowedTags(tags);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// prompt closed
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getBlockedTagsFromPage = () => {
|
const getBlockedTagsFromPage = () => {
|
||||||
return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) {
|
return Array.prototype.map.call(page.querySelectorAll('.blockedTag'), function (elem) {
|
||||||
return elem.getAttribute('data-tag');
|
return elem.getAttribute('data-tag');
|
||||||
|
@ -277,7 +267,6 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const showBlockedTagPopup = () => {
|
const showBlockedTagPopup = () => {
|
||||||
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
|
|
||||||
prompt({
|
prompt({
|
||||||
label: globalize.translate('LabelTag')
|
label: globalize.translate('LabelTag')
|
||||||
}).then(function (value) {
|
}).then(function (value) {
|
||||||
|
@ -285,19 +274,27 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
|
|
||||||
if (tags.indexOf(value) == -1) {
|
if (tags.indexOf(value) == -1) {
|
||||||
tags.push(value);
|
tags.push(value);
|
||||||
loadBlockedTags(tags);
|
setBlockedTags(tags);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// prompt closed
|
// prompt closed
|
||||||
});
|
});
|
||||||
}).catch(err => {
|
|
||||||
console.error('[userparentalcontrol] failed to load prompt', err);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSaveComplete = () => {
|
||||||
|
loading.hide();
|
||||||
|
toast(globalize.translate('SettingsSaved'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||||
|
|
||||||
const onSubmit = (e: Event) => {
|
const onSubmit = (e: Event) => {
|
||||||
|
if (!userId) {
|
||||||
|
console.error('[userparentalcontrol.onSubmit] missing user id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.show();
|
loading.show();
|
||||||
const userId = getParameterByName('userId');
|
|
||||||
window.ApiClient.getUser(userId).then(function (result) {
|
window.ApiClient.getUser(userId).then(function (result) {
|
||||||
saveUser(result);
|
saveUser(result);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
@ -308,7 +305,8 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', function () {
|
// The following is still hacky and should migrate to pure react implementation for callbacks in the future
|
||||||
|
const accessSchedulesPopupCallback = function () {
|
||||||
showSchedulePopup({
|
showSchedulePopup({
|
||||||
Id: 0,
|
Id: 0,
|
||||||
UserId: '',
|
UserId: '',
|
||||||
|
@ -316,24 +314,57 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
StartHour: 0,
|
StartHour: 0,
|
||||||
EndHour: 0
|
EndHour: 0
|
||||||
}, -1);
|
}, -1);
|
||||||
});
|
};
|
||||||
|
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', accessSchedulesPopupCallback);
|
||||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
|
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', showAllowedTagPopup);
|
||||||
showBlockedTagPopup();
|
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', showBlockedTagPopup);
|
||||||
});
|
|
||||||
|
|
||||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||||
}, [loadBlockedTags, loadData, renderAccessSchedule]);
|
|
||||||
|
return () => {
|
||||||
|
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).removeEventListener('click', accessSchedulesPopupCallback);
|
||||||
|
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).removeEventListener('click', showAllowedTagPopup);
|
||||||
|
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).removeEventListener('click', showBlockedTagPopup);
|
||||||
|
(page.querySelector('.userParentalControlForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||||
|
};
|
||||||
|
}, [setAllowedTags, setBlockedTags, loadData, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const page = element.current;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = String(maxParentalRating);
|
||||||
|
}, [maxParentalRating, parentalRatings]);
|
||||||
|
|
||||||
const optionMaxParentalRating = () => {
|
const optionMaxParentalRating = () => {
|
||||||
let content = '';
|
let content = '';
|
||||||
content += '<option value=\'\'></option>';
|
content += '<option value=\'\'></option>';
|
||||||
for (const rating of parentalRatings) {
|
for (const rating of parentalRatings) {
|
||||||
|
if (rating.Value != null) {
|
||||||
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
|
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeAllowedTagsCallback = useCallback((tag: string) => {
|
||||||
|
const newTags = allowedTags.filter(t => t !== tag);
|
||||||
|
setAllowedTags(newTags);
|
||||||
|
}, [allowedTags, setAllowedTags]);
|
||||||
|
|
||||||
|
const removeBlockedTagsTagsCallback = useCallback((tag: string) => {
|
||||||
|
const newTags = blockedTags.filter(t => t !== tag);
|
||||||
|
setBlockedTags(newTags);
|
||||||
|
}, [blockedTags, setBlockedTags]);
|
||||||
|
|
||||||
|
const removeScheduleCallback = useCallback((index: number) => {
|
||||||
|
const newSchedules = accessSchedules.filter((_e, i) => i != index);
|
||||||
|
setAccessSchedules(newSchedules);
|
||||||
|
}, [accessSchedules, setAccessSchedules]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
id='userParentalControlPage'
|
id='userParentalControlPage'
|
||||||
|
@ -343,7 +374,6 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={userName}
|
title={userName}
|
||||||
url='https://jellyfin.org/docs/general/server/users/'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionTabs activeTab='userparentalcontrol'/>
|
<SectionTabs activeTab='userparentalcontrol'/>
|
||||||
|
@ -378,6 +408,30 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||||
|
<SectionTitleContainer
|
||||||
|
SectionClassName='detailSectionHeader'
|
||||||
|
title={globalize.translate('LabelAllowContentWithTags')}
|
||||||
|
isBtnVisible={true}
|
||||||
|
btnId='btnAddAllowedTag'
|
||||||
|
btnClassName='fab submit sectionTitleButton'
|
||||||
|
btnTitle='Add'
|
||||||
|
btnIcon='add'
|
||||||
|
/>
|
||||||
|
<div className='fieldDescription'>
|
||||||
|
{globalize.translate('AllowContentWithTagsHelp')}
|
||||||
|
</div>
|
||||||
|
<div className='allowedTags' style={{ marginTop: '.5em' }}>
|
||||||
|
{allowedTags?.map(tag => {
|
||||||
|
return <TagList
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
tagType='allowedTag'
|
||||||
|
removeTagCallback={removeAllowedTagsCallback}
|
||||||
|
/>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
SectionClassName='detailSectionHeader'
|
SectionClassName='detailSectionHeader'
|
||||||
|
@ -387,13 +441,17 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
btnClassName='fab submit sectionTitleButton'
|
btnClassName='fab submit sectionTitleButton'
|
||||||
btnTitle='Add'
|
btnTitle='Add'
|
||||||
btnIcon='add'
|
btnIcon='add'
|
||||||
isLinkVisible={false}
|
|
||||||
/>
|
/>
|
||||||
|
<div className='fieldDescription'>
|
||||||
|
{globalize.translate('BlockContentWithTagsHelp')}
|
||||||
|
</div>
|
||||||
<div className='blockedTags' style={{ marginTop: '.5em' }}>
|
<div className='blockedTags' style={{ marginTop: '.5em' }}>
|
||||||
{blockedTags.map(tag => {
|
{blockedTags.map(tag => {
|
||||||
return <BlockedTagList
|
return <TagList
|
||||||
key={tag}
|
key={tag}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
|
tagType='blockedTag'
|
||||||
|
removeTagCallback={removeBlockedTagsTagsCallback}
|
||||||
/>;
|
/>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
@ -406,17 +464,17 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
btnClassName='fab submit sectionTitleButton'
|
btnClassName='fab submit sectionTitleButton'
|
||||||
btnTitle='Add'
|
btnTitle='Add'
|
||||||
btnIcon='add'
|
btnIcon='add'
|
||||||
isLinkVisible={false}
|
|
||||||
/>
|
/>
|
||||||
<p>{globalize.translate('HeaderAccessScheduleHelp')}</p>
|
<p>{globalize.translate('HeaderAccessScheduleHelp')}</p>
|
||||||
<div className='accessScheduleList paperList'>
|
<div className='accessScheduleList paperList'>
|
||||||
{accessSchedules.map((accessSchedule, index) => {
|
{accessSchedules.map((accessSchedule, index) => {
|
||||||
return <AccessScheduleList
|
return <AccessScheduleList
|
||||||
key={accessSchedule.Id}
|
key={`${accessSchedule.DayOfWeek}${accessSchedule.StartHour}${accessSchedule.EndHour}`}
|
||||||
index={index}
|
index={index}
|
||||||
DayOfWeek={accessSchedule.DayOfWeek}
|
DayOfWeek={accessSchedule.DayOfWeek}
|
||||||
StartHour={accessSchedule.StartHour}
|
StartHour={accessSchedule.StartHour}
|
||||||
EndHour={accessSchedule.EndHour}
|
EndHour={accessSchedule.EndHour}
|
||||||
|
removeScheduleCallback={removeScheduleCallback}
|
||||||
/>;
|
/>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,17 +1,23 @@
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||||
import { getParameterByName } from '../../../../utils/url';
|
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
|
|
||||||
const UserPassword: FunctionComponent = () => {
|
const UserPassword = () => {
|
||||||
const userId = getParameterByName('userId');
|
const [ searchParams ] = useSearchParams();
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ userName, setUserName ] = useState('');
|
||||||
|
|
||||||
const loadUser = useCallback(() => {
|
const loadUser = useCallback(() => {
|
||||||
|
if (!userId) {
|
||||||
|
console.error('[userpassword] missing user id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.show();
|
loading.show();
|
||||||
window.ApiClient.getUser(userId).then(function (user) {
|
window.ApiClient.getUser(userId).then(function (user) {
|
||||||
if (!user.Name) {
|
if (!user.Name) {
|
||||||
|
@ -36,7 +42,6 @@ const UserPassword: FunctionComponent = () => {
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={userName}
|
title={userName}
|
||||||
url='https://jellyfin.org/docs/general/server/users/'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SectionTabs activeTab='userpassword'/>
|
<SectionTabs activeTab='userpassword'/>
|
||||||
|
|
|
@ -1,31 +1,25 @@
|
||||||
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
|
||||||
import escapeHTML from 'escape-html';
|
import escapeHTML from 'escape-html';
|
||||||
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import Dashboard from '../../../../utils/dashboard';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import globalize from '../../../../scripts/globalize';
|
import globalize from '../../../../lib/globalize';
|
||||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
|
||||||
import ButtonElement from '../../../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
import InputElement from '../../../../elements/InputElement';
|
import InputElement from '../../../../elements/InputElement';
|
||||||
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
|
import LinkButton from '../../../../elements/emby-button/LinkButton';
|
||||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
import loading from '../../../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import toast from '../../../../components/toast/toast';
|
import toast from '../../../../components/toast/toast';
|
||||||
import { getParameterByName } from '../../../../utils/url';
|
|
||||||
import SelectElement from '../../../../elements/SelectElement';
|
import SelectElement from '../../../../elements/SelectElement';
|
||||||
import Page from '../../../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type ResetProvider = AuthProvider & {
|
type ResetProvider = BaseItemDto & {
|
||||||
checkedAttribute: string
|
checkedAttribute: string
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthProvider = {
|
|
||||||
Name?: string;
|
|
||||||
Id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||||
Array.prototype.filter.call(elements, e => e.checked)
|
Array.prototype.filter.call(elements, e => e.checked)
|
||||||
.map(e => e.getAttribute('data-id'))
|
.map(e => e.getAttribute('data-id'))
|
||||||
|
@ -40,11 +34,14 @@ function onSaveComplete() {
|
||||||
toast(globalize.translate('SettingsSaved'));
|
toast(globalize.translate('SettingsSaved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserEdit: FunctionComponent = () => {
|
const UserEdit = () => {
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ searchParams ] = useSearchParams();
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
const [ userDto, setUserDto ] = useState<UserDto>();
|
||||||
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
||||||
const [ authProviders, setAuthProviders ] = useState<AuthProvider[]>([]);
|
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
|
||||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<ResetProvider[]>([]);
|
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
|
||||||
|
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||||
|
|
||||||
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
||||||
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
||||||
|
@ -57,52 +54,31 @@ const UserEdit: FunctionComponent = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUser = () => {
|
const getUser = () => {
|
||||||
const userId = getParameterByName('userId');
|
if (!userId) throw new Error('missing user id');
|
||||||
return window.ApiClient.getUser(userId);
|
return window.ApiClient.getUser(userId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAuthProviders = useCallback((user, providers) => {
|
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||||
const page = element.current;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
console.error('Unexpected null reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||||
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||||
|
|
||||||
setAuthProviders(providers);
|
setAuthProviders(providers);
|
||||||
|
|
||||||
const currentProviderId = user.Policy.AuthenticationProviderId;
|
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
|
||||||
setAuthenticationProviderId(currentProviderId);
|
setAuthenticationProviderId(currentProviderId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadPasswordResetProviders = useCallback((user, providers) => {
|
const loadPasswordResetProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||||
const page = element.current;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
console.error('Unexpected null reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||||
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||||
|
|
||||||
setPasswordResetProviders(providers);
|
setPasswordResetProviders(providers);
|
||||||
|
|
||||||
const currentProviderId = user.Policy.PasswordResetProviderId;
|
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
|
||||||
setPasswordResetProviderId(currentProviderId);
|
setPasswordResetProviderId(currentProviderId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadDeleteFolders = useCallback((user, mediaFolders) => {
|
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||||
const page = element.current;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
console.error('Unexpected null reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||||
SupportsMediaDeletion: true
|
SupportsMediaDeletion: true
|
||||||
})).then(function (channelsResult) {
|
})).then(function (channelsResult) {
|
||||||
|
@ -110,22 +86,20 @@ const UserEdit: FunctionComponent = () => {
|
||||||
let checkedAttribute;
|
let checkedAttribute;
|
||||||
const itemsArr: ResetProvider[] = [];
|
const itemsArr: ResetProvider[] = [];
|
||||||
|
|
||||||
for (const folder of mediaFolders) {
|
for (const mediaFolder of mediaFolders) {
|
||||||
isChecked = user.Policy.EnableContentDeletion || user.Policy.EnableContentDeletionFromFolders.indexOf(folder.Id) != -1;
|
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
itemsArr.push({
|
||||||
Id: folder.Id,
|
...mediaFolder,
|
||||||
Name: folder.Name,
|
|
||||||
checkedAttribute: checkedAttribute
|
checkedAttribute: checkedAttribute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const folder of channelsResult.Items) {
|
for (const channel of channelsResult.Items) {
|
||||||
isChecked = user.Policy.EnableContentDeletion || user.Policy.EnableContentDeletionFromFolders.indexOf(folder.Id) != -1;
|
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||||
itemsArr.push({
|
itemsArr.push({
|
||||||
Id: folder.Id,
|
...channel,
|
||||||
Name: folder.Name,
|
|
||||||
checkedAttribute: checkedAttribute
|
checkedAttribute: checkedAttribute
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -133,74 +107,71 @@ const UserEdit: FunctionComponent = () => {
|
||||||
setDeleteFoldersAccess(itemsArr);
|
setDeleteFoldersAccess(itemsArr);
|
||||||
|
|
||||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||||
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
|
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||||
triggerChange(chkEnableDeleteAllFolders);
|
triggerChange(chkEnableDeleteAllFolders);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[useredit] failed to fetch channels', err);
|
console.error('[useredit] failed to fetch channels', err);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUser = useCallback((user) => {
|
const loadUser = useCallback((user: UserDto) => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[useredit] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
||||||
loadAuthProviders(user, providers);
|
loadAuthProviders(page, user, providers);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[useredit] failed to fetch auth providers', err);
|
console.error('[useredit] failed to fetch auth providers', err);
|
||||||
});
|
});
|
||||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
||||||
loadPasswordResetProviders(user, providers);
|
loadPasswordResetProviders(page, user, providers);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[useredit] failed to fetch password reset providers', err);
|
console.error('[useredit] failed to fetch password reset providers', err);
|
||||||
});
|
});
|
||||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||||
IsHidden: false
|
IsHidden: false
|
||||||
})).then(function (folders) {
|
})).then(function (folders) {
|
||||||
loadDeleteFolders(user, folders.Items);
|
loadDeleteFolders(page, user, folders.Items);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[useredit] failed to fetch media folders', err);
|
console.error('[useredit] failed to fetch media folders', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||||
disabledUserBanner.classList.toggle('hide', !user.Policy.IsDisabled);
|
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
|
||||||
|
|
||||||
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
|
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
|
||||||
txtUserName.disabled = false;
|
txtUserName.disabled = false;
|
||||||
txtUserName.removeAttribute('disabled');
|
txtUserName.removeAttribute('disabled');
|
||||||
|
|
||||||
const lnkEditUserPreferences = page.querySelector('.lnkEditUserPreferences') as HTMLDivElement;
|
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||||
lnkEditUserPreferences.setAttribute('href', 'mypreferencesmenu.html?userId=' + user.Id);
|
|
||||||
LibraryMenu.setTitle(user.Name);
|
setUserDto(user);
|
||||||
setUserName(user.Name);
|
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
|
||||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name;
|
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
|
||||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = user.Policy.IsAdministrator;
|
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
|
||||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
|
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = !!user.Policy?.IsHidden;
|
||||||
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
|
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = !!user.Policy?.EnableCollectionManagement;
|
||||||
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement;
|
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = !!user.Policy?.EnableSubtitleManagement;
|
||||||
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = user.Policy.EnableSubtitleManagement;
|
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = !!user.Policy?.EnableSharedDeviceControl;
|
||||||
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
|
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = !!user.Policy?.EnableRemoteControlOfOtherUsers;
|
||||||
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
|
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = !!user.Policy?.EnableContentDownloading;
|
||||||
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading;
|
(page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvManagement;
|
||||||
(page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked = user.Policy.EnableLiveTvManagement;
|
(page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvAccess;
|
||||||
(page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked = user.Policy.EnableLiveTvAccess;
|
(page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked = !!user.Policy?.EnableMediaPlayback;
|
||||||
(page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked = user.Policy.EnableMediaPlayback;
|
(page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableAudioPlaybackTranscoding;
|
||||||
(page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked = user.Policy.EnableAudioPlaybackTranscoding;
|
(page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableVideoPlaybackTranscoding;
|
||||||
(page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked = user.Policy.EnableVideoPlaybackTranscoding;
|
(page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked = !!user.Policy?.EnablePlaybackRemuxing;
|
||||||
(page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked = user.Policy.EnablePlaybackRemuxing;
|
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = !!user.Policy?.ForceRemoteSourceTranscoding;
|
||||||
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = user.Policy.ForceRemoteSourceTranscoding;
|
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy?.EnableRemoteAccess == null || user.Policy?.EnableRemoteAccess;
|
||||||
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy.EnableRemoteAccess == null || user.Policy.EnableRemoteAccess;
|
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy?.RemoteClientBitrateLimit && user.Policy?.RemoteClientBitrateLimit > 0 ?
|
||||||
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy.RemoteClientBitrateLimit > 0 ?
|
(user.Policy?.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
|
||||||
(user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
|
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = String(user.Policy?.LoginAttemptsBeforeLockout) || '-1';
|
||||||
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0';
|
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
|
||||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
|
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
|
||||||
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
|
||||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = user.Policy.SyncPlayAccess;
|
|
||||||
}
|
|
||||||
loading.hide();
|
loading.hide();
|
||||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||||
|
|
||||||
|
@ -217,7 +188,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
console.error('Unexpected null reference');
|
console.error('[useredit] Unexpected null page reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -318,8 +289,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
<div ref={element} className='content-primary'>
|
<div ref={element} className='content-primary'>
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={userName}
|
title={userDto?.Name || ''}
|
||||||
url='https://jellyfin.org/docs/general/server/users/'
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -328,10 +298,9 @@ const UserEdit: FunctionComponent = () => {
|
||||||
className='lnkEditUserPreferencesContainer'
|
className='lnkEditUserPreferencesContainer'
|
||||||
style={{ paddingBottom: '1em' }}
|
style={{ paddingBottom: '1em' }}
|
||||||
>
|
>
|
||||||
<LinkEditUserPreferences
|
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu.html?userId=${userDto.Id}` : undefined}>
|
||||||
className= 'lnkEditUserPreferences button-link'
|
{globalize.translate('ButtonEditOtherUserPreferences')}
|
||||||
title= 'ButtonEditOtherUserPreferences'
|
</LinkButton>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<form className='editUserProfileForm'>
|
<form className='editUserProfileForm'>
|
||||||
<div className='disabledUserBanner hide'>
|
<div className='disabledUserBanner hide'>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { StrictMode, useCallback, useState } from 'react';
|
||||||
import AppBar from '@mui/material/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { type Theme } from '@mui/material/styles';
|
import { type Theme } from '@mui/material/styles';
|
||||||
|
@ -15,7 +15,7 @@ import AppDrawer, { isDrawerPath } from './components/drawers/AppDrawer';
|
||||||
|
|
||||||
import './AppOverrides.scss';
|
import './AppOverrides.scss';
|
||||||
|
|
||||||
const AppLayout = () => {
|
export const Component = () => {
|
||||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||||
const { user } = useApi();
|
const { user } = useApi();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
@ -29,7 +29,8 @@ const AppLayout = () => {
|
||||||
}, [ isDrawerActive, setIsDrawerActive ]);
|
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
|
||||||
|
<StrictMode>
|
||||||
<ElevationScroll elevate={false}>
|
<ElevationScroll elevate={false}>
|
||||||
<AppBar
|
<AppBar
|
||||||
position='fixed'
|
position='fixed'
|
||||||
|
@ -61,6 +62,7 @@ const AppLayout = () => {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
</StrictMode>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component='main'
|
component='main'
|
||||||
|
@ -76,5 +78,3 @@ const AppLayout = () => {
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AppLayout;
|
|
||||||
|
|
|
@ -5,9 +5,17 @@ $mui-bp-md: 900px;
|
||||||
$mui-bp-lg: 1200px;
|
$mui-bp-lg: 1200px;
|
||||||
$mui-bp-xl: 1536px;
|
$mui-bp-xl: 1536px;
|
||||||
|
|
||||||
|
$drawer-width: 240px;
|
||||||
|
|
||||||
|
#reactRoot {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// Fix main pages layout to work with drawer
|
// Fix main pages layout to work with drawer
|
||||||
.mainAnimatedPage {
|
.mainAnimatedPage {
|
||||||
position: relative;
|
@media all and (min-width: $mui-bp-md) {
|
||||||
|
left: $drawer-width;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide some items from the user "settings" page that are in the drawer
|
// Hide some items from the user "settings" page that are in the drawer
|
||||||
|
@ -36,3 +44,8 @@ $mui-bp-xl: 1536px;
|
||||||
padding-top: 3.25rem !important;
|
padding-top: 3.25rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix backdrop position on mobile item details page
|
||||||
|
.layout-mobile .itemBackdrop {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useTheme } from '@mui/material/styles';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
import { playbackManager } from 'components/playback/playbackmanager';
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
import Events from 'utils/events';
|
import Events from 'utils/events';
|
||||||
|
|
||||||
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||||
|
@ -33,7 +33,7 @@ const RemotePlayButton = () => {
|
||||||
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
|
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
|
||||||
|
|
||||||
const onRemotePlayButtonClick = useCallback((event) => {
|
const onRemotePlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
setRemotePlayMenuAnchorEl(event.currentTarget);
|
setRemotePlayMenuAnchorEl(event.currentTarget);
|
||||||
}, [ setRemotePlayMenuAnchorEl ]);
|
}, [ setRemotePlayMenuAnchorEl ]);
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ const RemotePlayButton = () => {
|
||||||
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
|
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
|
||||||
|
|
||||||
const onRemotePlayActiveButtonClick = useCallback((event) => {
|
const onRemotePlayActiveButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
|
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
|
||||||
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||||
|
|
||||||
|
|
62
src/apps/experimental/components/AppToolbar/SearchButton.tsx
Normal file
62
src/apps/experimental/components/AppToolbar/SearchButton.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { type FC } from 'react';
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
URLSearchParamsInit,
|
||||||
|
createSearchParams,
|
||||||
|
useLocation,
|
||||||
|
useSearchParams
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
|
const getUrlParams = (searchParams: URLSearchParams) => {
|
||||||
|
const parentId =
|
||||||
|
searchParams.get('parentId') || searchParams.get('topParentId');
|
||||||
|
const collectionType = searchParams.get('collectionType');
|
||||||
|
const params: URLSearchParamsInit = {};
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
params.parentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionType) {
|
||||||
|
params.collectionType = collectionType;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SearchButtonProps {
|
||||||
|
isTabsAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const isSearchPath = location.pathname === '/search.html';
|
||||||
|
const createSearchLink = isTabsAvailable ?
|
||||||
|
{
|
||||||
|
pathname: '/search.html',
|
||||||
|
search: `?${createSearchParams(getUrlParams(searchParams))}`
|
||||||
|
} :
|
||||||
|
'/search.html';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={globalize.translate('Search')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
aria-label={globalize.translate('Search')}
|
||||||
|
color='inherit'
|
||||||
|
component={Link}
|
||||||
|
disabled={isSearchPath}
|
||||||
|
to={createSearchLink}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchButton;
|
|
@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { pluginManager } from 'components/pluginManager';
|
import { pluginManager } from 'components/pluginManager';
|
||||||
import { useApi } from 'hooks/useApi';
|
import { useApi } from 'hooks/useApi';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
import { PluginType } from 'types/plugin';
|
import { PluginType } from 'types/plugin';
|
||||||
|
|
||||||
import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu';
|
import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu';
|
||||||
|
@ -17,7 +17,7 @@ const SyncPlayButton = () => {
|
||||||
const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl);
|
const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl);
|
||||||
|
|
||||||
const onSyncPlayButtonClick = useCallback((event) => {
|
const onSyncPlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
setSyncPlayMenuAnchorEl(event.currentTarget);
|
setSyncPlayMenuAnchorEl(event.currentTarget);
|
||||||
}, [ setSyncPlayMenuAnchorEl ]);
|
}, [ setSyncPlayMenuAnchorEl ]);
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import React, { type FC } from 'react';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import { useLocation } from 'react-router-dom';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import React, { FC } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||||
import globalize from 'scripts/globalize';
|
|
||||||
|
|
||||||
import AppTabs from '../tabs/AppTabs';
|
import AppTabs from '../tabs/AppTabs';
|
||||||
import RemotePlayButton from './RemotePlayButton';
|
import RemotePlayButton from './RemotePlayButton';
|
||||||
import SyncPlayButton from './SyncPlayButton';
|
import SyncPlayButton from './SyncPlayButton';
|
||||||
|
import SearchButton from './SearchButton';
|
||||||
import { isTabPath } from '../tabs/tabRoutes';
|
import { isTabPath } from '../tabs/tabRoutes';
|
||||||
|
|
||||||
interface AppToolbarProps {
|
interface AppToolbarProps {
|
||||||
|
@ -18,37 +13,40 @@ interface AppToolbarProps {
|
||||||
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
'/addserver.html',
|
||||||
|
'/selectserver.html',
|
||||||
|
'/login.html',
|
||||||
|
'/forgotpassword.html',
|
||||||
|
'/forgotpasswordpin.html'
|
||||||
|
];
|
||||||
|
|
||||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||||
isDrawerAvailable,
|
isDrawerAvailable,
|
||||||
isDrawerOpen,
|
isDrawerOpen,
|
||||||
onDrawerButtonClick
|
onDrawerButtonClick
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
// The video osd does not show the standard toolbar
|
||||||
|
if (location.pathname === '/video') return null;
|
||||||
|
|
||||||
const isTabsAvailable = isTabPath(location.pathname);
|
const isTabsAvailable = isTabPath(location.pathname);
|
||||||
|
const isPublicPath = PUBLIC_PATHS.includes(location.pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppToolbar
|
<AppToolbar
|
||||||
buttons={
|
buttons={!isPublicPath && (
|
||||||
<>
|
<>
|
||||||
<SyncPlayButton />
|
<SyncPlayButton />
|
||||||
<RemotePlayButton />
|
<RemotePlayButton />
|
||||||
|
<SearchButton isTabsAvailable={isTabsAvailable} />
|
||||||
<Tooltip title={globalize.translate('Search')}>
|
|
||||||
<IconButton
|
|
||||||
size='large'
|
|
||||||
aria-label={globalize.translate('Search')}
|
|
||||||
color='inherit'
|
|
||||||
component={Link}
|
|
||||||
to='/search.html'
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
isDrawerAvailable={isDrawerAvailable}
|
isDrawerAvailable={isDrawerAvailable}
|
||||||
isDrawerOpen={isDrawerOpen}
|
isDrawerOpen={isDrawerOpen}
|
||||||
onDrawerButtonClick={onDrawerButtonClick}
|
onDrawerButtonClick={onDrawerButtonClick}
|
||||||
|
isUserMenuAvailable={!isPublicPath}
|
||||||
>
|
>
|
||||||
{isTabsAvailable && (<AppTabs isDrawerOpen={isDrawerOpen} />)}
|
{isTabsAvailable && (<AppTabs isDrawerOpen={isDrawerOpen} />)}
|
||||||
</AppToolbar>
|
</AppToolbar>
|
||||||
|
|
|
@ -12,8 +12,8 @@ import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
import React, { FC, useCallback, useState } from 'react';
|
import React, { FC, useCallback, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { enable, isEnabled, supported } from 'scripts/autocast';
|
import { enable, isEnabled } from 'scripts/autocast';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
|
||||||
interface RemotePlayActiveMenuProps extends MenuProps {
|
interface RemotePlayActiveMenuProps extends MenuProps {
|
||||||
onMenuClose: () => void
|
onMenuClose: () => void
|
||||||
|
@ -43,11 +43,10 @@ const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
|
||||||
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
|
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
|
||||||
|
|
||||||
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
|
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
|
||||||
const isAutoCastSupported = supported();
|
|
||||||
const toggleAutoCast = useCallback(() => {
|
const toggleAutoCast = useCallback(() => {
|
||||||
enable(!isAutoCastEnabled);
|
enable(!isAutoCastEnabled);
|
||||||
setIsAutoCastEnabled(!isAutoCastEnabled);
|
setIsAutoCastEnabled(!isAutoCastEnabled);
|
||||||
}, [ isAutoCastEnabled, setIsAutoCastEnabled ]);
|
}, [ isAutoCastEnabled ]);
|
||||||
|
|
||||||
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
|
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
|
||||||
|
|
||||||
|
@ -117,7 +116,6 @@ const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAutoCastSupported && (
|
|
||||||
<MenuItem onClick={toggleAutoCast}>
|
<MenuItem onClick={toggleAutoCast}>
|
||||||
{isAutoCastEnabled && (
|
{isAutoCastEnabled && (
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
|
@ -128,9 +126,8 @@ const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
|
||||||
{globalize.translate('EnableAutoCast')}
|
{globalize.translate('EnableAutoCast')}
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
|
||||||
|
|
||||||
{(isDisplayMirrorSupported || isAutoCastSupported) && <Divider />}
|
<Divider />
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Menu, { type MenuProps } from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import React, { FC, useEffect, useState } from 'react';
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
import { playbackManager } from 'components/playback/playbackmanager';
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
import { pluginManager } from 'components/pluginManager';
|
import { pluginManager } from 'components/pluginManager';
|
||||||
import type { PlayTarget } from 'types/playTarget';
|
import type { PlayTarget } from 'types/playTarget';
|
||||||
|
@ -65,17 +65,20 @@ const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onMenuClose}
|
onClose={onMenuClose}
|
||||||
>
|
>
|
||||||
{!isChromecastPluginLoaded && ([
|
{!isChromecastPluginLoaded && (
|
||||||
<MenuItem key='cast-unsupported-item' disabled>
|
<MenuItem disabled>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Warning />
|
<Warning />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText>
|
<ListItemText>
|
||||||
{globalize.translate('GoogleCastUnsupported')}
|
{globalize.translate('GoogleCastUnsupported')}
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</MenuItem>,
|
</MenuItem>
|
||||||
<Divider key='cast-unsupported-divider' />
|
)}
|
||||||
])}
|
|
||||||
|
{!isChromecastPluginLoaded && playbackTargets.length > 0 && (
|
||||||
|
<Divider />
|
||||||
|
)}
|
||||||
|
|
||||||
{playbackTargets.map(target => (
|
{playbackTargets.map(target => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
|
@ -20,9 +20,9 @@ import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||||
import { pluginManager } from 'components/pluginManager';
|
import { pluginManager } from 'components/pluginManager';
|
||||||
import { useApi } from 'hooks/useApi';
|
import { useApi } from 'hooks/useApi';
|
||||||
import { useSyncPlayGroups } from 'hooks/useSyncPlayGroups';
|
import { useSyncPlayGroups } from 'hooks/useSyncPlayGroups';
|
||||||
import globalize from 'scripts/globalize';
|
import globalize from 'lib/globalize';
|
||||||
import { PluginType } from 'types/plugin';
|
import { PluginType } from 'types/plugin';
|
||||||
import Events from 'utils/events';
|
import Events, { Event } from 'utils/events';
|
||||||
|
|
||||||
export const ID = 'app-sync-play-menu';
|
export const ID = 'app-sync-play-menu';
|
||||||
|
|
||||||
|
@ -136,7 +136,7 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
|
||||||
}
|
}
|
||||||
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
|
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
|
||||||
|
|
||||||
const updateSyncPlayGroup = useCallback((_e, enabled) => {
|
const updateSyncPlayGroup = useCallback((_e: Event, enabled: boolean) => {
|
||||||
if (syncPlay && enabled) {
|
if (syncPlay && enabled) {
|
||||||
setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined);
|
setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined);
|
||||||
} else {
|
} else {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue