mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into segment-deletion
This commit is contained in:
commit
128184cc72
497 changed files with 70077 additions and 54756 deletions
|
@ -26,8 +26,6 @@ jobs:
|
||||||
|
|
||||||
- script: 'npm ci --no-audit'
|
- script: 'npm ci --no-audit'
|
||||||
displayName: 'Install Dependencies'
|
displayName: 'Install Dependencies'
|
||||||
env:
|
|
||||||
SKIP_PREPARE: 'true'
|
|
||||||
|
|
||||||
- script: 'npm run build:development'
|
- script: 'npm run build:development'
|
||||||
displayName: 'Build Development'
|
displayName: 'Build Development'
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
"./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/libraries/wasm-gen/libarchive.js",
|
||||||
"./dist/node_modules.@jellyfin.libass-wasm.*.chunk.js",
|
|
||||||
"./dist/serviceworker.js"
|
"./dist/serviceworker.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
65
.eslintrc.js
65
.eslintrc.js
|
@ -2,8 +2,9 @@ const restrictedGlobals = require('confusing-browser-globals');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: [
|
plugins: [
|
||||||
'@babel',
|
'@typescript-eslint',
|
||||||
'react',
|
'react',
|
||||||
'promise',
|
'promise',
|
||||||
'import',
|
'import',
|
||||||
|
@ -16,14 +17,6 @@ module.exports = {
|
||||||
es2017: true,
|
es2017: true,
|
||||||
es2020: true
|
es2020: true
|
||||||
},
|
},
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
impliedStrict: true,
|
|
||||||
jsx: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
|
@ -46,50 +39,72 @@ module.exports = {
|
||||||
'keyword-spacing': ['error'],
|
'keyword-spacing': ['error'],
|
||||||
'no-throw-literal': ['error'],
|
'no-throw-literal': ['error'],
|
||||||
'max-statements-per-line': ['error'],
|
'max-statements-per-line': ['error'],
|
||||||
|
'max-params': ['error', 7],
|
||||||
'no-duplicate-imports': ['error'],
|
'no-duplicate-imports': ['error'],
|
||||||
'no-empty-function': ['error'],
|
'no-empty-function': ['error'],
|
||||||
'no-floating-decimal': ['error'],
|
'no-floating-decimal': ['error'],
|
||||||
'no-multi-spaces': ['error'],
|
'no-multi-spaces': ['error'],
|
||||||
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||||
'no-nested-ternary': ['error'],
|
'no-nested-ternary': ['error'],
|
||||||
|
'no-redeclare': ['off'],
|
||||||
|
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||||
'no-restricted-globals': ['error'].concat(restrictedGlobals),
|
'no-restricted-globals': ['error'].concat(restrictedGlobals),
|
||||||
'no-return-assign': ['error'],
|
'no-return-assign': ['error'],
|
||||||
'no-return-await': ['error'],
|
'no-return-await': ['error'],
|
||||||
'no-sequences': ['error', { 'allowInParentheses': false }],
|
'no-sequences': ['error', { 'allowInParentheses': false }],
|
||||||
|
'no-shadow': ['off'],
|
||||||
|
'@typescript-eslint/no-shadow': ['error'],
|
||||||
'no-trailing-spaces': ['error'],
|
'no-trailing-spaces': ['error'],
|
||||||
'@babel/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
|
'no-unused-expressions': ['off'],
|
||||||
'no-useless-constructor': ['error'],
|
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
|
||||||
|
'no-useless-constructor': ['off'],
|
||||||
|
'@typescript-eslint/no-useless-constructor': ['error'],
|
||||||
'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'],
|
'padded-blocks': ['error', 'never'],
|
||||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||||
|
'@typescript-eslint/prefer-for-of': ['error'],
|
||||||
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||||
'@babel/semi': ['error'],
|
'radix': ['error'],
|
||||||
|
'@typescript-eslint/semi': ['error'],
|
||||||
'space-before-blocks': ['error'],
|
'space-before-blocks': ['error'],
|
||||||
'space-infix-ops': 'error',
|
'space-infix-ops': 'error',
|
||||||
'yoda': 'error',
|
'yoda': 'error',
|
||||||
'@typescript-eslint/no-shadow': 'error',
|
|
||||||
|
|
||||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||||
|
'react/jsx-no-bind': ['error'],
|
||||||
|
'react/jsx-no-constructed-context-values': ['error'],
|
||||||
|
'react/no-array-index-key': ['error'],
|
||||||
|
|
||||||
'sonarjs/cognitive-complexity': ['warn'],
|
'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/no-duplicate-string': ['off']
|
'sonarjs/no-duplicate-string': ['off']
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
version: 'detect'
|
version: 'detect'
|
||||||
},
|
},
|
||||||
'import/extensions': [
|
'import/parsers': {
|
||||||
|
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
node: {
|
||||||
|
extensions: [
|
||||||
'.js',
|
'.js',
|
||||||
'.ts',
|
'.ts',
|
||||||
'.jsx',
|
'.jsx',
|
||||||
'.tsx'
|
'.tsx'
|
||||||
],
|
],
|
||||||
'import/parsers': {
|
moduleDirectory: [
|
||||||
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
|
'node_modules',
|
||||||
|
'src'
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
polyfills: [
|
polyfills: [
|
||||||
// Native Promises Only
|
// Native Promises Only
|
||||||
|
@ -193,9 +208,12 @@ module.exports = {
|
||||||
files: [
|
files: [
|
||||||
'./src/**/*.js',
|
'./src/**/*.js',
|
||||||
'./src/**/*.jsx',
|
'./src/**/*.jsx',
|
||||||
'./src/**/*.ts'
|
'./src/**/*.ts',
|
||||||
|
'./src/**/*.tsx'
|
||||||
],
|
],
|
||||||
parser: '@babel/eslint-parser',
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json']
|
||||||
|
},
|
||||||
env: {
|
env: {
|
||||||
node: false,
|
node: false,
|
||||||
amd: true,
|
amd: true,
|
||||||
|
@ -235,6 +253,8 @@ module.exports = {
|
||||||
'Windows': 'readonly'
|
'Windows': 'readonly'
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
'@typescript-eslint/no-floating-promises': ['warn'],
|
||||||
|
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// TypeScript source files
|
// TypeScript source files
|
||||||
|
@ -243,8 +263,6 @@ module.exports = {
|
||||||
'./src/**/*.ts',
|
'./src/**/*.ts',
|
||||||
'./src/**/*.tsx'
|
'./src/**/*.tsx'
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['@typescript-eslint'],
|
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:import/typescript',
|
'plugin:import/typescript',
|
||||||
|
@ -255,8 +273,9 @@ module.exports = {
|
||||||
'plugin:jsx-a11y/recommended'
|
'plugin:jsx-a11y/recommended'
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-useless-constructor': ['off'],
|
'@typescript-eslint/no-floating-promises': ['error'],
|
||||||
'@typescript-eslint/no-useless-constructor': ['error']
|
|
||||||
|
'sonarjs/cognitive-complexity': ['error']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
|
@ -1,6 +1,2 @@
|
||||||
.ci @dkanada @EraYaN
|
* @jellyfin/web
|
||||||
.github @jellyfin/core
|
.github @jellyfin/core
|
||||||
fedora @joshuaboniface
|
|
||||||
debian @joshuaboniface
|
|
||||||
.copr @joshuaboniface
|
|
||||||
deployment @joshuaboniface
|
|
||||||
|
|
1
.github/workflows/automation.yml
vendored
1
.github/workflows/automation.yml
vendored
|
@ -17,4 +17,5 @@ jobs:
|
||||||
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
|
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
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 }}
|
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
|
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
|
@ -19,13 +19,13 @@ jobs:
|
||||||
language: [ 'javascript' ]
|
language: [ 'javascript' ]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2
|
uses: github/codeql-action/init@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2
|
uses: github/codeql-action/autobuild@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # tag=v2
|
uses: github/codeql-action/analyze@83f0fe6c4988d98a455712a27f0255212bba9bd4 # v2.3.6
|
||||||
|
|
14
.github/workflows/commands.yml
vendored
14
.github/workflows/commands.yml
vendored
|
@ -12,17 +12,25 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Notify as seen
|
- name: Notify as seen
|
||||||
uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2.1.0
|
uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
comment-id: ${{ github.event.comment.id }}
|
comment-id: ${{ github.event.comment.id }}
|
||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3.1.0
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Automatic Rebase
|
- name: Automatic Rebase
|
||||||
uses: cirrus-actions/rebase@6e572f08c244e2f04f9beb85a943eb618218714d # tag=1.7
|
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
- name: Comment on failure
|
||||||
|
if: failure()
|
||||||
|
uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
|
||||||
|
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.
|
||||||
|
|
24
.github/workflows/lint.yml
vendored
24
.github/workflows/lint.yml
vendored
|
@ -13,10 +13,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
|
|
||||||
- name: Setup node environment
|
- name: Setup node environment
|
||||||
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1
|
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
@ -24,8 +24,6 @@ jobs:
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
- name: Install Node.js dependencies
|
||||||
run: npm ci --no-audit
|
run: npm ci --no-audit
|
||||||
env:
|
|
||||||
SKIP_PREPARE: true
|
|
||||||
|
|
||||||
- name: Run a production build
|
- name: Run a production build
|
||||||
run: npm run build:production
|
run: npm run build:production
|
||||||
|
@ -39,10 +37,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
|
|
||||||
- name: Setup node environment
|
- name: Setup node environment
|
||||||
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1
|
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
@ -50,8 +48,6 @@ jobs:
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
- name: Install Node.js dependencies
|
||||||
run: npm ci --no-audit
|
run: npm ci --no-audit
|
||||||
env:
|
|
||||||
SKIP_PREPARE: true
|
|
||||||
|
|
||||||
- name: Run eslint
|
- name: Run eslint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
@ -62,10 +58,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
|
|
||||||
- name: Setup node environment
|
- name: Setup node environment
|
||||||
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1
|
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
@ -76,8 +72,6 @@ jobs:
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
- name: Install Node.js dependencies
|
||||||
run: npm ci --no-audit
|
run: npm ci --no-audit
|
||||||
env:
|
|
||||||
SKIP_PREPARE: true
|
|
||||||
|
|
||||||
- name: Run stylelint
|
- name: Run stylelint
|
||||||
run: npm run stylelint:css
|
run: npm run stylelint:css
|
||||||
|
@ -88,10 +82,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
|
|
||||||
- name: Setup node environment
|
- name: Setup node environment
|
||||||
uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3.5.1
|
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
@ -102,8 +96,6 @@ jobs:
|
||||||
|
|
||||||
- name: Install Node.js dependencies
|
- name: Install Node.js dependencies
|
||||||
run: npm ci --no-audit
|
run: npm ci --no-audit
|
||||||
env:
|
|
||||||
SKIP_PREPARE: true
|
|
||||||
|
|
||||||
- name: Run stylelint
|
- name: Run stylelint
|
||||||
run: npm run stylelint:scss
|
run: npm run stylelint:scss
|
||||||
|
|
30
.github/workflows/repo-stale.yaml
vendored
30
.github/workflows/repo-stale.yaml
vendored
|
@ -1,18 +1,24 @@
|
||||||
name: Issue Stale Check
|
name: Stale Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 1 * * *'
|
- cron: '30 1 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
issues:
|
||||||
|
name: Check issues
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6.0.1
|
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
operations-per-run: 75
|
||||||
days-before-stale: 120
|
days-before-stale: 120
|
||||||
days-before-pr-stale: -1
|
days-before-pr-stale: -1
|
||||||
days-before-close: 21
|
days-before-close: 21
|
||||||
|
@ -25,3 +31,21 @@ jobs:
|
||||||
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
|
||||||
|
|
||||||
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
|
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
|
||||||
|
|
||||||
|
prs-conflicts:
|
||||||
|
name: Check PRs with merge conflicts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
|
operations-per-run: 75
|
||||||
|
# The merge conflict action will remove the label when updated
|
||||||
|
remove-stale-when-updated: false
|
||||||
|
days-before-stale: -1
|
||||||
|
days-before-close: 90
|
||||||
|
days-before-issue-close: -1
|
||||||
|
stale-pr-label: merge conflict
|
||||||
|
close-pr-message: |-
|
||||||
|
This PR has been closed due to having unresolved merge conflicts.
|
||||||
|
|
29
.github/workflows/tsc.yml
vendored
Normal file
29
.github/workflows/tsc.yml
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
name: TypeScript Build Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master, release* ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, release* ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tsc:
|
||||||
|
name: Run TypeScript build check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
|
||||||
|
|
||||||
|
- name: Setup node environment
|
||||||
|
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
check-latest: true
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci --no-audit
|
||||||
|
|
||||||
|
- name: Run tsc
|
||||||
|
run: npm run build:check
|
|
@ -13,6 +13,7 @@
|
||||||
"at-rule-name-case": "lower",
|
"at-rule-name-case": "lower",
|
||||||
"at-rule-name-space-after": "always-single-line",
|
"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-semicolon-newline-after": "always",
|
"at-rule-semicolon-newline-after": "always",
|
||||||
"block-closing-brace-empty-line-before": "never",
|
"block-closing-brace-empty-line-before": "never",
|
||||||
"block-closing-brace-newline-after": "always",
|
"block-closing-brace-newline-after": "always",
|
||||||
|
@ -77,6 +78,7 @@
|
||||||
"media-feature-colon-space-before": "never",
|
"media-feature-colon-space-before": "never",
|
||||||
"media-feature-name-case": "lower",
|
"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-parentheses-space-inside": "never",
|
"media-feature-parentheses-space-inside": "never",
|
||||||
"media-feature-range-operator-space-after": "always",
|
"media-feature-range-operator-space-after": "always",
|
||||||
"media-feature-range-operator-space-before": "always",
|
"media-feature-range-operator-space-before": "always",
|
||||||
|
@ -103,6 +105,7 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"property-no-vendor-prefix": true,
|
||||||
"rule-empty-line-before": [ "always-multi-line", {
|
"rule-empty-line-before": [ "always-multi-line", {
|
||||||
"except": ["first-nested"],
|
"except": ["first-nested"],
|
||||||
"ignore": ["after-comment"]
|
"ignore": ["after-comment"]
|
||||||
|
@ -116,6 +119,7 @@
|
||||||
"selector-list-comma-newline-after": "always",
|
"selector-list-comma-newline-after": "always",
|
||||||
"selector-list-comma-space-before": "never",
|
"selector-list-comma-space-before": "never",
|
||||||
"selector-max-empty-lines": 0,
|
"selector-max-empty-lines": 0,
|
||||||
|
"selector-no-vendor-prefix": true,
|
||||||
"selector-pseudo-class-case": "lower",
|
"selector-pseudo-class-case": "lower",
|
||||||
"selector-pseudo-class-no-unknown": true,
|
"selector-pseudo-class-no-unknown": true,
|
||||||
"selector-pseudo-class-parentheses-space-inside": "never",
|
"selector-pseudo-class-parentheses-space-inside": "never",
|
||||||
|
@ -134,6 +138,7 @@
|
||||||
"string-no-newline": true,
|
"string-no-newline": true,
|
||||||
"unit-case": "lower",
|
"unit-case": "lower",
|
||||||
"unit-no-unknown": true,
|
"unit-no-unknown": true,
|
||||||
|
"value-no-vendor-prefix": true,
|
||||||
"value-list-comma-newline-after": "always-multi-line",
|
"value-list-comma-newline-after": "always-multi-line",
|
||||||
"value-list-comma-space-after": "always-single-line",
|
"value-list-comma-space-after": "always-single-line",
|
||||||
"value-list-comma-space-before": "never",
|
"value-list-comma-space-before": "never",
|
||||||
|
|
|
@ -56,6 +56,15 @@
|
||||||
- [is343](https://github.com/is343)
|
- [is343](https://github.com/is343)
|
||||||
- [Meet Pandya](https://github.com/meet-k-pandya)
|
- [Meet Pandya](https://github.com/meet-k-pandya)
|
||||||
- [Peter Spenler](https://github.com/peterspenler)
|
- [Peter Spenler](https://github.com/peterspenler)
|
||||||
|
- [Vankerkom](https://github.com/vankerkom)
|
||||||
|
- [edvwib](https://github.com/edvwib)
|
||||||
|
- [Rob Farraher](https://github.com/farraherbg)
|
||||||
|
- [TelepathicWalrus](https://github.com/TelepathicWalrus)
|
||||||
|
- [Pier-Luc Ducharme](https://github.com/pl-ducharme)
|
||||||
|
- [Anantharaju S](https://github.com/Anantharajus)
|
||||||
|
- [Merlin Sievers](https://github.com/dann-merlin)
|
||||||
|
- [Fishbigger](https://github.com/fishbigger)
|
||||||
|
- [sleepycatcoding](https://github.com/sleepycatcoding)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
|
|
|
@ -76,10 +76,14 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
└── src
|
└── src
|
||||||
|
├── apps
|
||||||
|
│ ├── experimental # New experimental app layout
|
||||||
|
│ └── stable # Classic (stable) app layout
|
||||||
├── 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 🧹
|
├── controllers # Legacy page views and controllers 🧹
|
||||||
├── elements # Basic webcomponents and React wrappers 🧹
|
├── elements # Basic webcomponents and React wrappers 🧹
|
||||||
|
├── hooks # Custom React hooks
|
||||||
├── legacy # Polyfills for legacy browsers
|
├── legacy # Polyfills for legacy browsers
|
||||||
├── libraries # Third party libraries 🧹
|
├── libraries # Third party libraries 🧹
|
||||||
├── plugins # Client plugins
|
├── plugins # Client plugins
|
||||||
|
@ -88,6 +92,7 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||||
├── strings # Translation files
|
├── strings # Translation files
|
||||||
├── styles # Common app Sass stylesheets
|
├── styles # Common app Sass stylesheets
|
||||||
├── themes # CSS themes
|
├── themes # CSS themes
|
||||||
|
├── types # Common TypeScript interfaces/types
|
||||||
└── utils # Utility functions
|
└── utils # Utility functions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -12,14 +12,7 @@ module.exports = {
|
||||||
corejs: 3
|
corejs: 3
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'@babel/preset-react',
|
'@babel/preset-react'
|
||||||
[
|
|
||||||
'@babel/preset-typescript',
|
|
||||||
{
|
|
||||||
isTSX: true,
|
|
||||||
allExtensions: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
'@babel/plugin-proposal-class-properties',
|
'@babel/plugin-proposal-class-properties',
|
||||||
|
|
1
debian/rules
vendored
1
debian/rules
vendored
|
@ -12,6 +12,7 @@ override_dh_clistrip:
|
||||||
|
|
||||||
override_dh_auto_build:
|
override_dh_auto_build:
|
||||||
npm ci --no-audit --unsafe-perm
|
npm ci --no-audit --unsafe-perm
|
||||||
|
npm run build:production
|
||||||
mv $(CURDIR)/dist $(CURDIR)/web
|
mv $(CURDIR)/dist $(CURDIR)/web
|
||||||
|
|
||||||
override_dh_auto_clean:
|
override_dh_auto_clean:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM centos:8
|
FROM quay.io/centos/centos:stream8
|
||||||
|
|
||||||
# Docker build arguments
|
# Docker build arguments
|
||||||
ARG SOURCE_DIR=/jellyfin
|
ARG SOURCE_DIR=/jellyfin
|
||||||
|
@ -12,7 +12,7 @@ ENV IS_DOCKER=YES
|
||||||
# Prepare CentOS environment
|
# Prepare CentOS environment
|
||||||
RUN yum update -y \
|
RUN yum update -y \
|
||||||
&& yum install -y epel-release \
|
&& yum install -y epel-release \
|
||||||
&& yum install -y @buildsys-build rpmdevtools git yum-plugins-core autoconf automake glibc-devel gcc-c++ make \
|
&& yum install -y rpmdevtools git autoconf automake glibc-devel gcc-c++ make \
|
||||||
&& curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - \
|
&& curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - \
|
||||||
&& yum install -y nodejs
|
&& yum install -y nodejs
|
||||||
|
|
||||||
|
|
|
@ -8,4 +8,6 @@ RUN apk add autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool ma
|
||||||
WORKDIR ${SOURCE_DIR}
|
WORKDIR ${SOURCE_DIR}
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm ci --no-audit --unsafe-perm && mv dist ${ARTIFACT_DIR}
|
RUN npm ci --no-audit --unsafe-perm \
|
||||||
|
&& npm run build:production \
|
||||||
|
&& mv dist ${ARTIFACT_DIR}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM fedora:37
|
FROM fedora:39
|
||||||
|
|
||||||
# Docker build arguments
|
# Docker build arguments
|
||||||
ARG SOURCE_DIR=/jellyfin
|
ARG SOURCE_DIR=/jellyfin
|
||||||
|
|
|
@ -15,6 +15,7 @@ fi
|
||||||
|
|
||||||
# build archives
|
# build archives
|
||||||
npm ci --no-audit --unsafe-perm
|
npm ci --no-audit --unsafe-perm
|
||||||
|
npm run build:production
|
||||||
mv dist jellyfin-web_${version}
|
mv dist jellyfin-web_${version}
|
||||||
tar -czf jellyfin-web_${version}_portable.tar.gz jellyfin-web_${version}
|
tar -czf jellyfin-web_${version}_portable.tar.gz jellyfin-web_${version}
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
|
|
|
@ -35,6 +35,7 @@ chown root:root -R .
|
||||||
|
|
||||||
%build
|
%build
|
||||||
npm ci --no-audit --unsafe-perm
|
npm ci --no-audit --unsafe-perm
|
||||||
|
npm run build:production
|
||||||
|
|
||||||
|
|
||||||
%install
|
%install
|
||||||
|
|
10672
package-lock.json
generated
10672
package-lock.json
generated
File diff suppressed because it is too large
Load diff
118
package.json
118
package.json
|
@ -5,106 +5,110 @@
|
||||||
"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.19.3",
|
"@babel/core": "7.21.8",
|
||||||
"@babel/eslint-parser": "7.19.1",
|
|
||||||
"@babel/eslint-plugin": "7.19.1",
|
|
||||||
"@babel/plugin-proposal-class-properties": "7.18.6",
|
"@babel/plugin-proposal-class-properties": "7.18.6",
|
||||||
"@babel/plugin-proposal-private-methods": "7.18.6",
|
"@babel/plugin-proposal-private-methods": "7.18.6",
|
||||||
"@babel/plugin-transform-modules-umd": "7.18.6",
|
"@babel/plugin-transform-modules-umd": "7.18.6",
|
||||||
"@babel/preset-env": "7.19.4",
|
"@babel/preset-env": "7.21.5",
|
||||||
"@babel/preset-react": "7.18.6",
|
"@babel/preset-react": "7.18.6",
|
||||||
"@babel/preset-typescript": "7.18.6",
|
|
||||||
"@types/escape-html": "1.0.2",
|
"@types/escape-html": "1.0.2",
|
||||||
"@types/lodash-es": "4.17.6",
|
"@types/loadable__component": "5.13.4",
|
||||||
"@types/react": "17.0.51",
|
"@types/lodash-es": "4.17.7",
|
||||||
"@types/react-dom": "17.0.17",
|
"@types/react": "17.0.59",
|
||||||
"@typescript-eslint/eslint-plugin": "5.41.0",
|
"@types/react-dom": "17.0.20",
|
||||||
"@typescript-eslint/parser": "5.41.0",
|
"@typescript-eslint/eslint-plugin": "5.59.7",
|
||||||
|
"@typescript-eslint/parser": "5.59.7",
|
||||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||||
"autoprefixer": "10.4.12",
|
"autoprefixer": "10.4.14",
|
||||||
"babel-loader": "8.2.5",
|
"babel-loader": "9.1.2",
|
||||||
"babel-plugin-dynamic-import-polyfill": "1.0.0",
|
"babel-plugin-dynamic-import-polyfill": "1.0.0",
|
||||||
"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": "11.0.0",
|
"copy-webpack-plugin": "11.0.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"css-loader": "6.7.1",
|
"css-loader": "6.7.4",
|
||||||
"cssnano": "5.1.13",
|
"cssnano": "6.0.1",
|
||||||
"es-check": "7.0.1",
|
"es-check": "7.1.1",
|
||||||
"eslint": "8.26.0",
|
"eslint": "8.41.0",
|
||||||
"eslint-plugin-compat": "4.0.2",
|
"eslint-plugin-compat": "4.1.4",
|
||||||
"eslint-plugin-eslint-comments": "3.2.0",
|
"eslint-plugin-eslint-comments": "3.2.0",
|
||||||
"eslint-plugin-import": "2.26.0",
|
"eslint-plugin-import": "2.27.5",
|
||||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
"eslint-plugin-promise": "6.1.1",
|
"eslint-plugin-promise": "6.1.1",
|
||||||
"eslint-plugin-react": "7.31.10",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"eslint-plugin-sonarjs": "0.16.0",
|
"eslint-plugin-sonarjs": "0.19.0",
|
||||||
"expose-loader": "4.0.0",
|
"expose-loader": "4.1.0",
|
||||||
"html-loader": "4.2.0",
|
"html-loader": "4.2.0",
|
||||||
"html-webpack-plugin": "5.5.0",
|
"html-webpack-plugin": "5.5.1",
|
||||||
"mini-css-extract-plugin": "2.6.1",
|
"mini-css-extract-plugin": "2.7.6",
|
||||||
"postcss": "8.4.18",
|
"postcss": "8.4.24",
|
||||||
"postcss-loader": "7.0.1",
|
"postcss-loader": "7.3.1",
|
||||||
"postcss-preset-env": "7.8.2",
|
"postcss-preset-env": "8.4.1",
|
||||||
"postcss-scss": "4.0.5",
|
"postcss-scss": "4.0.6",
|
||||||
"sass": "1.55.0",
|
"sass": "1.62.1",
|
||||||
"sass-loader": "13.1.0",
|
"sass-loader": "13.3.0",
|
||||||
"source-map-loader": "4.0.1",
|
"source-map-loader": "4.0.1",
|
||||||
"style-loader": "3.3.1",
|
"style-loader": "3.3.3",
|
||||||
"stylelint": "14.14.0",
|
"stylelint": "15.6.2",
|
||||||
"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.2.1",
|
||||||
"stylelint-order": "5.0.0",
|
"stylelint-order": "6.0.3",
|
||||||
"stylelint-scss": "4.3.0",
|
"stylelint-scss": "5.0.0",
|
||||||
"ts-loader": "9.4.1",
|
"ts-loader": "9.4.3",
|
||||||
"typescript": "4.8.4",
|
"typescript": "5.0.4",
|
||||||
"webpack": "5.74.0",
|
"webpack": "5.84.1",
|
||||||
"webpack-cli": "4.10.0",
|
"webpack-cli": "5.1.1",
|
||||||
"webpack-dev-server": "4.11.1",
|
"webpack-dev-server": "4.15.0",
|
||||||
"webpack-merge": "5.8.0",
|
"webpack-merge": "5.9.0",
|
||||||
"workbox-webpack-plugin": "6.5.4",
|
"workbox-webpack-plugin": "6.5.4",
|
||||||
"worker-loader": "3.0.8"
|
"worker-loader": "3.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "11.11.0",
|
||||||
|
"@emotion/styled": "11.11.0",
|
||||||
"@fontsource/noto-sans": "4.5.11",
|
"@fontsource/noto-sans": "4.5.11",
|
||||||
"@fontsource/noto-sans-hk": "4.5.12",
|
"@fontsource/noto-sans-hk": "4.5.12",
|
||||||
"@fontsource/noto-sans-jp": "4.5.12",
|
"@fontsource/noto-sans-jp": "4.5.12",
|
||||||
"@fontsource/noto-sans-kr": "4.5.12",
|
"@fontsource/noto-sans-kr": "4.5.12",
|
||||||
"@fontsource/noto-sans-sc": "4.5.12",
|
"@fontsource/noto-sans-sc": "4.5.12",
|
||||||
"@fontsource/noto-sans-tc": "4.5.12",
|
"@fontsource/noto-sans-tc": "4.5.12",
|
||||||
"@jellyfin/libass-wasm": "4.1.1",
|
"@jellyfin/sdk": "unstable",
|
||||||
"@jellyfin/sdk": "0.7.0",
|
"@loadable/component": "5.15.3",
|
||||||
"blurhash": "2.0.3",
|
"@mui/icons-material": "5.11.16",
|
||||||
|
"@mui/material": "5.13.3",
|
||||||
|
"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.3.2",
|
"classnames": "2.3.2",
|
||||||
"core-js": "3.26.0",
|
"core-js": "3.30.2",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.30.0",
|
||||||
"dompurify": "2.4.0",
|
"dompurify": "3.0.1",
|
||||||
"epubjs": "0.4.2",
|
"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": "0.14.17",
|
"hls.js": "1.4.4",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
|
"jassub": "1.7.1",
|
||||||
"jellyfin-apiclient": "1.10.0",
|
"jellyfin-apiclient": "1.10.0",
|
||||||
"jquery": "3.6.1",
|
"jquery": "3.7.0",
|
||||||
"jstree": "3.3.12",
|
"jstree": "3.3.15",
|
||||||
"libarchive.js": "1.3.0",
|
"libarchive.js": "1.3.0",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"marked": "4.1.1",
|
"marked": "4.3.0",
|
||||||
"material-design-icons-iconfont": "6.7.0",
|
"material-design-icons-iconfont": "6.7.0",
|
||||||
"native-promise-only": "0.8.1",
|
"native-promise-only": "0.8.1",
|
||||||
"pdfjs-dist": "2.16.105",
|
"pdfjs-dist": "3.6.172",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-router-dom": "6.4.2",
|
"react-router-dom": "6.11.1",
|
||||||
"resize-observer-polyfill": "1.5.1",
|
"resize-observer-polyfill": "1.5.1",
|
||||||
"screenfull": "6.0.2",
|
"screenfull": "6.0.2",
|
||||||
"sortablejs": "1.15.0",
|
"sortablejs": "1.15.0",
|
||||||
"swiper": "8.4.4",
|
"swiper": "9.3.2",
|
||||||
"webcomponents.js": "0.7.24",
|
"webcomponents.js": "0.7.24",
|
||||||
"whatwg-fetch": "3.6.2",
|
"whatwg-fetch": "3.6.2",
|
||||||
"workbox-core": "6.5.4",
|
"workbox-core": "6.5.4",
|
||||||
|
@ -129,9 +133,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run serve",
|
"start": "npm run serve",
|
||||||
"serve": "webpack serve --config webpack.dev.js",
|
"serve": "webpack serve --config webpack.dev.js",
|
||||||
"prepare": "node ./scripts/prepare.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",
|
||||||
"escheck": "es-check",
|
"escheck": "es-check",
|
||||||
"lint": "eslint \"./\"",
|
"lint": "eslint \"./\"",
|
||||||
"stylelint": "npm run stylelint:css && npm run stylelint:scss",
|
"stylelint": "npm run stylelint:css && npm run stylelint:scss",
|
||||||
|
|
|
@ -7,8 +7,8 @@ const config = () => ({
|
||||||
plugins: [
|
plugins: [
|
||||||
// Explicitly specify browserslist to override ones from node_modules
|
// Explicitly specify browserslist to override ones from node_modules
|
||||||
// For example, Swiper has it in its package.json
|
// For example, Swiper has it in its package.json
|
||||||
postcssPresetEnv({browsers: packageConfig.browserslist}),
|
postcssPresetEnv({ browsers: packageConfig.browserslist }),
|
||||||
autoprefixer({overrideBrowserslist: packageConfig.browserslist}),
|
autoprefixer({ overrideBrowserslist: packageConfig.browserslist }),
|
||||||
cssnano()
|
cssnano()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The npm `prepare` script needs to run a build to support installing
|
|
||||||
* a package from git repositories (this is dumb but a limitation of how
|
|
||||||
* npm behaves). We don't want to run these in CI though because
|
|
||||||
* building is slow so this script will skip the build when the
|
|
||||||
* `SKIP_PREPARE` environment variable has been set.
|
|
||||||
*/
|
|
||||||
if (!process.env.SKIP_PREPARE) {
|
|
||||||
execSync('webpack --config webpack.prod.js', { stdio: 'inherit' });
|
|
||||||
}
|
|
30
src/RootApp.tsx
Normal file
30
src/RootApp.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import loadable from '@loadable/component';
|
||||||
|
import { History } from '@remix-run/router';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import StableApp from './apps/stable/App';
|
||||||
|
import { HistoryRouter } from './components/router/HistoryRouter';
|
||||||
|
import { ApiProvider } from './hooks/useApi';
|
||||||
|
import { WebConfigProvider } from './hooks/useWebConfig';
|
||||||
|
|
||||||
|
const ExperimentalApp = loadable(() => import('./apps/experimental/App'));
|
||||||
|
|
||||||
|
const RootApp = ({ history }: { history: History }) => {
|
||||||
|
const layoutMode = localStorage.getItem('layout');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiProvider>
|
||||||
|
<WebConfigProvider>
|
||||||
|
<HistoryRouter history={history}>
|
||||||
|
{
|
||||||
|
layoutMode === 'experimental' ?
|
||||||
|
<ExperimentalApp /> :
|
||||||
|
<StableApp />
|
||||||
|
}
|
||||||
|
</HistoryRouter>
|
||||||
|
</WebConfigProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RootApp;
|
3
src/apiclient.d.ts
vendored
3
src/apiclient.d.ts
vendored
|
@ -117,6 +117,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
getCountries(): Promise<CountryInfo[]>;
|
getCountries(): Promise<CountryInfo[]>;
|
||||||
getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
getCriticReviews(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||||
getCultures(): Promise<CultureDto[]>;
|
getCultures(): Promise<CultureDto[]>;
|
||||||
|
getCurrentUser(cache?: boolean): Promise<UserDto>;
|
||||||
getCurrentUserId(): string;
|
getCurrentUserId(): string;
|
||||||
getDateParamValue(date: Date): string;
|
getDateParamValue(date: Date): string;
|
||||||
getDefaultImageQuality(imageType: ImageType): number;
|
getDefaultImageQuality(imageType: ImageType): number;
|
||||||
|
@ -267,7 +268,7 @@ declare module 'jellyfin-apiclient' {
|
||||||
sendWebSocketMessage(name: string, data: any): void;
|
sendWebSocketMessage(name: string, data: any): void;
|
||||||
serverAddress(val?: string): string;
|
serverAddress(val?: string): string;
|
||||||
serverId(): string;
|
serverId(): string;
|
||||||
serverVersion(): string
|
serverVersion(): string;
|
||||||
setAuthenticationInfo(accessKey?: string, userId?: string): void;
|
setAuthenticationInfo(accessKey?: string, userId?: string): void;
|
||||||
setRequestHeaders(headers: any): void;
|
setRequestHeaders(headers: any): void;
|
||||||
setSystemInfo(info: SystemInfo): void;
|
setSystemInfo(info: SystemInfo): void;
|
||||||
|
|
44
src/apps/experimental/App.tsx
Normal file
44
src/apps/experimental/App.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ConnectionRequired from 'components/ConnectionRequired';
|
||||||
|
import ServerContentPage from 'components/ServerContentPage';
|
||||||
|
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||||
|
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||||
|
|
||||||
|
import AppLayout from './AppLayout';
|
||||||
|
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
|
||||||
|
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
|
||||||
|
|
||||||
|
const ExperimentalApp = () => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path='/*' element={<AppLayout />}>
|
||||||
|
{/* User routes */}
|
||||||
|
<Route element={<ConnectionRequired />}>
|
||||||
|
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
|
||||||
|
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route element={<ConnectionRequired isAdminRequired />}>
|
||||||
|
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
|
||||||
|
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
|
||||||
|
<Route path='configurationpage' element={
|
||||||
|
<ServerContentPage view='/web/configurationpage' />
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route element={<ConnectionRequired isUserRequired={false} />}>
|
||||||
|
<Route index element={<Navigate replace to='/home.html' />} />
|
||||||
|
|
||||||
|
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExperimentalApp;
|
114
src/apps/experimental/AppLayout.tsx
Normal file
114
src/apps/experimental/AppLayout.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import AppHeader from 'components/AppHeader';
|
||||||
|
import Backdrop from 'components/Backdrop';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||||
|
|
||||||
|
import AppToolbar from './components/AppToolbar';
|
||||||
|
import AppDrawer, { DRAWER_WIDTH, isDrawerPath } from './components/drawers/AppDrawer';
|
||||||
|
import ElevationScroll from './components/ElevationScroll';
|
||||||
|
import theme from './theme';
|
||||||
|
|
||||||
|
import './AppOverrides.scss';
|
||||||
|
|
||||||
|
interface ExperimentalAppSettings {
|
||||||
|
isDrawerPinned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXPERIMENTAL_APP_SETTINGS: ExperimentalAppSettings = {
|
||||||
|
isDrawerPinned: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppLayout = () => {
|
||||||
|
const [ appSettings, setAppSettings ] = useLocalStorage<ExperimentalAppSettings>('ExperimentalAppSettings', DEFAULT_EXPERIMENTAL_APP_SETTINGS);
|
||||||
|
const [ isDrawerActive, setIsDrawerActive ] = useState(appSettings.isDrawerPinned);
|
||||||
|
const { user } = useApi();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isDrawerAvailable = isDrawerPath(location.pathname);
|
||||||
|
const isDrawerOpen = isDrawerActive && isDrawerAvailable && Boolean(user);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDrawerActive !== appSettings.isDrawerPinned) {
|
||||||
|
setAppSettings({
|
||||||
|
...appSettings,
|
||||||
|
isDrawerPinned: isDrawerActive
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ appSettings, isDrawerActive, setAppSettings ]);
|
||||||
|
|
||||||
|
const onToggleDrawer = useCallback(() => {
|
||||||
|
setIsDrawerActive(!isDrawerActive);
|
||||||
|
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<Backdrop />
|
||||||
|
|
||||||
|
<div style={{ display: 'none' }}>
|
||||||
|
{/*
|
||||||
|
* TODO: These components are not used, but views interact with them directly so the need to be
|
||||||
|
* present in the dom. We add them in a hidden element to prevent errors.
|
||||||
|
*/}
|
||||||
|
<AppHeader />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<ElevationScroll elevate={isDrawerOpen}>
|
||||||
|
<AppBar
|
||||||
|
position='fixed'
|
||||||
|
sx={{ zIndex: (muiTheme) => muiTheme.zIndex.drawer + 1 }}
|
||||||
|
>
|
||||||
|
<AppToolbar
|
||||||
|
isDrawerOpen={isDrawerOpen}
|
||||||
|
onDrawerButtonClick={onToggleDrawer}
|
||||||
|
/>
|
||||||
|
</AppBar>
|
||||||
|
</ElevationScroll>
|
||||||
|
|
||||||
|
<AppDrawer
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={onToggleDrawer}
|
||||||
|
onOpen={onToggleDrawer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component='main'
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
transition: theme.transitions.create('margin', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.leavingScreen
|
||||||
|
}),
|
||||||
|
marginLeft: 0,
|
||||||
|
...(isDrawerAvailable && {
|
||||||
|
marginLeft: {
|
||||||
|
sm: `-${DRAWER_WIDTH}px`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(isDrawerActive && {
|
||||||
|
transition: theme.transitions.create('margin', {
|
||||||
|
easing: theme.transitions.easing.easeOut,
|
||||||
|
duration: theme.transitions.duration.enteringScreen
|
||||||
|
}),
|
||||||
|
marginLeft: 0
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='mainAnimatedPages skinBody' />
|
||||||
|
<div className='skinBody'>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
40
src/apps/experimental/AppOverrides.scss
Normal file
40
src/apps/experimental/AppOverrides.scss
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
// Default MUI breakpoints
|
||||||
|
// https://mui.com/material-ui/customization/breakpoints/#default-breakpoints
|
||||||
|
$mui-bp-sm: 600px;
|
||||||
|
$mui-bp-md: 900px;
|
||||||
|
$mui-bp-lg: 1200px;
|
||||||
|
$mui-bp-xl: 1536px;
|
||||||
|
|
||||||
|
// Fix main pages layout to work with drawer
|
||||||
|
.mainAnimatedPage {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix dashboard pages layout to work with drawer
|
||||||
|
.dashboardDocument .skinBody {
|
||||||
|
position: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide some items from the user "settings" page that are in the drawer
|
||||||
|
#myPreferencesMenuPage {
|
||||||
|
.lnkQuickConnectPreferences,
|
||||||
|
.adminSection,
|
||||||
|
.userSection {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix the padding of some pages
|
||||||
|
.homePage.libraryPage, // Home page
|
||||||
|
.libraryPage:not(.withTabs), // Tabless library pages
|
||||||
|
.content-primary.content-primary { // Dashboard pages
|
||||||
|
padding-top: 3.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.libraryPage.withTabs {
|
||||||
|
padding-top: 6.5rem !important;
|
||||||
|
|
||||||
|
@media all and (min-width: $mui-bp-lg) {
|
||||||
|
padding-top: 3.25rem !important;
|
||||||
|
}
|
||||||
|
}
|
112
src/apps/experimental/components/AppToolbar/RemotePlayButton.tsx
Normal file
112
src/apps/experimental/components/AppToolbar/RemotePlayButton.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import CastConnected from '@mui/icons-material/CastConnected';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Cast from '@mui/icons-material/Cast';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import Events from 'utils/events';
|
||||||
|
|
||||||
|
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||||
|
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
|
||||||
|
|
||||||
|
const RemotePlayButton = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
|
||||||
|
|
||||||
|
const updatePlayerInfo = useCallback(() => {
|
||||||
|
setPlayerInfo(playbackManager.getPlayerInfo());
|
||||||
|
}, [ setPlayerInfo ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Events.on(playbackManager, 'playerchange', updatePlayerInfo);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Events.off(playbackManager, 'playerchange', updatePlayerInfo);
|
||||||
|
};
|
||||||
|
}, [ updatePlayerInfo ]);
|
||||||
|
|
||||||
|
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
|
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
|
||||||
|
|
||||||
|
const onRemotePlayButtonClick = useCallback((event) => {
|
||||||
|
setRemotePlayMenuAnchorEl(event.currentTarget);
|
||||||
|
}, [ setRemotePlayMenuAnchorEl ]);
|
||||||
|
|
||||||
|
const onRemotePlayMenuClose = useCallback(() => {
|
||||||
|
setRemotePlayMenuAnchorEl(null);
|
||||||
|
}, [ setRemotePlayMenuAnchorEl ]);
|
||||||
|
|
||||||
|
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
|
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
|
||||||
|
|
||||||
|
const onRemotePlayActiveButtonClick = useCallback((event) => {
|
||||||
|
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
|
||||||
|
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||||
|
|
||||||
|
const onRemotePlayActiveMenuClose = useCallback(() => {
|
||||||
|
setRemotePlayActiveMenuAnchorEl(null);
|
||||||
|
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(playerInfo && !playerInfo.isLocalPlayer) ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={globalize.translate('ButtonCast')}>
|
||||||
|
<Button
|
||||||
|
variant='text'
|
||||||
|
size='large'
|
||||||
|
startIcon={<CastConnected />}
|
||||||
|
aria-label={globalize.translate('ButtonCast')}
|
||||||
|
aria-controls={ACTIVE_ID}
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={onRemotePlayActiveButtonClick}
|
||||||
|
color='inherit'
|
||||||
|
sx={{
|
||||||
|
color: theme.palette.primary.main
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playerInfo.deviceName || playerInfo.name}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={globalize.translate('ButtonCast')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
aria-label={globalize.translate('ButtonCast')}
|
||||||
|
aria-controls={ID}
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={onRemotePlayButtonClick}
|
||||||
|
color='inherit'
|
||||||
|
>
|
||||||
|
<Cast />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RemotePlayMenu
|
||||||
|
open={isRemotePlayMenuOpen}
|
||||||
|
anchorEl={remotePlayMenuAnchorEl}
|
||||||
|
onMenuClose={onRemotePlayMenuClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RemotePlayActiveMenu
|
||||||
|
open={isRemotePlayActiveMenuOpen}
|
||||||
|
anchorEl={remotePlayActiveMenuAnchorEl}
|
||||||
|
onMenuClose={onRemotePlayActiveMenuClose}
|
||||||
|
playerInfo={playerInfo}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemotePlayButton;
|
|
@ -0,0 +1,64 @@
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import AppUserMenu, { ID } from './menus/AppUserMenu';
|
||||||
|
|
||||||
|
const UserMenuButton = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { api, user } = useApi();
|
||||||
|
|
||||||
|
const [ userMenuAnchorEl, setUserMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||||
|
const isUserMenuOpen = Boolean(userMenuAnchorEl);
|
||||||
|
|
||||||
|
const onUserButtonClick = useCallback((event) => {
|
||||||
|
setUserMenuAnchorEl(event.currentTarget);
|
||||||
|
}, [ setUserMenuAnchorEl ]);
|
||||||
|
|
||||||
|
const onUserMenuClose = useCallback(() => {
|
||||||
|
setUserMenuAnchorEl(null);
|
||||||
|
}, [ setUserMenuAnchorEl ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title={globalize.translate('UserMenu')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
edge='end'
|
||||||
|
aria-label={globalize.translate('UserMenu')}
|
||||||
|
aria-controls={ID}
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={onUserButtonClick}
|
||||||
|
color='inherit'
|
||||||
|
sx={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
alt={user?.Name || undefined}
|
||||||
|
src={
|
||||||
|
api && user?.Id ?
|
||||||
|
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
bgcolor: theme.palette.primary.dark,
|
||||||
|
color: 'inherit'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<AppUserMenu
|
||||||
|
open={isUserMenuOpen}
|
||||||
|
anchorEl={userMenuAnchorEl}
|
||||||
|
onMenuClose={onUserMenuClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserMenuButton;
|
117
src/apps/experimental/components/AppToolbar/index.tsx
Normal file
117
src/apps/experimental/components/AppToolbar/index.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import appIcon from 'assets/img/icon-transparent.png';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import AppTabs from '../tabs/AppTabs';
|
||||||
|
import { isDrawerPath } from '../drawers/AppDrawer';
|
||||||
|
import UserMenuButton from './UserMenuButton';
|
||||||
|
import RemotePlayButton from './RemotePlayButton';
|
||||||
|
|
||||||
|
interface AppToolbarProps {
|
||||||
|
isDrawerOpen: boolean
|
||||||
|
onDrawerButtonClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppToolbar: FC<AppToolbarProps> = ({
|
||||||
|
isDrawerOpen,
|
||||||
|
onDrawerButtonClick
|
||||||
|
}) => {
|
||||||
|
const { user } = useApi();
|
||||||
|
const isUserLoggedIn = Boolean(user);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isDrawerAvailable = isDrawerPath(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar
|
||||||
|
variant='dense'
|
||||||
|
sx={{
|
||||||
|
flexWrap: {
|
||||||
|
xs: 'wrap',
|
||||||
|
lg: 'nowrap'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUserLoggedIn && isDrawerAvailable && (
|
||||||
|
<Tooltip title={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
edge='start'
|
||||||
|
color='inherit'
|
||||||
|
aria-label={globalize.translate(isDrawerOpen ? 'MenuClose' : 'MenuOpen')}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={onDrawerButtonClick}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component={Link}
|
||||||
|
to='/'
|
||||||
|
color='inherit'
|
||||||
|
aria-label={globalize.translate('Home')}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
textDecoration: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component='img'
|
||||||
|
src={appIcon}
|
||||||
|
sx={{
|
||||||
|
height: '2rem',
|
||||||
|
marginInlineEnd: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant='h6'
|
||||||
|
noWrap
|
||||||
|
component='div'
|
||||||
|
sx={{ display: { xs: 'none', sm: 'inline-block' } }}
|
||||||
|
>
|
||||||
|
Jellyfin
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||||
|
|
||||||
|
{isUserLoggedIn && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', flexGrow: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<RemotePlayButton />
|
||||||
|
|
||||||
|
<Tooltip title={globalize.translate('Search')}>
|
||||||
|
<IconButton
|
||||||
|
size='large'
|
||||||
|
aria-label={globalize.translate('Search')}
|
||||||
|
color='inherit'
|
||||||
|
component={Link}
|
||||||
|
to='/search.html'
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 0 }}>
|
||||||
|
<UserMenuButton />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppToolbar;
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { AppSettingsAlt, Close } from '@mui/icons-material';
|
||||||
|
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||||
|
import Logout from '@mui/icons-material/Logout';
|
||||||
|
import PhonelinkLock from '@mui/icons-material/PhonelinkLock';
|
||||||
|
import Settings from '@mui/icons-material/Settings';
|
||||||
|
import Storage from '@mui/icons-material/Storage';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Menu, { MenuProps } from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { appHost } from 'components/apphost';
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import Dashboard from 'utils/dashboard';
|
||||||
|
|
||||||
|
export const ID = 'app-user-menu';
|
||||||
|
|
||||||
|
interface AppUserMenuProps extends MenuProps {
|
||||||
|
onMenuClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppUserMenu: FC<AppUserMenuProps> = ({
|
||||||
|
anchorEl,
|
||||||
|
open,
|
||||||
|
onMenuClose
|
||||||
|
}) => {
|
||||||
|
const { user } = useApi();
|
||||||
|
|
||||||
|
const onClientSettingsClick = useCallback(() => {
|
||||||
|
window.NativeShell?.openClientSettings();
|
||||||
|
onMenuClose();
|
||||||
|
}, [ onMenuClose ]);
|
||||||
|
|
||||||
|
const onExitAppClick = useCallback(() => {
|
||||||
|
appHost.exit();
|
||||||
|
onMenuClose();
|
||||||
|
}, [ onMenuClose ]);
|
||||||
|
|
||||||
|
const onLogoutClick = useCallback(() => {
|
||||||
|
Dashboard.logout();
|
||||||
|
onMenuClose();
|
||||||
|
}, [ onMenuClose ]);
|
||||||
|
|
||||||
|
const onSelectServerClick = useCallback(() => {
|
||||||
|
Dashboard.selectServer();
|
||||||
|
onMenuClose();
|
||||||
|
}, [ onMenuClose ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
id={ID}
|
||||||
|
keepMounted
|
||||||
|
open={open}
|
||||||
|
onClose={onMenuClose}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to={`/userprofile.html?userId=${user?.Id}`}
|
||||||
|
onClick={onMenuClose}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AccountCircle />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('Profile')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to='/mypreferencesmenu.html'
|
||||||
|
onClick={onMenuClose}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Settings />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('Settings')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{appHost.supports('clientsettings') && ([
|
||||||
|
<Divider key='client-settings-divider' />,
|
||||||
|
<MenuItem
|
||||||
|
key='client-settings-button'
|
||||||
|
onClick={onClientSettingsClick}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<AppSettingsAlt />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('ClientSettings')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
])}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to='/mypreferencesquickconnect.html'
|
||||||
|
onClick={onMenuClose}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PhonelinkLock />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('QuickConnect')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{appHost.supports('multiserver') && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={onSelectServerClick}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Storage />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('SelectServer')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={onLogoutClick}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Logout />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('ButtonSignOut')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{appHost.supports('exitmenu') && ([
|
||||||
|
<Divider key='exit-menu-divider' />,
|
||||||
|
<MenuItem
|
||||||
|
key='exit-menu-button'
|
||||||
|
onClick={onExitAppClick}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Close />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('ButtonExitApp')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
])}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppUserMenu;
|
|
@ -0,0 +1,160 @@
|
||||||
|
import Check from '@mui/icons-material/Check';
|
||||||
|
import Close from '@mui/icons-material/Close';
|
||||||
|
import SettingsRemote from '@mui/icons-material/SettingsRemote';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import Menu, { MenuProps } from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import dialog from 'components/dialog/dialog';
|
||||||
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
|
import React, { FC, useCallback, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { enable, isEnabled, supported } from 'scripts/autocast';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
interface RemotePlayActiveMenuProps extends MenuProps {
|
||||||
|
onMenuClose: () => void
|
||||||
|
playerInfo: {
|
||||||
|
name: string
|
||||||
|
isLocalPlayer: boolean
|
||||||
|
id?: string
|
||||||
|
deviceName?: string
|
||||||
|
playableMediaTypes?: string[]
|
||||||
|
supportedCommands?: string[]
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ID = 'app-remote-play-active-menu';
|
||||||
|
|
||||||
|
const RemotePlayActiveMenu: FC<RemotePlayActiveMenuProps> = ({
|
||||||
|
anchorEl,
|
||||||
|
open,
|
||||||
|
onMenuClose,
|
||||||
|
playerInfo
|
||||||
|
}) => {
|
||||||
|
const [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ] = useState(playbackManager.enableDisplayMirroring());
|
||||||
|
const isDisplayMirrorSupported = playerInfo?.supportedCommands && playerInfo.supportedCommands.indexOf('DisplayContent') !== -1;
|
||||||
|
const toggleDisplayMirror = useCallback(() => {
|
||||||
|
playbackManager.enableDisplayMirroring(!isDisplayMirrorEnabled);
|
||||||
|
setIsDisplayMirrorEnabled(!isDisplayMirrorEnabled);
|
||||||
|
}, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]);
|
||||||
|
|
||||||
|
const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled());
|
||||||
|
const isAutoCastSupported = supported();
|
||||||
|
const toggleAutoCast = useCallback(() => {
|
||||||
|
enable(!isAutoCastEnabled);
|
||||||
|
setIsAutoCastEnabled(!isAutoCastEnabled);
|
||||||
|
}, [ isAutoCastEnabled, setIsAutoCastEnabled ]);
|
||||||
|
|
||||||
|
const remotePlayerName = playerInfo?.deviceName || playerInfo?.name;
|
||||||
|
|
||||||
|
const disconnectRemotePlayer = useCallback(() => {
|
||||||
|
if (playbackManager.getSupportedCommands().indexOf('EndSession') !== -1) {
|
||||||
|
dialog.show({
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
name: globalize.translate('Yes'),
|
||||||
|
id: 'yes'
|
||||||
|
}, {
|
||||||
|
name: globalize.translate('No'),
|
||||||
|
id: 'no'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
text: globalize.translate('ConfirmEndPlayerSession', remotePlayerName)
|
||||||
|
}).then(id => {
|
||||||
|
onMenuClose();
|
||||||
|
|
||||||
|
if (id === 'yes') {
|
||||||
|
playbackManager.getCurrentPlayer().endSession();
|
||||||
|
}
|
||||||
|
playbackManager.setDefaultPlayerActive();
|
||||||
|
}).catch(() => {
|
||||||
|
// Dialog closed
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onMenuClose();
|
||||||
|
playbackManager.setDefaultPlayerActive();
|
||||||
|
}
|
||||||
|
}, [ onMenuClose, remotePlayerName ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
id={ID}
|
||||||
|
keepMounted
|
||||||
|
open={open}
|
||||||
|
onClose={onMenuClose}
|
||||||
|
MenuListProps={{
|
||||||
|
'aria-labelledby': 'remote-play-active-subheader',
|
||||||
|
subheader: (
|
||||||
|
<ListSubheader component='div' id='remote-play-active-subheader'>
|
||||||
|
{remotePlayerName}
|
||||||
|
</ListSubheader>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDisplayMirrorSupported && (
|
||||||
|
<MenuItem onClick={toggleDisplayMirror}>
|
||||||
|
{isDisplayMirrorEnabled && (
|
||||||
|
<ListItemIcon>
|
||||||
|
<Check />
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
|
<ListItemText inset={!isDisplayMirrorEnabled}>
|
||||||
|
{globalize.translate('EnableDisplayMirroring')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAutoCastSupported && (
|
||||||
|
<MenuItem onClick={toggleAutoCast}>
|
||||||
|
{isAutoCastEnabled && (
|
||||||
|
<ListItemIcon>
|
||||||
|
<Check />
|
||||||
|
</ListItemIcon>
|
||||||
|
)}
|
||||||
|
<ListItemText inset={!isAutoCastEnabled}>
|
||||||
|
{globalize.translate('EnableAutoCast')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isDisplayMirrorSupported || isAutoCastSupported) && <Divider />}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to='/queue'
|
||||||
|
onClick={onMenuClose}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsRemote />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('HeaderRemoteControl')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={disconnectRemotePlayer}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Close />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('Disconnect')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemotePlayActiveMenu;
|
|
@ -0,0 +1,100 @@
|
||||||
|
import Warning from '@mui/icons-material/Warning';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Menu, { type MenuProps } from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import { playbackManager } from 'components/playback/playbackmanager';
|
||||||
|
import { pluginManager } from 'components/pluginManager';
|
||||||
|
import type { PlayTarget } from 'types/playTarget';
|
||||||
|
|
||||||
|
import PlayTargetIcon from '../../PlayTargetIcon';
|
||||||
|
|
||||||
|
interface RemotePlayMenuProps extends MenuProps {
|
||||||
|
onMenuClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ID = 'app-remote-play-menu';
|
||||||
|
|
||||||
|
const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
|
||||||
|
anchorEl,
|
||||||
|
open,
|
||||||
|
onMenuClose
|
||||||
|
}) => {
|
||||||
|
// TODO: Add other checks for support (Android app, secure context, etc)
|
||||||
|
const isChromecastPluginLoaded = !!pluginManager.plugins.find(plugin => plugin.id === 'chromecast');
|
||||||
|
|
||||||
|
const [ playbackTargets, setPlaybackTargets ] = useState<PlayTarget[]>([]);
|
||||||
|
|
||||||
|
const onPlayTargetClick = (target: PlayTarget) => {
|
||||||
|
playbackManager.trySetActivePlayer(target.playerName, target);
|
||||||
|
onMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlaybackTargets = async () => {
|
||||||
|
setPlaybackTargets(
|
||||||
|
await playbackManager.getTargets()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
fetchPlaybackTargets()
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[AppRemotePlayMenu] unable to get playback targets', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ open, setPlaybackTargets ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right'
|
||||||
|
}}
|
||||||
|
id={ID}
|
||||||
|
keepMounted
|
||||||
|
open={open}
|
||||||
|
onClose={onMenuClose}
|
||||||
|
>
|
||||||
|
{!isChromecastPluginLoaded && ([
|
||||||
|
<MenuItem key='cast-unsupported-item' disabled>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Warning />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{globalize.translate('GoogleCastUnsupported')}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>,
|
||||||
|
<Divider key='cast-unsupported-divider' />
|
||||||
|
])}
|
||||||
|
|
||||||
|
{playbackTargets.map(target => (
|
||||||
|
<MenuItem
|
||||||
|
key={target.id}
|
||||||
|
// Since we are looping over targets there is no good way to avoid creating a new function here
|
||||||
|
// eslint-disable-next-line react/jsx-no-bind
|
||||||
|
onClick={() => onPlayTargetClick(target)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PlayTargetIcon target={target} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={ target.appName ? `${target.name} - ${target.appName}` : target.name }
|
||||||
|
secondary={ target.user?.Name }
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemotePlayMenu;
|
21
src/apps/experimental/components/ElevationScroll.tsx
Normal file
21
src/apps/experimental/components/ElevationScroll.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import useScrollTrigger from '@mui/material/useScrollTrigger';
|
||||||
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that changes the elevation of a child component when scrolled.
|
||||||
|
*/
|
||||||
|
const ElevationScroll = ({ children, elevate = false }: { children: ReactElement, elevate?: boolean }) => {
|
||||||
|
const trigger = useScrollTrigger({
|
||||||
|
disableHysteresis: true,
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const isElevated = elevate || trigger;
|
||||||
|
|
||||||
|
return React.cloneElement(children, {
|
||||||
|
color: isElevated ? 'primary' : 'transparent',
|
||||||
|
elevation: isElevated ? 4 : 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElevationScroll;
|
50
src/apps/experimental/components/LibraryIcon.tsx
Normal file
50
src/apps/experimental/components/LibraryIcon.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import Movie from '@mui/icons-material/Movie';
|
||||||
|
import MusicNote from '@mui/icons-material/MusicNote';
|
||||||
|
import Photo from '@mui/icons-material/Photo';
|
||||||
|
import LiveTv from '@mui/icons-material/LiveTv';
|
||||||
|
import Tv from '@mui/icons-material/Tv';
|
||||||
|
import Theaters from '@mui/icons-material/Theaters';
|
||||||
|
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||||
|
import Book from '@mui/icons-material/Book';
|
||||||
|
import Collections from '@mui/icons-material/Collections';
|
||||||
|
import Queue from '@mui/icons-material/Queue';
|
||||||
|
import Folder from '@mui/icons-material/Folder';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { CollectionType } from 'types/collectionType';
|
||||||
|
|
||||||
|
interface LibraryIconProps {
|
||||||
|
item: BaseItemDto
|
||||||
|
}
|
||||||
|
|
||||||
|
const LibraryIcon: FC<LibraryIconProps> = ({
|
||||||
|
item
|
||||||
|
}) => {
|
||||||
|
switch (item.CollectionType) {
|
||||||
|
case CollectionType.Movies:
|
||||||
|
return <Movie />;
|
||||||
|
case CollectionType.Music:
|
||||||
|
return <MusicNote />;
|
||||||
|
case CollectionType.HomeVideos:
|
||||||
|
case CollectionType.Photos:
|
||||||
|
return <Photo />;
|
||||||
|
case CollectionType.LiveTv:
|
||||||
|
return <LiveTv />;
|
||||||
|
case CollectionType.TvShows:
|
||||||
|
return <Tv />;
|
||||||
|
case CollectionType.Trailers:
|
||||||
|
return <Theaters />;
|
||||||
|
case CollectionType.MusicVideos:
|
||||||
|
return <MusicVideo />;
|
||||||
|
case CollectionType.Books:
|
||||||
|
return <Book />;
|
||||||
|
case CollectionType.BoxSets:
|
||||||
|
return <Collections />;
|
||||||
|
case CollectionType.Playlists:
|
||||||
|
return <Queue />;
|
||||||
|
default:
|
||||||
|
return <Folder />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryIcon;
|
38
src/apps/experimental/components/PlayTargetIcon.tsx
Normal file
38
src/apps/experimental/components/PlayTargetIcon.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Cast from '@mui/icons-material/Cast';
|
||||||
|
import Computer from '@mui/icons-material/Computer';
|
||||||
|
import Devices from '@mui/icons-material/Devices';
|
||||||
|
import Smartphone from '@mui/icons-material/Smartphone';
|
||||||
|
import Tablet from '@mui/icons-material/Tablet';
|
||||||
|
import Tv from '@mui/icons-material/Tv';
|
||||||
|
|
||||||
|
import browser from 'scripts/browser';
|
||||||
|
import type { PlayTarget } from 'types/playTarget';
|
||||||
|
|
||||||
|
const PlayTargetIcon = ({ target }: { target: PlayTarget }) => {
|
||||||
|
if (!target.deviceType && target.isLocalPlayer) {
|
||||||
|
if (browser.tv) {
|
||||||
|
return <Tv />;
|
||||||
|
} else if (browser.mobile) {
|
||||||
|
return <Smartphone />;
|
||||||
|
}
|
||||||
|
return <Computer />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (target.deviceType) {
|
||||||
|
case 'smartphone':
|
||||||
|
return <Smartphone />;
|
||||||
|
case 'tablet':
|
||||||
|
return <Tablet />;
|
||||||
|
case 'desktop':
|
||||||
|
return <Computer />;
|
||||||
|
case 'cast':
|
||||||
|
return <Cast />;
|
||||||
|
case 'tv':
|
||||||
|
return <Tv />;
|
||||||
|
default:
|
||||||
|
return <Devices />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayTargetIcon;
|
86
src/apps/experimental/components/drawers/AppDrawer.tsx
Normal file
86
src/apps/experimental/components/drawers/AppDrawer.tsx
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from '../../routes/asyncRoutes';
|
||||||
|
import { LEGACY_ADMIN_ROUTES, LEGACY_USER_ROUTES } from '../../routes/legacyRoutes';
|
||||||
|
|
||||||
|
import AdvancedDrawerSection from './dashboard/AdvancedDrawerSection';
|
||||||
|
import DevicesDrawerSection from './dashboard/DevicesDrawerSection';
|
||||||
|
import LiveTvDrawerSection from './dashboard/LiveTvDrawerSection';
|
||||||
|
import PluginDrawerSection from './dashboard/PluginDrawerSection';
|
||||||
|
import ServerDrawerSection from './dashboard/ServerDrawerSection';
|
||||||
|
import MainDrawerContent from './MainDrawerContent';
|
||||||
|
import ResponsiveDrawer, { ResponsiveDrawerProps } from './ResponsiveDrawer';
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
const DRAWERLESS_ROUTES = [
|
||||||
|
'edititemmetadata.html', // metadata manager
|
||||||
|
'video' // video player
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAIN_DRAWER_ROUTES = [
|
||||||
|
...ASYNC_USER_ROUTES,
|
||||||
|
...LEGACY_USER_ROUTES
|
||||||
|
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
|
||||||
|
|
||||||
|
const ADMIN_DRAWER_ROUTES = [
|
||||||
|
...ASYNC_ADMIN_ROUTES,
|
||||||
|
...LEGACY_ADMIN_ROUTES,
|
||||||
|
{ path: '/configurationpage' } // Plugin configuration page
|
||||||
|
].filter(route => !DRAWERLESS_ROUTES.includes(route.path));
|
||||||
|
|
||||||
|
/** Utility function to check if a path has a drawer. */
|
||||||
|
export const isDrawerPath = (path: string) => (
|
||||||
|
MAIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|
||||||
|
|| ADMIN_DRAWER_ROUTES.some(route => route.path === path || `/${route.path}` === path)
|
||||||
|
);
|
||||||
|
|
||||||
|
const AppDrawer: FC<ResponsiveDrawerProps> = ({
|
||||||
|
open = false,
|
||||||
|
onClose,
|
||||||
|
onOpen
|
||||||
|
}) => (
|
||||||
|
<Routes>
|
||||||
|
{
|
||||||
|
MAIN_DRAWER_ROUTES.map(route => (
|
||||||
|
<Route
|
||||||
|
key={route.path}
|
||||||
|
path={route.path}
|
||||||
|
element={
|
||||||
|
<ResponsiveDrawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpen={onOpen}
|
||||||
|
>
|
||||||
|
<MainDrawerContent />
|
||||||
|
</ResponsiveDrawer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ADMIN_DRAWER_ROUTES.map(route => (
|
||||||
|
<Route
|
||||||
|
key={route.path}
|
||||||
|
path={route.path}
|
||||||
|
element={
|
||||||
|
<ResponsiveDrawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpen={onOpen}
|
||||||
|
>
|
||||||
|
<ServerDrawerSection />
|
||||||
|
<DevicesDrawerSection />
|
||||||
|
<LiveTvDrawerSection />
|
||||||
|
<AdvancedDrawerSection />
|
||||||
|
<PluginDrawerSection />
|
||||||
|
</ResponsiveDrawer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AppDrawer;
|
45
src/apps/experimental/components/drawers/ListItemLink.tsx
Normal file
45
src/apps/experimental/components/drawers/ListItemLink.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import ListItemButton, { ListItemButtonBaseProps } from '@mui/material/ListItemButton';
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ListItemLinkProps extends ListItemButtonBaseProps {
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatchingParams = (routeParams: URLSearchParams, currentParams: URLSearchParams) => {
|
||||||
|
for (const param of routeParams) {
|
||||||
|
if (currentParams.get(param[0]) !== param[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItemLink: FC<ListItemLinkProps> = ({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
...params
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [ searchParams ] = useSearchParams();
|
||||||
|
|
||||||
|
const [ toPath, toParams ] = to.split('?');
|
||||||
|
// eslint-disable-next-line compat/compat
|
||||||
|
const toSearchParams = new URLSearchParams(`?${toParams}`);
|
||||||
|
|
||||||
|
const selected = location.pathname === toPath && (!toParams || isMatchingParams(toSearchParams, searchParams));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
component={Link}
|
||||||
|
to={to}
|
||||||
|
selected={selected}
|
||||||
|
{...params}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListItemLink;
|
186
src/apps/experimental/components/drawers/MainDrawerContent.tsx
Normal file
186
src/apps/experimental/components/drawers/MainDrawerContent.tsx
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||||
|
import type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models/system-info';
|
||||||
|
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api';
|
||||||
|
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||||
|
import Dashboard from '@mui/icons-material/Dashboard';
|
||||||
|
import Edit from '@mui/icons-material/Edit';
|
||||||
|
import Favorite from '@mui/icons-material/Favorite';
|
||||||
|
import Home from '@mui/icons-material/Home';
|
||||||
|
import Link from '@mui/icons-material/Link';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import { useWebConfig } from 'hooks/useWebConfig';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import { appRouter } from 'components/router/appRouter';
|
||||||
|
|
||||||
|
import ListItemLink from './ListItemLink';
|
||||||
|
import LibraryIcon from '../LibraryIcon';
|
||||||
|
|
||||||
|
const MainDrawerContent = () => {
|
||||||
|
const { api, user } = useApi();
|
||||||
|
const location = useLocation();
|
||||||
|
const [ systemInfo, setSystemInfo ] = useState<SystemInfo>();
|
||||||
|
const [ userViews, setUserViews ] = useState<BaseItemDto[]>([]);
|
||||||
|
const webConfig = useWebConfig();
|
||||||
|
|
||||||
|
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (api && user?.Id) {
|
||||||
|
getUserViewsApi(api)
|
||||||
|
.getUserViews({ userId: user.Id })
|
||||||
|
.then(({ data }) => {
|
||||||
|
setUserViews(data.Items || []);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[MainDrawer] failed to fetch user views', err);
|
||||||
|
setUserViews([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
getSystemApi(api)
|
||||||
|
.getSystemInfo()
|
||||||
|
.then(({ data }) => {
|
||||||
|
setSystemInfo(data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[MainDrawer] failed to fetch system info', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUserViews([]);
|
||||||
|
}
|
||||||
|
}, [ api, user?.Id ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* MAIN LINKS */}
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/home.html' selected={isHomeSelected}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Home />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('Home')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/home.html?tab=1'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Favorite />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('Favorites')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{/* CUSTOM LINKS */}
|
||||||
|
{(!!webConfig.menuLinks && webConfig.menuLinks.length > 0) && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{webConfig.menuLinks.map(menuLink => (
|
||||||
|
<ListItem
|
||||||
|
key={`${menuLink.name}_${menuLink.url}`}
|
||||||
|
disablePadding
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
component='a'
|
||||||
|
href={menuLink.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{/* TODO: Support custom icons */}
|
||||||
|
<Link />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={menuLink.name} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LIBRARY LINKS */}
|
||||||
|
{userViews.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<List
|
||||||
|
aria-labelledby='libraries-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='libraries-subheader'>
|
||||||
|
{globalize.translate('HeaderLibraries')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userViews.map(view => (
|
||||||
|
<ListItem key={view.Id} disablePadding>
|
||||||
|
<ListItemLink
|
||||||
|
to={appRouter.getRouteUrl(view, { context: view.CollectionType }).substring(1)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LibraryIcon item={view} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={view.Name} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ADMIN LINKS */}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<List
|
||||||
|
aria-labelledby='admin-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='admin-subheader'>
|
||||||
|
{globalize.translate('HeaderAdmin')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/dashboard.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Dashboard />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/edititemmetadata.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Edit />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('MetadataManager')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<Divider style={{ marginTop: 'auto' }} />
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={systemInfo?.ServerName ? systemInfo.ServerName : 'Jellyfin'}
|
||||||
|
secondary={systemInfo?.Version ? `v${systemInfo.Version}` : ''}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainDrawerContent;
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Theme } from '@mui/material/styles';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import browser from 'scripts/browser';
|
||||||
|
|
||||||
|
import { DRAWER_WIDTH } from './AppDrawer';
|
||||||
|
import { isTabPath } from '../tabs/tabRoutes';
|
||||||
|
|
||||||
|
export interface ResponsiveDrawerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResponsiveDrawer: FC<ResponsiveDrawerProps> = ({
|
||||||
|
children,
|
||||||
|
open = false,
|
||||||
|
onClose,
|
||||||
|
onOpen
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||||
|
const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
|
||||||
|
const isTallToolbar = isTabPath(location.pathname) && !isLargeScreen;
|
||||||
|
|
||||||
|
const getToolbarStyles = useCallback((theme: Theme) => ({
|
||||||
|
marginBottom: isTallToolbar ? theme.spacing(6) : 0
|
||||||
|
}), [ isTallToolbar ]);
|
||||||
|
|
||||||
|
return ( isSmallScreen ? (
|
||||||
|
/* DESKTOP DRAWER */
|
||||||
|
<Drawer
|
||||||
|
sx={{
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant='persistent'
|
||||||
|
anchor='left'
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
variant='dense'
|
||||||
|
sx={getToolbarStyles}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
/* MOBILE DRAWER */
|
||||||
|
<SwipeableDrawer
|
||||||
|
anchor='left'
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpen={onOpen}
|
||||||
|
// Disable swipe to open on iOS since it interferes with back navigation
|
||||||
|
disableDiscovery={browser.iOS}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true // Better open performance on mobile.
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
variant='dense'
|
||||||
|
sx={getToolbarStyles}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
role='presentation'
|
||||||
|
// Close the drawer when the content is clicked
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={onClose}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</SwipeableDrawer>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResponsiveDrawer;
|
|
@ -0,0 +1,110 @@
|
||||||
|
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 Schedule from '@mui/icons-material/Schedule';
|
||||||
|
import VpnKey from '@mui/icons-material/VpnKey';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import ListItemLink from '../ListItemLink';
|
||||||
|
|
||||||
|
const PLUGIN_PATHS = [
|
||||||
|
'/installedplugins.html',
|
||||||
|
'/availableplugins.html',
|
||||||
|
'/repositories.html',
|
||||||
|
'/addplugin.html',
|
||||||
|
'/configurationpage'
|
||||||
|
];
|
||||||
|
|
||||||
|
const AdvancedDrawerSection = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
aria-labelledby='advanced-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='advanced-subheader'>
|
||||||
|
{globalize.translate('TabAdvanced')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/networking.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Lan />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabNetworking')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/apikeys.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<VpnKey />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/log.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Article />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabLogs')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/notificationsettings.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<EditNotifications />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('Notifications')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/installedplugins.html' 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='/installedplugins.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/availableplugins.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabCatalog')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/repositories.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabRepositories')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/scheduledtasks.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Schedule />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabScheduledTasks')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdvancedDrawerSection;
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Devices, Analytics, Input, ExpandLess, ExpandMore } from '@mui/icons-material';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import ListItemLink from '../ListItemLink';
|
||||||
|
|
||||||
|
const DLNA_PATHS = [
|
||||||
|
'/dlnasettings.html',
|
||||||
|
'/dlnaprofiles.html'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DevicesDrawerSection = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isDlnaSectionOpen = DLNA_PATHS.includes(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
aria-labelledby='devices-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='devices-subheader'>
|
||||||
|
{globalize.translate('HeaderDevices')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/devices.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Devices />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderDevices')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/serveractivity.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Analytics />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/dlnasettings.html' selected={false}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Input />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={'DLNA'} />
|
||||||
|
{isDlnaSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<Collapse in={isDlnaSectionOpen} timeout='auto' unmountOnExit>
|
||||||
|
<List component='div' disablePadding>
|
||||||
|
<ListItemLink to='/dlnasettings.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('Settings')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/dlnaprofiles.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabProfiles')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DevicesDrawerSection;
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Dvr, LiveTv } from '@mui/icons-material';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import ListItemLink from '../ListItemLink';
|
||||||
|
|
||||||
|
const LiveTvDrawerSection = () => {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
aria-labelledby='livetv-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='livetv-subheader'>
|
||||||
|
{globalize.translate('LiveTV')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/livetvstatus.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LiveTv />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('LiveTV')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/livetvsettings.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Dvr />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderDVR')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LiveTvDrawerSection;
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||||
|
import { Folder } from '@mui/icons-material';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useApi } from 'hooks/useApi';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import Dashboard from 'utils/dashboard';
|
||||||
|
|
||||||
|
import ListItemLink from '../ListItemLink';
|
||||||
|
|
||||||
|
const PluginDrawerSection = () => {
|
||||||
|
const { api } = useApi();
|
||||||
|
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPluginPages = async () => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<List
|
||||||
|
aria-labelledby='plugins-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='plugins-subheader'>
|
||||||
|
{globalize.translate('TabPlugins')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
pagesInfo.map(pageInfo => (
|
||||||
|
<ListItem key={pageInfo.PluginId} disablePadding>
|
||||||
|
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{/* TODO: Support different icons? */}
|
||||||
|
<Folder />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={pageInfo.DisplayName} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginDrawerSection;
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
import ListItemLink from '../ListItemLink';
|
||||||
|
|
||||||
|
const LIBRARY_PATHS = [
|
||||||
|
'/library.html',
|
||||||
|
'/librarydisplay.html',
|
||||||
|
'/metadataimages.html',
|
||||||
|
'/metadatanfo.html'
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLAYBACK_PATHS = [
|
||||||
|
'/encodingsettings.html',
|
||||||
|
'/playbackconfiguration.html',
|
||||||
|
'/streamingsettings.html'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ServerDrawerSection = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
|
||||||
|
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
aria-labelledby='server-subheader'
|
||||||
|
subheader={
|
||||||
|
<ListSubheader component='div' id='server-subheader'>
|
||||||
|
{globalize.translate('TabServer')}
|
||||||
|
</ListSubheader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/dashboard.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Dashboard />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TabDashboard')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/dashboardgeneral.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Settings />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('General')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/userprofiles.html'>
|
||||||
|
<ListItemIcon>
|
||||||
|
<People />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderUsers')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/library.html' selected={false}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LibraryAdd />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
||||||
|
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
||||||
|
<List component='div' disablePadding>
|
||||||
|
<ListItemLink to='/library.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('HeaderLibraries')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/librarydisplay.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('Display')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/metadataimages.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('Metadata')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/metadatanfo.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemLink to='/encodingsettings.html' selected={false}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PlayCircle />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
||||||
|
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</ListItemLink>
|
||||||
|
</ListItem>
|
||||||
|
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
||||||
|
<List component='div' disablePadding>
|
||||||
|
<ListItemLink to='/encodingsettings.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('Transcoding')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/playbackconfiguration.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('ButtonResume')} />
|
||||||
|
</ListItemLink>
|
||||||
|
<ListItemLink to='/streamingsettings.html' sx={{ pl: 4 }}>
|
||||||
|
<ListItemText inset primary={globalize.translate('TabStreaming')} />
|
||||||
|
</ListItemLink>
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerDrawerSection;
|
90
src/apps/experimental/components/tabs/AppTabs.tsx
Normal file
90
src/apps/experimental/components/tabs/AppTabs.tsx
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
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';
|
||||||
|
import React, { FC, useCallback, useEffect } from 'react';
|
||||||
|
import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import TabRoutes, { getDefaultTabIndex } from './tabRoutes';
|
||||||
|
|
||||||
|
interface AppTabsParams {
|
||||||
|
isDrawerOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResize = debounce(() => window.dispatchEvent(new Event('resize')), 100);
|
||||||
|
|
||||||
|
const AppTabs: FC<AppTabsParams> = ({
|
||||||
|
isDrawerOpen
|
||||||
|
}) => {
|
||||||
|
const isBigScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm'));
|
||||||
|
const location = useLocation();
|
||||||
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||||
|
const searchParamsTab = searchParams.get('tab');
|
||||||
|
const libraryId = location.pathname === '/livetv.html' ?
|
||||||
|
'livetv' : searchParams.get('topParentId');
|
||||||
|
const activeTab = searchParamsTab !== null ?
|
||||||
|
parseInt(searchParamsTab, 10) :
|
||||||
|
getDefaultTabIndex(location.pathname, libraryId);
|
||||||
|
|
||||||
|
// HACK: Force resizing to workaround upstream bug with tab resizing
|
||||||
|
// https://github.com/mui/material-ui/issues/24011
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
}, [ isDrawerOpen ]);
|
||||||
|
|
||||||
|
const onTabClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const tabIndex = event.currentTarget.dataset.tabIndex;
|
||||||
|
|
||||||
|
if (tabIndex) {
|
||||||
|
searchParams.set('tab', tabIndex);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
}, [ searchParams, setSearchParams ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{
|
||||||
|
TabRoutes.map(route => (
|
||||||
|
<Route
|
||||||
|
key={route.path}
|
||||||
|
path={route.path}
|
||||||
|
element={
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: {
|
||||||
|
xs: 0,
|
||||||
|
lg: 'unset'
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
xs: 100,
|
||||||
|
lg: 'unset'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant={isBigScreen ? 'standard' : 'scrollable'}
|
||||||
|
centered={isBigScreen}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
route.tabs.map(({ index, label }) => (
|
||||||
|
<Tab
|
||||||
|
key={`${route}-tab-${index}`}
|
||||||
|
label={label}
|
||||||
|
data-tab-index={`${index}`}
|
||||||
|
onClick={onTabClick}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppTabs;
|
190
src/apps/experimental/components/tabs/tabRoutes.ts
Normal file
190
src/apps/experimental/components/tabs/tabRoutes.ts
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
import * as userSettings from 'scripts/settings/userSettings';
|
||||||
|
import { LibraryTab } from 'types/libraryTab';
|
||||||
|
|
||||||
|
interface TabDefinition {
|
||||||
|
index: number
|
||||||
|
label: string
|
||||||
|
value: LibraryTab
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabRoute {
|
||||||
|
path: string,
|
||||||
|
tabs: TabDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if a path has tabs.
|
||||||
|
*/
|
||||||
|
export const isTabPath = (path: string) => (
|
||||||
|
TabRoutes.some(route => route.path === path)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to get the default tab index for a specified URL path and library.
|
||||||
|
*/
|
||||||
|
export const getDefaultTabIndex = (path: string, libraryId?: string | null) => {
|
||||||
|
if (!libraryId) return 0;
|
||||||
|
|
||||||
|
const tabs = TabRoutes.find(route => route.path === path)?.tabs ?? [];
|
||||||
|
const defaultTab = userSettings.get('landing-' + libraryId, false);
|
||||||
|
|
||||||
|
return tabs.find(tab => tab.value === defaultTab)?.index
|
||||||
|
?? tabs.find(tab => tab.isDefault)?.index
|
||||||
|
?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabRoutes: TabRoute[] = [
|
||||||
|
{
|
||||||
|
path: '/livetv.html',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
label: globalize.translate('Programs'),
|
||||||
|
value: LibraryTab.Programs,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
label: globalize.translate('Guide'),
|
||||||
|
value: LibraryTab.Guide
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
label: globalize.translate('Channels'),
|
||||||
|
value: LibraryTab.Channels
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
label: globalize.translate('Recordings'),
|
||||||
|
value: LibraryTab.Recordings
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
label: globalize.translate('Schedule'),
|
||||||
|
value: LibraryTab.Schedule
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
label: globalize.translate('Series'),
|
||||||
|
value: LibraryTab.Series
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/movies.html',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
label: globalize.translate('Movies'),
|
||||||
|
value: LibraryTab.Movies,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
label: globalize.translate('Suggestions'),
|
||||||
|
value: LibraryTab.Suggestions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
label: globalize.translate('Trailers'),
|
||||||
|
value: LibraryTab.Trailers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
label: globalize.translate('Favorites'),
|
||||||
|
value: LibraryTab.Favorites
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
label: globalize.translate('Collections'),
|
||||||
|
value: LibraryTab.Collections
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
label: globalize.translate('Genres'),
|
||||||
|
value: LibraryTab.Genres
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/music.html',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
label: globalize.translate('Albums'),
|
||||||
|
value: LibraryTab.Albums,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
label: globalize.translate('Suggestions'),
|
||||||
|
value: LibraryTab.Suggestions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
label: globalize.translate('HeaderAlbumArtists'),
|
||||||
|
value: LibraryTab.AlbumArtists
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
label: globalize.translate('Artists'),
|
||||||
|
value: LibraryTab.Artists
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
label: globalize.translate('Playlists'),
|
||||||
|
value: LibraryTab.Playlists
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
label: globalize.translate('Songs'),
|
||||||
|
value: LibraryTab.Songs
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 6,
|
||||||
|
label: globalize.translate('Genres'),
|
||||||
|
value: LibraryTab.Genres
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tv.html',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
label: globalize.translate('Shows'),
|
||||||
|
value: LibraryTab.Shows,
|
||||||
|
isDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
label: globalize.translate('Suggestions'),
|
||||||
|
value: LibraryTab.Suggestions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
label: globalize.translate('TabUpcoming'),
|
||||||
|
value: LibraryTab.Upcoming
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
label: globalize.translate('Genres'),
|
||||||
|
value: LibraryTab.Genres
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
label: globalize.translate('TabNetworks'),
|
||||||
|
value: LibraryTab.Networks
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
label: globalize.translate('Episodes'),
|
||||||
|
value: LibraryTab.Episodes
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default TabRoutes;
|
11
src/apps/experimental/routes/asyncRoutes/admin.ts
Normal file
11
src/apps/experimental/routes/asyncRoutes/admin.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
|
||||||
|
|
||||||
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
|
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
|
||||||
|
{ path: 'usernew.html', page: 'user/usernew' },
|
||||||
|
{ path: 'userprofiles.html', page: 'user/userprofiles' },
|
||||||
|
{ path: 'useredit.html', page: 'user/useredit' },
|
||||||
|
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
|
||||||
|
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
|
||||||
|
{ path: 'userpassword.html', page: 'user/userpassword' }
|
||||||
|
];
|
2
src/apps/experimental/routes/asyncRoutes/index.ts
Normal file
2
src/apps/experimental/routes/asyncRoutes/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './admin';
|
||||||
|
export * from './user';
|
8
src/apps/experimental/routes/asyncRoutes/user.ts
Normal file
8
src/apps/experimental/routes/asyncRoutes/user.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { AsyncRoute, AsyncRouteType } from '../../../../components/router/AsyncRoute';
|
||||||
|
|
||||||
|
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
|
||||||
|
{ path: 'search.html', page: 'search' },
|
||||||
|
{ path: 'userprofile.html', page: 'user/userprofile' },
|
||||||
|
{ path: 'home.html', page: 'home', type: AsyncRouteType.Experimental },
|
||||||
|
{ path: 'movies.html', page: 'movies', type: AsyncRouteType.Experimental }
|
||||||
|
];
|
|
@ -1,22 +1,20 @@
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import globalize from '../scripts/globalize';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import LibraryMenu from '../scripts/libraryMenu';
|
|
||||||
import { clearBackdrop } from '../components/backdrop/backdrop';
|
|
||||||
import layoutManager from '../components/layoutManager';
|
|
||||||
import * as mainTabsManager from '../components/maintabsmanager';
|
|
||||||
import '../elements/emby-tabs/emby-tabs';
|
|
||||||
import '../elements/emby-button/emby-button';
|
|
||||||
import '../elements/emby-scroller/emby-scroller';
|
|
||||||
import Page from '../components/Page';
|
|
||||||
|
|
||||||
type IProps = {
|
import globalize from '../../../scripts/globalize';
|
||||||
tab?: string;
|
import LibraryMenu from '../../../scripts/libraryMenu';
|
||||||
}
|
import { clearBackdrop } from '../../../components/backdrop/backdrop';
|
||||||
|
import layoutManager from '../../../components/layoutManager';
|
||||||
|
import * as mainTabsManager from '../../../components/maintabsmanager';
|
||||||
|
import '../../../elements/emby-tabs/emby-tabs';
|
||||||
|
import '../../../elements/emby-button/emby-button';
|
||||||
|
import '../../../elements/emby-scroller/emby-scroller';
|
||||||
|
import Page from '../../../components/Page';
|
||||||
|
|
||||||
type OnResumeOptions = {
|
type OnResumeOptions = {
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
refresh?: boolean
|
refresh?: boolean
|
||||||
}
|
};
|
||||||
|
|
||||||
type ControllerProps = {
|
type ControllerProps = {
|
||||||
onResume: (
|
onResume: (
|
||||||
|
@ -25,17 +23,14 @@ type ControllerProps = {
|
||||||
refreshed: boolean;
|
refreshed: boolean;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Home: FunctionComponent<IProps> = (props: IProps) => {
|
const Home: FunctionComponent = () => {
|
||||||
const getDefaultTabIndex = () => {
|
const [ searchParams ] = useSearchParams();
|
||||||
return 0;
|
const initialTabIndex = parseInt(searchParams.get('tab') || '0', 10);
|
||||||
};
|
|
||||||
|
|
||||||
const tabController = useRef<ControllerProps | null>();
|
const tabController = useRef<ControllerProps | null>();
|
||||||
const currentTabIndex = useRef(parseInt(props.tab || getDefaultTabIndex().toString()));
|
|
||||||
const tabControllers = useMemo<ControllerProps[]>(() => [], []);
|
const tabControllers = useMemo<ControllerProps[]>(() => [], []);
|
||||||
const initialTabIndex = useRef<number | null>(currentTabIndex.current);
|
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setTitle = () => {
|
const setTitle = () => {
|
||||||
|
@ -70,18 +65,18 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
depends = 'favorites';
|
depends = 'favorites';
|
||||||
}
|
}
|
||||||
|
|
||||||
return import(/* webpackChunkName: "[request]" */ `../controllers/${depends}`).then(({ default: controllerFactory }) => {
|
return import(/* webpackChunkName: "[request]" */ `../../../controllers/${depends}`).then(({ default: controllerFactory }) => {
|
||||||
let controller = tabControllers[index];
|
let controller = tabControllers[index];
|
||||||
|
|
||||||
if (!controller) {
|
if (!controller) {
|
||||||
const tabContent = element.current?.querySelector(".tabContent[data-index='" + index + "']");
|
const tabContent = element.current?.querySelector(".tabContent[data-index='" + index + "']");
|
||||||
controller = new controllerFactory(tabContent, props);
|
controller = new controllerFactory(tabContent, null);
|
||||||
tabControllers[index] = controller;
|
tabControllers[index] = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
return controller;
|
return controller;
|
||||||
});
|
});
|
||||||
}, [props, tabControllers]);
|
}, [ tabControllers ]);
|
||||||
|
|
||||||
const onViewDestroy = useCallback(() => {
|
const onViewDestroy = useCallback(() => {
|
||||||
if (tabControllers) {
|
if (tabControllers) {
|
||||||
|
@ -93,8 +88,7 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
tabController.current = null;
|
tabController.current = null;
|
||||||
initialTabIndex.current = null;
|
}, [ tabControllers ]);
|
||||||
}, [tabControllers]);
|
|
||||||
|
|
||||||
const loadTab = useCallback((index: number, previousIndex: number | null) => {
|
const loadTab = useCallback((index: number, previousIndex: number | null) => {
|
||||||
getTabController(index).then((controller) => {
|
getTabController(index).then((controller) => {
|
||||||
|
@ -106,22 +100,23 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.refreshed = true;
|
controller.refreshed = true;
|
||||||
currentTabIndex.current = index;
|
|
||||||
tabController.current = controller;
|
tabController.current = controller;
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[Home] failed to get tab controller', err);
|
||||||
});
|
});
|
||||||
}, [getTabController]);
|
}, [ getTabController ]);
|
||||||
|
|
||||||
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; previousIndex: number | null }; }) => {
|
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; previousIndex: number | null }; }) => {
|
||||||
const newIndex = parseInt(e.detail.selectedTabIndex);
|
const newIndex = parseInt(e.detail.selectedTabIndex, 10);
|
||||||
const previousIndex = e.detail.previousIndex;
|
const previousIndex = e.detail.previousIndex;
|
||||||
|
|
||||||
const previousTabController = previousIndex == null ? null : tabControllers[previousIndex];
|
const previousTabController = previousIndex == null ? null : tabControllers[previousIndex];
|
||||||
if (previousTabController && previousTabController.onPause) {
|
if (previousTabController?.onPause) {
|
||||||
previousTabController.onPause();
|
previousTabController.onPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTab(newIndex, previousIndex);
|
loadTab(newIndex, previousIndex);
|
||||||
}, [loadTab, tabControllers]);
|
}, [ loadTab, tabControllers ]);
|
||||||
|
|
||||||
const onResume = useCallback(() => {
|
const onResume = useCallback(() => {
|
||||||
setTitle();
|
setTitle();
|
||||||
|
@ -130,30 +125,29 @@ const Home: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
const currentTabController = tabController.current;
|
const currentTabController = tabController.current;
|
||||||
|
|
||||||
if (!currentTabController) {
|
if (!currentTabController) {
|
||||||
mainTabsManager.selectedTabIndex(initialTabIndex.current);
|
mainTabsManager.selectedTabIndex(initialTabIndex);
|
||||||
} else if (currentTabController && currentTabController.onResume) {
|
} else if (currentTabController?.onResume) {
|
||||||
currentTabController.onResume({});
|
currentTabController.onResume({});
|
||||||
}
|
}
|
||||||
(document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
|
(document.querySelector('.skinHeader') as HTMLDivElement).classList.add('noHomeButtonHeader');
|
||||||
}, []);
|
}, [ initialTabIndex ]);
|
||||||
|
|
||||||
const onPause = useCallback(() => {
|
const onPause = useCallback(() => {
|
||||||
const currentTabController = tabController.current;
|
const currentTabController = tabController.current;
|
||||||
if (currentTabController && currentTabController.onPause) {
|
if (currentTabController?.onPause) {
|
||||||
currentTabController.onPause();
|
currentTabController.onPause();
|
||||||
}
|
}
|
||||||
(document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
|
(document.querySelector('.skinHeader') as HTMLDivElement).classList.remove('noHomeButtonHeader');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mainTabsManager.setTabs(element.current, currentTabIndex.current, getTabs, getTabContainers, null, onTabChange, false);
|
mainTabsManager.setTabs(element.current, initialTabIndex, getTabs, getTabContainers, null, onTabChange, false);
|
||||||
|
|
||||||
onResume();
|
onResume();
|
||||||
return () => {
|
return () => {
|
||||||
onPause();
|
onPause();
|
||||||
onViewDestroy();
|
|
||||||
};
|
};
|
||||||
}, [onPause, onResume, onTabChange, onViewDestroy]);
|
}, [ initialTabIndex, onPause, onResume, onTabChange, onViewDestroy ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={element}>
|
<div ref={element}>
|
|
@ -1,4 +1,4 @@
|
||||||
import { LegacyRoute } from '.';
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
{
|
{
|
||||||
|
@ -103,18 +103,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
controller: 'dashboard/metadatanfo',
|
controller: 'dashboard/metadatanfo',
|
||||||
view: 'dashboard/metadatanfo.html'
|
view: 'dashboard/metadatanfo.html'
|
||||||
}
|
}
|
||||||
}, {
|
|
||||||
path: 'notificationsetting.html',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/notifications/notification/index',
|
|
||||||
view: 'dashboard/notifications/notification/index.html'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
path: 'notificationsettings.html',
|
|
||||||
pageProps: {
|
|
||||||
controller: 'dashboard/notifications/notifications/index',
|
|
||||||
view: 'dashboard/notifications/notifications/index.html'
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
path: 'playbackconfiguration.html',
|
path: 'playbackconfiguration.html',
|
||||||
pageProps: {
|
pageProps: {
|
3
src/apps/experimental/routes/legacyRoutes/index.ts
Normal file
3
src/apps/experimental/routes/legacyRoutes/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './admin';
|
||||||
|
export * from './public';
|
||||||
|
export * from './user';
|
81
src/apps/experimental/routes/legacyRoutes/public.ts
Normal file
81
src/apps/experimental/routes/legacyRoutes/public.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
|
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
|
||||||
|
{
|
||||||
|
path: 'addserver.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/addServer/index',
|
||||||
|
view: 'session/addServer/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'selectserver.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/selectServer/index',
|
||||||
|
view: 'session/selectServer/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/login/index',
|
||||||
|
view: 'session/login/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgotpassword.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/forgotPassword/index',
|
||||||
|
view: 'session/forgotPassword/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgotpasswordpin.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/resetPassword/index',
|
||||||
|
view: 'session/resetPassword/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardremoteaccess.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/remote/index',
|
||||||
|
view: 'wizard/remote/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardfinish.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/finish/index',
|
||||||
|
view: 'wizard/finish/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardlibrary.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/library',
|
||||||
|
view: 'wizard/library.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardsettings.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/settings/index',
|
||||||
|
view: 'wizard/settings/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardstart.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/start/index',
|
||||||
|
view: 'wizard/start/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizarduser.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/user/index',
|
||||||
|
view: 'wizard/user/index.html'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
|
@ -1,4 +1,4 @@
|
||||||
import { LegacyRoute } from '.';
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
||||||
{
|
{
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
|
|
||||||
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
|
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
|
const CollectionsView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const getBasekey = useCallback(() => {
|
const getBasekey = useCallback(() => {
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
|
|
||||||
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
|
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
|
const FavoritesView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const getBasekey = useCallback(() => {
|
const getBasekey = useCallback(() => {
|
|
@ -1,9 +1,9 @@
|
||||||
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
|
import type { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import loading from '../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import GenresItemsContainer from '../../components/common/GenresItemsContainer';
|
import GenresItemsContainer from '../../../../components/common/GenresItemsContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
|
const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
|
const [ itemsResult, setItemsResult ] = useState<BaseItemDtoQueryResult>({});
|
||||||
|
@ -23,6 +23,8 @@ const GenresView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
setItemsResult(result);
|
setItemsResult(result);
|
||||||
loading.hide();
|
loading.hide();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[GenresView] failed to fetch genres', err);
|
||||||
});
|
});
|
||||||
}, [topParentId]);
|
}, [topParentId]);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
|
|
||||||
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
|
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
|
const MoviesView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const getBasekey = useCallback(() => {
|
const getBasekey = useCallback(() => {
|
|
@ -1,15 +1,15 @@
|
||||||
import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { BaseItemDto, BaseItemDtoQueryResult, RecommendationDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import layoutManager from '../../components/layoutManager';
|
import layoutManager from '../../../../components/layoutManager';
|
||||||
import loading from '../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import dom from '../../scripts/dom';
|
import dom from '../../../../scripts/dom';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../../../scripts/globalize';
|
||||||
import RecommendationContainer from '../../components/common/RecommendationContainer';
|
import RecommendationContainer from '../../../../components/common/RecommendationContainer';
|
||||||
import SectionContainer from '../../components/common/SectionContainer';
|
import SectionContainer from '../../../../components/common/SectionContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
const SuggestionsView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]);
|
const [ latestItems, setLatestItems ] = useState<BaseItemDto[]>([]);
|
||||||
const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({});
|
const [ resumeResult, setResumeResult ] = useState<BaseItemDtoQueryResult>({});
|
||||||
const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]);
|
const [ recommendations, setRecommendations ] = useState<RecommendationDto[]>([]);
|
||||||
|
@ -28,8 +28,10 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
||||||
}, [enableScrollX]);
|
}, [enableScrollX]);
|
||||||
|
|
||||||
const autoFocus = useCallback((page) => {
|
const autoFocus = useCallback((page) => {
|
||||||
import('../../components/autoFocuser').then(({default: autoFocuser}) => {
|
import('../../../../components/autoFocuser').then(({ default: autoFocuser }) => {
|
||||||
autoFocuser.autoFocus(page);
|
autoFocuser.autoFocus(page);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[SuggestionsView] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -55,6 +57,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
||||||
|
|
||||||
loading.hide();
|
loading.hide();
|
||||||
autoFocus(page);
|
autoFocus(page);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[SuggestionsView] failed to fetch items', err);
|
||||||
});
|
});
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
@ -72,6 +76,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
||||||
setLatestItems(items);
|
setLatestItems(items);
|
||||||
|
|
||||||
autoFocus(page);
|
autoFocus(page);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[SuggestionsView] failed to fetch latest items', err);
|
||||||
});
|
});
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
@ -95,6 +101,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
||||||
setRecommendations(result);
|
setRecommendations(result);
|
||||||
|
|
||||||
autoFocus(page);
|
autoFocus(page);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[SuggestionsView] failed to fetch recommendations', err);
|
||||||
});
|
});
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
@ -143,8 +151,8 @@ const SuggestionsView: FC<LibraryViewProps> = ({topParentId}) => {
|
||||||
{!recommendations.length ? <div className='noItemsMessage centerMessage'>
|
{!recommendations.length ? <div className='noItemsMessage centerMessage'>
|
||||||
<h1>{globalize.translate('MessageNothingHere')}</h1>
|
<h1>{globalize.translate('MessageNothingHere')}</h1>
|
||||||
<p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p>
|
<p>{globalize.translate('MessageNoMovieSuggestionsAvailable')}</p>
|
||||||
</div> : recommendations.map((recommendation, index) => {
|
</div> : recommendations.map(recommendation => {
|
||||||
return <RecommendationContainer key={index} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
|
return <RecommendationContainer key={recommendation.CategoryId} getPortraitShape={getPortraitShape} enableScrollX={enableScrollX} recommendation={recommendation} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -1,8 +1,8 @@
|
||||||
|
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
|
|
||||||
import ViewItemsContainer from '../../components/common/ViewItemsContainer';
|
import ViewItemsContainer from '../../../../components/common/ViewItemsContainer';
|
||||||
import { LibraryViewProps } from '../../types/interface';
|
import { LibraryViewProps } from '../../../../types/interface';
|
||||||
|
|
||||||
const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
|
const TrailersView: FC<LibraryViewProps> = ({ topParentId }) => {
|
||||||
const getBasekey = useCallback(() => {
|
const getBasekey = useCallback(() => {
|
|
@ -1,62 +1,28 @@
|
||||||
import '../../elements/emby-scroller/emby-scroller';
|
import '../../../../elements/emby-scroller/emby-scroller';
|
||||||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
import '../../../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||||
import '../../elements/emby-tabs/emby-tabs';
|
import '../../../../elements/emby-tabs/emby-tabs';
|
||||||
import '../../elements/emby-button/emby-button';
|
import '../../../../elements/emby-button/emby-button';
|
||||||
|
|
||||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { FC, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import * as mainTabsManager from '../../components/maintabsmanager';
|
import Page from '../../../../components/Page';
|
||||||
import Page from '../../components/Page';
|
import globalize from '../../../../scripts/globalize';
|
||||||
import globalize from '../../scripts/globalize';
|
import libraryMenu from '../../../../scripts/libraryMenu';
|
||||||
import libraryMenu from '../../scripts/libraryMenu';
|
|
||||||
import * as userSettings from '../../scripts/settings/userSettings';
|
|
||||||
import CollectionsView from './CollectionsView';
|
import CollectionsView from './CollectionsView';
|
||||||
import FavoritesView from './FavoritesView';
|
import FavoritesView from './FavoritesView';
|
||||||
import GenresView from './GenresView';
|
import GenresView from './GenresView';
|
||||||
import MoviesView from './MoviesView';
|
import MoviesView from './MoviesView';
|
||||||
import SuggestionsView from './SuggestionsView';
|
import SuggestionsView from './SuggestionsView';
|
||||||
import TrailersView from './TrailersView';
|
import TrailersView from './TrailersView';
|
||||||
|
import { getDefaultTabIndex } from '../../components/tabs/tabRoutes';
|
||||||
const getDefaultTabIndex = (folderId: string | null) => {
|
|
||||||
switch (userSettings.get('landing-' + folderId, false)) {
|
|
||||||
case 'suggestions':
|
|
||||||
return 1;
|
|
||||||
|
|
||||||
case 'favorites':
|
|
||||||
return 3;
|
|
||||||
|
|
||||||
case 'collections':
|
|
||||||
return 4;
|
|
||||||
|
|
||||||
case 'genres':
|
|
||||||
return 5;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTabs = () => {
|
|
||||||
return [{
|
|
||||||
name: globalize.translate('Movies')
|
|
||||||
}, {
|
|
||||||
name: globalize.translate('Suggestions')
|
|
||||||
}, {
|
|
||||||
name: globalize.translate('Trailers')
|
|
||||||
}, {
|
|
||||||
name: globalize.translate('Favorites')
|
|
||||||
}, {
|
|
||||||
name: globalize.translate('Collections')
|
|
||||||
}, {
|
|
||||||
name: globalize.translate('Genres')
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
|
|
||||||
const Movies: FC = () => {
|
const Movies: FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
const [ searchParams ] = useSearchParams();
|
const [ searchParams ] = useSearchParams();
|
||||||
const currentTabIndex = parseInt(searchParams.get('tab') || getDefaultTabIndex(searchParams.get('topParentId')).toString());
|
const searchParamsTab = searchParams.get('tab');
|
||||||
const [ selectedIndex, setSelectedIndex ] = useState(currentTabIndex);
|
const currentTabIndex = searchParamsTab !== null ? parseInt(searchParamsTab, 10) :
|
||||||
|
getDefaultTabIndex(location.pathname, searchParams.get('topParentId'));
|
||||||
const element = useRef<HTMLDivElement>(null);
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getTabComponent = (index: number) => {
|
const getTabComponent = (index: number) => {
|
||||||
|
@ -94,11 +60,6 @@ const Movies: FC = () => {
|
||||||
return component;
|
return component;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = useCallback((e: { detail: { selectedTabIndex: string; }; }) => {
|
|
||||||
const newIndex = parseInt(e.detail.selectedTabIndex);
|
|
||||||
setSelectedIndex(newIndex);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const page = element.current;
|
const page = element.current;
|
||||||
|
|
||||||
|
@ -106,7 +67,7 @@ const Movies: FC = () => {
|
||||||
console.error('Unexpected null reference');
|
console.error('Unexpected null reference');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mainTabsManager.setTabs(page, selectedIndex, getTabs, undefined, undefined, onTabChange);
|
|
||||||
if (!page.getAttribute('data-title')) {
|
if (!page.getAttribute('data-title')) {
|
||||||
const parentId = searchParams.get('topParentId');
|
const parentId = searchParams.get('topParentId');
|
||||||
|
|
||||||
|
@ -114,13 +75,17 @@ const Movies: FC = () => {
|
||||||
window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => {
|
window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), parentId).then((item) => {
|
||||||
page.setAttribute('data-title', item.Name as string);
|
page.setAttribute('data-title', item.Name as string);
|
||||||
libraryMenu.setTitle(item.Name);
|
libraryMenu.setTitle(item.Name);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[movies] failed to fetch library', err);
|
||||||
|
page.setAttribute('data-title', globalize.translate('Movies'));
|
||||||
|
libraryMenu.setTitle(globalize.translate('Movies'));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
page.setAttribute('data-title', globalize.translate('Movies'));
|
page.setAttribute('data-title', globalize.translate('Movies'));
|
||||||
libraryMenu.setTitle(globalize.translate('Movies'));
|
libraryMenu.setTitle(globalize.translate('Movies'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onTabChange, searchParams, selectedIndex]);
|
}, [ searchParams ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={element}>
|
<div ref={element}>
|
||||||
|
@ -129,7 +94,7 @@ const Movies: FC = () => {
|
||||||
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
|
className='mainAnimatedPage libraryPage backdropPage collectionEditorPage pageWithAbsoluteTabs withTabs'
|
||||||
backDropType='movie'
|
backDropType='movie'
|
||||||
>
|
>
|
||||||
{getTabComponent(selectedIndex)}
|
{getTabComponent(currentTabIndex)}
|
||||||
|
|
||||||
</Page>
|
</Page>
|
||||||
</div>
|
</div>
|
53
src/apps/experimental/theme.ts
Normal file
53
src/apps/experimental/theme.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: '#00a4dc'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#aa5cc3'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#101010',
|
||||||
|
paper: '#202020'
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
selectedOpacity: 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Noto Sans", sans-serif',
|
||||||
|
button: {
|
||||||
|
textTransform: 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: {
|
||||||
|
variant: 'contained'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiFormControl: {
|
||||||
|
defaultProps: {
|
||||||
|
variant: 'filled'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: {
|
||||||
|
variant: 'filled'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiListSubheader: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
// NOTE: Added for drawer subheaders, but maybe it won't work in other cases?
|
||||||
|
backgroundColor: 'inherit'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default theme;
|
58
src/apps/stable/App.tsx
Normal file
58
src/apps/stable/App.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import AppHeader from 'components/AppHeader';
|
||||||
|
import Backdrop from 'components/Backdrop';
|
||||||
|
import ServerContentPage from 'components/ServerContentPage';
|
||||||
|
import ConnectionRequired from 'components/ConnectionRequired';
|
||||||
|
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||||
|
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||||
|
|
||||||
|
import { ASYNC_ADMIN_ROUTES, ASYNC_USER_ROUTES } from './routes/asyncRoutes';
|
||||||
|
import { LEGACY_ADMIN_ROUTES, LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './routes/legacyRoutes';
|
||||||
|
|
||||||
|
const Layout = () => (
|
||||||
|
<>
|
||||||
|
<Backdrop />
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div className='mainAnimatedPages skinBody' />
|
||||||
|
<div className='skinBody'>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StableApp = () => (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
{/* User routes */}
|
||||||
|
<Route path='/' element={<ConnectionRequired />}>
|
||||||
|
{ASYNC_USER_ROUTES.map(toAsyncPageRoute)}
|
||||||
|
{LEGACY_USER_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route path='/' element={<ConnectionRequired isAdminRequired />}>
|
||||||
|
{ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute)}
|
||||||
|
{LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
|
||||||
|
<Route path='configurationpage' element={
|
||||||
|
<ServerContentPage view='/web/configurationpage' />
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Public routes */}
|
||||||
|
<Route path='/' element={<ConnectionRequired isUserRequired={false} />}>
|
||||||
|
<Route index element={<Navigate replace to='/home.html' />} />
|
||||||
|
|
||||||
|
{LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Suppress warnings for unhandled routes */}
|
||||||
|
<Route path='*' element={null} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StableApp;
|
11
src/apps/stable/routes/asyncRoutes/admin.ts
Normal file
11
src/apps/stable/routes/asyncRoutes/admin.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
|
||||||
|
|
||||||
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
|
{ path: 'notificationsettings.html', page: 'dashboard/notifications' },
|
||||||
|
{ path: 'usernew.html', page: 'user/usernew' },
|
||||||
|
{ path: 'userprofiles.html', page: 'user/userprofiles' },
|
||||||
|
{ path: 'useredit.html', page: 'user/useredit' },
|
||||||
|
{ path: 'userlibraryaccess.html', page: 'user/userlibraryaccess' },
|
||||||
|
{ path: 'userparentalcontrol.html', page: 'user/userparentalcontrol' },
|
||||||
|
{ path: 'userpassword.html', page: 'user/userpassword' }
|
||||||
|
];
|
2
src/apps/stable/routes/asyncRoutes/index.ts
Normal file
2
src/apps/stable/routes/asyncRoutes/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './admin';
|
||||||
|
export * from './user';
|
6
src/apps/stable/routes/asyncRoutes/user.ts
Normal file
6
src/apps/stable/routes/asyncRoutes/user.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { AsyncRoute } from '../../../../components/router/AsyncRoute';
|
||||||
|
|
||||||
|
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
|
||||||
|
{ path: 'search.html', page: 'search' },
|
||||||
|
{ path: 'userprofile.html', page: 'user/userprofile' }
|
||||||
|
];
|
36
src/apps/stable/routes/dashboard/notifications.tsx
Normal file
36
src/apps/stable/routes/dashboard/notifications.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Page from 'components/Page';
|
||||||
|
import globalize from 'scripts/globalize';
|
||||||
|
|
||||||
|
const PluginLink = () => (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `<a
|
||||||
|
is='emby-linkbutton'
|
||||||
|
class='button-link'
|
||||||
|
href='#/addplugin.html?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
||||||
|
>
|
||||||
|
${globalize.translate('GetThePlugin')}
|
||||||
|
</a>`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Notifications = () => (
|
||||||
|
<Page
|
||||||
|
id='notificationSettingPage'
|
||||||
|
title={globalize.translate('Notifications')}
|
||||||
|
className='mainAnimatedPage type-interior'
|
||||||
|
>
|
||||||
|
<div className='content-primary'>
|
||||||
|
<h2>{globalize.translate('Notifications')}</h2>
|
||||||
|
<p>
|
||||||
|
{globalize.translate('NotificationsMovedMessage')}
|
||||||
|
</p>
|
||||||
|
<PluginLink />
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Notifications;
|
185
src/apps/stable/routes/legacyRoutes/admin.ts
Normal file
185
src/apps/stable/routes/legacyRoutes/admin.ts
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
|
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
|
{
|
||||||
|
path: 'dashboard.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/dashboard',
|
||||||
|
view: 'dashboard/dashboard.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'dashboardgeneral.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/general',
|
||||||
|
view: 'dashboard/general.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'networking.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/networking',
|
||||||
|
view: 'dashboard/networking.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'devices.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/devices/devices',
|
||||||
|
view: 'dashboard/devices/devices.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'device.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/devices/device',
|
||||||
|
view: 'dashboard/devices/device.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'quickConnect.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/quickConnect',
|
||||||
|
view: 'dashboard/quickConnect.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'dlnaprofile.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/dlna/profile',
|
||||||
|
view: 'dashboard/dlna/profile.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'dlnaprofiles.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/dlna/profiles',
|
||||||
|
view: 'dashboard/dlna/profiles.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'dlnasettings.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/dlna/settings',
|
||||||
|
view: 'dashboard/dlna/settings.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'addplugin.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/plugins/add/index',
|
||||||
|
view: 'dashboard/plugins/add/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'library.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/library',
|
||||||
|
view: 'dashboard/library.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'librarydisplay.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/librarydisplay',
|
||||||
|
view: 'dashboard/librarydisplay.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'edititemmetadata.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'edititemmetadata',
|
||||||
|
view: 'edititemmetadata.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'encodingsettings.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/encodingsettings',
|
||||||
|
view: 'dashboard/encodingsettings.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'log.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/logs',
|
||||||
|
view: 'dashboard/logs.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'metadataimages.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/metadataImages',
|
||||||
|
view: 'dashboard/metadataimages.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'metadatanfo.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/metadatanfo',
|
||||||
|
view: 'dashboard/metadatanfo.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'playbackconfiguration.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/playback',
|
||||||
|
view: 'dashboard/playback.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'availableplugins.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/plugins/available/index',
|
||||||
|
view: 'dashboard/plugins/available/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'repositories.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/plugins/repositories/index',
|
||||||
|
view: 'dashboard/plugins/repositories/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'livetvguideprovider.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'livetvguideprovider',
|
||||||
|
view: 'livetvguideprovider.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'livetvsettings.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'livetvsettings',
|
||||||
|
view: 'livetvsettings.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'livetvstatus.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'livetvstatus',
|
||||||
|
view: 'livetvstatus.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'livetvtuner.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'livetvtuner',
|
||||||
|
view: 'livetvtuner.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'installedplugins.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/plugins/installed/index',
|
||||||
|
view: 'dashboard/plugins/installed/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'scheduledtask.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/scheduledtasks/scheduledtask',
|
||||||
|
view: 'dashboard/scheduledtasks/scheduledtask.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'scheduledtasks.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
||||||
|
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'serveractivity.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/serveractivity',
|
||||||
|
view: 'dashboard/serveractivity.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'apikeys.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/apikeys',
|
||||||
|
view: 'dashboard/apikeys.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'streamingsettings.html',
|
||||||
|
pageProps: {
|
||||||
|
view: 'dashboard/streaming.html',
|
||||||
|
controller: 'dashboard/streaming'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
3
src/apps/stable/routes/legacyRoutes/index.ts
Normal file
3
src/apps/stable/routes/legacyRoutes/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './admin';
|
||||||
|
export * from './public';
|
||||||
|
export * from './user';
|
81
src/apps/stable/routes/legacyRoutes/public.ts
Normal file
81
src/apps/stable/routes/legacyRoutes/public.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
|
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
|
||||||
|
{
|
||||||
|
path: 'addserver.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/addServer/index',
|
||||||
|
view: 'session/addServer/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'selectserver.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/selectServer/index',
|
||||||
|
view: 'session/selectServer/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'login.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/login/index',
|
||||||
|
view: 'session/login/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgotpassword.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/forgotPassword/index',
|
||||||
|
view: 'session/forgotPassword/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgotpasswordpin.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'session/resetPassword/index',
|
||||||
|
view: 'session/resetPassword/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardremoteaccess.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/remote/index',
|
||||||
|
view: 'wizard/remote/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardfinish.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/finish/index',
|
||||||
|
view: 'wizard/finish/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardlibrary.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'dashboard/library',
|
||||||
|
view: 'wizard/library.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardsettings.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/settings/index',
|
||||||
|
view: 'wizard/settings/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizardstart.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/start/index',
|
||||||
|
view: 'wizard/start/index.html'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'wizarduser.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'wizard/user/index',
|
||||||
|
view: 'wizard/user/index.html'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
108
src/apps/stable/routes/legacyRoutes/user.ts
Normal file
108
src/apps/stable/routes/legacyRoutes/user.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
||||||
|
|
||||||
|
export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
||||||
|
{
|
||||||
|
path: 'details',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'itemDetails/index',
|
||||||
|
view: 'itemDetails/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'list.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'list',
|
||||||
|
view: 'list.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'livetv.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'livetv/livetvsuggested',
|
||||||
|
view: 'livetv.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'music.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'music/musicrecommended',
|
||||||
|
view: 'music/music.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencesmenu.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/menu/index',
|
||||||
|
view: 'user/menu/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencescontrols.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/controls/index',
|
||||||
|
view: 'user/controls/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencesdisplay.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/display/index',
|
||||||
|
view: 'user/display/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferenceshome.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/home/index',
|
||||||
|
view: 'user/home/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencesquickconnect.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/quickConnect/index',
|
||||||
|
view: 'user/quickConnect/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencesplayback.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/playback/index',
|
||||||
|
view: 'user/playback/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'mypreferencessubtitles.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'user/subtitles/index',
|
||||||
|
view: 'user/subtitles/index.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'tv.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'shows/tvrecommended',
|
||||||
|
view: 'shows/tvrecommended.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'video',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'playback/video/index',
|
||||||
|
view: 'playback/video/index.html',
|
||||||
|
type: 'video-osd',
|
||||||
|
isFullscreen: true,
|
||||||
|
isNowPlayingBarEnabled: false,
|
||||||
|
isThemeMediaSupported: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'queue',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'playback/queue/index',
|
||||||
|
view: 'playback/queue/index.html',
|
||||||
|
isFullscreen: true,
|
||||||
|
isNowPlayingBarEnabled: false,
|
||||||
|
isThemeMediaSupported: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'home.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'home',
|
||||||
|
view: 'home.html'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
path: 'movies.html',
|
||||||
|
pageProps: {
|
||||||
|
controller: 'movies/moviesrecommended',
|
||||||
|
view: 'movies/movies.html'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
|
@ -1,12 +1,12 @@
|
||||||
import React, { FunctionComponent, useState } from 'react';
|
import React, { FunctionComponent, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import Page from '../components/Page';
|
import Page from '../../../components/Page';
|
||||||
import SearchFields from '../components/search/SearchFields';
|
import SearchFields from '../../../components/search/SearchFields';
|
||||||
import SearchResults from '../components/search/SearchResults';
|
import SearchResults from '../../../components/search/SearchResults';
|
||||||
import SearchSuggestions from '../components/search/SearchSuggestions';
|
import SearchSuggestions from '../../../components/search/SearchSuggestions';
|
||||||
import LiveTVSearchResults from '../components/search/LiveTVSearchResults';
|
import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults';
|
||||||
import globalize from '../scripts/globalize';
|
import globalize from '../../../scripts/globalize';
|
||||||
|
|
||||||
const Search: FunctionComponent = () => {
|
const Search: FunctionComponent = () => {
|
||||||
const [ query, setQuery ] = useState<string>();
|
const [ query, setQuery ] = useState<string>();
|
||||||
|
@ -19,9 +19,8 @@ const Search: FunctionComponent = () => {
|
||||||
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
|
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
|
||||||
>
|
>
|
||||||
<SearchFields onSearch={setQuery} />
|
<SearchFields onSearch={setQuery} />
|
||||||
{!query &&
|
{!query
|
||||||
<SearchSuggestions
|
&& <SearchSuggestions
|
||||||
serverId={searchParams.get('serverId') || window.ApiClient.serverId()}
|
|
||||||
parentId={searchParams.get('parentId')}
|
parentId={searchParams.get('parentId')}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
|
@ -1,28 +1,43 @@
|
||||||
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import Dashboard from '../../utils/dashboard';
|
|
||||||
import globalize from '../../scripts/globalize';
|
|
||||||
import LibraryMenu from '../../scripts/libraryMenu';
|
|
||||||
import ButtonElement from '../../elements/ButtonElement';
|
|
||||||
import CheckBoxElement from '../../elements/CheckBoxElement';
|
|
||||||
import InputElement from '../../elements/InputElement';
|
|
||||||
import LinkEditUserPreferences from '../../components/dashboard/users/LinkEditUserPreferences';
|
|
||||||
import SectionTitleContainer from '../../elements/SectionTitleContainer';
|
|
||||||
import SectionTabs from '../../components/dashboard/users/SectionTabs';
|
|
||||||
import loading from '../../components/loading/loading';
|
|
||||||
import toast from '../../components/toast/toast';
|
|
||||||
import { getParameterByName } from '../../utils/url';
|
|
||||||
import escapeHTML from 'escape-html';
|
import escapeHTML from 'escape-html';
|
||||||
import SelectElement from '../../elements/SelectElement';
|
|
||||||
import Page from '../../components/Page';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
|
import globalize from '../../../../scripts/globalize';
|
||||||
|
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||||
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
|
import InputElement from '../../../../elements/InputElement';
|
||||||
|
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
|
||||||
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
|
import loading from '../../../../components/loading/loading';
|
||||||
|
import toast from '../../../../components/toast/toast';
|
||||||
|
import { getParameterByName } from '../../../../utils/url';
|
||||||
|
import SelectElement from '../../../../elements/SelectElement';
|
||||||
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type ResetProvider = AuthProvider & {
|
type ResetProvider = AuthProvider & {
|
||||||
checkedAttribute: string
|
checkedAttribute: string
|
||||||
}
|
};
|
||||||
|
|
||||||
type AuthProvider = {
|
type AuthProvider = {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
Id?: string;
|
Id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||||
|
Array.prototype.filter.call(elements, e => e.checked)
|
||||||
|
.map(e => e.getAttribute('data-id'))
|
||||||
|
);
|
||||||
|
|
||||||
|
function onSaveComplete() {
|
||||||
|
Dashboard.navigate('userprofiles.html')
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[useredit] failed to navigate to user profile', err);
|
||||||
|
});
|
||||||
|
loading.hide();
|
||||||
|
toast(globalize.translate('SettingsSaved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserEdit: FunctionComponent = () => {
|
const UserEdit: FunctionComponent = () => {
|
||||||
|
@ -56,7 +71,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||||
providers.length > 1 ? fldSelectLoginProvider.classList.remove('hide') : fldSelectLoginProvider.classList.add('hide');
|
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||||
|
|
||||||
setAuthProviders(providers);
|
setAuthProviders(providers);
|
||||||
|
|
||||||
|
@ -73,7 +88,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||||
providers.length > 1 ? fldSelectPasswordResetProvider.classList.remove('hide') : fldSelectPasswordResetProvider.classList.add('hide');
|
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||||
|
|
||||||
setPasswordResetProviders(providers);
|
setPasswordResetProviders(providers);
|
||||||
|
|
||||||
|
@ -121,6 +136,8 @@ const UserEdit: FunctionComponent = () => {
|
||||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||||
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
|
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
|
||||||
triggerChange(chkEnableDeleteAllFolders);
|
triggerChange(chkEnableDeleteAllFolders);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to fetch channels', err);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -134,18 +151,24 @@ const UserEdit: FunctionComponent = () => {
|
||||||
|
|
||||||
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(user, providers);
|
||||||
|
}).catch(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(user, providers);
|
||||||
|
}).catch(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(user, folders.Items);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to fetch media folders', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||||
user.Policy.IsDisabled ? disabledUserBanner.classList.remove('hide') : disabledUserBanner.classList.add('hide');
|
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;
|
||||||
|
@ -159,6 +182,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
(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('.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;
|
||||||
|
@ -171,7 +195,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
(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 > 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 = user.Policy.LoginAttemptsBeforeLockout || '0';
|
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0';
|
||||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
|
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
|
||||||
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
||||||
|
@ -184,6 +208,8 @@ const UserEdit: FunctionComponent = () => {
|
||||||
loading.show();
|
loading.show();
|
||||||
getUser().then(function (user) {
|
getUser().then(function (user) {
|
||||||
loadUser(user);
|
loadUser(user);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadUser]);
|
}, [loadUser]);
|
||||||
|
|
||||||
|
@ -197,19 +223,9 @@ const UserEdit: FunctionComponent = () => {
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
function onSaveComplete() {
|
|
||||||
Dashboard.navigate('userprofiles.html');
|
|
||||||
loading.hide();
|
|
||||||
toast(globalize.translate('SettingsSaved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveUser = (user: UserDto) => {
|
const saveUser = (user: UserDto) => {
|
||||||
if (!user.Id) {
|
if (!user.Id || !user.Policy) {
|
||||||
throw new Error('Unexpected null user.Id');
|
throw new Error('Unexpected null user id or policy');
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.Policy) {
|
|
||||||
throw new Error('Unexpected null user.Policy');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
|
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
|
||||||
|
@ -224,27 +240,25 @@ const UserEdit: FunctionComponent = () => {
|
||||||
user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked;
|
user.Policy.EnableAudioPlaybackTranscoding = (page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked;
|
||||||
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
|
user.Policy.EnableVideoPlaybackTranscoding = (page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked;
|
||||||
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
|
user.Policy.EnablePlaybackRemuxing = (page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked;
|
||||||
|
user.Policy.EnableCollectionManagement = (page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked;
|
||||||
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
|
user.Policy.ForceRemoteSourceTranscoding = (page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked;
|
||||||
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
|
user.Policy.EnableContentDownloading = (page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked;
|
||||||
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
|
user.Policy.EnableRemoteAccess = (page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked;
|
||||||
user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0'));
|
user.Policy.RemoteClientBitrateLimit = Math.floor(1e6 * parseFloat((page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value || '0'));
|
||||||
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0');
|
user.Policy.LoginAttemptsBeforeLockout = parseInt((page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value || '0', 10);
|
||||||
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0');
|
user.Policy.MaxActiveSessions = parseInt((page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value || '0', 10);
|
||||||
user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value;
|
user.Policy.AuthenticationProviderId = (page.querySelector('#selectLoginProvider') as HTMLSelectElement).value;
|
||||||
user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value;
|
user.Policy.PasswordResetProviderId = (page.querySelector('#selectPasswordResetProvider') as HTMLSelectElement).value;
|
||||||
user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked;
|
user.Policy.EnableContentDeletion = (page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).checked;
|
||||||
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (c) {
|
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
|
||||||
return c.checked;
|
|
||||||
}).map(function (c) {
|
|
||||||
return c.getAttribute('data-id');
|
|
||||||
});
|
|
||||||
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
|
||||||
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
||||||
}
|
|
||||||
window.ApiClient.updateUser(user).then(function () {
|
window.ApiClient.updateUser(user).then(() => (
|
||||||
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {}).then(function () {
|
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || {})
|
||||||
|
)).then(() => {
|
||||||
onSaveComplete();
|
onSaveComplete();
|
||||||
});
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to update user', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -252,6 +266,8 @@ const UserEdit: FunctionComponent = () => {
|
||||||
loading.show();
|
loading.show();
|
||||||
getUser().then(function (result) {
|
getUser().then(function (result) {
|
||||||
saveUser(result);
|
saveUser(result);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to fetch user', err);
|
||||||
});
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -259,16 +275,13 @@ const UserEdit: FunctionComponent = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||||
if (this.checked) {
|
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||||
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.add('hide');
|
|
||||||
} else {
|
|
||||||
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.remove('hide');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.ApiClient.getNamedConfiguration('network').then(function (config) {
|
window.ApiClient.getNamedConfiguration('network').then(function (config) {
|
||||||
const fldRemoteAccess = page.querySelector('.fldRemoteAccess') as HTMLDivElement;
|
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
|
||||||
config.EnableRemoteAccess ? fldRemoteAccess.classList.remove('hide') : fldRemoteAccess.classList.add('hide');
|
}).catch(err => {
|
||||||
|
console.error('[useredit] failed to load network config', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||||
|
@ -312,7 +325,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
<SectionTabs activeTab='useredit'/>
|
<SectionTabs activeTab='useredit'/>
|
||||||
<div
|
<div
|
||||||
className='lnkEditUserPreferencesContainer'
|
className='lnkEditUserPreferencesContainer'
|
||||||
style={{paddingBottom: '1em'}}
|
style={{ paddingBottom: '1em' }}
|
||||||
>
|
>
|
||||||
<LinkEditUserPreferences
|
<LinkEditUserPreferences
|
||||||
className= 'lnkEditUserPreferences button-link'
|
className= 'lnkEditUserPreferences button-link'
|
||||||
|
@ -325,7 +338,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
<div>
|
<div>
|
||||||
{globalize.translate('HeaderThisUserIsCurrentlyDisabled')}
|
{globalize.translate('HeaderThisUserIsCurrentlyDisabled')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{marginTop: 5}}>
|
<div style={{ marginTop: 5 }}>
|
||||||
{globalize.translate('MessageReenableUser')}
|
{globalize.translate('MessageReenableUser')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -375,11 +388,16 @@ const UserEdit: FunctionComponent = () => {
|
||||||
className='chkIsAdmin'
|
className='chkIsAdmin'
|
||||||
title='OptionAllowUserToManageServer'
|
title='OptionAllowUserToManageServer'
|
||||||
/>
|
/>
|
||||||
|
<CheckBoxElement
|
||||||
|
labelClassName='checkboxContainer'
|
||||||
|
className='chkEnableCollectionManagement'
|
||||||
|
title='AllowCollectionManagement'
|
||||||
|
/>
|
||||||
<div id='featureAccessFields' className='verticalSection'>
|
<div id='featureAccessFields' className='verticalSection'>
|
||||||
<h2 className='paperListLabel'>
|
<h2 className='paperListLabel'>
|
||||||
{globalize.translate('HeaderFeatureAccess')}
|
{globalize.translate('HeaderFeatureAccess')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
|
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||||
<CheckBoxElement
|
<CheckBoxElement
|
||||||
className='chkEnableLiveTvAccess'
|
className='chkEnableLiveTvAccess'
|
||||||
title='OptionAllowBrowsingLiveTv'
|
title='OptionAllowBrowsingLiveTv'
|
||||||
|
@ -394,7 +412,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
<h2 className='paperListLabel'>
|
<h2 className='paperListLabel'>
|
||||||
{globalize.translate('HeaderPlayback')}
|
{globalize.translate('HeaderPlayback')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
|
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||||
<CheckBoxElement
|
<CheckBoxElement
|
||||||
className='chkEnableMediaPlayback'
|
className='chkEnableMediaPlayback'
|
||||||
title='OptionAllowMediaPlayback'
|
title='OptionAllowMediaPlayback'
|
||||||
|
@ -451,7 +469,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='verticalSection'>
|
<div className='verticalSection'>
|
||||||
<h2 className='checkboxListLabel' style={{marginBottom: '1em'}}>
|
<h2 className='checkboxListLabel' style={{ marginBottom: '1em' }}>
|
||||||
{globalize.translate('HeaderAllowMediaDeletionFrom')}
|
{globalize.translate('HeaderAllowMediaDeletionFrom')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className='checkboxList paperList checkboxList-paperList'>
|
<div className='checkboxList paperList checkboxList-paperList'>
|
||||||
|
@ -477,7 +495,7 @@ const UserEdit: FunctionComponent = () => {
|
||||||
<h2 className='checkboxListLabel'>
|
<h2 className='checkboxListLabel'>
|
||||||
{globalize.translate('HeaderRemoteControl')}
|
{globalize.translate('HeaderRemoteControl')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className='checkboxList paperList' style={{padding: '.5em 1em'}}>
|
<div className='checkboxList paperList' style={{ padding: '.5em 1em' }}>
|
||||||
<CheckBoxElement
|
<CheckBoxElement
|
||||||
className='chkEnableRemoteControlOtherUsers'
|
className='chkEnableRemoteControlOtherUsers'
|
||||||
title='OptionAllowRemoteControlOthers'
|
title='OptionAllowRemoteControlOthers'
|
|
@ -1,24 +1,24 @@
|
||||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
import loading from '../../components/loading/loading';
|
import loading from '../../../../components/loading/loading';
|
||||||
import libraryMenu from '../../scripts/libraryMenu';
|
import libraryMenu from '../../../../scripts/libraryMenu';
|
||||||
import globalize from '../../scripts/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 { 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;
|
||||||
Id?: string;
|
Id?: string;
|
||||||
AppName?: string;
|
AppName?: string;
|
||||||
checkedAttribute?: string
|
checkedAttribute?: string
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserLibraryAccess: FunctionComponent = () => {
|
const UserLibraryAccess: FunctionComponent = () => {
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ userName, setUserName ] = useState('');
|
||||||
|
@ -148,6 +148,8 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices'));
|
const promise4 = window.ApiClient.getJSON(window.ApiClient.getUrl('Devices'));
|
||||||
Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) {
|
Promise.all([promise1, promise2, promise3, promise4]).then(function (responses) {
|
||||||
loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items);
|
loadUser(responses[0], responses[1].Items, responses[2].Items, responses[3].Items);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userlibraryaccess] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadUser]);
|
}, [loadUser]);
|
||||||
|
|
||||||
|
@ -166,6 +168,8 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
const userId = getParameterByName('userId');
|
const userId = getParameterByName('userId');
|
||||||
window.ApiClient.getUser(userId).then(function (result) {
|
window.ApiClient.getUser(userId).then(function (result) {
|
||||||
saveUser(result);
|
saveUser(result);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userlibraryaccess] failed to fetch user', err);
|
||||||
});
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -203,6 +207,8 @@ const UserLibraryAccess: FunctionComponent = () => {
|
||||||
user.Policy.BlockedMediaFolders = null;
|
user.Policy.BlockedMediaFolders = null;
|
||||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||||
onSaveComplete();
|
onSaveComplete();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userlibraryaccess] failed to update user policy', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
import Dashboard from '../../utils/dashboard';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../../../scripts/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';
|
||||||
import InputElement from '../../elements/InputElement';
|
import InputElement from '../../../../elements/InputElement';
|
||||||
import ButtonElement from '../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
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 userInput = {
|
type userInput = {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
Password?: string;
|
Password?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ItemsArr = {
|
type ItemsArr = {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
Id?: string;
|
Id?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserNew: FunctionComponent = () => {
|
const UserNew: FunctionComponent = () => {
|
||||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||||
|
@ -93,6 +93,8 @@ const UserNew: FunctionComponent = () => {
|
||||||
loadMediaFolders(responses[0].Items);
|
loadMediaFolders(responses[0].Items);
|
||||||
loadChannels(responses[1].Items);
|
loadChannels(responses[1].Items);
|
||||||
loading.hide();
|
loading.hide();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[usernew] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadChannels, loadMediaFolders]);
|
}, [loadChannels, loadMediaFolders]);
|
||||||
|
|
||||||
|
@ -111,12 +113,8 @@ const UserNew: FunctionComponent = () => {
|
||||||
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) {
|
||||||
if (!user.Id) {
|
if (!user.Id || !user.Policy) {
|
||||||
throw new Error('Unexpected null user.Id');
|
throw new Error('Unexpected null user id or policy');
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.Policy) {
|
|
||||||
throw new Error('Unexpected null user.Policy');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||||
|
@ -142,7 +140,12 @@ const UserNew: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||||
Dashboard.navigate('useredit.html?userId=' + user.Id);
|
Dashboard.navigate('useredit.html?userId=' + user.Id)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[usernew] failed to navigate to edit user page', err);
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[usernew] failed to update user policy', err);
|
||||||
});
|
});
|
||||||
}, function () {
|
}, function () {
|
||||||
toast(globalize.translate('ErrorDefault'));
|
toast(globalize.translate('ErrorDefault'));
|
|
@ -1,26 +1,27 @@
|
||||||
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
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 React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import globalize from '../../scripts/globalize';
|
|
||||||
import LibraryMenu from '../../scripts/libraryMenu';
|
|
||||||
import AccessScheduleList from '../../components/dashboard/users/AccessScheduleList';
|
|
||||||
import BlockedTagList from '../../components/dashboard/users/BlockedTagList';
|
|
||||||
import ButtonElement from '../../elements/ButtonElement';
|
|
||||||
import SectionTitleContainer from '../../elements/SectionTitleContainer';
|
|
||||||
import SectionTabs from '../../components/dashboard/users/SectionTabs';
|
|
||||||
import loading from '../../components/loading/loading';
|
|
||||||
import toast from '../../components/toast/toast';
|
|
||||||
import { getParameterByName } from '../../utils/url';
|
|
||||||
import CheckBoxElement from '../../elements/CheckBoxElement';
|
|
||||||
import escapeHTML from 'escape-html';
|
import escapeHTML from 'escape-html';
|
||||||
import SelectElement from '../../elements/SelectElement';
|
|
||||||
import Page from '../../components/Page';
|
import globalize from '../../../../scripts/globalize';
|
||||||
|
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||||
|
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
||||||
|
import BlockedTagList from '../../../../components/dashboard/users/BlockedTagList';
|
||||||
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
|
import loading from '../../../../components/loading/loading';
|
||||||
|
import toast from '../../../../components/toast/toast';
|
||||||
|
import { getParameterByName } from '../../../../utils/url';
|
||||||
|
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||||
|
import SelectElement from '../../../../elements/SelectElement';
|
||||||
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type UnratedItem = {
|
type UnratedItem = {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
checkedAttribute: string
|
checkedAttribute: string
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserParentalControl: FunctionComponent = () => {
|
const UserParentalControl: FunctionComponent = () => {
|
||||||
const [ userName, setUserName ] = useState('');
|
const [ userName, setUserName ] = useState('');
|
||||||
|
@ -142,7 +143,7 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
|
|
||||||
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
|
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
|
||||||
btnDelete.addEventListener('click', function () {
|
btnDelete.addEventListener('click', function () {
|
||||||
const index = parseInt(btnDelete.getAttribute('data-index') || '0', 10);
|
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
|
||||||
schedules.splice(index, 1);
|
schedules.splice(index, 1);
|
||||||
const newindex = schedules.filter(function (i: number) {
|
const newindex = schedules.filter(function (i: number) {
|
||||||
return i != index;
|
return i != index;
|
||||||
|
@ -196,6 +197,8 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
const promise2 = window.ApiClient.getParentalRatings();
|
const promise2 = window.ApiClient.getParentalRatings();
|
||||||
Promise.all([promise1, promise2]).then(function (responses) {
|
Promise.all([promise1, promise2]).then(function (responses) {
|
||||||
loadUser(responses[0], responses[1]);
|
loadUser(responses[0], responses[1]);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userparentalcontrol] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [loadUser]);
|
}, [loadUser]);
|
||||||
|
|
||||||
|
@ -215,15 +218,11 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveUser = (user: UserDto) => {
|
const saveUser = (user: UserDto) => {
|
||||||
if (!user.Id) {
|
if (!user.Id || !user.Policy) {
|
||||||
throw new Error('Unexpected null user.Id');
|
throw new Error('Unexpected null user id or policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.Policy) {
|
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||||
throw new Error('Unexpected null user.Policy');
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value || '0', 10);
|
|
||||||
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
user.Policy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||||
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
|
user.Policy.BlockUnratedItems = Array.prototype.filter.call(page.querySelectorAll('.chkUnratedItem'), function (i) {
|
||||||
return i.checked;
|
return i.checked;
|
||||||
|
@ -234,12 +233,14 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
user.Policy.BlockedTags = getBlockedTagsFromPage();
|
user.Policy.BlockedTags = getBlockedTagsFromPage();
|
||||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||||
onSaveComplete();
|
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 }) => {
|
||||||
accessschedule.show({
|
accessschedule.show({
|
||||||
schedule: schedule
|
schedule: schedule
|
||||||
}).then(function (updatedSchedule) {
|
}).then(function (updatedSchedule) {
|
||||||
|
@ -251,7 +252,11 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
|
|
||||||
schedules[index] = updatedSchedule;
|
schedules[index] = updatedSchedule;
|
||||||
renderAccessSchedule(schedules);
|
renderAccessSchedule(schedules);
|
||||||
|
}).catch(() => {
|
||||||
|
// access schedule closed
|
||||||
});
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userparentalcontrol] failed to load access schedule', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -272,7 +277,7 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const showBlockedTagPopup = () => {
|
const showBlockedTagPopup = () => {
|
||||||
import('../../components/prompt/prompt').then(({default: prompt}) => {
|
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||||
prompt({
|
prompt({
|
||||||
label: globalize.translate('LabelTag')
|
label: globalize.translate('LabelTag')
|
||||||
}).then(function (value) {
|
}).then(function (value) {
|
||||||
|
@ -282,7 +287,11 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
tags.push(value);
|
tags.push(value);
|
||||||
loadBlockedTags(tags);
|
loadBlockedTags(tags);
|
||||||
}
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// prompt closed
|
||||||
});
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userparentalcontrol] failed to load prompt', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -291,6 +300,8 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
const userId = getParameterByName('userId');
|
const userId = getParameterByName('userId');
|
||||||
window.ApiClient.getUser(userId).then(function (result) {
|
window.ApiClient.getUser(userId).then(function (result) {
|
||||||
saveUser(result);
|
saveUser(result);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userparentalcontrol] failed to fetch user', err);
|
||||||
});
|
});
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -367,7 +378,7 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div className='verticalSection' style={{marginBottom: '2em'}}>
|
<div className='verticalSection' style={{ marginBottom: '2em' }}>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
SectionClassName='detailSectionHeader'
|
SectionClassName='detailSectionHeader'
|
||||||
title={globalize.translate('LabelBlockContentWithTags')}
|
title={globalize.translate('LabelBlockContentWithTags')}
|
||||||
|
@ -378,16 +389,16 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
btnIcon='add'
|
btnIcon='add'
|
||||||
isLinkVisible={false}
|
isLinkVisible={false}
|
||||||
/>
|
/>
|
||||||
<div className='blockedTags' style={{marginTop: '.5em'}}>
|
<div className='blockedTags' style={{ marginTop: '.5em' }}>
|
||||||
{blockedTags.map((tag, index) => {
|
{blockedTags.map(tag => {
|
||||||
return <BlockedTagList
|
return <BlockedTagList
|
||||||
key={index}
|
key={tag}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
/>;
|
/>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='accessScheduleSection verticalSection' style={{marginBottom: '2em'}}>
|
<div className='accessScheduleSection verticalSection' style={{ marginBottom: '2em' }}>
|
||||||
<SectionTitleContainer
|
<SectionTitleContainer
|
||||||
title={globalize.translate('HeaderAccessSchedule')}
|
title={globalize.translate('HeaderAccessSchedule')}
|
||||||
isBtnVisible={true}
|
isBtnVisible={true}
|
||||||
|
@ -401,7 +412,7 @@ const UserParentalControl: FunctionComponent = () => {
|
||||||
<div className='accessScheduleList paperList'>
|
<div className='accessScheduleList paperList'>
|
||||||
{accessSchedules.map((accessSchedule, index) => {
|
{accessSchedules.map((accessSchedule, index) => {
|
||||||
return <AccessScheduleList
|
return <AccessScheduleList
|
||||||
key={index}
|
key={accessSchedule.Id}
|
||||||
index={index}
|
index={index}
|
||||||
Id={accessSchedule.Id}
|
Id={accessSchedule.Id}
|
||||||
DayOfWeek={accessSchedule.DayOfWeek}
|
DayOfWeek={accessSchedule.DayOfWeek}
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
|
||||||
import SectionTabs from '../../components/dashboard/users/SectionTabs';
|
|
||||||
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm';
|
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||||
import { getParameterByName } from '../../utils/url';
|
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||||
import SectionTitleContainer from '../../elements/SectionTitleContainer';
|
import { getParameterByName } from '../../../../utils/url';
|
||||||
import Page from '../../components/Page';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import loading from '../../components/loading/loading';
|
import Page from '../../../../components/Page';
|
||||||
|
import loading from '../../../../components/loading/loading';
|
||||||
|
|
||||||
const UserPassword: FunctionComponent = () => {
|
const UserPassword: FunctionComponent = () => {
|
||||||
const userId = getParameterByName('userId');
|
const userId = getParameterByName('userId');
|
||||||
|
@ -18,6 +19,8 @@ const UserPassword: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
setUserName(user.Name);
|
setUserName(user.Name);
|
||||||
loading.hide();
|
loading.hide();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userpassword] failed to fetch user', err);
|
||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
|
@ -2,17 +2,17 @@ import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||||
import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react';
|
import React, { FunctionComponent, useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
import Dashboard from '../../utils/dashboard';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../../../scripts/globalize';
|
||||||
import LibraryMenu from '../../scripts/libraryMenu';
|
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||||
import { appHost } from '../../components/apphost';
|
import { appHost } from '../../../../components/apphost';
|
||||||
import confirm from '../../components/confirm/confirm';
|
import confirm from '../../../../components/confirm/confirm';
|
||||||
import ButtonElement from '../../elements/ButtonElement';
|
import ButtonElement from '../../../../elements/ButtonElement';
|
||||||
import UserPasswordForm from '../../components/dashboard/users/UserPasswordForm';
|
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||||
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 { getParameterByName } from '../../../../utils/url';
|
||||||
import Page from '../../components/Page';
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
const UserProfile: FunctionComponent = () => {
|
const UserProfile: FunctionComponent = () => {
|
||||||
const userId = getParameterByName('userId');
|
const userId = getParameterByName('userId');
|
||||||
|
@ -30,12 +30,8 @@ const UserProfile: FunctionComponent = () => {
|
||||||
|
|
||||||
loading.show();
|
loading.show();
|
||||||
window.ApiClient.getUser(userId).then(function (user) {
|
window.ApiClient.getUser(userId).then(function (user) {
|
||||||
if (!user.Name) {
|
if (!user.Name || !user.Id) {
|
||||||
throw new Error('Unexpected null user.Name');
|
throw new Error('Unexpected null user name or id');
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.Id) {
|
|
||||||
throw new Error('Unexpected null user.Id');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserName(user.Name);
|
setUserName(user.Name);
|
||||||
|
@ -63,8 +59,12 @@ const UserProfile: FunctionComponent = () => {
|
||||||
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
|
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
|
||||||
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
|
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
|
||||||
}
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofile] failed to get current user', err);
|
||||||
});
|
});
|
||||||
loading.hide();
|
loading.hide();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofile] failed to load data', err);
|
||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ const UserProfile: FunctionComponent = () => {
|
||||||
const target = evt.target as HTMLInputElement;
|
const target = evt.target as HTMLInputElement;
|
||||||
const file = (target.files as FileList)[0];
|
const file = (target.files as FileList)[0];
|
||||||
|
|
||||||
if (!file || !file.type.match('image.*')) {
|
if (!file || !/image.*/.exec(file.type)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,6 +114,8 @@ const UserProfile: FunctionComponent = () => {
|
||||||
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
|
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
|
||||||
loading.hide();
|
loading.hide();
|
||||||
reloadUser();
|
reloadUser();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofile] failed to upload image', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -129,7 +131,11 @@ const UserProfile: FunctionComponent = () => {
|
||||||
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
|
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
|
||||||
loading.hide();
|
loading.hide();
|
||||||
reloadUser();
|
reloadUser();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofile] failed to delete image', err);
|
||||||
});
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// confirm dialog closed
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -153,25 +159,25 @@ const UserProfile: FunctionComponent = () => {
|
||||||
<div ref={element} className='padded-left padded-right padded-bottom-page'>
|
<div ref={element} className='padded-left padded-right padded-bottom-page'>
|
||||||
<div
|
<div
|
||||||
className='readOnlyContent'
|
className='readOnlyContent'
|
||||||
style={{margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center'}}
|
style={{ margin: '0 auto', marginBottom: '1.8em', padding: '0 1em', display: 'flex', flexDirection: 'row', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className='imagePlaceHolder'
|
className='imagePlaceHolder'
|
||||||
style={{position: 'relative', display: 'inline-block', maxWidth: 200 }}
|
style={{ position: 'relative', display: 'inline-block', maxWidth: 200 }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id='uploadImage'
|
id='uploadImage'
|
||||||
type='file'
|
type='file'
|
||||||
accept='image/*'
|
accept='image/*'
|
||||||
style={{position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer'}}
|
style={{ position: 'absolute', right: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id='image'
|
id='image'
|
||||||
style={{width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover'}}
|
style={{ width: 200, height: 200, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', borderRadius: '100%', backgroundSize: 'cover' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
|
<div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<h2 className='username' style={{margin: 0, fontSize: 'xx-large'}}>
|
<h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
|
||||||
{userName}
|
{userName}
|
||||||
</h2>
|
</h2>
|
||||||
<br />
|
<br />
|
|
@ -1,24 +1,25 @@
|
||||||
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, { FunctionComponent, useEffect, useState, useRef } from 'react';
|
||||||
import Dashboard from '../../utils/dashboard';
|
|
||||||
import globalize from '../../scripts/globalize';
|
import Dashboard from '../../../../utils/dashboard';
|
||||||
import loading from '../../components/loading/loading';
|
import globalize from '../../../../scripts/globalize';
|
||||||
import dom from '../../scripts/dom';
|
import loading from '../../../../components/loading/loading';
|
||||||
import confirm from '../../components/confirm/confirm';
|
import dom from '../../../../scripts/dom';
|
||||||
import UserCardBox from '../../components/dashboard/users/UserCardBox';
|
import confirm from '../../../../components/confirm/confirm';
|
||||||
import SectionTitleContainer from '../../elements/SectionTitleContainer';
|
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
|
||||||
import '../../elements/emby-button/emby-button';
|
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||||
import '../../elements/emby-button/paper-icon-button-light';
|
import '../../../../elements/emby-button/emby-button';
|
||||||
import '../../components/cardbuilder/card.scss';
|
import '../../../../elements/emby-button/paper-icon-button-light';
|
||||||
import '../../components/indicators/indicators.scss';
|
import '../../../../components/cardbuilder/card.scss';
|
||||||
import '../../assets/css/flexstyles.scss';
|
import '../../../../components/indicators/indicators.scss';
|
||||||
import Page from '../../components/Page';
|
import '../../../../styles/flexstyles.scss';
|
||||||
|
import Page from '../../../../components/Page';
|
||||||
|
|
||||||
type MenuEntry = {
|
type MenuEntry = {
|
||||||
name?: string;
|
name?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const UserProfiles: FunctionComponent = () => {
|
const UserProfiles: FunctionComponent = () => {
|
||||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||||
|
@ -30,6 +31,8 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
window.ApiClient.getUsers().then(function (result) {
|
window.ApiClient.getUsers().then(function (result) {
|
||||||
setUsers(result);
|
setUsers(result);
|
||||||
loading.hide();
|
loading.hide();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofiles] failed to fetch users', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -75,29 +78,42 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
icon: 'delete'
|
icon: 'delete'
|
||||||
});
|
});
|
||||||
|
|
||||||
import('../../components/actionSheet/actionSheet').then(({default: actionsheet}) => {
|
import('../../../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||||
actionsheet.show({
|
actionsheet.show({
|
||||||
items: menuItems,
|
items: menuItems,
|
||||||
positionTo: card,
|
positionTo: card,
|
||||||
callback: function (id: string) {
|
callback: function (id: string) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'open':
|
case 'open':
|
||||||
Dashboard.navigate('useredit.html?userId=' + userId);
|
Dashboard.navigate('useredit.html?userId=' + userId)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[userprofiles] failed to navigate to user edit page', err);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'access':
|
case 'access':
|
||||||
Dashboard.navigate('userlibraryaccess.html?userId=' + userId);
|
Dashboard.navigate('userlibraryaccess.html?userId=' + userId)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[userprofiles] failed to navigate to user library page', err);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'parentalcontrol':
|
case 'parentalcontrol':
|
||||||
Dashboard.navigate('userparentalcontrol.html?userId=' + userId);
|
Dashboard.navigate('userparentalcontrol.html?userId=' + userId)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[userprofiles] failed to navigate to parental control page', err);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
deleteUser(userId);
|
deleteUser(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// action sheet closed
|
||||||
});
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofiles] failed to load action sheet', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -113,7 +129,11 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
loading.show();
|
loading.show();
|
||||||
window.ApiClient.deleteUser(id).then(function () {
|
window.ApiClient.deleteUser(id).then(function () {
|
||||||
loadData();
|
loadData();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[userprofiles] failed to delete user', err);
|
||||||
});
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// confirm dialog closed
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -126,7 +146,10 @@ const UserProfiles: FunctionComponent = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
|
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
|
||||||
Dashboard.navigate('usernew.html');
|
Dashboard.navigate('usernew.html')
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[userprofiles] failed to navigate to new user page', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
20
src/components/AppHeader.tsx
Normal file
20
src/components/AppHeader.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
const AppHeader = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize the UI components after first render
|
||||||
|
import('../scripts/libraryMenu');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='mainDrawer hide'>
|
||||||
|
<div className='mainDrawer-scrollContainer scrollContainer focuscontainer-y' />
|
||||||
|
</div>
|
||||||
|
<div className='skinHeader focuscontainer-x' />
|
||||||
|
<div className='mainDrawerHandle' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppHeader;
|
17
src/components/Backdrop.tsx
Normal file
17
src/components/Backdrop.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
const Backdrop = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize the UI components after first render
|
||||||
|
import('../scripts/autoBackdrops');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='backdropContainer' />
|
||||||
|
<div className='backgroundContainer' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Backdrop;
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import type { ConnectResponse } from 'jellyfin-apiclient';
|
import type { ConnectResponse } from 'jellyfin-apiclient';
|
||||||
|
|
||||||
import alert from './alert';
|
import alert from './alert';
|
||||||
import { appRouter } from './appRouter';
|
import { appRouter } from './router/appRouter';
|
||||||
import Loading from './loading/LoadingComponent';
|
import Loading from './loading/LoadingComponent';
|
||||||
import ServerConnections from './ServerConnections';
|
import ServerConnections from './ServerConnections';
|
||||||
import globalize from '../scripts/globalize';
|
import globalize from '../scripts/globalize';
|
||||||
|
@ -31,12 +31,11 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
isUserRequired = true
|
isUserRequired = true
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const [ isLoading, setIsLoading ] = useState(true);
|
const [ isLoading, setIsLoading ] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const bounce = useCallback(async (connectionResponse: ConnectResponse) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const bounce = async (connectionResponse: ConnectResponse) => {
|
|
||||||
switch (connectionResponse.State) {
|
switch (connectionResponse.State) {
|
||||||
case ConnectionState.SignedIn:
|
case ConnectionState.SignedIn:
|
||||||
// Already logged in, bounce to the home page
|
// Already logged in, bounce to the home page
|
||||||
|
@ -45,12 +44,12 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
return;
|
return;
|
||||||
case ConnectionState.ServerSignIn:
|
case ConnectionState.ServerSignIn:
|
||||||
// Bounce to the login page
|
// Bounce to the login page
|
||||||
|
if (location.pathname === BounceRoutes.Login) {
|
||||||
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
console.debug('[ConnectionRequired] not logged in, redirecting to login page');
|
console.debug('[ConnectionRequired] not logged in, redirecting to login page');
|
||||||
navigate(BounceRoutes.Login, {
|
navigate(`${BounceRoutes.Login}?serverid=${connectionResponse.ApiClient.serverId()}`);
|
||||||
state: {
|
|
||||||
serverid: connectionResponse.ApiClient.serverId()
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
case ConnectionState.ServerSelection:
|
case ConnectionState.ServerSelection:
|
||||||
// Bounce to select server page
|
// Bounce to select server page
|
||||||
|
@ -73,14 +72,9 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
|
console.warn('[ConnectionRequired] unhandled connection state', connectionResponse.State);
|
||||||
};
|
}, [location.pathname, navigate]);
|
||||||
|
|
||||||
const validateConnection = async () => {
|
const handleIncompleteWizard = useCallback(async (firstConnection: ConnectResponse) => {
|
||||||
// Check connection status on initial page load
|
|
||||||
const firstConnection = appRouter.firstConnectionResult;
|
|
||||||
appRouter.firstConnectionResult = null;
|
|
||||||
|
|
||||||
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
|
|
||||||
if (firstConnection.State === ConnectionState.ServerSignIn) {
|
if (firstConnection.State === ConnectionState.ServerSignIn) {
|
||||||
// Verify the wizard is complete
|
// Verify the wizard is complete
|
||||||
try {
|
try {
|
||||||
|
@ -105,20 +99,23 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bounce to the correct page in the login flow
|
// Bounce to the correct page in the login flow
|
||||||
bounce(firstConnection);
|
bounce(firstConnection)
|
||||||
return;
|
.catch(err => {
|
||||||
}
|
console.error('[ConnectionRequired] failed to bounce', err);
|
||||||
|
});
|
||||||
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
|
}, [bounce, navigate]);
|
||||||
// This case will need to be handled elsewhere before appRouter can be killed.
|
|
||||||
|
|
||||||
|
const validateUserAccess = useCallback(async () => {
|
||||||
const client = ServerConnections.currentApiClient();
|
const client = ServerConnections.currentApiClient();
|
||||||
|
|
||||||
// If this is a user route, ensure a user is logged in
|
// If this is a user route, ensure a user is logged in
|
||||||
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
|
if ((isAdminRequired || isUserRequired) && !client?.isLoggedIn()) {
|
||||||
try {
|
try {
|
||||||
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
|
console.warn('[ConnectionRequired] unauthenticated user attempted to access user route');
|
||||||
bounce(await ServerConnections.connect());
|
bounce(await ServerConnections.connect())
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[ConnectionRequired] failed to bounce', err);
|
||||||
|
});
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
console.warn('[ConnectionRequired] error bouncing from user route', ex);
|
console.warn('[ConnectionRequired] error bouncing from user route', ex);
|
||||||
}
|
}
|
||||||
|
@ -128,10 +125,13 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
// If this is an admin route, ensure the user has access
|
// If this is an admin route, ensure the user has access
|
||||||
if (isAdminRequired) {
|
if (isAdminRequired) {
|
||||||
try {
|
try {
|
||||||
const user = await client.getCurrentUser();
|
const user = await client?.getCurrentUser();
|
||||||
if (!user.Policy.IsAdministrator) {
|
if (!user?.Policy?.IsAdministrator) {
|
||||||
console.warn('[ConnectionRequired] normal user attempted to access admin route');
|
console.warn('[ConnectionRequired] normal user attempted to access admin route');
|
||||||
bounce(await ServerConnections.connect());
|
bounce(await ServerConnections.connect())
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[ConnectionRequired] failed to bounce', err);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
@ -141,10 +141,28 @@ const ConnectionRequired: FunctionComponent<ConnectionRequiredProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
}, [bounce, isAdminRequired, isUserRequired]);
|
||||||
|
|
||||||
validateConnection();
|
useEffect(() => {
|
||||||
}, [ isAdminRequired, isUserRequired, navigate ]);
|
// TODO: appRouter will call appHost.exit() if navigating back when you are already at the default route.
|
||||||
|
// This case will need to be handled elsewhere before appRouter can be killed.
|
||||||
|
|
||||||
|
// Check connection status on initial page load
|
||||||
|
const firstConnection = appRouter.firstConnectionResult;
|
||||||
|
appRouter.firstConnectionResult = null;
|
||||||
|
|
||||||
|
if (firstConnection && firstConnection.State !== ConnectionState.SignedIn) {
|
||||||
|
handleIncompleteWizard(firstConnection)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[ConnectionRequired] failed to start wizard', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validateUserAccess()
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[ConnectionRequired] failed to validate user access', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [handleIncompleteWizard, validateUserAccess]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
|
|
|
@ -86,6 +86,10 @@ class ServerConnections extends ConnectionManager {
|
||||||
return this.localApiClient;
|
return this.localApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the ApiClient that is currently connected.
|
||||||
|
* @returns {ApiClient|undefined} apiClient
|
||||||
|
*/
|
||||||
currentApiClient() {
|
currentApiClient() {
|
||||||
let apiClient = this.getLocalApiClient();
|
let apiClient = this.getLocalApiClient();
|
||||||
|
|
||||||
|
|
62
src/components/ServerContentPage.tsx
Normal file
62
src/components/ServerContentPage.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { FunctionComponent, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ServerConnections from './ServerConnections';
|
||||||
|
import viewManager from './viewManager/viewManager';
|
||||||
|
import globalize from '../scripts/globalize';
|
||||||
|
import type { RestoreViewFailResponse } from '../types/viewManager';
|
||||||
|
|
||||||
|
interface ServerContentPageProps {
|
||||||
|
view: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page component that renders html content from a server request.
|
||||||
|
* Uses the ViewManager to dynamically load and execute the page JS.
|
||||||
|
*/
|
||||||
|
const ServerContentPage: FunctionComponent<ServerContentPageProps> = ({ view }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPage = () => {
|
||||||
|
const viewOptions = {
|
||||||
|
url: location.pathname + location.search,
|
||||||
|
state: location.state,
|
||||||
|
autoFocus: false,
|
||||||
|
options: {
|
||||||
|
supportsThemeMedia: false,
|
||||||
|
enableMediaControl: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
viewManager.tryRestoreView(viewOptions)
|
||||||
|
.catch(async (result?: RestoreViewFailResponse) => {
|
||||||
|
if (!result?.cancelled) {
|
||||||
|
const apiClient = ServerConnections.currentApiClient();
|
||||||
|
|
||||||
|
// Fetch the view html from the server and translate it
|
||||||
|
const viewHtml = await apiClient?.get(apiClient.getUrl(view + location.search))
|
||||||
|
.then((html: string) => globalize.translateHtml(html));
|
||||||
|
|
||||||
|
viewManager.loadView({
|
||||||
|
...viewOptions,
|
||||||
|
view: viewHtml
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPage();
|
||||||
|
},
|
||||||
|
// location.state is NOT included as a dependency here since dialogs will update state while the current view stays the same
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
view,
|
||||||
|
location.pathname,
|
||||||
|
location.search
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerContentPage;
|
|
@ -1,6 +1,3 @@
|
||||||
|
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for controlling user parental control from.
|
* Module for controlling user parental control from.
|
||||||
* @module components/accessSchedule/accessSchedule
|
* @module components/accessSchedule/accessSchedule
|
||||||
|
@ -14,36 +11,36 @@ import '../../elements/emby-button/paper-icon-button-light';
|
||||||
import '../formdialog.scss';
|
import '../formdialog.scss';
|
||||||
import template from './accessSchedule.template.html';
|
import template from './accessSchedule.template.html';
|
||||||
|
|
||||||
function getDisplayTime(hours) {
|
function getDisplayTime(hours) {
|
||||||
let minutes = 0;
|
let minutes = 0;
|
||||||
const pct = hours % 1;
|
const pct = hours % 1;
|
||||||
|
|
||||||
if (pct) {
|
if (pct) {
|
||||||
minutes = parseInt(60 * pct);
|
minutes = parseInt(60 * pct, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
|
return datetime.getDisplayTime(new Date(2000, 1, 1, hours, minutes, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateHours(context) {
|
function populateHours(context) {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
for (let i = 0; i < 24; i++) {
|
for (let i = 0; i < 24; i += 0.5) {
|
||||||
html += `<option value="${i}">${getDisplayTime(i)}</option>`;
|
html += `<option value="${i}">${getDisplayTime(i)}</option>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<option value="24">${getDisplayTime(0)}</option>`;
|
html += `<option value="24">${getDisplayTime(0)}</option>`;
|
||||||
context.querySelector('#selectStart').innerHTML = html;
|
context.querySelector('#selectStart').innerHTML = html;
|
||||||
context.querySelector('#selectEnd').innerHTML = html;
|
context.querySelector('#selectEnd').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSchedule(context, {DayOfWeek, StartHour, EndHour}) {
|
function loadSchedule(context, { DayOfWeek, StartHour, EndHour }) {
|
||||||
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday';
|
context.querySelector('#selectDay').value = DayOfWeek || 'Sunday';
|
||||||
context.querySelector('#selectStart').value = StartHour || 0;
|
context.querySelector('#selectStart').value = StartHour || 0;
|
||||||
context.querySelector('#selectEnd').value = EndHour || 0;
|
context.querySelector('#selectEnd').value = EndHour || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSchedule(context, options) {
|
function submitSchedule(context, options) {
|
||||||
const updatedSchedule = {
|
const updatedSchedule = {
|
||||||
DayOfWeek: context.querySelector('#selectDay').value,
|
DayOfWeek: context.querySelector('#selectDay').value,
|
||||||
StartHour: context.querySelector('#selectStart').value,
|
StartHour: context.querySelector('#selectStart').value,
|
||||||
|
@ -58,9 +55,9 @@ import template from './accessSchedule.template.html';
|
||||||
context.submitted = true;
|
context.submitted = true;
|
||||||
options.schedule = Object.assign(options.schedule, updatedSchedule);
|
options.schedule = Object.assign(options.schedule, updatedSchedule);
|
||||||
dialogHelper.close(context);
|
dialogHelper.close(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function show(options) {
|
export function show(options) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const dlg = dialogHelper.createDialog({
|
const dlg = dialogHelper.createDialog({
|
||||||
removeOnClose: true,
|
removeOnClose: true,
|
||||||
|
@ -89,9 +86,7 @@ import template from './accessSchedule.template.html';
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
show: show
|
show: show
|
||||||
|
|
|
@ -6,7 +6,7 @@ import dom from '../../scripts/dom';
|
||||||
import '../../elements/emby-button/emby-button';
|
import '../../elements/emby-button/emby-button';
|
||||||
import './actionSheet.scss';
|
import './actionSheet.scss';
|
||||||
import 'material-design-icons-iconfont';
|
import 'material-design-icons-iconfont';
|
||||||
import '../../assets/css/scrollstyles.scss';
|
import '../../styles/scrollstyles.scss';
|
||||||
import '../../components/listview/listview.scss';
|
import '../../components/listview/listview.scss';
|
||||||
|
|
||||||
function getOffsets(elems) {
|
function getOffsets(elems) {
|
||||||
|
|
|
@ -11,9 +11,7 @@ import alert from './alert';
|
||||||
import { getLocale } from '../utils/dateFnsLocale.ts';
|
import { getLocale } from '../utils/dateFnsLocale.ts';
|
||||||
import { toBoolean } from '../utils/string.ts';
|
import { toBoolean } from '../utils/string.ts';
|
||||||
|
|
||||||
/* eslint-disable indent */
|
function getEntryHtml(entry, apiClient) {
|
||||||
|
|
||||||
function getEntryHtml(entry, apiClient) {
|
|
||||||
let html = '';
|
let html = '';
|
||||||
html += '<div class="listItem listItem-border">';
|
html += '<div class="listItem listItem-border">';
|
||||||
let color = '#00a4dc';
|
let color = '#00a4dc';
|
||||||
|
@ -54,20 +52,20 @@ import { toBoolean } from '../utils/string.ts';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(elem, apiClient, result) {
|
function renderList(elem, apiClient, result) {
|
||||||
elem.innerHTML = result.Items.map(function (i) {
|
elem.innerHTML = result.Items.map(function (i) {
|
||||||
return getEntryHtml(i, apiClient);
|
return getEntryHtml(i, apiClient);
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadData(instance, elem, apiClient, startIndex, limit) {
|
function reloadData(instance, elem, apiClient, startIndex, limit) {
|
||||||
if (startIndex == null) {
|
if (startIndex == null) {
|
||||||
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0');
|
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0', 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7');
|
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7', 10);
|
||||||
const minDate = new Date();
|
const minDate = new Date();
|
||||||
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
|
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
|
||||||
|
|
||||||
|
@ -101,17 +99,17 @@ import { toBoolean } from '../utils/string.ts';
|
||||||
instance.items = result.Items;
|
instance.items = result.Items;
|
||||||
renderList(elem, apiClient, result);
|
renderList(elem, apiClient, result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onActivityLogUpdate(e, apiClient) {
|
function onActivityLogUpdate(e, apiClient) {
|
||||||
const options = this.options;
|
const options = this.options;
|
||||||
|
|
||||||
if (options && options.serverId === apiClient.serverId()) {
|
if (options && options.serverId === apiClient.serverId()) {
|
||||||
reloadData(this, options.element, apiClient);
|
reloadData(this, options.element, apiClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onListClick(e) {
|
function onListClick(e) {
|
||||||
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
|
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
|
||||||
|
|
||||||
if (btnEntryInfo) {
|
if (btnEntryInfo) {
|
||||||
|
@ -128,13 +126,13 @@ import { toBoolean } from '../utils/string.ts';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showItemOverview(item) {
|
function showItemOverview(item) {
|
||||||
alert({
|
alert({
|
||||||
text: item.Overview
|
text: item.Overview
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ActivityLog {
|
class ActivityLog {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
|
@ -169,5 +167,3 @@ class ActivityLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ActivityLog;
|
export default ActivityLog;
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
import { appRouter } from './appRouter';
|
import { appRouter } from './router/appRouter';
|
||||||
import browser from '../scripts/browser';
|
import browser from '../scripts/browser';
|
||||||
import dialog from './dialog/dialog';
|
import dialog from './dialog/dialog';
|
||||||
import globalize from '../scripts/globalize';
|
import globalize from '../scripts/globalize';
|
||||||
|
|
||||||
/* eslint-disable indent */
|
function useNativeAlert() {
|
||||||
|
|
||||||
function useNativeAlert() {
|
|
||||||
// webOS seems to block modals
|
// webOS seems to block modals
|
||||||
// Tizen 2.x seems to block modals
|
// Tizen 2.x seems to block modals
|
||||||
return !browser.web0s
|
return !browser.web0s
|
||||||
&& !(browser.tizenVersion && browser.tizenVersion < 3)
|
&& !(browser.tizenVersion && browser.tizenVersion < 3)
|
||||||
&& browser.tv
|
&& browser.tv
|
||||||
&& window.alert;
|
&& window.alert;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function (text, title) {
|
export default async function (text, title) {
|
||||||
let options;
|
let options;
|
||||||
if (typeof text === 'string') {
|
if (typeof text === 'string') {
|
||||||
options = {
|
options = {
|
||||||
|
@ -42,6 +40,4 @@ import globalize from '../scripts/globalize';
|
||||||
options.buttons = items;
|
options.buttons = items;
|
||||||
return dialog.show(options);
|
return dialog.show(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module alphaPicker.
|
* Module alphaPicker.
|
||||||
* @module components/alphaPicker/alphaPicker
|
* @module components/alphaPicker/alphaPicker
|
||||||
|
@ -13,9 +11,9 @@ import './style.scss';
|
||||||
import '../../elements/emby-button/paper-icon-button-light';
|
import '../../elements/emby-button/paper-icon-button-light';
|
||||||
import 'material-design-icons-iconfont';
|
import 'material-design-icons-iconfont';
|
||||||
|
|
||||||
const selectedButtonClass = 'alphaPickerButton-selected';
|
const selectedButtonClass = 'alphaPickerButton-selected';
|
||||||
|
|
||||||
function focus() {
|
function focus() {
|
||||||
const scope = this;
|
const scope = this;
|
||||||
const selected = scope.querySelector(`.${selectedButtonClass}`);
|
const selected = scope.querySelector(`.${selectedButtonClass}`);
|
||||||
|
|
||||||
|
@ -24,9 +22,9 @@ import 'material-design-icons-iconfont';
|
||||||
} else {
|
} else {
|
||||||
focusManager.autoFocus(scope, true);
|
focusManager.autoFocus(scope, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlphaPickerButtonClassName(vertical) {
|
function getAlphaPickerButtonClassName(vertical) {
|
||||||
let alphaPickerButtonClassName = 'alphaPickerButton';
|
let alphaPickerButtonClassName = 'alphaPickerButton';
|
||||||
|
|
||||||
if (layoutManager.tv) {
|
if (layoutManager.tv) {
|
||||||
|
@ -38,19 +36,19 @@ import 'material-design-icons-iconfont';
|
||||||
}
|
}
|
||||||
|
|
||||||
return alphaPickerButtonClassName;
|
return alphaPickerButtonClassName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLetterButton(l, vertical) {
|
function getLetterButton(l, vertical) {
|
||||||
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`;
|
return `<button data-value="${l}" class="${getAlphaPickerButtonClassName(vertical)}">${l}</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapLetters(letters, vertical) {
|
function mapLetters(letters, vertical) {
|
||||||
return letters.map(l => {
|
return letters.map(l => {
|
||||||
return getLetterButton(l, vertical);
|
return getLetterButton(l, vertical);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(element, options) {
|
function render(element, options) {
|
||||||
element.classList.add('alphaPicker');
|
element.classList.add('alphaPicker');
|
||||||
|
|
||||||
if (layoutManager.tv) {
|
if (layoutManager.tv) {
|
||||||
|
@ -102,9 +100,9 @@ import 'material-design-icons-iconfont';
|
||||||
|
|
||||||
element.classList.add('focusable');
|
element.classList.add('focusable');
|
||||||
element.focus = focus;
|
element.focus = focus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AlphaPicker {
|
export class AlphaPicker {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
|
@ -181,7 +179,7 @@ import 'material-design-icons-iconfont';
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
const prefix = item.getAttribute('data-prefix');
|
const prefix = item.getAttribute('data-prefix');
|
||||||
if (prefix && prefix.length) {
|
if (prefix?.length) {
|
||||||
itemFocusValue = prefix[0];
|
itemFocusValue = prefix[0];
|
||||||
if (itemFocusTimeout) {
|
if (itemFocusTimeout) {
|
||||||
clearTimeout(itemFocusTimeout);
|
clearTimeout(itemFocusTimeout);
|
||||||
|
@ -318,7 +316,6 @@ import 'material-design-icons-iconfont';
|
||||||
element.classList.remove('focuscontainer-x');
|
element.classList.remove('focuscontainer-x');
|
||||||
this.options = null;
|
this.options = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
export default AlphaPicker;
|
export default AlphaPicker;
|
||||||
|
|
|
@ -309,8 +309,8 @@ function askForExit() {
|
||||||
exitPromise = actionsheet.show({
|
exitPromise = actionsheet.show({
|
||||||
title: globalize.translate('MessageConfirmAppExit'),
|
title: globalize.translate('MessageConfirmAppExit'),
|
||||||
items: [
|
items: [
|
||||||
{id: 'yes', name: globalize.translate('Yes')},
|
{ id: 'yes', name: globalize.translate('Yes') },
|
||||||
{id: 'no', name: globalize.translate('No')}
|
{ id: 'no', name: globalize.translate('No') }
|
||||||
]
|
]
|
||||||
}).then(function (value) {
|
}).then(function (value) {
|
||||||
if (value === 'yes') {
|
if (value === 'yes') {
|
||||||
|
@ -366,20 +366,20 @@ export const appHost = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
deviceName: function () {
|
deviceName: function () {
|
||||||
return window.NativeShell?.AppHost?.deviceName
|
return window.NativeShell?.AppHost?.deviceName ?
|
||||||
? window.NativeShell.AppHost.deviceName() : getDeviceName();
|
window.NativeShell.AppHost.deviceName() : getDeviceName();
|
||||||
},
|
},
|
||||||
deviceId: function () {
|
deviceId: function () {
|
||||||
return window.NativeShell?.AppHost?.deviceId
|
return window.NativeShell?.AppHost?.deviceId ?
|
||||||
? window.NativeShell.AppHost.deviceId() : getDeviceId();
|
window.NativeShell.AppHost.deviceId() : getDeviceId();
|
||||||
},
|
},
|
||||||
appName: function () {
|
appName: function () {
|
||||||
return window.NativeShell?.AppHost?.appName
|
return window.NativeShell?.AppHost?.appName ?
|
||||||
? window.NativeShell.AppHost.appName() : appName;
|
window.NativeShell.AppHost.appName() : appName;
|
||||||
},
|
},
|
||||||
appVersion: function () {
|
appVersion: function () {
|
||||||
return window.NativeShell?.AppHost?.appVersion
|
return window.NativeShell?.AppHost?.appVersion ?
|
||||||
? window.NativeShell.AppHost.appVersion() : Package.version;
|
window.NativeShell.AppHost.appVersion() : Package.version;
|
||||||
},
|
},
|
||||||
getPushTokenInfo: function () {
|
getPushTokenInfo: function () {
|
||||||
return {};
|
return {};
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for performing auto-focus.
|
* Module for performing auto-focus.
|
||||||
* @module components/autoFocuser
|
* @module components/autoFocuser
|
||||||
|
@ -8,22 +6,22 @@
|
||||||
import focusManager from './focusManager';
|
import focusManager from './focusManager';
|
||||||
import layoutManager from './layoutManager';
|
import layoutManager from './layoutManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Previously selected element.
|
* Previously selected element.
|
||||||
*/
|
*/
|
||||||
let activeElement;
|
let activeElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns _true_ if AutoFocuser is enabled.
|
* Returns _true_ if AutoFocuser is enabled.
|
||||||
*/
|
*/
|
||||||
export function isEnabled() {
|
export function isEnabled() {
|
||||||
return layoutManager.tv;
|
return layoutManager.tv;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start AutoFocuser.
|
* Start AutoFocuser.
|
||||||
*/
|
*/
|
||||||
export function enable() {
|
export function enable() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -33,14 +31,14 @@ import layoutManager from './layoutManager';
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug('AutoFocuser enabled');
|
console.debug('AutoFocuser enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set focus on a suitable element, taking into account the previously selected.
|
* Set focus on a suitable element, taking into account the previously selected.
|
||||||
* @param {HTMLElement} [container] - Element to limit scope.
|
* @param {HTMLElement} [container] - Element to limit scope.
|
||||||
* @returns {HTMLElement} Focused element.
|
* @returns {HTMLElement} Focused element.
|
||||||
*/
|
*/
|
||||||
export function autoFocus(container) {
|
export function autoFocus(container) {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -92,9 +90,7 @@ import layoutManager from './layoutManager';
|
||||||
}
|
}
|
||||||
|
|
||||||
return focusedElement;
|
return focusedElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
|
|
|
@ -7,19 +7,17 @@ import ServerConnections from '../ServerConnections';
|
||||||
|
|
||||||
import './backdrop.scss';
|
import './backdrop.scss';
|
||||||
|
|
||||||
/* eslint-disable indent */
|
function enableAnimation() {
|
||||||
|
|
||||||
function enableAnimation() {
|
|
||||||
return !browser.slow;
|
return !browser.slow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableRotation() {
|
function enableRotation() {
|
||||||
return !browser.tv
|
return !browser.tv
|
||||||
// Causes high cpu usage
|
// Causes high cpu usage
|
||||||
&& !browser.firefox;
|
&& !browser.firefox;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Backdrop {
|
class Backdrop {
|
||||||
load(url, parent, existingBackdropImage) {
|
load(url, parent, existingBackdropImage) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const self = this;
|
const self = this;
|
||||||
|
@ -80,10 +78,10 @@ import './backdrop.scss';
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
this.cancelAnimation();
|
this.cancelAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let backdropContainer;
|
let backdropContainer;
|
||||||
function getBackdropContainer() {
|
function getBackdropContainer() {
|
||||||
if (!backdropContainer) {
|
if (!backdropContainer) {
|
||||||
backdropContainer = document.querySelector('.backdropContainer');
|
backdropContainer = document.querySelector('.backdropContainer');
|
||||||
}
|
}
|
||||||
|
@ -95,9 +93,9 @@ import './backdrop.scss';
|
||||||
}
|
}
|
||||||
|
|
||||||
return backdropContainer;
|
return backdropContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearBackdrop(clearAll) {
|
export function clearBackdrop(clearAll) {
|
||||||
clearRotation();
|
clearRotation();
|
||||||
|
|
||||||
if (currentLoadingBackdrop) {
|
if (currentLoadingBackdrop) {
|
||||||
|
@ -113,38 +111,38 @@ import './backdrop.scss';
|
||||||
}
|
}
|
||||||
|
|
||||||
internalBackdrop(false);
|
internalBackdrop(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundContainer;
|
let backgroundContainer;
|
||||||
function getBackgroundContainer() {
|
function getBackgroundContainer() {
|
||||||
if (!backgroundContainer) {
|
if (!backgroundContainer) {
|
||||||
backgroundContainer = document.querySelector('.backgroundContainer');
|
backgroundContainer = document.querySelector('.backgroundContainer');
|
||||||
}
|
}
|
||||||
return backgroundContainer;
|
return backgroundContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackgroundContainerBackgroundEnabled() {
|
function setBackgroundContainerBackgroundEnabled() {
|
||||||
if (hasInternalBackdrop || hasExternalBackdrop) {
|
if (hasInternalBackdrop || hasExternalBackdrop) {
|
||||||
getBackgroundContainer().classList.add('withBackdrop');
|
getBackgroundContainer().classList.add('withBackdrop');
|
||||||
} else {
|
} else {
|
||||||
getBackgroundContainer().classList.remove('withBackdrop');
|
getBackgroundContainer().classList.remove('withBackdrop');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasInternalBackdrop;
|
let hasInternalBackdrop;
|
||||||
function internalBackdrop(isEnabled) {
|
function internalBackdrop(isEnabled) {
|
||||||
hasInternalBackdrop = isEnabled;
|
hasInternalBackdrop = isEnabled;
|
||||||
setBackgroundContainerBackgroundEnabled();
|
setBackgroundContainerBackgroundEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasExternalBackdrop;
|
let hasExternalBackdrop;
|
||||||
export function externalBackdrop(isEnabled) {
|
export function externalBackdrop(isEnabled) {
|
||||||
hasExternalBackdrop = isEnabled;
|
hasExternalBackdrop = isEnabled;
|
||||||
setBackgroundContainerBackgroundEnabled();
|
setBackgroundContainerBackgroundEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentLoadingBackdrop;
|
let currentLoadingBackdrop;
|
||||||
function setBackdropImage(url) {
|
function setBackdropImage(url) {
|
||||||
if (currentLoadingBackdrop) {
|
if (currentLoadingBackdrop) {
|
||||||
currentLoadingBackdrop.destroy();
|
currentLoadingBackdrop.destroy();
|
||||||
currentLoadingBackdrop = null;
|
currentLoadingBackdrop = null;
|
||||||
|
@ -163,9 +161,9 @@ import './backdrop.scss';
|
||||||
const instance = new Backdrop();
|
const instance = new Backdrop();
|
||||||
instance.load(url, elem, existingBackdropImage);
|
instance.load(url, elem, existingBackdropImage);
|
||||||
currentLoadingBackdrop = instance;
|
currentLoadingBackdrop = instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemImageUrls(item, imageOptions) {
|
function getItemImageUrls(item, imageOptions) {
|
||||||
imageOptions = imageOptions || {};
|
imageOptions = imageOptions || {};
|
||||||
|
|
||||||
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||||
|
@ -192,9 +190,9 @@ import './backdrop.scss';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageUrls(items, imageOptions) {
|
function getImageUrls(items, imageOptions) {
|
||||||
const list = [];
|
const list = [];
|
||||||
const onImg = img => {
|
const onImg = img => {
|
||||||
list.push(img);
|
list.push(img);
|
||||||
|
@ -206,16 +204,16 @@ import './backdrop.scss';
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enabled() {
|
function enabled() {
|
||||||
return userSettings.enableBackdrops();
|
return userSettings.enableBackdrops();
|
||||||
}
|
}
|
||||||
|
|
||||||
let rotationInterval;
|
let rotationInterval;
|
||||||
let currentRotatingImages = [];
|
let currentRotatingImages = [];
|
||||||
let currentRotationIndex = -1;
|
let currentRotationIndex = -1;
|
||||||
export function setBackdrops(items, imageOptions, enableImageRotation) {
|
export function setBackdrops(items, imageOptions, enableImageRotation) {
|
||||||
if (enabled()) {
|
if (enabled()) {
|
||||||
const images = getImageUrls(items, imageOptions);
|
const images = getImageUrls(items, imageOptions);
|
||||||
|
|
||||||
|
@ -225,9 +223,9 @@ import './backdrop.scss';
|
||||||
clearBackdrop();
|
clearBackdrop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRotation(images, enableImageRotation) {
|
function startRotation(images, enableImageRotation) {
|
||||||
if (isEqual(images, currentRotatingImages)) {
|
if (isEqual(images, currentRotatingImages)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -242,9 +240,9 @@ import './backdrop.scss';
|
||||||
}
|
}
|
||||||
|
|
||||||
onRotationInterval();
|
onRotationInterval();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRotationInterval() {
|
function onRotationInterval() {
|
||||||
if (playbackManager.isPlayingLocally(['Video'])) {
|
if (playbackManager.isPlayingLocally(['Video'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -256,9 +254,9 @@ import './backdrop.scss';
|
||||||
|
|
||||||
currentRotationIndex = newIndex;
|
currentRotationIndex = newIndex;
|
||||||
setBackdropImage(currentRotatingImages[newIndex]);
|
setBackdropImage(currentRotatingImages[newIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRotation() {
|
function clearRotation() {
|
||||||
const interval = rotationInterval;
|
const interval = rotationInterval;
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
@ -267,9 +265,9 @@ import './backdrop.scss';
|
||||||
rotationInterval = null;
|
rotationInterval = null;
|
||||||
currentRotatingImages = [];
|
currentRotatingImages = [];
|
||||||
currentRotationIndex = -1;
|
currentRotationIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBackdrop(url, imageOptions) {
|
export function setBackdrop(url, imageOptions) {
|
||||||
if (url && typeof url !== 'string') {
|
if (url && typeof url !== 'string') {
|
||||||
url = getImageUrls([url], imageOptions)[0];
|
url = getImageUrls([url], imageOptions)[0];
|
||||||
}
|
}
|
||||||
|
@ -280,9 +278,7 @@ import './backdrop.scss';
|
||||||
} else {
|
} else {
|
||||||
clearBackdrop();
|
clearBackdrop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @enum TransparencyLevel
|
* @enum TransparencyLevel
|
||||||
|
|
|
@ -114,7 +114,7 @@ button::-moz-focus-inner {
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.show-animation:focus > .cardBox {
|
.card.show-animation:focus > .cardBox {
|
||||||
transform: scale(1.18, 1.18);
|
transform: scale(1.07, 1.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardBox-bottompadded {
|
.cardBox-bottompadded {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for building cards from item data.
|
* Module for building cards from item data.
|
||||||
|
@ -18,37 +17,38 @@ import browser from '../../scripts/browser';
|
||||||
import { playbackManager } from '../playback/playbackmanager';
|
import { playbackManager } from '../playback/playbackmanager';
|
||||||
import itemShortcuts from '../shortcuts';
|
import itemShortcuts from '../shortcuts';
|
||||||
import imageHelper from '../../scripts/imagehelper';
|
import imageHelper from '../../scripts/imagehelper';
|
||||||
|
import { randomInt } from '../../utils/number.ts';
|
||||||
import './card.scss';
|
import './card.scss';
|
||||||
import '../../elements/emby-button/paper-icon-button-light';
|
import '../../elements/emby-button/paper-icon-button-light';
|
||||||
import '../guide/programs.scss';
|
import '../guide/programs.scss';
|
||||||
import ServerConnections from '../ServerConnections';
|
import ServerConnections from '../ServerConnections';
|
||||||
import { appRouter } from '../appRouter';
|
import { appRouter } from '../router/appRouter';
|
||||||
|
|
||||||
const enableFocusTransform = !browser.slow && !browser.edge;
|
const enableFocusTransform = !browser.slow && !browser.edge;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the HTML markup for cards for a set of items.
|
* Generate the HTML markup for cards for a set of items.
|
||||||
* @param items - The items used to generate cards.
|
* @param items - The items used to generate cards.
|
||||||
* @param options - The options of the cards.
|
* @param options - The options of the cards.
|
||||||
* @returns {string} The HTML markup for the cards.
|
* @returns {string} The HTML markup for the cards.
|
||||||
*/
|
*/
|
||||||
export function getCardsHtml(items, options) {
|
export function getCardsHtml(items, options) {
|
||||||
if (arguments.length === 1) {
|
if (arguments.length === 1) {
|
||||||
options = arguments[0];
|
options = arguments[0];
|
||||||
items = options.items;
|
items = options.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCardsHtmlInternal(items, options);
|
return buildCardsHtmlInternal(items, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the number of posters per row.
|
* Computes the number of posters per row.
|
||||||
* @param {string} shape - Shape of the cards.
|
* @param {string} shape - Shape of the cards.
|
||||||
* @param {number} screenWidth - Width of the screen.
|
* @param {number} screenWidth - Width of the screen.
|
||||||
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
|
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
|
||||||
* @returns {number} Number of cards per row for an itemsContainer.
|
* @returns {number} Number of cards per row for an itemsContainer.
|
||||||
*/
|
*/
|
||||||
function getPostersPerRow(shape, screenWidth, isOrientationLandscape) {
|
function getPostersPerRow(shape, screenWidth, isOrientationLandscape) {
|
||||||
switch (shape) {
|
switch (shape) {
|
||||||
case 'portrait':
|
case 'portrait':
|
||||||
if (layoutManager.tv) {
|
if (layoutManager.tv) {
|
||||||
|
@ -250,14 +250,14 @@ import { appRouter } from '../appRouter';
|
||||||
default:
|
default:
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the window is resizable.
|
* Checks if the window is resizable.
|
||||||
* @param {number} windowWidth - Width of the device's screen.
|
* @param {number} windowWidth - Width of the device's screen.
|
||||||
* @returns {boolean} - Result of the check.
|
* @returns {boolean} - Result of the check.
|
||||||
*/
|
*/
|
||||||
function isResizable(windowWidth) {
|
function isResizable(windowWidth) {
|
||||||
const screen = window.screen;
|
const screen = window.screen;
|
||||||
if (screen) {
|
if (screen) {
|
||||||
const screenWidth = screen.availWidth;
|
const screenWidth = screen.availWidth;
|
||||||
|
@ -268,26 +268,26 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the width of a card's image according to the shape and amount of cards per row.
|
* Gets the width of a card's image according to the shape and amount of cards per row.
|
||||||
* @param {string} shape - Shape of the card.
|
* @param {string} shape - Shape of the card.
|
||||||
* @param {number} screenWidth - Width of the screen.
|
* @param {number} screenWidth - Width of the screen.
|
||||||
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
|
* @param {boolean} isOrientationLandscape - Flag for the orientation of the screen.
|
||||||
* @returns {number} Width of the image for a card.
|
* @returns {number} Width of the image for a card.
|
||||||
*/
|
*/
|
||||||
function getImageWidth(shape, screenWidth, isOrientationLandscape) {
|
function getImageWidth(shape, screenWidth, isOrientationLandscape) {
|
||||||
const imagesPerRow = getPostersPerRow(shape, screenWidth, isOrientationLandscape);
|
const imagesPerRow = getPostersPerRow(shape, screenWidth, isOrientationLandscape);
|
||||||
return Math.round(screenWidth / imagesPerRow);
|
return Math.round(screenWidth / imagesPerRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes the options for a card.
|
* Normalizes the options for a card.
|
||||||
* @param {Object} items - A set of items.
|
* @param {Object} items - A set of items.
|
||||||
* @param {Object} options - Options for handling the items.
|
* @param {Object} options - Options for handling the items.
|
||||||
*/
|
*/
|
||||||
function setCardData(items, options) {
|
function setCardData(items, options) {
|
||||||
options.shape = options.shape || 'auto';
|
options.shape = options.shape || 'auto';
|
||||||
|
|
||||||
const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items);
|
const primaryImageAspectRatio = imageLoader.getPrimaryImageAspectRatio(items);
|
||||||
|
@ -340,15 +340,15 @@ import { appRouter } from '../appRouter';
|
||||||
|
|
||||||
options.width = getImageWidth(options.shape, screenWidth, screenWidth > (screenHeight * 1.3));
|
options.width = getImageWidth(options.shape, screenWidth, screenWidth > (screenHeight * 1.3));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the internal HTML markup for cards.
|
* Generates the internal HTML markup for cards.
|
||||||
* @param {Object} items - Items for which to generate the markup.
|
* @param {Object} items - Items for which to generate the markup.
|
||||||
* @param {Object} options - Options for generating the markup.
|
* @param {Object} options - Options for generating the markup.
|
||||||
* @returns {string} The internal HTML markup of the cards.
|
* @returns {string} The internal HTML markup of the cards.
|
||||||
*/
|
*/
|
||||||
function buildCardsHtmlInternal(items, options) {
|
function buildCardsHtmlInternal(items, options) {
|
||||||
let isVertical = false;
|
let isVertical = false;
|
||||||
|
|
||||||
if (options.shape === 'autoVertical') {
|
if (options.shape === 'autoVertical') {
|
||||||
|
@ -458,14 +458,14 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the aspect ratio for a card given its shape.
|
* Computes the aspect ratio for a card given its shape.
|
||||||
* @param {string} shape - Shape for which to get the aspect ratio.
|
* @param {string} shape - Shape for which to get the aspect ratio.
|
||||||
* @returns {null|number} Ratio of the shape.
|
* @returns {null|number} Ratio of the shape.
|
||||||
*/
|
*/
|
||||||
function getDesiredAspect(shape) {
|
function getDesiredAspect(shape) {
|
||||||
if (shape) {
|
if (shape) {
|
||||||
shape = shape.toLowerCase();
|
shape = shape.toLowerCase();
|
||||||
if (shape.indexOf('portrait') !== -1) {
|
if (shape.indexOf('portrait') !== -1) {
|
||||||
|
@ -482,9 +482,9 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} CardImageUrl
|
* @typedef {Object} CardImageUrl
|
||||||
* @property {string} imgUrl - Image URL.
|
* @property {string} imgUrl - Image URL.
|
||||||
* @property {string} blurhash - Image blurhash.
|
* @property {string} blurhash - Image blurhash.
|
||||||
|
@ -492,14 +492,14 @@ import { appRouter } from '../appRouter';
|
||||||
* @property {boolean} coverImage - Use cover style.
|
* @property {boolean} coverImage - Use cover style.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Get the URL of the card's image.
|
/** Get the URL of the card's image.
|
||||||
* @param {Object} item - Item for which to generate a card.
|
* @param {Object} item - Item for which to generate a card.
|
||||||
* @param {Object} apiClient - API client object.
|
* @param {Object} apiClient - API client object.
|
||||||
* @param {Object} options - Options of the card.
|
* @param {Object} options - Options of the card.
|
||||||
* @param {string} shape - Shape of the desired image.
|
* @param {string} shape - Shape of the desired image.
|
||||||
* @returns {CardImageUrl} Object representing the URL of the card's image.
|
* @returns {CardImageUrl} Object representing the URL of the card's image.
|
||||||
*/
|
*/
|
||||||
function getCardImageUrl(item, apiClient, options, shape) {
|
function getCardImageUrl(item, apiClient, options, shape) {
|
||||||
item = item.ProgramInfo || item;
|
item = item.ProgramInfo || item;
|
||||||
|
|
||||||
const width = options.width;
|
const width = options.width;
|
||||||
|
@ -638,24 +638,14 @@ import { appRouter } from '../appRouter';
|
||||||
forceName: forceName,
|
forceName: forceName,
|
||||||
coverImage: coverImage
|
coverImage: coverImage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random integer in a given range.
|
|
||||||
* @param {number} min - Minimum of the range.
|
|
||||||
* @param {number} max - Maximum of the range.
|
|
||||||
* @returns {number} Randomly generated number.
|
|
||||||
*/
|
|
||||||
function getRandomInt(min, max) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an index used to select the default color of a card based on a string.
|
* Generates an index used to select the default color of a card based on a string.
|
||||||
* @param {?string} [str] - String to use for generating the index.
|
* @param {?string} [str] - String to use for generating the index.
|
||||||
* @returns {number} Index of the color.
|
* @returns {number} Index of the color.
|
||||||
*/
|
*/
|
||||||
function getDefaultColorIndex(str) {
|
function getDefaultColorIndex(str) {
|
||||||
const numRandomColors = 5;
|
const numRandomColors = 5;
|
||||||
|
|
||||||
if (str) {
|
if (str) {
|
||||||
|
@ -663,17 +653,17 @@ import { appRouter } from '../appRouter';
|
||||||
const character = String(str.slice(charIndex, charIndex + 1).charCodeAt());
|
const character = String(str.slice(charIndex, charIndex + 1).charCodeAt());
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < character.length; i++) {
|
for (let i = 0; i < character.length; i++) {
|
||||||
sum += parseInt(character.charAt(i));
|
sum += parseInt(character.charAt(i), 10);
|
||||||
}
|
}
|
||||||
const index = String(sum).slice(-1);
|
const index = String(sum).slice(-1);
|
||||||
|
|
||||||
return (index % numRandomColors) + 1;
|
return (index % numRandomColors) + 1;
|
||||||
} else {
|
} else {
|
||||||
return getRandomInt(1, numRandomColors);
|
return randomInt(1, numRandomColors);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the HTML markup for a card's text.
|
* Generates the HTML markup for a card's text.
|
||||||
* @param {Array} lines - Array containing the text lines.
|
* @param {Array} lines - Array containing the text lines.
|
||||||
* @param {string} cssClass - Base CSS class to use for the lines.
|
* @param {string} cssClass - Base CSS class to use for the lines.
|
||||||
|
@ -684,14 +674,13 @@ import { appRouter } from '../appRouter';
|
||||||
* @param {number} maxLines - Maximum number of lines to render.
|
* @param {number} maxLines - Maximum number of lines to render.
|
||||||
* @returns {string} HTML markup for the card's text.
|
* @returns {string} HTML markup for the card's text.
|
||||||
*/
|
*/
|
||||||
function getCardTextLines(lines, cssClass, forceLines, isOuterFooter, cardLayout, addRightMargin, maxLines) {
|
function getCardTextLines(lines, cssClass, forceLines, isOuterFooter, cardLayout, addRightMargin, maxLines) {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
let valid = 0;
|
let valid = 0;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (const text of lines) {
|
||||||
let currentCssClass = cssClass;
|
let currentCssClass = cssClass;
|
||||||
const text = lines[i];
|
|
||||||
|
|
||||||
if (valid > 0 && isOuterFooter) {
|
if (valid > 0 && isOuterFooter) {
|
||||||
currentCssClass += ' cardText-secondary';
|
currentCssClass += ' cardText-secondary';
|
||||||
|
@ -725,25 +714,25 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the item is live TV.
|
* Determines if the item is live TV.
|
||||||
* @param {Object} item - Item to use for the check.
|
* @param {Object} item - Item to use for the check.
|
||||||
* @returns {boolean} Flag showing if the item is live TV.
|
* @returns {boolean} Flag showing if the item is live TV.
|
||||||
*/
|
*/
|
||||||
function isUsingLiveTvNaming(item) {
|
function isUsingLiveTvNaming(item) {
|
||||||
return item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording';
|
return item.Type === 'Program' || item.Type === 'Timer' || item.Type === 'Recording';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the air time text for the item based on the given times.
|
* Returns the air time text for the item based on the given times.
|
||||||
* @param {object} item - Item used to generate the air time text.
|
* @param {object} item - Item used to generate the air time text.
|
||||||
* @param {boolean} showAirDateTime - ISO8601 date for the start of the show.
|
* @param {boolean} showAirDateTime - ISO8601 date for the start of the show.
|
||||||
* @param {boolean} showAirEndTime - ISO8601 date for the end of the show.
|
* @param {boolean} showAirEndTime - ISO8601 date for the end of the show.
|
||||||
* @returns {string} The air time text for the item based on the given dates.
|
* @returns {string} The air time text for the item based on the given dates.
|
||||||
*/
|
*/
|
||||||
function getAirTimeText(item, showAirDateTime, showAirEndTime) {
|
function getAirTimeText(item, showAirDateTime, showAirEndTime) {
|
||||||
let airTimeText = '';
|
let airTimeText = '';
|
||||||
|
|
||||||
if (item.StartDate) {
|
if (item.StartDate) {
|
||||||
|
@ -766,34 +755,31 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return airTimeText;
|
return airTimeText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the HTML markup for the card's footer text.
|
* Generates the HTML markup for the card's footer text.
|
||||||
* @param {Object} item - Item used to generate the footer text.
|
* @param {Object} item - Item used to generate the footer text.
|
||||||
* @param {Object} apiClient - API client instance.
|
* @param {Object} apiClient - API client instance.
|
||||||
* @param {Object} options - Options used to generate the footer text.
|
* @param {Object} options - Options used to generate the footer text.
|
||||||
* @param {string} showTitle - Flag to show the title in the footer.
|
|
||||||
* @param {boolean} forceName - Flag to force showing the name of the item.
|
|
||||||
* @param {boolean} overlayText - Flag to show overlay text.
|
|
||||||
* @param {Object} imgUrl - Object representing the card's image URL.
|
|
||||||
* @param {string} footerClass - CSS classes of the footer element.
|
* @param {string} footerClass - CSS classes of the footer element.
|
||||||
* @param {string} progressHtml - HTML markup of the progress bar element.
|
* @param {string} progressHtml - HTML markup of the progress bar element.
|
||||||
* @param {string} logoUrl - URL of the logo for the item.
|
* @param {Object} flags - Various flags for the footer
|
||||||
* @param {boolean} isOuterFooter - Flag to mark the text as outer footer.
|
* @param {Object} urls - Various urls for the footer
|
||||||
* @returns {string} HTML markup of the card's footer text element.
|
* @returns {string} HTML markup of the card's footer text element.
|
||||||
*/
|
*/
|
||||||
function getCardFooterText(item, apiClient, options, showTitle, forceName, overlayText, imgUrl, footerClass, progressHtml, logoUrl, isOuterFooter) {
|
function getCardFooterText(item, apiClient, options, footerClass, progressHtml, flags, urls) {
|
||||||
item = item.ProgramInfo || item;
|
item = item.ProgramInfo || item;
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (logoUrl) {
|
if (urls.logoUrl) {
|
||||||
html += '<div class="lazy cardFooterLogo" data-src="' + logoUrl + '"></div>';
|
html += '<div class="lazy cardFooterLogo" data-src="' + urls.logoUrl + '"></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const showOtherText = isOuterFooter ? !overlayText : overlayText;
|
const showTitle = options.showTitle === 'auto' ? true : (options.showTitle || item.Type === 'PhotoAlbum' || item.Type === 'Folder');
|
||||||
|
const showOtherText = flags.isOuterFooter ? !flags.overlayText : flags.overlayText;
|
||||||
|
|
||||||
if (isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
|
if (flags.isOuterFooter && options.cardLayout && layoutManager.mobile && options.cardFooterAside !== 'none') {
|
||||||
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
html += `<button is="paper-icon-button-light" class="itemAction btnCardOptions cardText-secondary" data-action="menu" title="${globalize.translate('ButtonMore')}"><span class="material-icons more_vert" aria-hidden="true"></span></button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -805,7 +791,7 @@ import { appRouter } from '../appRouter';
|
||||||
let titleAdded;
|
let titleAdded;
|
||||||
|
|
||||||
if (showOtherText && (options.showParentTitle || options.showParentTitleOrTitle) && !parentTitleUnderneath) {
|
if (showOtherText && (options.showParentTitle || options.showParentTitleOrTitle) && !parentTitleUnderneath) {
|
||||||
if (isOuterFooter && item.Type === 'Episode' && item.SeriesName) {
|
if (flags.isOuterFooter && item.Type === 'Episode' && item.SeriesName) {
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
lines.push(getTextActionButton({
|
lines.push(getTextActionButton({
|
||||||
Id: item.SeriesId,
|
Id: item.SeriesId,
|
||||||
|
@ -835,7 +821,7 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
let showMediaTitle = (showTitle && !titleAdded) || (options.showParentTitleOrTitle && !lines.length);
|
let showMediaTitle = (showTitle && !titleAdded) || (options.showParentTitleOrTitle && !lines.length);
|
||||||
if (!showMediaTitle && !titleAdded && (showTitle || forceName)) {
|
if (!showMediaTitle && !titleAdded && (showTitle || flags.forceName)) {
|
||||||
showMediaTitle = true;
|
showMediaTitle = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -856,7 +842,7 @@ import { appRouter } from '../appRouter';
|
||||||
|
|
||||||
if (showOtherText) {
|
if (showOtherText) {
|
||||||
if (options.showParentTitle && parentTitleUnderneath) {
|
if (options.showParentTitle && parentTitleUnderneath) {
|
||||||
if (isOuterFooter && item.AlbumArtists && item.AlbumArtists.length) {
|
if (flags.isOuterFooter && item.AlbumArtists && item.AlbumArtists.length) {
|
||||||
item.AlbumArtists[0].Type = 'MusicArtist';
|
item.AlbumArtists[0].Type = 'MusicArtist';
|
||||||
item.AlbumArtists[0].IsFolder = true;
|
item.AlbumArtists[0].IsFolder = true;
|
||||||
lines.push(getTextActionButton(item.AlbumArtists[0], null, serverId));
|
lines.push(getTextActionButton(item.AlbumArtists[0], null, serverId));
|
||||||
|
@ -875,8 +861,8 @@ import { appRouter } from '../appRouter';
|
||||||
|
|
||||||
if (options.textLines) {
|
if (options.textLines) {
|
||||||
const additionalLines = options.textLines(item);
|
const additionalLines = options.textLines(item);
|
||||||
for (let i = 0; i < additionalLines.length; i++) {
|
for (const additionalLine of additionalLines) {
|
||||||
lines.push(additionalLines[i]);
|
lines.push(additionalLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -908,13 +894,13 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.showYear || options.showSeriesYear) {
|
if (options.showYear || options.showSeriesYear) {
|
||||||
const productionYear = item.ProductionYear && datetime.toLocaleString(item.ProductionYear, {useGrouping: false});
|
const productionYear = item.ProductionYear && datetime.toLocaleString(item.ProductionYear, { useGrouping: false });
|
||||||
if (item.Type === 'Series') {
|
if (item.Type === 'Series') {
|
||||||
if (item.Status === 'Continuing') {
|
if (item.Status === 'Continuing') {
|
||||||
lines.push(globalize.translate('SeriesYearToPresent', productionYear || ''));
|
lines.push(globalize.translate('SeriesYearToPresent', productionYear || ''));
|
||||||
} else {
|
} else {
|
||||||
if (item.EndDate && item.ProductionYear) {
|
if (item.EndDate && item.ProductionYear) {
|
||||||
const endYear = datetime.toLocaleString(datetime.parseISO8601Date(item.EndDate).getFullYear(), {useGrouping: false});
|
const endYear = datetime.toLocaleString(datetime.parseISO8601Date(item.EndDate).getFullYear(), { useGrouping: false });
|
||||||
lines.push(productionYear + ((endYear === item.ProductionYear) ? '' : (' - ' + endYear)));
|
lines.push(productionYear + ((endYear === item.ProductionYear) ? '' : (' - ' + endYear)));
|
||||||
} else {
|
} else {
|
||||||
lines.push(productionYear || '');
|
lines.push(productionYear || '');
|
||||||
|
@ -991,23 +977,23 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((showTitle || !imgUrl) && forceName && overlayText && lines.length === 1) {
|
if ((showTitle || !urls.imgUrl) && flags.forceName && flags.overlayText && lines.length === 1) {
|
||||||
lines = [];
|
lines = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overlayText && showTitle) {
|
if (flags.overlayText && showTitle) {
|
||||||
lines = [escapeHtml(item.Name)];
|
lines = [escapeHtml(item.Name)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRightTextMargin = isOuterFooter && options.cardLayout && !options.centerText && options.cardFooterAside !== 'none' && layoutManager.mobile;
|
const addRightTextMargin = flags.isOuterFooter && options.cardLayout && !options.centerText && options.cardFooterAside !== 'none' && layoutManager.mobile;
|
||||||
|
|
||||||
html += getCardTextLines(lines, cssClass, !options.overlayText, isOuterFooter, options.cardLayout, addRightTextMargin, options.lines);
|
html += getCardTextLines(lines, cssClass, !options.overlayText, flags.isOuterFooter, options.cardLayout, addRightTextMargin, options.lines);
|
||||||
|
|
||||||
if (progressHtml) {
|
if (progressHtml) {
|
||||||
html += progressHtml;
|
html += progressHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (html && (!isOuterFooter || logoUrl || options.cardLayout)) {
|
if (html && (!flags.isOuterFooter || urls.logoUrl || options.cardLayout)) {
|
||||||
html = '<div class="' + footerClass + '">' + html;
|
html = '<div class="' + footerClass + '">' + html;
|
||||||
|
|
||||||
//cardFooter
|
//cardFooter
|
||||||
|
@ -1015,16 +1001,16 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the HTML markup for the action button.
|
* Generates the HTML markup for the action button.
|
||||||
* @param {Object} item - Item used to generate the action button.
|
* @param {Object} item - Item used to generate the action button.
|
||||||
* @param {string} text - Text of the action button.
|
* @param {string} text - Text of the action button.
|
||||||
* @param {string} serverId - ID of the server.
|
* @param {string} serverId - ID of the server.
|
||||||
* @returns {string} HTML markup of the action button.
|
* @returns {string} HTML markup of the action button.
|
||||||
*/
|
*/
|
||||||
function getTextActionButton(item, text, serverId) {
|
function getTextActionButton(item, text, serverId) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
text = itemHelper.getDisplayName(item);
|
text = itemHelper.getDisplayName(item);
|
||||||
}
|
}
|
||||||
|
@ -1041,15 +1027,15 @@ import { appRouter } from '../appRouter';
|
||||||
html += '</a>';
|
html += '</a>';
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates HTML markup for the item count indicator.
|
* Generates HTML markup for the item count indicator.
|
||||||
* @param {Object} options - Options used to generate the item count.
|
* @param {Object} options - Options used to generate the item count.
|
||||||
* @param {Object} item - Item used to generate the item count.
|
* @param {Object} item - Item used to generate the item count.
|
||||||
* @returns {string} HTML markup for the item count indicator.
|
* @returns {string} HTML markup for the item count indicator.
|
||||||
*/
|
*/
|
||||||
function getItemCountsHtml(options, item) {
|
function getItemCountsHtml(options, item) {
|
||||||
const counts = [];
|
const counts = [];
|
||||||
let childText;
|
let childText;
|
||||||
|
|
||||||
|
@ -1121,31 +1107,30 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return counts.join(', ');
|
return counts.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshIndicatorLoaded;
|
let refreshIndicatorLoaded;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports the refresh indicator element.
|
* Imports the refresh indicator element.
|
||||||
*/
|
*/
|
||||||
function importRefreshIndicator() {
|
function importRefreshIndicator() {
|
||||||
if (!refreshIndicatorLoaded) {
|
if (!refreshIndicatorLoaded) {
|
||||||
refreshIndicatorLoaded = true;
|
refreshIndicatorLoaded = true;
|
||||||
/* eslint-disable-next-line @babel/no-unused-expressions */
|
|
||||||
import('../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator');
|
import('../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default background class for a card based on a string.
|
* Returns the default background class for a card based on a string.
|
||||||
* @param {?string} [str] - Text used to generate the background class.
|
* @param {?string} [str] - Text used to generate the background class.
|
||||||
* @returns {string} CSS classes for default card backgrounds.
|
* @returns {string} CSS classes for default card backgrounds.
|
||||||
*/
|
*/
|
||||||
export function getDefaultBackgroundClass(str) {
|
export function getDefaultBackgroundClass(str) {
|
||||||
return 'defaultCardBackground defaultCardBackground' + getDefaultColorIndex(str);
|
return 'defaultCardBackground defaultCardBackground' + getDefaultColorIndex(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the HTML markup for an individual card.
|
* Builds the HTML markup for an individual card.
|
||||||
* @param {number} index - Index of the card
|
* @param {number} index - Index of the card
|
||||||
* @param {object} item - Item used to generate the card.
|
* @param {object} item - Item used to generate the card.
|
||||||
|
@ -1153,7 +1138,7 @@ import { appRouter } from '../appRouter';
|
||||||
* @param {object} options - Options used to generate the card.
|
* @param {object} options - Options used to generate the card.
|
||||||
* @returns {string} HTML markup for the generated card.
|
* @returns {string} HTML markup for the generated card.
|
||||||
*/
|
*/
|
||||||
function buildCard(index, item, apiClient, options) {
|
function buildCard(index, item, apiClient, options) {
|
||||||
let action = options.action || 'link';
|
let action = options.action || 'link';
|
||||||
|
|
||||||
if (action === 'play' && item.IsFolder) {
|
if (action === 'play' && item.IsFolder) {
|
||||||
|
@ -1217,7 +1202,6 @@ import { appRouter } from '../appRouter';
|
||||||
|
|
||||||
const forceName = imgInfo.forceName;
|
const forceName = imgInfo.forceName;
|
||||||
|
|
||||||
const showTitle = options.showTitle === 'auto' ? true : (options.showTitle || item.Type === 'PhotoAlbum' || item.Type === 'Folder');
|
|
||||||
const overlayText = options.overlayText;
|
const overlayText = options.overlayText;
|
||||||
|
|
||||||
let cardImageContainerClass = 'cardImageContainer';
|
let cardImageContainerClass = 'cardImageContainer';
|
||||||
|
@ -1265,7 +1249,7 @@ import { appRouter } from '../appRouter';
|
||||||
logoUrl = null;
|
logoUrl = null;
|
||||||
|
|
||||||
footerCssClass = progressHtml ? 'innerCardFooter fullInnerCardFooter' : 'innerCardFooter';
|
footerCssClass = progressHtml ? 'innerCardFooter fullInnerCardFooter' : 'innerCardFooter';
|
||||||
innerCardFooter += getCardFooterText(item, apiClient, options, showTitle, forceName, overlayText, imgUrl, footerCssClass, progressHtml, logoUrl, false);
|
innerCardFooter += getCardFooterText(item, apiClient, options, footerCssClass, progressHtml, { forceName, overlayText, isOuterFooter: false }, { imgUrl, logoUrl });
|
||||||
footerOverlayed = true;
|
footerOverlayed = true;
|
||||||
} else if (progressHtml) {
|
} else if (progressHtml) {
|
||||||
innerCardFooter += '<div class="innerCardFooter fullInnerCardFooter innerCardFooterClear">';
|
innerCardFooter += '<div class="innerCardFooter fullInnerCardFooter innerCardFooterClear">';
|
||||||
|
@ -1292,7 +1276,7 @@ import { appRouter } from '../appRouter';
|
||||||
logoUrl = null;
|
logoUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
outerCardFooter = getCardFooterText(item, apiClient, options, showTitle, forceName, overlayText, imgUrl, footerCssClass, progressHtml, logoUrl, true);
|
outerCardFooter = getCardFooterText(item, apiClient, options, footerCssClass, progressHtml, { forceName, overlayText, isOuterFooter: true }, { imgUrl, logoUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outerCardFooter && !options.cardLayout) {
|
if (outerCardFooter && !options.cardLayout) {
|
||||||
|
@ -1457,15 +1441,15 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<' + tagName + ' data-index="' + index + '"' + timerAttributes + actionAttribute + ' data-isfolder="' + (item.IsFolder || false) + '" data-serverid="' + (item.ServerId || options.serverId) + '" data-id="' + (item.Id || item.ItemId) + '" data-type="' + item.Type + '"' + mediaTypeData + collectionTypeData + channelIdData + pathData + positionTicksData + collectionIdData + playlistIdData + contextData + parentIdData + startDate + endDate + ' data-prefix="' + escapeHtml(prefix) + '" class="' + className + '"' + ariaLabelAttribute + '>' + cardImageContainerOpen + innerCardFooter + cardImageContainerClose + overlayButtons + additionalCardContent + cardScalableClose + outerCardFooter + cardBoxClose + '</' + tagName + '>';
|
return '<' + tagName + ' data-index="' + index + '"' + timerAttributes + actionAttribute + ' data-isfolder="' + (item.IsFolder || false) + '" data-serverid="' + (item.ServerId || options.serverId) + '" data-id="' + (item.Id || item.ItemId) + '" data-type="' + item.Type + '"' + mediaTypeData + collectionTypeData + channelIdData + pathData + positionTicksData + collectionIdData + playlistIdData + contextData + parentIdData + startDate + endDate + ' data-prefix="' + escapeHtml(prefix) + '" class="' + className + '"' + ariaLabelAttribute + '>' + cardImageContainerOpen + innerCardFooter + cardImageContainerClose + overlayButtons + additionalCardContent + cardScalableClose + outerCardFooter + cardBoxClose + '</' + tagName + '>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates HTML markup for the card overlay.
|
* Generates HTML markup for the card overlay.
|
||||||
* @param {object} item - Item used to generate the card overlay.
|
* @param {object} item - Item used to generate the card overlay.
|
||||||
* @param {string} action - Action assigned to the overlay.
|
* @param {string} action - Action assigned to the overlay.
|
||||||
* @returns {string} HTML markup of the card overlay.
|
* @returns {string} HTML markup of the card overlay.
|
||||||
*/
|
*/
|
||||||
function getHoverMenuHtml(item, action) {
|
function getHoverMenuHtml(item, action) {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
html += '<div class="cardOverlayContainer itemAction" data-action="' + action + '">';
|
html += '<div class="cardOverlayContainer itemAction" data-action="' + action + '">';
|
||||||
|
@ -1483,7 +1467,6 @@ import { appRouter } from '../appRouter';
|
||||||
const userData = item.UserData || {};
|
const userData = item.UserData || {};
|
||||||
|
|
||||||
if (itemHelper.canMarkPlayed(item)) {
|
if (itemHelper.canMarkPlayed(item)) {
|
||||||
/* eslint-disable-next-line @babel/no-unused-expressions */
|
|
||||||
import('../../elements/emby-playstatebutton/emby-playstatebutton');
|
import('../../elements/emby-playstatebutton/emby-playstatebutton');
|
||||||
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
|
html += '<button is="emby-playstatebutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-played="' + (userData.Played) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check" aria-hidden="true"></span></button>';
|
||||||
}
|
}
|
||||||
|
@ -1491,7 +1474,6 @@ import { appRouter } from '../appRouter';
|
||||||
if (itemHelper.canRate(item)) {
|
if (itemHelper.canRate(item)) {
|
||||||
const likes = userData.Likes == null ? '' : userData.Likes;
|
const likes = userData.Likes == null ? '' : userData.Likes;
|
||||||
|
|
||||||
/* eslint-disable-next-line @babel/no-unused-expressions */
|
|
||||||
import('../../elements/emby-ratingbutton/emby-ratingbutton');
|
import('../../elements/emby-ratingbutton/emby-ratingbutton');
|
||||||
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
|
html += '<button is="emby-ratingbutton" type="button" data-action="none" class="' + btnCssClass + '" data-id="' + item.Id + '" data-serverid="' + item.ServerId + '" data-itemtype="' + item.Type + '" data-likes="' + likes + '" data-isfavorite="' + (userData.IsFavorite) + '"><span class="material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite" aria-hidden="true"></span></button>';
|
||||||
}
|
}
|
||||||
|
@ -1501,15 +1483,15 @@ import { appRouter } from '../appRouter';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the text or icon used for default card backgrounds.
|
* Generates the text or icon used for default card backgrounds.
|
||||||
* @param {object} item - Item used to generate the card overlay.
|
* @param {object} item - Item used to generate the card overlay.
|
||||||
* @param {object} options - Options used to generate the card overlay.
|
* @param {object} options - Options used to generate the card overlay.
|
||||||
* @returns {string} HTML markup of the card overlay.
|
* @returns {string} HTML markup of the card overlay.
|
||||||
*/
|
*/
|
||||||
export function getDefaultText(item, options) {
|
export function getDefaultText(item, options) {
|
||||||
if (item.CollectionType) {
|
if (item.CollectionType) {
|
||||||
return '<span class="cardImageIcon material-icons ' + imageHelper.getLibraryIcon(item.CollectionType) + '" aria-hidden="true"></span>';
|
return '<span class="cardImageIcon material-icons ' + imageHelper.getLibraryIcon(item.CollectionType) + '" aria-hidden="true"></span>';
|
||||||
}
|
}
|
||||||
|
@ -1549,14 +1531,14 @@ import { appRouter } from '../appRouter';
|
||||||
|
|
||||||
const defaultName = isUsingLiveTvNaming(item) ? item.Name : itemHelper.getDisplayName(item);
|
const defaultName = isUsingLiveTvNaming(item) ? item.Name : itemHelper.getDisplayName(item);
|
||||||
return '<div class="cardText cardDefaultText">' + escapeHtml(defaultName) + '</div>';
|
return '<div class="cardText cardDefaultText">' + escapeHtml(defaultName) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a set of cards and inserts them into the page.
|
* Builds a set of cards and inserts them into the page.
|
||||||
* @param {Array} items - Array of items used to build the cards.
|
* @param {Array} items - Array of items used to build the cards.
|
||||||
* @param {options} options - Options of the cards to build.
|
* @param {options} options - Options of the cards to build.
|
||||||
*/
|
*/
|
||||||
export function buildCards(items, options) {
|
export function buildCards(items, options) {
|
||||||
// Abort if the container has been disposed
|
// Abort if the container has been disposed
|
||||||
if (!document.body.contains(options.itemsContainer)) {
|
if (!document.body.contains(options.itemsContainer)) {
|
||||||
return;
|
return;
|
||||||
|
@ -1593,15 +1575,15 @@ import { appRouter } from '../appRouter';
|
||||||
if (options.autoFocus) {
|
if (options.autoFocus) {
|
||||||
focusManager.autoFocus(options.itemsContainer, true);
|
focusManager.autoFocus(options.itemsContainer, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the indicators for a card exist and creates them if they don't exist.
|
* Ensures the indicators for a card exist and creates them if they don't exist.
|
||||||
* @param {HTMLDivElement} card - DOM element of the card.
|
* @param {HTMLDivElement} card - DOM element of the card.
|
||||||
* @param {HTMLDivElement} indicatorsElem - DOM element of the indicators.
|
* @param {HTMLDivElement} indicatorsElem - DOM element of the indicators.
|
||||||
* @returns {HTMLDivElement} - DOM element of the indicators.
|
* @returns {HTMLDivElement} - DOM element of the indicators.
|
||||||
*/
|
*/
|
||||||
function ensureIndicators(card, indicatorsElem) {
|
function ensureIndicators(card, indicatorsElem) {
|
||||||
if (indicatorsElem) {
|
if (indicatorsElem) {
|
||||||
return indicatorsElem;
|
return indicatorsElem;
|
||||||
}
|
}
|
||||||
|
@ -1616,14 +1598,14 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
|
|
||||||
return indicatorsElem;
|
return indicatorsElem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds user data to the card such as progress indicators and played status.
|
* Adds user data to the card such as progress indicators and played status.
|
||||||
* @param {HTMLDivElement} card - DOM element of the card.
|
* @param {HTMLDivElement} card - DOM element of the card.
|
||||||
* @param {Object} userData - User data to apply to the card.
|
* @param {Object} userData - User data to apply to the card.
|
||||||
*/
|
*/
|
||||||
function updateUserData(card, userData) {
|
function updateUserData(card, userData) {
|
||||||
const type = card.getAttribute('data-type');
|
const type = card.getAttribute('data-type');
|
||||||
const enableCountIndicator = type === 'Series' || type === 'BoxSet' || type === 'Season';
|
const enableCountIndicator = type === 'Series' || type === 'BoxSet' || type === 'Season';
|
||||||
let indicatorsElem = null;
|
let indicatorsElem = null;
|
||||||
|
@ -1695,28 +1677,28 @@ import { appRouter } from '../appRouter';
|
||||||
itemProgressBar.parentNode.removeChild(itemProgressBar);
|
itemProgressBar.parentNode.removeChild(itemProgressBar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles when user data has changed.
|
* Handles when user data has changed.
|
||||||
* @param {Object} userData - User data to apply to the card.
|
* @param {Object} userData - User data to apply to the card.
|
||||||
* @param {HTMLElement} scope - DOM element to use as a scope when selecting cards.
|
* @param {HTMLElement} scope - DOM element to use as a scope when selecting cards.
|
||||||
*/
|
*/
|
||||||
export function onUserDataChanged(userData, scope) {
|
export function onUserDataChanged(userData, scope) {
|
||||||
const cards = (scope || document.body).querySelectorAll('.card-withuserdata[data-id="' + userData.ItemId + '"]');
|
const cards = (scope || document.body).querySelectorAll('.card-withuserdata[data-id="' + userData.ItemId + '"]');
|
||||||
|
|
||||||
for (let i = 0, length = cards.length; i < length; i++) {
|
for (let i = 0, length = cards.length; i < length; i++) {
|
||||||
updateUserData(cards[i], userData);
|
updateUserData(cards[i], userData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles when a timer has been created.
|
* Handles when a timer has been created.
|
||||||
* @param {string} programId - ID of the program.
|
* @param {string} programId - ID of the program.
|
||||||
* @param {string} newTimerId - ID of the new timer.
|
* @param {string} newTimerId - ID of the new timer.
|
||||||
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
||||||
*/
|
*/
|
||||||
export function onTimerCreated(programId, newTimerId, itemsContainer) {
|
export function onTimerCreated(programId, newTimerId, itemsContainer) {
|
||||||
const cells = itemsContainer.querySelectorAll('.card[data-id="' + programId + '"]');
|
const cells = itemsContainer.querySelectorAll('.card[data-id="' + programId + '"]');
|
||||||
|
|
||||||
for (let i = 0, length = cells.length; i < length; i++) {
|
for (let i = 0, length = cells.length; i < length; i++) {
|
||||||
|
@ -1728,45 +1710,41 @@ import { appRouter } from '../appRouter';
|
||||||
}
|
}
|
||||||
cell.setAttribute('data-timerid', newTimerId);
|
cell.setAttribute('data-timerid', newTimerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles when a timer has been cancelled.
|
* Handles when a timer has been cancelled.
|
||||||
* @param {string} timerId - ID of the cancelled timer.
|
* @param {string} timerId - ID of the cancelled timer.
|
||||||
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
||||||
*/
|
*/
|
||||||
export function onTimerCancelled(timerId, itemsContainer) {
|
export function onTimerCancelled(timerId, itemsContainer) {
|
||||||
const cells = itemsContainer.querySelectorAll('.card[data-timerid="' + timerId + '"]');
|
const cells = itemsContainer.querySelectorAll('.card[data-timerid="' + timerId + '"]');
|
||||||
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (const cell of cells) {
|
||||||
const cell = cells[i];
|
|
||||||
const icon = cell.querySelector('.timerIndicator');
|
const icon = cell.querySelector('.timerIndicator');
|
||||||
if (icon) {
|
if (icon) {
|
||||||
icon.parentNode.removeChild(icon);
|
icon.parentNode.removeChild(icon);
|
||||||
}
|
}
|
||||||
cell.removeAttribute('data-timerid');
|
cell.removeAttribute('data-timerid');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles when a series timer has been cancelled.
|
* Handles when a series timer has been cancelled.
|
||||||
* @param {string} cancelledTimerId - ID of the cancelled timer.
|
* @param {string} cancelledTimerId - ID of the cancelled timer.
|
||||||
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
* @param {HTMLElement} itemsContainer - DOM element of the itemsContainer.
|
||||||
*/
|
*/
|
||||||
export function onSeriesTimerCancelled(cancelledTimerId, itemsContainer) {
|
export function onSeriesTimerCancelled(cancelledTimerId, itemsContainer) {
|
||||||
const cells = itemsContainer.querySelectorAll('.card[data-seriestimerid="' + cancelledTimerId + '"]');
|
const cells = itemsContainer.querySelectorAll('.card[data-seriestimerid="' + cancelledTimerId + '"]');
|
||||||
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (const cell of cells) {
|
||||||
const cell = cells[i];
|
|
||||||
const icon = cell.querySelector('.timerIndicator');
|
const icon = cell.querySelector('.timerIndicator');
|
||||||
if (icon) {
|
if (icon) {
|
||||||
icon.parentNode.removeChild(icon);
|
icon.parentNode.removeChild(icon);
|
||||||
}
|
}
|
||||||
cell.removeAttribute('data-seriestimerid');
|
cell.removeAttribute('data-seriestimerid');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getCardsHtml: getCardsHtml,
|
getCardsHtml: getCardsHtml,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for building cards from item data.
|
* Module for building cards from item data.
|
||||||
|
@ -12,9 +11,9 @@ import layoutManager from '../layoutManager';
|
||||||
import browser from '../../scripts/browser';
|
import browser from '../../scripts/browser';
|
||||||
import ServerConnections from '../ServerConnections';
|
import ServerConnections from '../ServerConnections';
|
||||||
|
|
||||||
const enableFocusTransform = !browser.slow && !browser.edge;
|
const enableFocusTransform = !browser.slow && !browser.edge;
|
||||||
|
|
||||||
function buildChapterCardsHtml(item, chapters, options) {
|
function buildChapterCardsHtml(item, chapters, options) {
|
||||||
// TODO move card creation code to Card component
|
// TODO move card creation code to Card component
|
||||||
|
|
||||||
let className = 'card itemAction chapterCard';
|
let className = 'card itemAction chapterCard';
|
||||||
|
@ -28,7 +27,7 @@ import ServerConnections from '../ServerConnections';
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
|
const mediaStreams = ((item.MediaSources || [])[0] || {}).MediaStreams || [];
|
||||||
const videoStream = mediaStreams.filter(({Type}) => {
|
const videoStream = mediaStreams.filter(({ Type }) => {
|
||||||
return Type === 'Video';
|
return Type === 'Video';
|
||||||
})[0] || {};
|
})[0] || {};
|
||||||
|
|
||||||
|
@ -66,9 +65,9 @@ import ServerConnections from '../ServerConnections';
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImgUrl({Id}, {ImageTag}, index, maxWidth, apiClient) {
|
function getImgUrl({ Id }, { ImageTag }, index, maxWidth, apiClient) {
|
||||||
if (ImageTag) {
|
if (ImageTag) {
|
||||||
return apiClient.getScaledImageUrl(Id, {
|
return apiClient.getScaledImageUrl(Id, {
|
||||||
|
|
||||||
|
@ -80,9 +79,9 @@ import ServerConnections from '../ServerConnections';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChapterCard(item, apiClient, chapter, index, {width, coverImage}, className, shape) {
|
function buildChapterCard(item, apiClient, chapter, index, { width, coverImage }, className, shape) {
|
||||||
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
|
const imgUrl = getImgUrl(item, chapter, index, width || 400, apiClient);
|
||||||
|
|
||||||
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer';
|
let cardImageContainerClass = 'cardContent cardContent-shadow cardImageContainer chapterCardImageContainer';
|
||||||
|
@ -104,9 +103,9 @@ import ServerConnections from '../ServerConnections';
|
||||||
const cardScalableClass = 'cardScalable';
|
const cardScalableClass = 'cardScalable';
|
||||||
|
|
||||||
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
|
return `<button type="button" class="${className}"${dataAttributes}><div class="${cardBoxCssClass}"><div class="${cardScalableClass}"><div class="cardPadder-${shape}"></div>${cardImageContainer}</div><div class="innerCardFooter">${nameHtml}</div></div></div></button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildChapterCards(item, chapters, options) {
|
export function buildChapterCards(item, chapters, options) {
|
||||||
if (options.parentContainer) {
|
if (options.parentContainer) {
|
||||||
// Abort if the container has been disposed
|
// Abort if the container has been disposed
|
||||||
if (!document.body.contains(options.parentContainer)) {
|
if (!document.body.contains(options.parentContainer)) {
|
||||||
|
@ -126,9 +125,7 @@ import ServerConnections from '../ServerConnections';
|
||||||
options.itemsContainer.innerHTML = html;
|
options.itemsContainer.innerHTML = html;
|
||||||
|
|
||||||
imageLoader.lazyChildren(options.itemsContainer);
|
imageLoader.lazyChildren(options.itemsContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
buildChapterCards: buildChapterCards
|
buildChapterCards: buildChapterCards
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable indent */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module for building cards from item data.
|
* Module for building cards from item data.
|
||||||
|
@ -7,7 +6,7 @@
|
||||||
|
|
||||||
import cardBuilder from './cardBuilder';
|
import cardBuilder from './cardBuilder';
|
||||||
|
|
||||||
export function buildPeopleCards(items, options) {
|
export function buildPeopleCards(items, options) {
|
||||||
options = Object.assign(options || {}, {
|
options = Object.assign(options || {}, {
|
||||||
cardLayout: false,
|
cardLayout: false,
|
||||||
centerText: true,
|
centerText: true,
|
||||||
|
@ -18,9 +17,7 @@ import cardBuilder from './cardBuilder';
|
||||||
defaultCardImageIcon: 'person'
|
defaultCardImageIcon: 'person'
|
||||||
});
|
});
|
||||||
cardBuilder.buildCards(items, options);
|
cardBuilder.buildCards(items, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
buildPeopleCards: buildPeopleCards
|
buildPeopleCards: buildPeopleCards
|
||||||
|
|
|
@ -3,7 +3,7 @@ import dom from '../../scripts/dom';
|
||||||
import dialogHelper from '../dialogHelper/dialogHelper';
|
import dialogHelper from '../dialogHelper/dialogHelper';
|
||||||
import loading from '../loading/loading';
|
import loading from '../loading/loading';
|
||||||
import layoutManager from '../layoutManager';
|
import layoutManager from '../layoutManager';
|
||||||
import { appRouter } from '../appRouter';
|
import { appRouter } from '../router/appRouter';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../scripts/globalize';
|
||||||
import '../../elements/emby-button/emby-button';
|
import '../../elements/emby-button/emby-button';
|
||||||
import '../../elements/emby-button/paper-icon-button-light';
|
import '../../elements/emby-button/paper-icon-button-light';
|
||||||
|
@ -12,15 +12,13 @@ import '../../elements/emby-input/emby-input';
|
||||||
import '../../elements/emby-select/emby-select';
|
import '../../elements/emby-select/emby-select';
|
||||||
import 'material-design-icons-iconfont';
|
import 'material-design-icons-iconfont';
|
||||||
import '../formdialog.scss';
|
import '../formdialog.scss';
|
||||||
import '../../assets/css/flexstyles.scss';
|
import '../../styles/flexstyles.scss';
|
||||||
import ServerConnections from '../ServerConnections';
|
import ServerConnections from '../ServerConnections';
|
||||||
import toast from '../toast/toast';
|
import toast from '../toast/toast';
|
||||||
|
|
||||||
/* eslint-disable indent */
|
let currentServerId;
|
||||||
|
|
||||||
let currentServerId;
|
function onSubmit(e) {
|
||||||
|
|
||||||
function onSubmit(e) {
|
|
||||||
loading.show();
|
loading.show();
|
||||||
|
|
||||||
const panel = dom.parentWithClass(this, 'dialog');
|
const panel = dom.parentWithClass(this, 'dialog');
|
||||||
|
@ -37,9 +35,9 @@ import toast from '../toast/toast';
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCollection(apiClient, dlg) {
|
function createCollection(apiClient, dlg) {
|
||||||
const url = apiClient.getUrl('Collections', {
|
const url = apiClient.getUrl('Collections', {
|
||||||
|
|
||||||
Name: dlg.querySelector('#txtNewCollectionName').value,
|
Name: dlg.querySelector('#txtNewCollectionName').value,
|
||||||
|
@ -61,13 +59,13 @@ import toast from '../toast/toast';
|
||||||
dialogHelper.close(dlg);
|
dialogHelper.close(dlg);
|
||||||
redirectToCollection(apiClient, id);
|
redirectToCollection(apiClient, id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToCollection(apiClient, id) {
|
function redirectToCollection(apiClient, id) {
|
||||||
appRouter.showItem(id, apiClient.serverId());
|
appRouter.showItem(id, apiClient.serverId());
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToCollection(apiClient, dlg, id) {
|
function addToCollection(apiClient, dlg, id) {
|
||||||
const url = apiClient.getUrl(`Collections/${id}/Items`, {
|
const url = apiClient.getUrl(`Collections/${id}/Items`, {
|
||||||
|
|
||||||
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
|
Ids: dlg.querySelector('.fldSelectedItemIds').value || ''
|
||||||
|
@ -85,13 +83,13 @@ import toast from '../toast/toast';
|
||||||
|
|
||||||
toast(globalize.translate('MessageItemsAdded'));
|
toast(globalize.translate('MessageItemsAdded'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerChange(select) {
|
function triggerChange(select) {
|
||||||
select.dispatchEvent(new CustomEvent('change', {}));
|
select.dispatchEvent(new CustomEvent('change', {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateCollections(panel) {
|
function populateCollections(panel) {
|
||||||
loading.show();
|
loading.show();
|
||||||
|
|
||||||
const select = panel.querySelector('#selectCollectionToAddTo');
|
const select = panel.querySelector('#selectCollectionToAddTo');
|
||||||
|
@ -122,9 +120,9 @@ import toast from '../toast/toast';
|
||||||
|
|
||||||
loading.hide();
|
loading.hide();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditorHtml() {
|
function getEditorHtml() {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
|
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
|
||||||
|
@ -169,9 +167,9 @@ import toast from '../toast/toast';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initEditor(content, items) {
|
function initEditor(content, items) {
|
||||||
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
|
content.querySelector('#selectCollectionToAddTo').addEventListener('change', function () {
|
||||||
if (this.value) {
|
if (this.value) {
|
||||||
content.querySelector('.newCollectionInfo').classList.add('hide');
|
content.querySelector('.newCollectionInfo').classList.add('hide');
|
||||||
|
@ -197,16 +195,16 @@ import toast from '../toast/toast';
|
||||||
selectCollectionToAddTo.value = '';
|
selectCollectionToAddTo.value = '';
|
||||||
triggerChange(selectCollectionToAddTo);
|
triggerChange(selectCollectionToAddTo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerFocus(elem, horiz, on) {
|
function centerFocus(elem, horiz, on) {
|
||||||
import('../../scripts/scrollHelper').then((scrollHelper) => {
|
import('../../scripts/scrollHelper').then((scrollHelper) => {
|
||||||
const fn = on ? 'on' : 'off';
|
const fn = on ? 'on' : 'off';
|
||||||
scrollHelper.centerFocus[fn](elem, horiz);
|
scrollHelper.centerFocus[fn](elem, horiz);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class CollectionEditor {
|
class CollectionEditor {
|
||||||
show(options) {
|
show(options) {
|
||||||
const items = options.items || {};
|
const items = options.items || {};
|
||||||
currentServerId = options.serverId;
|
currentServerId = options.serverId;
|
||||||
|
@ -263,7 +261,6 @@ import toast from '../toast/toast';
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-enable indent */
|
|
||||||
export default CollectionEditor;
|
export default CollectionEditor;
|
||||||
|
|
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