mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Integrate branch 'master' into feature/langugae_filters
This commit is contained in:
commit
73baf3b92a
216 changed files with 11791 additions and 7400 deletions
|
@ -1,5 +0,0 @@
|
|||
node_modules
|
||||
coverage
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
338
.eslintrc.js
338
.eslintrc.js
|
@ -1,338 +0,0 @@
|
|||
const restrictedGlobals = require('confusing-browser-globals');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@stylistic',
|
||||
'@typescript-eslint',
|
||||
'react',
|
||||
'import',
|
||||
'sonarjs'
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
es2017: true,
|
||||
es2020: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:import/errors',
|
||||
'plugin:@eslint-community/eslint-comments/recommended',
|
||||
'plugin:compat/recommended',
|
||||
'plugin:sonarjs/recommended'
|
||||
],
|
||||
rules: {
|
||||
'array-callback-return': ['error', { 'checkForEach': true }],
|
||||
'curly': ['error', 'multi-line', 'consistent'],
|
||||
'default-case-last': ['error'],
|
||||
'max-params': ['error', 7],
|
||||
'new-cap': [
|
||||
'error',
|
||||
{
|
||||
'capIsNewExceptions': ['jQuery.Deferred'],
|
||||
'newIsCapExceptionPattern': '\\.default$'
|
||||
}
|
||||
],
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extend-native': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-nested-ternary': ['error'],
|
||||
'no-redeclare': ['off'],
|
||||
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||
'no-restricted-globals': ['error'].concat(restrictedGlobals),
|
||||
'no-return-assign': ['error'],
|
||||
'no-return-await': ['error'],
|
||||
'no-sequences': ['error', { 'allowInParentheses': false }],
|
||||
'no-shadow': ['off'],
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
'no-throw-literal': ['error'],
|
||||
'no-undef-init': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unused-expressions': ['off'],
|
||||
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
|
||||
'no-unused-private-class-members': ['error'],
|
||||
'no-useless-rename': ['error'],
|
||||
'no-useless-constructor': ['off'],
|
||||
'@typescript-eslint/no-useless-constructor': ['error'],
|
||||
'no-var': ['error'],
|
||||
'no-void': ['error', { 'allowAsStatement': true }],
|
||||
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
|
||||
'one-var': ['error', 'never'],
|
||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
|
||||
'@typescript-eslint/prefer-for-of': ['error'],
|
||||
'radix': ['error'],
|
||||
'yoda': 'error',
|
||||
|
||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-bind': ['error'],
|
||||
'react/jsx-no-useless-fragment': ['error'],
|
||||
'react/jsx-no-constructed-context-values': ['error'],
|
||||
'react/no-array-index-key': ['error'],
|
||||
|
||||
'sonarjs/no-inverted-boolean-check': ['error'],
|
||||
// TODO: Enable the following rules and fix issues
|
||||
'sonarjs/cognitive-complexity': ['off'],
|
||||
'sonarjs/no-duplicate-string': ['off'],
|
||||
|
||||
'@stylistic/block-spacing': ['error'],
|
||||
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/comma-spacing': ['error'],
|
||||
'@stylistic/eol-last': ['error'],
|
||||
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||
'@stylistic/keyword-spacing': ['error'],
|
||||
'@stylistic/max-statements-per-line': ['error'],
|
||||
'@stylistic/no-floating-decimal': ['error'],
|
||||
'@stylistic/no-multi-spaces': ['error'],
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'@stylistic/no-trailing-spaces': ['error'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||
'@stylistic/padded-blocks': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||
'@stylistic/semi': ['error'],
|
||||
'@stylistic/space-before-blocks': ['error'],
|
||||
'@stylistic/space-infix-ops': ['error']
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
},
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': [ '.ts', '.tsx' ]
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: [
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx'
|
||||
],
|
||||
moduleDirectory: [
|
||||
'node_modules',
|
||||
'src'
|
||||
]
|
||||
}
|
||||
},
|
||||
polyfills: [
|
||||
// Native Promises Only
|
||||
'Promise',
|
||||
// whatwg-fetch
|
||||
'fetch',
|
||||
// document-register-element
|
||||
'document.registerElement',
|
||||
// resize-observer-polyfill
|
||||
'ResizeObserver',
|
||||
// fast-text-encoding
|
||||
'TextEncoder',
|
||||
// intersection-observer
|
||||
'IntersectionObserver',
|
||||
// Core-js
|
||||
'Object.assign',
|
||||
'Object.is',
|
||||
'Object.setPrototypeOf',
|
||||
'Object.toString',
|
||||
'Object.freeze',
|
||||
'Object.seal',
|
||||
'Object.preventExtensions',
|
||||
'Object.isFrozen',
|
||||
'Object.isSealed',
|
||||
'Object.isExtensible',
|
||||
'Object.getOwnPropertyDescriptor',
|
||||
'Object.getPrototypeOf',
|
||||
'Object.keys',
|
||||
'Object.entries',
|
||||
'Object.getOwnPropertyNames',
|
||||
'Function.name',
|
||||
'Function.hasInstance',
|
||||
'Array.from',
|
||||
'Array.arrayOf',
|
||||
'Array.copyWithin',
|
||||
'Array.fill',
|
||||
'Array.find',
|
||||
'Array.findIndex',
|
||||
'Array.iterator',
|
||||
'String.fromCodePoint',
|
||||
'String.raw',
|
||||
'String.iterator',
|
||||
'String.codePointAt',
|
||||
'String.endsWith',
|
||||
'String.includes',
|
||||
'String.repeat',
|
||||
'String.startsWith',
|
||||
'String.trim',
|
||||
'String.anchor',
|
||||
'String.big',
|
||||
'String.blink',
|
||||
'String.bold',
|
||||
'String.fixed',
|
||||
'String.fontcolor',
|
||||
'String.fontsize',
|
||||
'String.italics',
|
||||
'String.link',
|
||||
'String.small',
|
||||
'String.strike',
|
||||
'String.sub',
|
||||
'String.sup',
|
||||
'RegExp',
|
||||
'Number',
|
||||
'Math',
|
||||
'Date',
|
||||
'async',
|
||||
'Symbol',
|
||||
'Map',
|
||||
'Set',
|
||||
'WeakMap',
|
||||
'WeakSet',
|
||||
'ArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'Reflect',
|
||||
// Temporary while eslint-compat-plugin is buggy
|
||||
'document.querySelector'
|
||||
]
|
||||
},
|
||||
overrides: [
|
||||
// Config files and development scripts
|
||||
{
|
||||
files: [
|
||||
'./babel.config.js',
|
||||
'./.eslintrc.js',
|
||||
'./postcss.config.js',
|
||||
'./webpack.*.js',
|
||||
'./scripts/**/*.js'
|
||||
]
|
||||
},
|
||||
// JavaScript source files
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.{js,jsx,ts,tsx}'
|
||||
],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json']
|
||||
},
|
||||
env: {
|
||||
node: false,
|
||||
amd: true,
|
||||
browser: true,
|
||||
es6: true,
|
||||
es2017: true,
|
||||
es2020: true
|
||||
},
|
||||
globals: {
|
||||
// Browser globals
|
||||
'MediaMetadata': 'readonly',
|
||||
// Tizen globals
|
||||
'tizen': 'readonly',
|
||||
'webapis': 'readonly',
|
||||
// WebOS globals
|
||||
'webOS': 'readonly',
|
||||
// Dependency globals
|
||||
'$': 'readonly',
|
||||
'jQuery': 'readonly',
|
||||
// Jellyfin globals
|
||||
'ApiClient': 'writable',
|
||||
'Events': 'writable',
|
||||
'chrome': 'writable',
|
||||
'DlnaProfilePage': 'writable',
|
||||
'DashboardPage': 'writable',
|
||||
'Emby': 'readonly',
|
||||
'Globalize': 'writable',
|
||||
'Hls': 'writable',
|
||||
'LibraryMenu': 'writable',
|
||||
'LinkParser': 'writable',
|
||||
'LiveTvHelpers': 'writable',
|
||||
'Loading': 'writable',
|
||||
'MetadataEditor': 'writable',
|
||||
'ServerNotifications': 'writable',
|
||||
'TaskButton': 'writable',
|
||||
'UserParentalControlPage': 'writable',
|
||||
'Windows': 'readonly',
|
||||
// Build time definitions
|
||||
__COMMIT_SHA__: 'readonly',
|
||||
__JF_BUILD_VERSION__: 'readonly',
|
||||
__PACKAGE_JSON_NAME__: 'readonly',
|
||||
__PACKAGE_JSON_VERSION__: 'readonly',
|
||||
__USE_SYSTEM_FONTS__: 'readonly',
|
||||
__WEBPACK_SERVE__: 'readonly'
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allow'
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: [ 'PascalCase' ]
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: [ 'PascalCase', 'UPPER_CASE' ]
|
||||
},
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
|
||||
// and api_key parameter
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
|
||||
match: true
|
||||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||
}
|
||||
},
|
||||
// TypeScript source files
|
||||
{
|
||||
files: [
|
||||
'./src/**/*.{ts,tsx}'
|
||||
],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@eslint-community/eslint-comments/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended'
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': ['error'],
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
|
||||
'sonarjs/cognitive-complexity': ['error']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
32
.github/ISSUE_TEMPLATE/1-bug-report.md
vendored
32
.github/ISSUE_TEMPLATE/1-bug-report.md
vendored
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
name: Bug Report
|
||||
about: You have noticed a general issue or regression, and would like to report it
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Describe The Bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**Steps To Reproduce**
|
||||
<!-- Steps to reproduce the behavior: -->
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected Behavior**
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Logs**
|
||||
<!-- Please paste any log errors. -->
|
||||
|
||||
**Screenshots**
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
|
||||
- Browser: [e.g. Firefox, Chrome, Safari]
|
||||
- Jellyfin Version: [e.g. 10.6.0]
|
||||
|
||||
**Additional Context**
|
||||
<!-- Add any other context about the problem here. -->
|
122
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
Normal file
122
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
Normal file
|
@ -0,0 +1,122 @@
|
|||
name: Bug Report
|
||||
description: You have noticed a general issue or regression, and would like to report it
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: |
|
||||
A clear and concise description of the bug.
|
||||
You can also attach screenshots or screen recordings here to help explain your issue.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction Steps
|
||||
description: |
|
||||
Steps to reproduce the behavior:
|
||||
placeholder: |
|
||||
1. Go to …
|
||||
2. Click on …
|
||||
3. Scroll down to …
|
||||
4. See error / the app crashes
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: behaviour
|
||||
attributes:
|
||||
label: Expected/Actual behaviour
|
||||
description: |
|
||||
Describe the behavior you were expecting versus what actually occurred.
|
||||
placeholder: |
|
||||
I expected the app to... However, the actual behavior was that...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Please paste any log errors.
|
||||
placeholder: Paste logs…
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Server
|
||||
You will find these values in your Admin Dashboard
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Server version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: web-version
|
||||
attributes:
|
||||
label: Web version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: build-version
|
||||
attributes:
|
||||
label: Build version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Client
|
||||
Information about the device you are seeing the issue on
|
||||
- type: input
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
|
||||
placeholder: e.g. Linux, Windows, iPhone, Tizen
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
|
||||
placeholder: e.g. Firefox, Chrome, Safari
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
|
||||
placeholder: Add any additional context here.
|
||||
validations:
|
||||
required: false
|
22
.github/ISSUE_TEMPLATE/2-playback-issue.md
vendored
22
.github/ISSUE_TEMPLATE/2-playback-issue.md
vendored
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
name: Playback Issue
|
||||
about: You have playback issues with some files
|
||||
labels: playback
|
||||
---
|
||||
|
||||
**Describe The Bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**Media Information**
|
||||
<!-- Please paste any ffprobe or MediaInfo logs. -->
|
||||
|
||||
**Screenshots**
|
||||
<!-- Add screenshots from the Playback Data and Media Info. -->
|
||||
|
||||
**System (please complete the following information):**
|
||||
- Platform: [e.g. Linux, Windows, iPhone, Tizen]
|
||||
- Browser: [e.g. Firefox, Chrome, Safari]
|
||||
- Jellyfin Version: [e.g. 10.6.0]
|
||||
|
||||
**Additional Context**
|
||||
<!-- Add any other context about the problem here. -->
|
145
.github/ISSUE_TEMPLATE/2-playback-issue.yml
vendored
Normal file
145
.github/ISSUE_TEMPLATE/2-playback-issue.yml
vendored
Normal file
|
@ -0,0 +1,145 @@
|
|||
name: Playback Issue
|
||||
description: Create a bug report related to media playback
|
||||
labels:
|
||||
- bug
|
||||
- playback
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**.
|
||||
options:
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-web/issues?q=is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug information
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: |
|
||||
A clear and concise description of the bug.
|
||||
You can also attach screenshots or screen recordings here to help explain your issue.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction Steps
|
||||
description: |
|
||||
Steps to reproduce the behavior:
|
||||
placeholder: |
|
||||
1. Go to …
|
||||
2. Click on …
|
||||
3. Scroll down to …
|
||||
4. See error / the app crashes
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: behaviour
|
||||
attributes:
|
||||
label: Expected/Actual behaviour
|
||||
description: |
|
||||
Describe the behavior you were expecting versus what actually occurred.
|
||||
placeholder: |
|
||||
I expected the app to... However, the actual behavior was that...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: mediainfo
|
||||
attributes:
|
||||
label: Media info of the file
|
||||
description: |
|
||||
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
|
||||
- Use ffprobe (`ffprobe ./file.mp4`)
|
||||
- Copy the media info from the web interface
|
||||
placeholder: Paste media info…
|
||||
render: shell
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Logs
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Please paste your logs here if applicable.
|
||||
placeholder: Paste logs…
|
||||
- type: textarea
|
||||
id: logs-ffmpeg
|
||||
attributes:
|
||||
label: FFmpeg logs
|
||||
description: |
|
||||
Please paste your FFmpeg logs here if available. You can find these in your servers dashboard under "logs".
|
||||
placeholder: Paste logs…
|
||||
render: shell
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Server
|
||||
You will find these values in your Admin Dashboard
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Server version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: web-version
|
||||
attributes:
|
||||
label: Web version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: build-version
|
||||
attributes:
|
||||
label: Build version
|
||||
placeholder: 10.10.2
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Client
|
||||
Information about the device you are seeing the issue on
|
||||
- type: input
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Specify the operating system or device where the issue occurs. If relevant, include details like version or model.
|
||||
placeholder: e.g. Linux, Windows, iPhone, Tizen
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Indicate which browser you're using when encountering the issue. If possible, mention the browser version as well.
|
||||
placeholder: e.g. Firefox, Chrome, Safari
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
|
||||
placeholder: Add any additional context here.
|
||||
validations:
|
||||
required: false
|
13
.github/ISSUE_TEMPLATE/3-technical-discussion.md
vendored
13
.github/ISSUE_TEMPLATE/3-technical-discussion.md
vendored
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
name: Technical Discussion
|
||||
about: You want to discuss technical aspects of changes you intend to make
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
<!-- Explain the change and the motivations behind it.
|
||||
|
||||
For example, if you plan to rely on a new dependency, explain why and what
|
||||
it brings to the project.
|
||||
|
||||
If you plan to make significant changes, go roughly over the steps you intend
|
||||
to take and how you would divide the change in PRs of a manageable size. -->
|
9
.github/ISSUE_TEMPLATE/4-meta-issue.md
vendored
9
.github/ISSUE_TEMPLATE/4-meta-issue.md
vendored
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
name: Meta Issue
|
||||
about: You want to track a number of other issues as part of a larger project
|
||||
labels: meta
|
||||
---
|
||||
|
||||
* [ ] Issue 1 [#123]
|
||||
* [ ] Issue 2 [#456]
|
||||
* [ ] ...
|
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
|
@ -4,6 +4,9 @@
|
|||
"github>jellyfin/.github//renovate-presets/nodejs",
|
||||
":dependencyDashboard"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": false
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": [ "@jellyfin/sdk" ],
|
||||
|
|
6
.github/workflows/__codeql.yml
vendored
6
.github/workflows/__codeql.yml
vendored
|
@ -26,15 +26,15 @@ jobs:
|
|||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@ee117c905ab18f32fa0f66c2fe40ecc8013f3e04 # v3.28.4
|
||||
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
|
2
.github/workflows/__deploy.yml
vendored
2
.github/workflows/__deploy.yml
vendored
|
@ -35,7 +35,7 @@ jobs:
|
|||
path: dist
|
||||
|
||||
- name: Publish to Cloudflare Pages 📃
|
||||
uses: cloudflare/wrangler-action@7a5f8bbdfeedcde38e6777a50fe685f89259d4ca # v3.13.1
|
||||
uses: cloudflare/wrangler-action@392082e81ffbcb9ebdde27400634aa004b35ea37 # v3.14.0
|
||||
id: cf
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
|
4
.github/workflows/__package.yml
vendored
4
.github/workflows/__package.yml
vendored
|
@ -19,7 +19,7 @@ jobs:
|
|||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
@ -39,7 +39,7 @@ jobs:
|
|||
mv dist/config.tmp.json dist/config.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: frontend
|
||||
path: dist
|
||||
|
|
2
.github/workflows/__quality_checks.yml
vendored
2
.github/workflows/__quality_checks.yml
vendored
|
@ -48,7 +48,7 @@ jobs:
|
|||
show-progress: false
|
||||
|
||||
- name: Setup node environment ⚙️
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
|
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
|
@ -85,7 +85,7 @@ jobs:
|
|||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
@ -95,6 +95,6 @@ jobs:
|
|||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@9c12109c4943f26f0676b71c9c10e456748872cf # v4.1.7
|
||||
uses: CatChen/eslint-suggestion-action@3ba53ce078667d5f60a73a8005627cf95ab57dce # v4.1.9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
26
.github/workflows/schedule.yml
vendored
26
.github/workflows/schedule.yml
vendored
|
@ -10,7 +10,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
issues:
|
||||
name: Check issues
|
||||
name: Check stale issues and PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
|
@ -18,10 +18,9 @@ jobs:
|
|||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
operations-per-run: 75
|
||||
# Issues receive a stale warning after 120 days and close after an additional 21 days
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
|
@ -30,21 +29,10 @@ 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.
|
||||
|
||||
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@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.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
|
||||
# PRs are closed after having unresolved merge conflicts for 90 days
|
||||
days-before-pr-stale: 0
|
||||
days-before-pr-close: 90
|
||||
only-pr-labels: merge conflict
|
||||
stale-pr-label: stale
|
||||
close-pr-message: |-
|
||||
This PR has been closed due to having unresolved merge conflicts.
|
||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"[json][typescript][typescriptreact][javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
|
|
|
@ -94,6 +94,8 @@
|
|||
- [iFraan](https://github.com/iFraan)
|
||||
- [Ali](https://github.com/bu3alwa)
|
||||
- [K. Kyle Puchkov](https://github.com/kepper104)
|
||||
- [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
|
||||
- [Jxiced](https://github.com/Jxiced)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
|
385
eslint.config.mjs
Normal file
385
eslint.config.mjs
Normal file
|
@ -0,0 +1,385 @@
|
|||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
|
||||
import compat from 'eslint-plugin-compat';
|
||||
import globals from 'globals';
|
||||
// @ts-expect-error Missing type definition
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import restrictedGlobals from 'confusing-browser-globals';
|
||||
import sonarjs from 'eslint-plugin-sonarjs';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
// @ts-expect-error Harmless type mismatch in dependency
|
||||
comments.recommended,
|
||||
compat.configs['flat/recommended'],
|
||||
importPlugin.flatConfigs.errors,
|
||||
sonarjs.configs.recommended,
|
||||
|
||||
reactPlugin.configs.flat.recommended,
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'dist',
|
||||
'.idea',
|
||||
'.vscode'
|
||||
]
|
||||
},
|
||||
|
||||
// Global style rules
|
||||
{
|
||||
plugins: {
|
||||
'@stylistic': stylistic
|
||||
},
|
||||
extends: [ importPlugin.flatConfigs.typescript ],
|
||||
rules: {
|
||||
'array-callback-return': ['error', { 'checkForEach': true }],
|
||||
'curly': ['error', 'multi-line', 'consistent'],
|
||||
'default-case-last': 'error',
|
||||
'max-params': ['error', 7],
|
||||
'new-cap': [
|
||||
'error',
|
||||
{
|
||||
'capIsNewExceptions': ['jQuery.Deferred'],
|
||||
'newIsCapExceptionPattern': '\\.default$'
|
||||
}
|
||||
],
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-empty-function': 'error',
|
||||
'no-extend-native': 'error',
|
||||
'no-lonely-if': 'error',
|
||||
'no-nested-ternary': 'error',
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||
'no-restricted-globals': ['error'].concat(restrictedGlobals),
|
||||
'no-return-assign': 'error',
|
||||
'no-return-await': 'error',
|
||||
'no-sequences': ['error', { 'allowInParentheses': false }],
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-throw-literal': 'error',
|
||||
'no-undef-init': 'error',
|
||||
'no-unneeded-ternary': 'error',
|
||||
'no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': ['error', { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
|
||||
'no-unused-private-class-members': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-useless-rename': 'error',
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'error',
|
||||
'no-var': 'error',
|
||||
'no-void': ['error', { 'allowAsStatement': true }],
|
||||
'no-warning-comments': ['warn', { 'terms': ['hack', 'xxx'] }],
|
||||
'one-var': ['error', 'never'],
|
||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
|
||||
'@typescript-eslint/prefer-for-of': 'error',
|
||||
'radix': 'error',
|
||||
'yoda': 'error',
|
||||
|
||||
'sonarjs/fixme-tag': 'warn',
|
||||
'sonarjs/todo-tag': 'off',
|
||||
'sonarjs/deprecation': 'warn',
|
||||
'sonarjs/no-alphabetical-sort': 'warn',
|
||||
'sonarjs/no-inverted-boolean-check': 'error',
|
||||
'sonarjs/no-selector-parameter': 'off',
|
||||
'sonarjs/pseudo-random': 'warn',
|
||||
// TODO: Enable the following sonarjs rules and fix issues
|
||||
'sonarjs/no-duplicate-string': 'off',
|
||||
'sonarjs/no-nested-functions': 'warn',
|
||||
|
||||
// TODO: Replace with stylistic.configs.customize()
|
||||
'@stylistic/block-spacing': 'error',
|
||||
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/comma-spacing': 'error',
|
||||
'@stylistic/eol-last': 'error',
|
||||
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||
'@stylistic/keyword-spacing': 'error',
|
||||
'@stylistic/max-statements-per-line': 'error',
|
||||
'@stylistic/no-floating-decimal': 'error',
|
||||
'@stylistic/no-mixed-spaces-and-tabs': 'error',
|
||||
'@stylistic/no-multi-spaces': 'error',
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||
'@stylistic/padded-blocks': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||
'@stylistic/semi': 'error',
|
||||
'@stylistic/space-before-blocks': 'error',
|
||||
'@stylistic/space-infix-ops': 'error'
|
||||
}
|
||||
},
|
||||
|
||||
// Config files use node globals
|
||||
{
|
||||
ignores: [ 'src' ],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Config files are commonjs by default
|
||||
{
|
||||
files: [ '**/*.{cjs,js}' ],
|
||||
ignores: [ 'src' ],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs'
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// App files
|
||||
{
|
||||
files: [
|
||||
'src/**/*.{js,jsx,ts,tsx}'
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
// Tizen globals
|
||||
'tizen': false,
|
||||
'webapis': false,
|
||||
// WebOS globals
|
||||
'webOS': false,
|
||||
// Dependency globals
|
||||
'$': false,
|
||||
'jQuery': false,
|
||||
// Jellyfin globals
|
||||
'ApiClient': true,
|
||||
'Events': true,
|
||||
'chrome': true,
|
||||
'Emby': false,
|
||||
'Hls': true,
|
||||
'LibraryMenu': true,
|
||||
'Windows': false,
|
||||
// Build time definitions
|
||||
__COMMIT_SHA__: false,
|
||||
__JF_BUILD_VERSION__: false,
|
||||
__PACKAGE_JSON_NAME__: false,
|
||||
__PACKAGE_JSON_VERSION__: false,
|
||||
__USE_SYSTEM_FONTS__: false,
|
||||
__WEBPACK_SERVE__: false
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: [
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx'
|
||||
],
|
||||
moduleDirectory: [
|
||||
'node_modules',
|
||||
'src'
|
||||
]
|
||||
}
|
||||
},
|
||||
polyfills: [
|
||||
'Promise',
|
||||
// whatwg-fetch
|
||||
'fetch',
|
||||
// document-register-element
|
||||
'document.registerElement',
|
||||
// resize-observer-polyfill
|
||||
'ResizeObserver',
|
||||
// fast-text-encoding
|
||||
'TextEncoder',
|
||||
// intersection-observer
|
||||
'IntersectionObserver',
|
||||
// Core-js
|
||||
'Object.assign',
|
||||
'Object.is',
|
||||
'Object.setPrototypeOf',
|
||||
'Object.toString',
|
||||
'Object.freeze',
|
||||
'Object.seal',
|
||||
'Object.preventExtensions',
|
||||
'Object.isFrozen',
|
||||
'Object.isSealed',
|
||||
'Object.isExtensible',
|
||||
'Object.getOwnPropertyDescriptor',
|
||||
'Object.getPrototypeOf',
|
||||
'Object.keys',
|
||||
'Object.entries',
|
||||
'Object.getOwnPropertyNames',
|
||||
'Function.name',
|
||||
'Function.hasInstance',
|
||||
'Array.from',
|
||||
'Array.arrayOf',
|
||||
'Array.copyWithin',
|
||||
'Array.fill',
|
||||
'Array.find',
|
||||
'Array.findIndex',
|
||||
'Array.iterator',
|
||||
'String.fromCodePoint',
|
||||
'String.raw',
|
||||
'String.iterator',
|
||||
'String.codePointAt',
|
||||
'String.endsWith',
|
||||
'String.includes',
|
||||
'String.repeat',
|
||||
'String.startsWith',
|
||||
'String.trim',
|
||||
'String.anchor',
|
||||
'String.big',
|
||||
'String.blink',
|
||||
'String.bold',
|
||||
'String.fixed',
|
||||
'String.fontcolor',
|
||||
'String.fontsize',
|
||||
'String.italics',
|
||||
'String.link',
|
||||
'String.small',
|
||||
'String.strike',
|
||||
'String.sub',
|
||||
'String.sup',
|
||||
'RegExp',
|
||||
'Number',
|
||||
'Math',
|
||||
'Date',
|
||||
'async',
|
||||
'Symbol',
|
||||
'Map',
|
||||
'Set',
|
||||
'WeakMap',
|
||||
'WeakSet',
|
||||
'ArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'Reflect'
|
||||
]
|
||||
},
|
||||
rules: {
|
||||
// TODO: Add typescript recommended typed rules
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allow'
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: [ 'PascalCase' ]
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: [ 'PascalCase', 'UPPER_CASE' ]
|
||||
},
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
|
||||
// and api_key parameter
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
|
||||
match: true
|
||||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': 'error'
|
||||
}
|
||||
},
|
||||
|
||||
// React files
|
||||
{
|
||||
files: [ 'src/**/*.{jsx,tsx}' ],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
rules: {
|
||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||
'react/jsx-no-bind': 'error',
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/no-array-index-key': 'error',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn'
|
||||
}
|
||||
},
|
||||
|
||||
// Service worker
|
||||
{
|
||||
files: [ 'src/serviceworker.js' ],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.serviceworker
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy JS (less strict)
|
||||
{
|
||||
files: [ 'src/**/*.{js,jsx}' ],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'@typescript-eslint/no-this-alias': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
|
||||
'sonarjs/public-static-readonly': 'off',
|
||||
|
||||
// TODO: Enable the following rules and fix issues
|
||||
'sonarjs/cognitive-complexity': 'off',
|
||||
'sonarjs/constructor-for-side-effects': 'off',
|
||||
'sonarjs/function-return-type': 'off',
|
||||
'sonarjs/no-async-constructor': 'off',
|
||||
'sonarjs/no-duplicate-string': 'off',
|
||||
'sonarjs/no-ignored-exceptions': 'off',
|
||||
'sonarjs/no-invariant-returns': 'warn',
|
||||
'sonarjs/no-nested-functions': 'off',
|
||||
'sonarjs/void-use': 'off'
|
||||
}
|
||||
}
|
||||
);
|
8229
package-lock.json
generated
8229
package-lock.json
generated
File diff suppressed because it is too large
Load diff
80
package.json
80
package.json
|
@ -5,13 +5,14 @@
|
|||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.25.8",
|
||||
"@babel/plugin-transform-modules-umd": "7.25.7",
|
||||
"@babel/preset-env": "7.25.8",
|
||||
"@babel/preset-react": "7.25.7",
|
||||
"@babel/core": "7.26.9",
|
||||
"@babel/plugin-transform-modules-umd": "7.25.9",
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
|
||||
"@stylistic/eslint-plugin": "2.12.1",
|
||||
"@stylistic/stylelint-plugin": "3.1.1",
|
||||
"@eslint/js": "9.20.0",
|
||||
"@stylistic/eslint-plugin": "3.1.0",
|
||||
"@stylistic/stylelint-plugin": "3.1.2",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/loadable__component": "5.13.9",
|
||||
|
@ -21,10 +22,9 @@
|
|||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.17.0",
|
||||
"@typescript-eslint/parser": "8.17.0",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "2.1.8",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
|
@ -34,40 +34,42 @@
|
|||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.6",
|
||||
"es-check": "7.2.1",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
"eslint": "9.20.1",
|
||||
"eslint-plugin-compat": "6.0.2",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react": "7.37.3",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-sonarjs": "0.25.1",
|
||||
"expose-loader": "5.0.0",
|
||||
"eslint-plugin-react": "7.37.4",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"eslint-plugin-sonarjs": "3.0.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||
"globals": "15.15.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-plugin": "5.6.0",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"jsdom": "25.0.1",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"postcss": "8.4.49",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss": "8.5.2",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "10.1.3",
|
||||
"postcss-preset-env": "10.1.4",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.83.1",
|
||||
"sass-loader": "16.0.2",
|
||||
"sass": "1.85.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"source-map-loader": "5.0.0",
|
||||
"speed-measure-webpack-plugin": "1.5.0",
|
||||
"style-loader": "4.0.0",
|
||||
"stylelint": "16.12.0",
|
||||
"stylelint": "16.14.1",
|
||||
"stylelint-config-rational-order": "0.1.2",
|
||||
"stylelint-no-browser-hacks": "1.3.0",
|
||||
"stylelint-order": "6.0.4",
|
||||
"stylelint-scss": "6.10.0",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.6.3",
|
||||
"vitest": "2.1.8",
|
||||
"webpack": "5.95.0",
|
||||
"stylelint-scss": "6.11.0",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "8.24.1",
|
||||
"vitest": "3.0.5",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.1.0",
|
||||
"webpack-dev-server": "5.2.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
|
@ -81,27 +83,27 @@
|
|||
"@fontsource/noto-sans-sc": "5.1.1",
|
||||
"@fontsource/noto-sans-tc": "5.1.1",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202501180501",
|
||||
"@mui/icons-material": "5.16.7",
|
||||
"@mui/material": "5.16.7",
|
||||
"@mui/x-date-pickers": "7.20.0",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202502210501",
|
||||
"@mui/icons-material": "5.16.14",
|
||||
"@mui/material": "5.16.14",
|
||||
"@mui/x-date-pickers": "7.26.0",
|
||||
"@react-hook/resize-observer": "2.0.2",
|
||||
"@tanstack/react-query": "5.62.16",
|
||||
"@tanstack/react-query-devtools": "5.62.16",
|
||||
"abortcontroller-polyfill": "1.7.5",
|
||||
"abortcontroller-polyfill": "1.7.8",
|
||||
"blurhash": "2.0.5",
|
||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.38.1",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "2.5.7",
|
||||
"dompurify": "2.5.8",
|
||||
"epubjs": "0.3.93",
|
||||
"escape-html": "1.0.3",
|
||||
"fast-text-encoding": "1.0.6",
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.5.18",
|
||||
"hls.js": "1.5.20",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
|
@ -121,14 +123,14 @@
|
|||
"react-router-dom": "6.27.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.3",
|
||||
"swiper": "11.2.1",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"sortablejs": "1.15.6",
|
||||
"swiper": "11.2.3",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.83.1"
|
||||
"sass-embedded": "1.85.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import IconButton from '@mui/material/IconButton/IconButton';
|
||||
import React, { type FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
@ -7,14 +8,21 @@ import UserAvatar from 'components/UserAvatar';
|
|||
|
||||
interface UserAvatarButtonProps {
|
||||
user?: UserDto
|
||||
sx?: SxProps<Theme>
|
||||
}
|
||||
|
||||
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
|
||||
const UserAvatarButton: FC<UserAvatarButtonProps> = ({
|
||||
user,
|
||||
sx
|
||||
}) => (
|
||||
user?.Id ? (
|
||||
<IconButton
|
||||
size='large'
|
||||
color='inherit'
|
||||
sx={{ padding: 0 }}
|
||||
sx={{
|
||||
padding: 0,
|
||||
...sx
|
||||
}}
|
||||
title={user.Name || undefined}
|
||||
component={Link}
|
||||
to={`/dashboard/users/profile?userId=${user.Id}`}
|
17
src/apps/dashboard/components/table/DateTimeCell.tsx
Normal file
17
src/apps/dashboard/components/table/DateTimeCell.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import format from 'date-fns/format';
|
||||
import type { MRT_Cell, MRT_RowData } from 'material-react-table';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
|
||||
interface CellProps {
|
||||
cell: MRT_Cell<MRT_RowData>
|
||||
}
|
||||
|
||||
const DateTimeCell: FC<CellProps> = ({ cell }) => {
|
||||
const { dateFnsLocale } = useLocale();
|
||||
|
||||
return format(cell.getValue<Date>(), 'Pp', { locale: dateFnsLocale });
|
||||
};
|
||||
|
||||
export default DateTimeCell;
|
72
src/apps/dashboard/components/table/TablePage.tsx
Normal file
72
src/apps/dashboard/components/table/TablePage.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import Box from '@mui/material/Box/Box';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
|
||||
import React from 'react';
|
||||
|
||||
import Page, { type PageProps } from 'components/Page';
|
||||
|
||||
interface TablePageProps<T extends MRT_RowData> extends PageProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
table: MRT_TableInstance<T>
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_OPTIONS = {
|
||||
// Enable custom features
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
// Sticky header/footer
|
||||
enableStickyFooter: true,
|
||||
enableStickyHeader: true,
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TablePage = <T extends MRT_RowData>({
|
||||
title,
|
||||
subtitle,
|
||||
table,
|
||||
children,
|
||||
...pageProps
|
||||
}: TablePageProps<T>) => {
|
||||
return (
|
||||
<Page
|
||||
title={title}
|
||||
{...pageProps}
|
||||
>
|
||||
<Box
|
||||
className='content-primary'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={2}
|
||||
sx={{
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default TablePage;
|
|
@ -23,10 +23,10 @@
|
|||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised" onclick="DashboardPage.restart(this);">
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised">
|
||||
<span>${Restart}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised" onclick="DashboardPage.shutdown(this);">
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised">
|
||||
<span>${ButtonShutdown}</span>
|
||||
</button>
|
||||
</div>
|
|
@ -1,36 +1,36 @@
|
|||
import escapeHtml from 'escape-html';
|
||||
|
||||
import datetime from '../../scripts/datetime';
|
||||
import Events from '../../utils/events.ts';
|
||||
import itemHelper from '../../components/itemHelper';
|
||||
import serverNotifications from '../../scripts/serverNotifications';
|
||||
import dom from '../../scripts/dom';
|
||||
import globalize from '../../lib/globalize';
|
||||
import datetime from 'scripts/datetime';
|
||||
import Events from 'utils/events.ts';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getLocaleWithSuffix } from '../../utils/dateFnsLocale.ts';
|
||||
import loading from '../../components/loading/loading';
|
||||
import playMethodHelper from '../../components/playback/playmethodhelper';
|
||||
import cardBuilder from '../../components/cardbuilder/cardBuilder';
|
||||
import imageLoader from '../../components/images/imageLoader';
|
||||
import ActivityLog from '../../components/activitylog';
|
||||
import imageHelper from '../../utils/image';
|
||||
import indicators from '../../components/indicators/indicators';
|
||||
import taskButton from '../../scripts/taskbutton';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import ServerConnections from '../../components/ServerConnections';
|
||||
import alert from '../../components/alert';
|
||||
import confirm from '../../components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils';
|
||||
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
|
||||
import loading from 'components/loading/loading';
|
||||
import playMethodHelper from 'components/playback/playmethodhelper';
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import imageLoader from 'components/images/imageLoader';
|
||||
import ActivityLog from 'components/activitylog';
|
||||
import imageHelper from 'utils/image';
|
||||
import indicators from 'components/indicators/indicators';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import alert from 'components/alert';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
|
||||
import '../../components/listview/listview.scss';
|
||||
import '../../styles/flexstyles.scss';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import './dashboard.scss';
|
||||
|
||||
function showPlaybackInfo(btn, session) {
|
||||
|
@ -72,7 +72,7 @@ function showPlaybackInfo(btn, session) {
|
|||
}
|
||||
|
||||
function showSendMessageForm(btn, session) {
|
||||
import('../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
title: globalize.translate('HeaderSendMessage'),
|
||||
label: globalize.translate('LabelMessageText'),
|
||||
|
@ -89,7 +89,7 @@ function showSendMessageForm(btn, session) {
|
|||
}
|
||||
|
||||
function showOptionsMenu(btn, session) {
|
||||
import('../../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
const menuItems = [];
|
||||
|
||||
if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) {
|
||||
|
@ -394,7 +394,7 @@ function renderRunningTasks(view, tasks) {
|
|||
view.querySelector('#divRunningTasks').innerHTML = html;
|
||||
}
|
||||
|
||||
window.DashboardPage = {
|
||||
const DashboardPage = {
|
||||
startInterval: function (apiClient) {
|
||||
apiClient.sendMessage('SessionsStart', '0,1500');
|
||||
apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000');
|
||||
|
@ -714,33 +714,38 @@ window.DashboardPage = {
|
|||
pollForInfo(page, ApiClient);
|
||||
});
|
||||
},
|
||||
restart: function (btn) {
|
||||
restart: function (event) {
|
||||
confirm({
|
||||
title: globalize.translate('Restart'),
|
||||
text: globalize.translate('MessageConfirmRestart'),
|
||||
confirmText: globalize.translate('Restart'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
const page = dom.parentWithClass(btn, 'page');
|
||||
}).then(() => {
|
||||
const page = dom.parentWithClass(event.target, 'page');
|
||||
page.querySelector('#btnRestartServer').disabled = true;
|
||||
page.querySelector('#btnShutdown').disabled = true;
|
||||
ApiClient.restartServer();
|
||||
}).catch(() => {
|
||||
// Confirm dialog closed
|
||||
});
|
||||
},
|
||||
shutdown: function (btn) {
|
||||
shutdown: function (event) {
|
||||
confirm({
|
||||
title: globalize.translate('ButtonShutdown'),
|
||||
text: globalize.translate('MessageConfirmShutdown'),
|
||||
confirmText: globalize.translate('ButtonShutdown'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
const page = dom.parentWithClass(btn, 'page');
|
||||
}).then(() => {
|
||||
const page = dom.parentWithClass(event.target, 'page');
|
||||
page.querySelector('#btnRestartServer').disabled = true;
|
||||
page.querySelector('#btnShutdown').disabled = true;
|
||||
ApiClient.shutdownServer();
|
||||
}).catch(() => {
|
||||
// Confirm dialog closed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
function onRestartRequired(evt, apiClient) {
|
||||
console.debug('onRestartRequired not implemented', evt, apiClient);
|
||||
|
@ -816,7 +821,11 @@ export default function (view) {
|
|||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
|
||||
view.addEventListener('viewbeforehide', function () {
|
||||
const apiClient = ApiClient;
|
||||
const page = this;
|
||||
|
@ -838,6 +847,9 @@ export default function (view) {
|
|||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
view.addEventListener('viewdestroy', function () {
|
||||
const page = this;
|
|
@ -1,9 +1,9 @@
|
|||
import 'jquery';
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import dom from '../../scripts/dom';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import alert from '../../components/alert';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dom from 'scripts/dom';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config, systemInfo) {
|
||||
Array.prototype.forEach.call(page.querySelectorAll('.chkDecodeCodec'), function (c) {
|
||||
|
@ -263,7 +263,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
|
|||
setDecodingCodecsVisible(page, this.value);
|
||||
});
|
||||
$('#btnSelectTranscodingTempPath', page).on('click.selectDirectory', function () {
|
||||
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
|
@ -280,7 +280,7 @@ $(document).on('pageinit', '#encodingSettingsPage', function () {
|
|||
});
|
||||
});
|
||||
$('#btnSelectFallbackFontPath', page).on('click.selectDirectory', function () {
|
||||
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeDirectories: true,
|
|
@ -1,14 +1,14 @@
|
|||
import 'jquery';
|
||||
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import '../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../elements/emby-textarea/emby-textarea';
|
||||
import '../../elements/emby-input/emby-input';
|
||||
import '../../elements/emby-select/emby-select';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import alert from '../../components/alert';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-textarea/emby-textarea';
|
||||
import 'elements/emby-input/emby-input';
|
||||
import 'elements/emby-select/emby-select';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config, languageOptions, systemInfo) {
|
||||
page.querySelector('#txtServerName').value = systemInfo.ServerName;
|
||||
|
@ -53,7 +53,7 @@ function onSubmit() {
|
|||
|
||||
export default function (view) {
|
||||
$('#btnSelectCachePath', view).on('click.selectDirectory', function () {
|
||||
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
|
@ -70,7 +70,7 @@ export default function (view) {
|
|||
});
|
||||
});
|
||||
$('#btnSelectMetadataPath', view).on('click.selectDirectory', function () {
|
||||
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
path: view.querySelector('#txtMetadataPath').value,
|
|
@ -1,17 +1,18 @@
|
|||
import escapeHtml from 'escape-html';
|
||||
import taskButton from '../../scripts/taskbutton';
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import dom from '../../scripts/dom';
|
||||
import imageHelper from '../../utils/image';
|
||||
import '../../components/cardbuilder/card.scss';
|
||||
import '../../elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
|
||||
import Dashboard, { pageClassOn, pageIdOn } from '../../utils/dashboard';
|
||||
import confirm from '../../components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from '../../components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dom from 'scripts/dom';
|
||||
import imageHelper from 'utils/image';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
|
||||
import Dashboard, { pageClassOn, pageIdOn } from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
function addVirtualFolder(page) {
|
||||
import('../../components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
|
||||
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
|
||||
new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
|
||||
return !f.hidden;
|
||||
|
@ -26,7 +27,7 @@ function addVirtualFolder(page) {
|
|||
}
|
||||
|
||||
function editVirtualFolder(page, virtualFolder) {
|
||||
import('../../components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
|
||||
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
|
||||
new MediaLibraryEditor({
|
||||
refresh: shouldRefreshLibraryAfterChanges(page),
|
||||
library: virtualFolder
|
||||
|
@ -60,7 +61,7 @@ function deleteVirtualFolder(page, virtualFolder) {
|
|||
}
|
||||
|
||||
function refreshVirtualFolder(page, virtualFolder) {
|
||||
import('../../components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
|
||||
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
|
||||
new RefreshDialog({
|
||||
itemIds: [virtualFolder.ItemId],
|
||||
serverId: ApiClient.serverId(),
|
||||
|
@ -70,7 +71,7 @@ function refreshVirtualFolder(page, virtualFolder) {
|
|||
}
|
||||
|
||||
function renameVirtualFolder(page, virtualFolder) {
|
||||
import('../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelNewName'),
|
||||
description: globalize.translate('MessageRenameMediaFolder'),
|
||||
|
@ -117,7 +118,7 @@ function showCardMenu(page, elem, virtualFolders) {
|
|||
icon: 'delete'
|
||||
});
|
||||
|
||||
import('../../components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
||||
|
@ -206,7 +207,7 @@ function reloadVirtualFolders(page, virtualFolders) {
|
|||
}
|
||||
|
||||
function editImages(page, virtualFolder) {
|
||||
import('../../components/imageeditor/imageeditor').then((imageEditor) => {
|
||||
import('components/imageeditor/imageeditor').then((imageEditor) => {
|
||||
imageEditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ApiClient.serverId()
|
|
@ -1,7 +1,7 @@
|
|||
import loading from '../../components/loading/loading';
|
||||
import '../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import loading from 'components/loading/loading';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
|
||||
export default function(view) {
|
||||
function loadData() {
|
|
@ -1,15 +1,15 @@
|
|||
import loading from '../components/loading/loading';
|
||||
import globalize from '../lib/globalize';
|
||||
import Dashboard, { pageIdOn } from '../utils/dashboard';
|
||||
import { getParameterByName } from '../utils/url.ts';
|
||||
import Events from '../utils/events.ts';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import Dashboard, { pageIdOn } from 'utils/dashboard';
|
||||
import { getParameterByName } from 'utils/url';
|
||||
import Events from 'utils/events';
|
||||
|
||||
function onListingsSubmitted() {
|
||||
Dashboard.navigate('dashboard/livetv');
|
||||
}
|
||||
|
||||
function init(page, type, providerId) {
|
||||
import(`../components/tvproviders/${type}`).then(({ default: ProviderFactory }) => {
|
||||
import(`components/tvproviders/${type}`).then(({ default: ProviderFactory }) => {
|
||||
const instance = new ProviderFactory(page, providerId, {});
|
||||
Events.on(instance, 'submitted', onListingsSubmitted);
|
||||
instance.init();
|
||||
|
@ -17,7 +17,7 @@ function init(page, type, providerId) {
|
|||
}
|
||||
|
||||
function loadTemplate(page, type, providerId) {
|
||||
import(`../components/tvproviders/${type}.template.html`).then(({ default: html }) => {
|
||||
import(`components/tvproviders/${type}.template.html`).then(({ default: html }) => {
|
||||
page.querySelector('.providerTemplate').innerHTML = globalize.translateHtml(html);
|
||||
init(page, type, providerId);
|
||||
});
|
|
@ -1,9 +1,10 @@
|
|||
import 'jquery';
|
||||
import loading from '../components/loading/loading';
|
||||
import globalize from '../lib/globalize';
|
||||
import '../elements/emby-button/emby-button';
|
||||
import Dashboard from '../utils/dashboard';
|
||||
import alert from '../components/alert';
|
||||
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config) {
|
||||
page.querySelector('.liveTvSettingsForm').classList.remove('hide');
|
||||
|
@ -64,7 +65,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
|
|||
const page = this;
|
||||
$('.liveTvSettingsForm').off('submit', onSubmit).on('submit', onSubmit);
|
||||
$('#btnSelectRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
|
@ -79,7 +80,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
|
|||
});
|
||||
});
|
||||
$('#btnSelectMovieRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
|
@ -94,7 +95,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
|
|||
});
|
||||
});
|
||||
$('#btnSelectSeriesRecordingPath', page).on('click.selectDirectory', function () {
|
||||
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
|
@ -109,7 +110,7 @@ $(document).on('pageinit', '#liveTvSettingsPage', function () {
|
|||
});
|
||||
});
|
||||
$('#btnSelectPostProcessorPath', page).on('click.selectDirectory', function () {
|
||||
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeFiles: true,
|
|
@ -1,19 +1,20 @@
|
|||
import 'jquery';
|
||||
import globalize from '../lib/globalize';
|
||||
import taskButton from '../scripts/taskbutton';
|
||||
import dom from '../scripts/dom';
|
||||
import layoutManager from '../components/layoutManager';
|
||||
import loading from '../components/loading/loading';
|
||||
import browser from '../scripts/browser';
|
||||
import '../components/listview/listview.scss';
|
||||
import '../styles/flexstyles.scss';
|
||||
import '../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import '../components/cardbuilder/card.scss';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import dom from 'scripts/dom';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import loading from 'components/loading/loading';
|
||||
import browser from 'scripts/browser';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
import '../elements/emby-button/emby-button';
|
||||
import Dashboard from '../utils/dashboard';
|
||||
import confirm from '../components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from '../components/cardbuilder/cardBuilderUtils';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
const enableFocusTransform = !browser.slow && !browser.edge;
|
||||
|
||||
|
@ -153,7 +154,7 @@ function showProviderOptions(page, providerId, button) {
|
|||
id: 'map'
|
||||
});
|
||||
|
||||
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
||||
|
@ -171,7 +172,7 @@ function showProviderOptions(page, providerId, button) {
|
|||
}
|
||||
|
||||
function mapChannels(page, providerId) {
|
||||
import('../components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
|
||||
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
|
||||
new ChannelMapper({
|
||||
serverId: ApiClient.serverInfo().Id,
|
||||
providerId: providerId
|
||||
|
@ -243,7 +244,7 @@ function addProvider(button) {
|
|||
id: 'xmltv'
|
||||
});
|
||||
|
||||
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: button,
|
||||
|
@ -269,7 +270,7 @@ function showDeviceMenu(button, tunerDeviceId) {
|
|||
id: 'edit'
|
||||
});
|
||||
|
||||
import('../components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
|
@ -1,12 +1,12 @@
|
|||
import globalize from '../lib/globalize';
|
||||
import loading from '../components/loading/loading';
|
||||
import dom from '../scripts/dom';
|
||||
import '../elements/emby-input/emby-input';
|
||||
import '../elements/emby-button/emby-button';
|
||||
import '../elements/emby-checkbox/emby-checkbox';
|
||||
import '../elements/emby-select/emby-select';
|
||||
import Dashboard from '../utils/dashboard';
|
||||
import { getParameterByName } from '../utils/url.ts';
|
||||
import globalize from 'lib/globalize';
|
||||
import loading from 'components/loading/loading';
|
||||
import dom from 'scripts/dom';
|
||||
import 'elements/emby-input/emby-input';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { getParameterByName } from 'utils/url';
|
||||
|
||||
function isM3uVariant(type) {
|
||||
return ['nextpvr'].indexOf(type || '') !== -1;
|
||||
|
@ -112,7 +112,7 @@ function submitForm(page) {
|
|||
}
|
||||
|
||||
function getDetectedDevice() {
|
||||
return import('../components/tunerPicker').then(({ default: TunerPicker }) => {
|
||||
return import('components/tunerPicker').then(({ default: TunerPicker }) => {
|
||||
return new TunerPicker().show({
|
||||
serverId: ApiClient.serverId()
|
||||
});
|
||||
|
@ -235,7 +235,7 @@ export default function (view, params) {
|
|||
});
|
||||
});
|
||||
view.querySelector('.btnSelectPath').addEventListener('click', function () {
|
||||
import('../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeFiles: true,
|
|
@ -2,11 +2,11 @@ import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image
|
|||
|
||||
import 'jquery';
|
||||
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
|
||||
import '../../components/listview/listview.scss';
|
||||
import 'components/listview/listview.scss';
|
||||
|
||||
function populateImageResolutionOptions(select) {
|
||||
let html = '';
|
|
@ -1,9 +1,10 @@
|
|||
import escapeHtml from 'escape-html';
|
||||
import 'jquery';
|
||||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import alert from '../../components/alert';
|
||||
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config, users) {
|
||||
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';
|
|
@ -1,9 +1,9 @@
|
|||
import loading from '../../components/loading/loading';
|
||||
import globalize from '../../lib/globalize';
|
||||
import '../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../elements/emby-select/emby-select';
|
||||
import Dashboard from '../../utils/dashboard';
|
||||
import alert from '../../components/alert';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function onSubmit(e) {
|
||||
const form = this;
|
||||
|
@ -159,7 +159,7 @@ export default function (view) {
|
|||
}
|
||||
});
|
||||
view.querySelector('#btnSelectCertPath').addEventListener('click', function () {
|
||||
import('../../components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
includeFiles: true,
|
|
@ -1,11 +1,11 @@
|
|||
import loading from '../../../../components/loading/loading';
|
||||
import dom from '../../../../scripts/dom';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import '../../../../components/cardbuilder/card.scss';
|
||||
import '../../../../elements/emby-button/emby-button';
|
||||
import Dashboard, { pageIdOn } from '../../../../utils/dashboard';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from '../../../../components/cardbuilder/cardBuilderUtils';
|
||||
import loading from 'components/loading/loading';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard, { pageIdOn } from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
function deletePlugin(page, uniqueid, version, name) {
|
||||
const msg = globalize.translate('UninstallPluginConfirmation', name);
|
||||
|
@ -187,7 +187,7 @@ function showPluginMenu(page, elem) {
|
|||
});
|
||||
}
|
||||
|
||||
import('../../../../components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
|
@ -1,14 +1,14 @@
|
|||
import loading from '../../../../components/loading/loading';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import dialogHelper from '../../../../components/dialogHelper/dialogHelper';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dialogHelper from 'components/dialogHelper/dialogHelper';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
|
||||
import '../../../../elements/emby-button/emby-button';
|
||||
import '../../../../elements/emby-checkbox/emby-checkbox';
|
||||
import '../../../../elements/emby-select/emby-select';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
import 'elements/emby-select/emby-select';
|
||||
|
||||
import '../../../../components/formdialog.scss';
|
||||
import '../../../../components/listview/listview.scss';
|
||||
import 'components/formdialog.scss';
|
||||
import 'components/listview/listview.scss';
|
||||
|
||||
let repositories = [];
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import loading from '../../../components/loading/loading';
|
||||
import datetime from '../../../scripts/datetime';
|
||||
import dom from '../../../scripts/dom';
|
||||
import globalize from '../../../lib/globalize';
|
||||
import '../../../elements/emby-input/emby-input';
|
||||
import '../../../elements/emby-button/emby-button';
|
||||
import '../../../elements/emby-select/emby-select';
|
||||
import confirm from '../../../components/confirm/confirm';
|
||||
import { getParameterByName } from '../../../utils/url.ts';
|
||||
import loading from 'components/loading/loading';
|
||||
import datetime from 'scripts/datetime';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-input/emby-input';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-select/emby-select';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getParameterByName } from 'utils/url.ts';
|
||||
|
||||
function fillTimeOfDay(select) {
|
||||
const options = [];
|
||||
|
@ -35,7 +35,7 @@ const ScheduledTaskPage = {
|
|||
view.querySelector('.taskName').innerHTML = task.Name;
|
||||
view.querySelector('#pTaskDescription').innerHTML = task.Description;
|
||||
|
||||
import('../../../components/listview/listview.scss').then(() => {
|
||||
import('components/listview/listview.scss').then(() => {
|
||||
ScheduledTaskPage.loadTaskTriggers(view, task);
|
||||
});
|
||||
|
24
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal file
24
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { DevicesApiDeleteDeviceRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
|
||||
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useDevices';
|
||||
|
||||
export const useDeleteDevice = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: DevicesApiDeleteDeviceRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getDevicesApi(api!)
|
||||
.deleteDevice(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
38
src/apps/dashboard/features/devices/api/useDevices.ts
Normal file
38
src/apps/dashboard/features/devices/api/useDevices.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'Devices';
|
||||
|
||||
const fetchDevices = async (
|
||||
api?: Api,
|
||||
requestParams?: DevicesApiGetDevicesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchDevices] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getDevicesApi(api).getDevices(requestParams, {
|
||||
signal: options?.signal
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useDevices = (
|
||||
requestParams: DevicesApiGetDevicesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, requestParams],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchDevices(api, requestParams, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
24
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal file
24
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { DevicesApiUpdateDeviceOptionsRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api';
|
||||
import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useDevices';
|
||||
|
||||
export const useUpdateDevice = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: DevicesApiUpdateDeviceOptionsRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getDevicesApi(api!)
|
||||
.updateDeviceOptions(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell';
|
||||
import { getDeviceIcon } from 'utils/image';
|
||||
|
||||
const DeviceNameCell: FC<DeviceInfoCell> = ({ row, renderedCellValue }) => (
|
||||
<>
|
||||
<img
|
||||
alt={row.original.AppName || undefined}
|
||||
src={getDeviceIcon(row.original)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: '1.5em',
|
||||
maxHeight: '1.5em',
|
||||
marginRight: '1rem'
|
||||
}}
|
||||
/>
|
||||
{renderedCellValue}
|
||||
</>
|
||||
);
|
||||
|
||||
export default DeviceNameCell;
|
|
@ -0,0 +1,7 @@
|
|||
import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto';
|
||||
import type { MRT_Row } from 'material-react-table';
|
||||
|
||||
export interface DeviceInfoCell {
|
||||
renderedCellValue: React.ReactNode
|
||||
row: MRT_Row<DeviceInfoDto>
|
||||
}
|
25
src/apps/dashboard/features/logs/api/useServerLog.ts
Normal file
25
src/apps/dashboard/features/logs/api/useServerLog.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchServerLog = async (
|
||||
api: Api,
|
||||
name: string,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getSystemApi(api).getLogFile({ name }, options);
|
||||
|
||||
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
|
||||
return response.data as never as string;
|
||||
};
|
||||
export const useServerLog = (name: string) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['ServerLog', name],
|
||||
queryFn: ({ signal }) => fetchServerLog(api!, name, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
|
@ -2,28 +2,15 @@ import React, { FunctionComponent } from 'react';
|
|||
import type { LogFile } from '@jellyfin/sdk/lib/generated-client/models/log-file';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import datetime from 'scripts/datetime';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
|
||||
type LogItemProps = {
|
||||
logs: LogFile[];
|
||||
};
|
||||
|
||||
const LogItemList: FunctionComponent<LogItemProps> = ({ logs }: LogItemProps) => {
|
||||
const { api } = useApi();
|
||||
|
||||
const getLogFileUrl = (logFile: LogFile) => {
|
||||
if (!api) return '';
|
||||
|
||||
return api.getUri('/System/Logs/Log', {
|
||||
name: logFile.Name,
|
||||
api_key: api.accessToken
|
||||
});
|
||||
};
|
||||
|
||||
const getDate = (logFile: LogFile) => {
|
||||
const date = datetime.parseISO8601Date(logFile.DateModified, true);
|
||||
return datetime.toLocaleDateString(date) + ' ' + datetime.getDisplayTime(date);
|
||||
|
@ -34,15 +21,14 @@ const LogItemList: FunctionComponent<LogItemProps> = ({ logs }: LogItemProps) =>
|
|||
{logs.map(log => {
|
||||
return (
|
||||
<ListItem key={log.Name} disablePadding>
|
||||
<ListItemButton href={getLogFileUrl(log)} target='_blank'>
|
||||
<ListItemLink to={`/dashboard/logs/${log.Name}`}>
|
||||
<ListItemText
|
||||
primary={log.Name}
|
||||
primaryTypographyProps={{ variant: 'h3' }}
|
||||
secondary={getDate(log)}
|
||||
secondaryTypographyProps={{ variant: 'body1' }}
|
||||
/>
|
||||
<OpenInNewIcon />
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
|
||||
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useTasks';
|
||||
|
||||
export const useStartTask = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: ScheduledTasksApiStartTaskRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getScheduledTasksApi(api!)
|
||||
.startTask(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import { ScheduledTasksApiStartTaskRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
|
||||
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useTasks';
|
||||
|
||||
export const useStopTask = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: ScheduledTasksApiStartTaskRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getScheduledTasksApi(api!)
|
||||
.stopTask(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
35
src/apps/dashboard/features/scheduledtasks/api/useTasks.ts
Normal file
35
src/apps/dashboard/features/scheduledtasks/api/useTasks.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getScheduledTasksApi } from '@jellyfin/sdk/lib/utils/api/scheduled-tasks-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'Tasks';
|
||||
|
||||
const fetchTasks = async (
|
||||
api?: Api,
|
||||
params?: ScheduledTasksApiGetTasksRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchTasks] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getScheduledTasksApi(api).getTasks(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useTasks = (params?: ScheduledTasksApiGetTasksRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchTasks(api, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
|
@ -0,0 +1,67 @@
|
|||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { TaskProps } from '../types/taskProps';
|
||||
import TaskProgress from './TaskProgress';
|
||||
import TaskLastRan from './TaskLastRan';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||
import Stop from '@mui/icons-material/Stop';
|
||||
import { useStartTask } from '../api/useStartTask';
|
||||
import { useStopTask } from '../api/useStopTask';
|
||||
|
||||
const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const startTask = useStartTask();
|
||||
const stopTask = useStopTask();
|
||||
|
||||
const navigateTaskEdit = useCallback(() => {
|
||||
Dashboard.navigate(`/dashboard/tasks/edit?id=${task.Id}`)
|
||||
.catch(err => {
|
||||
console.error('[Task] failed to navigate to task edit page', err);
|
||||
});
|
||||
}, [task]);
|
||||
|
||||
const handleStartTask = useCallback(() => {
|
||||
if (task.Id) {
|
||||
startTask.mutate({ taskId: task.Id });
|
||||
}
|
||||
}, [task, startTask]);
|
||||
|
||||
const handleStopTask = useCallback(() => {
|
||||
if (task.Id) {
|
||||
stopTask.mutate({ taskId: task.Id });
|
||||
}
|
||||
}, [task, stopTask]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
<IconButton onClick={task.State == 'Running' ? handleStopTask : handleStartTask}>
|
||||
{task.State == 'Running' ? <Stop /> : <PlayArrow />}
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemButton onClick={navigateTaskEdit}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<AccessTimeIcon sx={{ color: '#fff' }} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={<Typography variant='h3'>{task.Name}</Typography>}
|
||||
secondary={task.State == 'Running' ? <TaskProgress task={task} /> : <TaskLastRan task={task} />}
|
||||
disableTypography
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default Task;
|
|
@ -0,0 +1,45 @@
|
|||
import React, { FunctionComponent, useMemo } from 'react';
|
||||
import { TaskProps } from '../types/taskProps';
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
import { formatDistance, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const TaskLastRan: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const { dateFnsLocale } = useLocale();
|
||||
|
||||
const [ lastRan, timeTaken ] = useMemo(() => {
|
||||
if (task.LastExecutionResult?.StartTimeUtc && task.LastExecutionResult?.EndTimeUtc) {
|
||||
const endTime = parseISO(task.LastExecutionResult.EndTimeUtc);
|
||||
const startTime = parseISO(task.LastExecutionResult.StartTimeUtc);
|
||||
|
||||
return [
|
||||
formatDistanceToNow(endTime, { locale: dateFnsLocale, addSuffix: true }),
|
||||
formatDistance(startTime, endTime, { locale: dateFnsLocale })
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}, [task, dateFnsLocale]);
|
||||
|
||||
if (task.State == 'Idle') {
|
||||
if (task.LastExecutionResult?.StartTimeUtc && task.LastExecutionResult?.EndTimeUtc) {
|
||||
const lastResultStatus = task.LastExecutionResult.Status;
|
||||
|
||||
return (
|
||||
<Typography sx={{ lineHeight: '1.2rem', color: 'text.secondary' }} variant='body1'>
|
||||
{globalize.translate('LabelScheduledTaskLastRan', lastRan, timeTaken)}
|
||||
|
||||
{lastResultStatus == 'Failed' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelFailed')})`}</Typography>}
|
||||
{lastResultStatus == 'Cancelled' && <Typography display='inline' color='blue'>{` (${globalize.translate('LabelCancelled')})`}</Typography>}
|
||||
{lastResultStatus == 'Aborted' && <Typography display='inline' color='error'>{` (${globalize.translate('LabelAbortedByServerShutdown')})`}</Typography>}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<Typography sx={{ color: 'text.secondary' }}>{globalize.translate('LabelStopping')}</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TaskLastRan;
|
|
@ -0,0 +1,32 @@
|
|||
import React, { FunctionComponent } from 'react';
|
||||
import { TaskProps } from '../types/taskProps';
|
||||
import Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const progress = task.CurrentProgressPercentage;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
|
||||
{progress != null ? (
|
||||
<>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<LinearProgress variant='determinate' value={progress} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant='body1'
|
||||
>{`${Math.round(progress)}%`}</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgress;
|
|
@ -0,0 +1,29 @@
|
|||
import React, { FunctionComponent } from 'react';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Task from './Task';
|
||||
|
||||
type TasksProps = {
|
||||
category: string;
|
||||
tasks: TaskInfo[];
|
||||
};
|
||||
|
||||
const Tasks: FunctionComponent<TasksProps> = ({ category, tasks }: TasksProps) => {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h2'>{category}</Typography>
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
{tasks.map(task => {
|
||||
return <Task
|
||||
key={task.Id}
|
||||
task={task}
|
||||
/>;
|
||||
})}
|
||||
</List>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
|
@ -0,0 +1,5 @@
|
|||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
|
||||
export type TaskProps = {
|
||||
task: TaskInfo;
|
||||
};
|
27
src/apps/dashboard/features/scheduledtasks/utils/tasks.ts
Normal file
27
src/apps/dashboard/features/scheduledtasks/utils/tasks.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
|
||||
export function getCategories(tasks: TaskInfo[] | undefined) {
|
||||
if (!tasks) return [];
|
||||
|
||||
const categories: string[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.Category && !categories.includes(task.Category)) {
|
||||
categories.push(task.Category);
|
||||
}
|
||||
}
|
||||
|
||||
return categories.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function getTasksByCategory(tasks: TaskInfo[] | undefined, category: string) {
|
||||
if (!tasks) return [];
|
||||
|
||||
return tasks.filter(task => task.Category == category).sort((a, b) => {
|
||||
if (a.Name && b.Name) {
|
||||
return a.Name?.localeCompare(b.Name);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -4,10 +4,15 @@ import { AppType } from 'constants/appType';
|
|||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AppType.Dashboard },
|
||||
{ path: 'branding', type: AppType.Dashboard },
|
||||
{ path: 'devices', type: AppType.Dashboard },
|
||||
{ path: 'keys', type: AppType.Dashboard },
|
||||
{ path: 'logs', type: AppType.Dashboard },
|
||||
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
|
||||
{ path: 'playback/resume', type: AppType.Dashboard },
|
||||
{ path: 'playback/streaming', type: AppType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
|
||||
{ path: 'tasks', type: AppType.Dashboard },
|
||||
{ path: 'users', type: AppType.Dashboard },
|
||||
{ path: 'users/access', type: AppType.Dashboard },
|
||||
{ path: 'users/add', type: AppType.Dashboard },
|
||||
|
|
|
@ -6,92 +6,71 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
path: '/dashboard',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/dashboard',
|
||||
view: 'dashboard/dashboard.html'
|
||||
controller: 'dashboard',
|
||||
view: 'dashboard.html'
|
||||
}
|
||||
}, {
|
||||
path: 'settings',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/general',
|
||||
view: 'dashboard/general.html'
|
||||
controller: 'general',
|
||||
view: 'general.html'
|
||||
}
|
||||
}, {
|
||||
path: 'networking',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/networking',
|
||||
view: 'dashboard/networking.html'
|
||||
}
|
||||
}, {
|
||||
path: 'devices',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/devices/devices',
|
||||
view: 'dashboard/devices/devices.html'
|
||||
}
|
||||
}, {
|
||||
path: 'devices/edit',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/devices/device',
|
||||
view: 'dashboard/devices/device.html'
|
||||
controller: 'networking',
|
||||
view: 'networking.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/library',
|
||||
view: 'dashboard/library.html'
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/display',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/librarydisplay',
|
||||
view: 'dashboard/librarydisplay.html'
|
||||
controller: 'librarydisplay',
|
||||
view: 'librarydisplay.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/transcoding',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/encodingsettings',
|
||||
view: 'dashboard/encodingsettings.html'
|
||||
controller: 'encodingsettings',
|
||||
view: 'encodingsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/metadata',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/metadataImages',
|
||||
view: 'dashboard/metadataimages.html'
|
||||
controller: 'metadataImages',
|
||||
view: 'metadataimages.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/nfo',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/metadatanfo',
|
||||
view: 'dashboard/metadatanfo.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/resume',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/playback',
|
||||
view: 'dashboard/playback.html'
|
||||
controller: 'metadatanfo',
|
||||
view: 'metadatanfo.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/catalog',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/plugins/available/index',
|
||||
view: 'dashboard/plugins/available/index.html'
|
||||
controller: 'plugins/available/index',
|
||||
view: 'plugins/available/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/repositories',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/plugins/repositories/index',
|
||||
view: 'dashboard/plugins/repositories/index.html'
|
||||
controller: 'plugins/repositories/index',
|
||||
view: 'plugins/repositories/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
|
@ -125,29 +104,15 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
path: 'plugins',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/plugins/installed/index',
|
||||
view: 'dashboard/plugins/installed/index.html'
|
||||
controller: 'plugins/installed/index',
|
||||
view: 'plugins/installed/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tasks/edit',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/scheduledtasks/scheduledtask',
|
||||
view: 'dashboard/scheduledtasks/scheduledtask.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tasks',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/scheduledtasks/scheduledtasks',
|
||||
view: 'dashboard/scheduledtasks/scheduledtasks.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/streaming',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
view: 'dashboard/streaming.html',
|
||||
controller: 'dashboard/streaming'
|
||||
controller: 'scheduledtasks/scheduledtask',
|
||||
view: 'scheduledtasks/scheduledtask.html'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
@ -1,28 +1,24 @@
|
|||
import parseISO from 'date-fns/parseISO';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import Box from '@mui/material/Box';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
|
||||
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
|
||||
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
|
||||
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
|
||||
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
|
||||
import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton';
|
||||
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||
import Page from 'components/Page';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
|
||||
import globalize from 'lib/globalize';
|
||||
import { toBoolean } from 'utils/string';
|
||||
|
||||
type UsersRecords = Record<string, UserDto>;
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const VIEW_PARAM = 'useractivity';
|
||||
|
||||
|
@ -44,7 +40,7 @@ const getUserCell = (users: UsersRecords) => function UserCell({ row }: Activity
|
|||
);
|
||||
};
|
||||
|
||||
const Activity = () => {
|
||||
export const Component = () => {
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
|
||||
const [ activityView, setActivityView ] = useState(
|
||||
|
@ -55,29 +51,7 @@ const Activity = () => {
|
|||
pageSize: DEFAULT_PAGE_SIZE
|
||||
});
|
||||
|
||||
const { data: usersData, isLoading: isUsersLoading } = useUsers();
|
||||
|
||||
const users: UsersRecords = useMemo(() => {
|
||||
if (!usersData) return {};
|
||||
|
||||
return usersData.reduce<UsersRecords>((acc, user) => {
|
||||
const userId = user.Id;
|
||||
if (!userId) return acc;
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[userId]: user
|
||||
};
|
||||
}, {});
|
||||
}, [ usersData ]);
|
||||
|
||||
const userNames = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
usersData?.forEach(user => {
|
||||
if (user.Name) names.push(user.Name);
|
||||
});
|
||||
return names;
|
||||
}, [ usersData ]);
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
|
||||
const UserCell = getUserCell(users);
|
||||
|
||||
|
@ -87,7 +61,13 @@ const Activity = () => {
|
|||
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
|
||||
}), [activityView, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const { data: logEntries, isLoading: isLogEntriesLoading } = useLogEntries(activityParams);
|
||||
const { data, isLoading: isLogEntriesLoading } = useLogEntries(activityParams);
|
||||
const logEntries = useMemo(() => (
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const rowCount = useMemo(() => (
|
||||
data?.TotalRecordCount || 0
|
||||
), [ data ]);
|
||||
|
||||
const isLoading = isUsersLoading || isLogEntriesLoading;
|
||||
|
||||
|
@ -109,10 +89,10 @@ const Activity = () => {
|
|||
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
|
||||
{
|
||||
id: 'Date',
|
||||
accessorFn: row => parseISO8601Date(row.Date),
|
||||
accessorFn: row => row.Date ? parseISO(row.Date) : undefined,
|
||||
header: globalize.translate('LabelTime'),
|
||||
size: 160,
|
||||
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
|
||||
Cell: DateTimeCell,
|
||||
filterVariant: 'datetime-range'
|
||||
},
|
||||
{
|
||||
|
@ -177,21 +157,10 @@ const Activity = () => {
|
|||
}, [ activityView, searchParams, setSearchParams ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
|
||||
columns,
|
||||
data: logEntries?.Items || [],
|
||||
|
||||
// Enable custom features
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
// Sticky header/footer
|
||||
enableStickyFooter: true,
|
||||
enableStickyHeader: true,
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||
}
|
||||
},
|
||||
data: logEntries,
|
||||
|
||||
// State
|
||||
initialState: {
|
||||
|
@ -205,7 +174,7 @@ const Activity = () => {
|
|||
// Server pagination
|
||||
manualPagination: true,
|
||||
onPaginationChange: setPagination,
|
||||
rowCount: logEntries?.TotalRecordCount || 0,
|
||||
rowCount,
|
||||
|
||||
// Custom toolbar contents
|
||||
renderTopToolbarCustomActions: () => (
|
||||
|
@ -229,32 +198,13 @@ const Activity = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Page
|
||||
<TablePage
|
||||
id='serverActivityPage'
|
||||
title={globalize.translate('HeaderActivity')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box
|
||||
className='content-primary'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('HeaderActivity')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
</Page>
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Activity;
|
||||
Component.displayName = 'ActivityPage';
|
||||
|
|
|
@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack';
|
|||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
|
||||
import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
|
@ -60,8 +60,9 @@ export const loader = () => {
|
|||
};
|
||||
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const [ isSubmitting, setIsSubmitting ] = useState(false);
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
data: defaultBrandingOptions,
|
||||
|
@ -69,14 +70,6 @@ export const Component = () => {
|
|||
} = useBrandingOptions();
|
||||
const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {});
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmitting(false);
|
||||
}, [ actionData ]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
setIsSubmitting(true);
|
||||
}, []);
|
||||
|
||||
const setSplashscreenEnabled = useCallback((_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
|
||||
setBrandingOptions({
|
||||
...brandingOptions,
|
||||
|
@ -98,13 +91,11 @@ export const Component = () => {
|
|||
return (
|
||||
<Page
|
||||
id='brandingPage'
|
||||
title={globalize.translate('HeaderBranding')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form
|
||||
method='POST'
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('HeaderBranding')}
|
||||
|
|
265
src/apps/dashboard/routes/devices/index.tsx
Normal file
265
src/apps/dashboard/routes/devices/index.tsx
Normal file
|
@ -0,0 +1,265 @@
|
|||
import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Edit from '@mui/icons-material/Edit';
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import IconButton from '@mui/material/IconButton/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip/Tooltip';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
|
||||
import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton';
|
||||
import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice';
|
||||
import { useDevices } from 'apps/dashboard/features/devices/api/useDevices';
|
||||
import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice';
|
||||
import DeviceNameCell from 'apps/dashboard/features/devices/components/DeviceNameCell';
|
||||
import type { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) {
|
||||
return (
|
||||
<>
|
||||
<UserAvatarButton
|
||||
user={row.original.LastUserId && users[row.original.LastUserId] || undefined}
|
||||
sx={{ mr: '1rem' }}
|
||||
/>
|
||||
{renderedCellValue}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const { api } = useApi();
|
||||
const { data, isLoading: isDevicesLoading, isRefetching } = useDevices({});
|
||||
const devices = useMemo(() => (
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
|
||||
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
|
||||
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
|
||||
const [ pendingDeleteDeviceId, setPendingDeleteDeviceId ] = useState<string>();
|
||||
const deleteDevice = useDeleteDevice();
|
||||
const updateDevice = useUpdateDevice();
|
||||
|
||||
const isLoading = isDevicesLoading || isUsersLoading;
|
||||
|
||||
const onDeleteDevice = useCallback((id: string | null | undefined) => () => {
|
||||
if (id) {
|
||||
setPendingDeleteDeviceId(id);
|
||||
setIsDeleteConfirmOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onCloseDeleteConfirmDialog = useCallback(() => {
|
||||
setPendingDeleteDeviceId(undefined);
|
||||
setIsDeleteConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
if (pendingDeleteDeviceId) {
|
||||
deleteDevice.mutate({
|
||||
id: pendingDeleteDeviceId
|
||||
}, {
|
||||
onSettled: onCloseDeleteConfirmDialog
|
||||
});
|
||||
}
|
||||
}, [ deleteDevice, onCloseDeleteConfirmDialog, pendingDeleteDeviceId ]);
|
||||
|
||||
const onDeleteAll = useCallback(() => {
|
||||
setIsDeleteAllConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCloseDeleteAllConfirmDialog = useCallback(() => {
|
||||
setIsDeleteAllConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDeleteAll = useCallback(() => {
|
||||
if (devices) {
|
||||
Promise
|
||||
.all(devices.map(item => {
|
||||
if (api && item.Id && api.deviceInfo.id === item.Id) {
|
||||
return deleteDevice.mutateAsync({ id: item.Id });
|
||||
}
|
||||
return Promise.resolve();
|
||||
}))
|
||||
.catch(err => {
|
||||
console.error('[DevicesPage] failed deleting all devices', err);
|
||||
})
|
||||
.finally(() => {
|
||||
onCloseDeleteAllConfirmDialog();
|
||||
});
|
||||
}
|
||||
}, [ api, deleteDevice, devices, onCloseDeleteAllConfirmDialog ]);
|
||||
|
||||
const UserCell = getUserCell(users);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<DeviceInfoDto>[]>(() => [
|
||||
{
|
||||
id: 'DateLastActivity',
|
||||
accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined,
|
||||
header: globalize.translate('LastActive'),
|
||||
size: 160,
|
||||
Cell: DateTimeCell,
|
||||
filterVariant: 'datetime-range',
|
||||
enableEditing: false
|
||||
},
|
||||
{
|
||||
id: 'Name',
|
||||
accessorFn: row => row.CustomName || row.Name,
|
||||
header: globalize.translate('LabelDevice'),
|
||||
size: 200,
|
||||
Cell: DeviceNameCell
|
||||
},
|
||||
{
|
||||
id: 'App',
|
||||
accessorFn: row => [row.AppName, row.AppVersion]
|
||||
.filter(v => !!v) // filter missing values
|
||||
.join(' '),
|
||||
header: globalize.translate('LabelAppName'),
|
||||
size: 200,
|
||||
enableEditing: false
|
||||
},
|
||||
{
|
||||
accessorKey: 'LastUserName',
|
||||
header: globalize.translate('LabelUser'),
|
||||
size: 120,
|
||||
enableEditing: false,
|
||||
Cell: UserCell,
|
||||
filterVariant: 'multi-select',
|
||||
filterSelectOptions: userNames
|
||||
}
|
||||
], [ UserCell, userNames ]);
|
||||
|
||||
const mrTable = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
|
||||
columns,
|
||||
data: devices,
|
||||
|
||||
// State
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 25
|
||||
}
|
||||
},
|
||||
state: {
|
||||
isLoading
|
||||
},
|
||||
|
||||
// Do not reset the page index when refetching data
|
||||
autoResetPageIndex: !isRefetching,
|
||||
|
||||
// Editing device name
|
||||
enableEditing: true,
|
||||
onEditingRowSave: ({ table, row, values }) => {
|
||||
const newName = values.Name?.trim();
|
||||
const hasChanged = row.original.CustomName ?
|
||||
newName !== row.original.CustomName :
|
||||
newName !== row.original.Name;
|
||||
|
||||
// If the name has changed, save it as the custom name
|
||||
if (row.original.Id && hasChanged) {
|
||||
updateDevice.mutate({
|
||||
id: row.original.Id,
|
||||
deviceOptionsDto: {
|
||||
CustomName: newName || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
table.setEditingRow(null); //exit editing mode
|
||||
},
|
||||
|
||||
// Custom actions
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
header: ''
|
||||
}
|
||||
},
|
||||
renderRowActions: ({ row, table }) => {
|
||||
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title={globalize.translate('Edit')}>
|
||||
<IconButton
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => table.setEditingRow(row)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Don't include Tooltip when disabled */}
|
||||
{isDeletable ? (
|
||||
<IconButton
|
||||
color='error'
|
||||
disabled
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip title={globalize.translate('Delete')}>
|
||||
<IconButton
|
||||
color='error'
|
||||
onClick={onDeleteDevice(row.original.Id)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
// Custom toolbar contents
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button
|
||||
color='error'
|
||||
startIcon={<Delete />}
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
{globalize.translate('DeleteAll')}
|
||||
</Button>
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
<TablePage
|
||||
id='devicesPage'
|
||||
title={globalize.translate('HeaderDevices')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
table={mrTable}
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={isDeleteConfirmOpen}
|
||||
title={globalize.translate('HeaderDeleteDevice')}
|
||||
text={globalize.translate('DeleteDeviceConfirmation')}
|
||||
onCancel={onCloseDeleteConfirmDialog}
|
||||
onConfirm={onConfirmDelete}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={isDeleteAllConfirmOpen}
|
||||
title={globalize.translate('HeaderDeleteDevices')}
|
||||
text={globalize.translate('DeleteDevicesConfirmation')}
|
||||
onCancel={onCloseDeleteAllConfirmDialog}
|
||||
onConfirm={onConfirmDeleteAll}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
/>
|
||||
</TablePage>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'DevicesPage';
|
|
@ -1,26 +1,30 @@
|
|||
import Page from 'components/Page';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/models/authentication-info';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
|
||||
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
|
||||
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
const ApiKeys = () => {
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
|
||||
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
|
||||
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
|
||||
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import prompt from 'components/prompt/prompt';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
export const Component = () => {
|
||||
const { api } = useApi();
|
||||
const { data: keys, isLoading } = useApiKeys();
|
||||
const { data, isLoading } = useApiKeys();
|
||||
const keys = useMemo(() => (
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const revokeKey = useRevokeKey();
|
||||
const createKey = useCreateKey();
|
||||
|
||||
|
@ -38,34 +42,23 @@ const ApiKeys = () => {
|
|||
},
|
||||
{
|
||||
id: 'DateIssued',
|
||||
accessorFn: item => parseISO8601Date(item.DateCreated),
|
||||
Cell: ({ cell }) => toLocaleDateString(cell.getValue<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
|
||||
accessorFn: item => item.DateCreated ? parseISO(item.DateCreated) : undefined,
|
||||
Cell: DateTimeCell,
|
||||
header: globalize.translate('HeaderDateIssued'),
|
||||
filterVariant: 'datetime-range'
|
||||
}
|
||||
], []);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
|
||||
columns,
|
||||
data: keys?.Items || [],
|
||||
data: keys,
|
||||
|
||||
state: {
|
||||
isLoading
|
||||
},
|
||||
|
||||
rowCount: keys?.TotalRecordCount || 0,
|
||||
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
enableStickyFooter: true,
|
||||
enableStickyHeader: true,
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||
}
|
||||
},
|
||||
|
||||
// Enable (delete) row actions
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
|
@ -77,8 +70,10 @@ const ApiKeys = () => {
|
|||
},
|
||||
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button onClick={showNewKeyPopup}>
|
||||
<AddIcon />
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={showNewKeyPopup}
|
||||
>
|
||||
{globalize.translate('HeaderNewApiKey')}
|
||||
</Button>
|
||||
),
|
||||
|
@ -115,53 +110,28 @@ const ApiKeys = () => {
|
|||
const showNewKeyPopup = useCallback(() => {
|
||||
if (!api) return;
|
||||
|
||||
import('../../../../components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
title: globalize.translate('HeaderNewApiKey'),
|
||||
label: globalize.translate('LabelAppName'),
|
||||
description: globalize.translate('LabelAppNameExample')
|
||||
}).then((value) => {
|
||||
createKey.mutate({
|
||||
app: value
|
||||
});
|
||||
}).catch(() => {
|
||||
// popup closed
|
||||
prompt({
|
||||
title: globalize.translate('HeaderNewApiKey'),
|
||||
label: globalize.translate('LabelAppName'),
|
||||
description: globalize.translate('LabelAppNameExample')
|
||||
}).then((value) => {
|
||||
createKey.mutate({
|
||||
app: value
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[apikeys] failed to load api key popup', err);
|
||||
}).catch(() => {
|
||||
// popup closed
|
||||
});
|
||||
}, [api, createKey]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
<TablePage
|
||||
id='apiKeysPage'
|
||||
title={globalize.translate('HeaderApiKeys')}
|
||||
subtitle={globalize.translate('HeaderApiKeysHelp')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box
|
||||
className='content-primary'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('HeaderApiKeys')}
|
||||
</Typography>
|
||||
<Typography>{globalize.translate('HeaderApiKeysHelp')}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
</Page>
|
||||
table={table}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeys;
|
||||
Component.displayName = 'ApiKeysPage';
|
||||
|
|
109
src/apps/dashboard/routes/logs/file.tsx
Normal file
109
src/apps/dashboard/routes/logs/file.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import Container from '@mui/material/Container';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ContentCopy, FileDownload } from '@mui/icons-material';
|
||||
import globalize from 'lib/globalize';
|
||||
import toast from 'components/toast/toast';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
|
||||
export const Component = () => {
|
||||
const { file: fileName } = useParams();
|
||||
const {
|
||||
isError: error,
|
||||
isPending: loading,
|
||||
data: log,
|
||||
refetch
|
||||
} = useServerLog(fileName ?? '');
|
||||
|
||||
const retry = useCallback(() => refetch(), [refetch]);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (log) {
|
||||
await copy(log);
|
||||
toast({ text: globalize.translate('CopyLogSuccess') });
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
const downloadFile = useCallback(() => {
|
||||
if ('URL' in globalThis && log && fileName) {
|
||||
const blob = new Blob([log], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [log, fileName]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='logPage'
|
||||
title={fileName}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
<Box>
|
||||
<Typography variant='h1'>{fileName}</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
key='error'
|
||||
severity='error'
|
||||
sx={{ mt: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color='inherit'
|
||||
size='small'
|
||||
onClick={retry}
|
||||
>
|
||||
{globalize.translate('Retry')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{globalize.translate('LogLoadFailure')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
{!error && !loading && (
|
||||
<>
|
||||
<ButtonGroup variant='contained' sx={{ mt: 2 }}>
|
||||
<Button
|
||||
startIcon={<ContentCopy />}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{globalize.translate('Copy')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<FileDownload />}
|
||||
onClick={downloadFile}
|
||||
>
|
||||
{globalize.translate('Download')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<code>
|
||||
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
|
||||
</code>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LogPage';
|
|
@ -11,7 +11,7 @@ import Stack from '@mui/material/Stack';
|
|||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { useServerLogs } from 'apps/dashboard/features/logs/api/useServerLogs';
|
||||
import { useConfiguration } from 'hooks/useConfiguration';
|
||||
|
@ -42,9 +42,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
};
|
||||
};
|
||||
|
||||
const Logs = () => {
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const [ isSubmitting, setIsSubmitting ] = useState(false);
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const { isPending: isLogEntriesPending, data: logs } = useServerLogs();
|
||||
const { isPending: isConfigurationPending, data: defaultConfiguration } = useConfiguration();
|
||||
|
@ -72,10 +73,6 @@ const Logs = () => {
|
|||
});
|
||||
}, [configuration]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
setIsSubmitting(true);
|
||||
}, []);
|
||||
|
||||
if (isLogEntriesPending || isConfigurationPending || loading || !logs) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
@ -87,13 +84,13 @@ const Logs = () => {
|
|||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST' onSubmit={onSubmit}>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('TabLogs')}
|
||||
</Typography>
|
||||
|
||||
{isSubmitting && actionData?.isSaved && (
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
|
@ -113,7 +110,7 @@ const Logs = () => {
|
|||
<TextField
|
||||
fullWidth
|
||||
type='number'
|
||||
name={'SlowResponseTime'}
|
||||
name='SlowResponseTime'
|
||||
label={globalize.translate('LabelSlowResponseTime')}
|
||||
value={configuration?.SlowResponseThresholdMs}
|
||||
disabled={!configuration?.EnableSlowResponseWarning}
|
||||
|
@ -136,4 +133,4 @@ const Logs = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
Component.displayName = 'LogsPage';
|
||||
|
|
156
src/apps/dashboard/routes/playback/resume.tsx
Normal file
156
src/apps/dashboard/routes/playback/resume.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
import React from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
const formData = await request.formData();
|
||||
|
||||
const minResumePercentage = formData.get('MinResumePercentage')?.toString();
|
||||
const maxResumePercentage = formData.get('MaxResumePercentage')?.toString();
|
||||
const minAudiobookResume = formData.get('MinAudiobookResume')?.toString();
|
||||
const maxAudiobookResume = formData.get('MaxAudiobookResume')?.toString();
|
||||
const minResumeDuration = formData.get('MinResumeDuration')?.toString();
|
||||
|
||||
if (minResumePercentage) config.MinResumePct = parseInt(minResumePercentage, 10);
|
||||
if (maxResumePercentage) config.MaxResumePct = parseInt(maxResumePercentage, 10);
|
||||
if (minAudiobookResume) config.MinAudiobookResume = parseInt(minAudiobookResume, 10);
|
||||
if (maxAudiobookResume) config.MaxAudiobookResume = parseInt(maxAudiobookResume, 10);
|
||||
if (minResumeDuration) config.MinResumeDurationSeconds = parseInt(minResumeDuration, 10);
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const { isPending: isConfigurationPending, data: config } = useConfiguration();
|
||||
|
||||
if (isConfigurationPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='playbackConfigurationPage'
|
||||
title={globalize.translate('ButtonResume')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('ButtonResume')}
|
||||
</Typography>
|
||||
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelMinResumePercentage')}
|
||||
name='MinResumePercentage'
|
||||
type='number'
|
||||
defaultValue={config?.MinResumePct}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinResumePercentageHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelMaxResumePercentage')}
|
||||
name='MaxResumePercentage'
|
||||
type='number'
|
||||
defaultValue={config?.MaxResumePct}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMaxResumePercentageHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelMinAudiobookResume')}
|
||||
name='MinAudiobookResume'
|
||||
type='number'
|
||||
defaultValue={config?.MinAudiobookResume}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinAudiobookResumeHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelMaxAudiobookResume')}
|
||||
name='MaxAudiobookResume'
|
||||
type='number'
|
||||
defaultValue={config?.MaxAudiobookResume}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMaxAudiobookResumeHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelMinResumeDuration')}
|
||||
name='MinResumeDuration'
|
||||
type='number'
|
||||
defaultValue={config?.MinResumeDurationSeconds}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinResumeDurationHelp')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'ResumePage';
|
95
src/apps/dashboard/routes/playback/streaming.tsx
Normal file
95
src/apps/dashboard/routes/playback/streaming.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
const formData = await request.formData();
|
||||
|
||||
const bitrateLimit = formData.get('StreamingBitrateLimit')?.toString();
|
||||
config.RemoteClientBitrateLimit = Math.trunc(1e6 * parseFloat(bitrateLimit || '0'));
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const { isPending: isConfigurationPending, data: defaultConfiguration } = useConfiguration();
|
||||
|
||||
if (isConfigurationPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='streamingSettingsPage'
|
||||
title={globalize.translate('TabStreaming')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('TabStreaming')}
|
||||
</Typography>
|
||||
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
type='number'
|
||||
inputMode='decimal'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 0.25
|
||||
}}
|
||||
name='StreamingBitrateLimit'
|
||||
label={globalize.translate('LabelRemoteClientBitrateLimit')}
|
||||
helperText={globalize.translate('LabelRemoteClientBitrateLimitHelp')}
|
||||
defaultValue={defaultConfiguration?.RemoteClientBitrateLimit ? defaultConfiguration?.RemoteClientBitrateLimit / 1e6 : ''}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'StreamingPage';
|
|
@ -1,325 +1,259 @@
|
|||
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import Page from 'components/Page';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
|
||||
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
|
||||
import React, { type FC, useCallback, useEffect, useRef } from 'react';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import Page from '../../../../components/Page';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import ServerConnections from '../../../../components/ServerConnections';
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
function onSaveComplete() {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const PlaybackTrickplay: FC = () => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
|
||||
const loadConfig = useCallback((config: ServerConfiguration) => {
|
||||
const page = element.current;
|
||||
const options = config.TrickplayOptions;
|
||||
const options = config.TrickplayOptions;
|
||||
if (!options) throw new Error('Unexpected null TrickplayOptions');
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
options.EnableHwAcceleration = data.HwAcceleration?.toString() === 'on';
|
||||
options.EnableHwEncoding = data.HwEncoding?.toString() === 'on';
|
||||
options.EnableKeyFrameOnlyExtraction = data.KeyFrameOnlyExtraction?.toString() === 'on';
|
||||
options.ScanBehavior = data.ScanBehavior.toString() as TrickplayScanBehavior;
|
||||
options.ProcessPriority = data.ProcessPriority.toString() as ProcessPriorityClass;
|
||||
options.Interval = parseInt(data.ImageInterval.toString() || '10000', 10);
|
||||
options.WidthResolutions = data.WidthResolutions.toString().replace(' ', '').split(',').map(Number);
|
||||
options.TileWidth = parseInt(data.TileWidth.toString() || '10', 10);
|
||||
options.TileHeight = parseInt(data.TileHeight.toString() || '10', 10);
|
||||
options.Qscale = parseInt(data.Qscale.toString() || '4', 10);
|
||||
options.JpegQuality = parseInt(data.JpegQuality.toString() || '90', 10);
|
||||
options.ProcessThreads = parseInt(data.TrickplayThreads.toString() || '1', 10);
|
||||
|
||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
|
||||
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
|
||||
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
|
||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
|
||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
|
||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
|
||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
|
||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
|
||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
|
||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
|
||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
|
||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
|
||||
await getConfigurationApi(api)
|
||||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
loading.hide();
|
||||
}, []);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
loading.show();
|
||||
|
||||
ServerConnections.currentApiClient()?.getServerConfiguration().then(function (config) {
|
||||
loadConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveConfig = (config: ServerConfiguration) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.TrickplayOptions) {
|
||||
throw new Error('Unexpected null TrickplayOptions');
|
||||
}
|
||||
|
||||
const options = config.TrickplayOptions;
|
||||
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
||||
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
|
||||
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
|
||||
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
||||
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
||||
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
||||
options.WidthResolutions = (page.querySelector('#txtWidthResolutions') as HTMLInputElement).value.replace(' ', '').split(',').map(Number);
|
||||
options.TileWidth = Math.max(1, parseInt((page.querySelector('#txtTileWidth') as HTMLInputElement).value || '10', 10));
|
||||
options.TileHeight = Math.max(1, parseInt((page.querySelector('#txtTileHeight') as HTMLInputElement).value || '10', 10));
|
||||
options.Qscale = Math.min(31, parseInt((page.querySelector('#txtQscale') as HTMLInputElement).value || '4', 10));
|
||||
options.JpegQuality = Math.min(100, parseInt((page.querySelector('#txtJpegQuality') as HTMLInputElement).value || '90', 10));
|
||||
options.ProcessThreads = parseInt((page.querySelector('#txtProcessThreads') as HTMLInputElement).value || '1', 10);
|
||||
|
||||
apiClient.updateServerConfiguration(config).then(() => {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to update config', err);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
|
||||
if (!apiClient) {
|
||||
console.error('[PlaybackTrickplay] No current apiclient instance');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
apiClient.getServerConfiguration().then(function (config) {
|
||||
saveConfig(config);
|
||||
}).catch(err => {
|
||||
console.error('[PlaybackTrickplay] failed to fetch server config', err);
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.trickplayConfigurationForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const optionScanBehavior = () => {
|
||||
let content = '';
|
||||
content += `<option value='NonBlocking'>${globalize.translate('NonBlockingScan')}</option>`;
|
||||
content += `<option value='Blocking'>${globalize.translate('BlockingScan')}</option>`;
|
||||
return content;
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
const optionProcessPriority = () => {
|
||||
let content = '';
|
||||
content += `<option value='High'>${globalize.translate('PriorityHigh')}</option>`;
|
||||
content += `<option value='AboveNormal'>${globalize.translate('PriorityAboveNormal')}</option>`;
|
||||
content += `<option value='Normal'>${globalize.translate('PriorityNormal')}</option>`;
|
||||
content += `<option value='BelowNormal'>${globalize.translate('PriorityBelowNormal')}</option>`;
|
||||
content += `<option value='Idle'>${globalize.translate('PriorityIdle')}</option>`;
|
||||
return content;
|
||||
};
|
||||
export const Component = () => {
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const { data: defaultConfig, isPending } = useConfiguration();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
if (!defaultConfig || isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='trickplayConfigurationPage'
|
||||
className='mainAnimatedPage type-interior playbackConfigurationPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
title={globalize.translate('Trickplay')}
|
||||
>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('Trickplay')}
|
||||
/>
|
||||
</div>
|
||||
<Box className='content-primary'>
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('Trickplay')}
|
||||
</Typography>
|
||||
|
||||
<form className='trickplayConfigurationForm'>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableHwAcceleration'
|
||||
title='LabelTrickplayAccel'
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name='HwAcceleration'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwAcceleration}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelTrickplayAccel')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name='HwEncoding'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwEncoding}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelTrickplayAccelEncoding')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelTrickplayAccelEncodingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name='KeyFrameOnlyExtraction'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableKeyFrameOnlyExtraction}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelTrickplayKeyFrameOnlyExtraction')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
name='ScanBehavior'
|
||||
select
|
||||
defaultValue={defaultConfig.TrickplayOptions?.ScanBehavior}
|
||||
label={globalize.translate('LabelScanBehavior')}
|
||||
helperText={globalize.translate('LabelScanBehaviorHelp')}
|
||||
>
|
||||
<MenuItem value={TrickplayScanBehavior.NonBlocking}>{globalize.translate('NonBlockingScan')}</MenuItem>
|
||||
<MenuItem value={TrickplayScanBehavior.Blocking}>{globalize.translate('BlockingScan')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name='ProcessPriority'
|
||||
select
|
||||
defaultValue={defaultConfig.TrickplayOptions?.ProcessPriority}
|
||||
label={globalize.translate('LabelProcessPriority')}
|
||||
helperText={globalize.translate('LabelProcessPriorityHelp')}
|
||||
>
|
||||
<MenuItem value={ProcessPriorityClass.High}>{globalize.translate('PriorityHigh')}</MenuItem>
|
||||
<MenuItem value={ProcessPriorityClass.AboveNormal}>{globalize.translate('PriorityAboveNormal')}</MenuItem>
|
||||
<MenuItem value={ProcessPriorityClass.Normal}>{globalize.translate('PriorityNormal')}</MenuItem>
|
||||
<MenuItem value={ProcessPriorityClass.BelowNormal}>{globalize.translate('PriorityBelowNormal')}</MenuItem>
|
||||
<MenuItem value={ProcessPriorityClass.Idle}>{globalize.translate('PriorityIdle')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelImageInterval')}
|
||||
name='ImageInterval'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.Interval}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelImageIntervalHelp')}
|
||||
/>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableHwEncoding'
|
||||
title='LabelTrickplayAccelEncoding'
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelWidthResolutions')}
|
||||
name='WidthResolutions'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
|
||||
inputProps={{
|
||||
required: true,
|
||||
pattern: '[0-9,]*'
|
||||
}}
|
||||
helperText={globalize.translate('LabelWidthResolutionsHelp')}
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayAccelEncodingHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableKeyFrameOnlyExtraction'
|
||||
title='LabelTrickplayKeyFrameOnlyExtraction'
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelTileWidth')}
|
||||
name='TileWidth'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTileWidthHelp')}
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectScanBehavior'>
|
||||
<SelectElement
|
||||
id='selectScanBehavior'
|
||||
label='LabelScanBehavior'
|
||||
>
|
||||
{optionScanBehavior()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelScanBehaviorHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label={globalize.translate('LabelTileHeight')}
|
||||
name='TileHeight'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTileHeightHelp')}
|
||||
/>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectProcessPriority'>
|
||||
<SelectElement
|
||||
id='selectProcessPriority'
|
||||
label='LabelProcessPriority'
|
||||
>
|
||||
{optionProcessPriority()}
|
||||
</SelectElement>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelProcessPriorityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label={globalize.translate('LabelJpegQuality')}
|
||||
name='JpegQuality'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelJpegQualityHelp')}
|
||||
/>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtInterval'
|
||||
label='LabelImageInterval'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelImageIntervalHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label={globalize.translate('LabelQscale')}
|
||||
name='Qscale'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.Qscale}
|
||||
inputProps={{
|
||||
min: 2,
|
||||
max: 31,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelQscaleHelp')}
|
||||
/>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='text'
|
||||
id='txtWidthResolutions'
|
||||
label='LabelWidthResolutions'
|
||||
options={'required pattern="[0-9,]*"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelWidthResolutionsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label={globalize.translate('LabelTrickplayThreads')}
|
||||
name='TrickplayThreads'
|
||||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTrickplayThreadsHelp')}
|
||||
/>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileWidth'
|
||||
label='LabelTileWidth'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileWidthHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtTileHeight'
|
||||
label='LabelTileHeight'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTileHeightHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtJpegQuality'
|
||||
label='LabelJpegQuality'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="1" max="100"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelJpegQualityHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtQscale'
|
||||
label='LabelQscale'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="2" max="31"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelQscaleHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='inputContainer'>
|
||||
<InputElement
|
||||
type='number'
|
||||
id='txtProcessThreads'
|
||||
label='LabelTrickplayThreads'
|
||||
options={'required inputMode="numeric" pattern="[0-9]*" min="0"'}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayThreadsHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ButtonElement
|
||||
<Button
|
||||
type='submit'
|
||||
className='raised button-submit block'
|
||||
title='Save'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaybackTrickplay;
|
||||
Component.displayName = 'TrickplayPage';
|
||||
|
|
75
src/apps/dashboard/routes/tasks/index.tsx
Normal file
75
src/apps/dashboard/routes/tasks/index.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { QUERY_KEY, useTasks } from '../../features/scheduledtasks/api/useTasks';
|
||||
import { getCategories, getTasksByCategory } from '../../features/scheduledtasks/utils/tasks';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Tasks from '../../features/scheduledtasks/components/Tasks';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import Events, { Event } from 'utils/events';
|
||||
import { ApiClient } from 'jellyfin-apiclient';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const Component = () => {
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
const { data: tasks, isPending } = useTasks({ isHidden: false });
|
||||
|
||||
// TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK.
|
||||
useEffect(() => {
|
||||
const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => {
|
||||
queryClient.setQueryData([ QUERY_KEY ], info);
|
||||
};
|
||||
|
||||
const fallbackInterval = setInterval(() => {
|
||||
if (!__legacyApiClient__?.isMessageChannelOpen()) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
}, 1e4);
|
||||
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000');
|
||||
Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null);
|
||||
Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
};
|
||||
}, [__legacyApiClient__]);
|
||||
|
||||
if (isPending || !tasks) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const categories = getCategories(tasks);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='scheduledTasksPage'
|
||||
title={globalize.translate('TabScheduledTasks')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Box className='readOnlyContent'>
|
||||
<Stack spacing={3} mt={2}>
|
||||
{categories.map(category => {
|
||||
return <Tasks
|
||||
key={category}
|
||||
category={category}
|
||||
tasks={getTasksByCategory(tasks, category)}
|
||||
/>;
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'TasksPage';
|
|
@ -111,8 +111,9 @@ const UserNew = () => {
|
|||
|
||||
const saveUser = () => {
|
||||
const userInput: UserInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
|
|
|
@ -199,7 +199,7 @@ const UserEdit = () => {
|
|||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value;
|
||||
user.Name = (page.querySelector('#txtUserName') as HTMLInputElement).value.trim();
|
||||
user.Policy.IsAdministrator = (page.querySelector('.chkIsAdmin') as HTMLInputElement).checked;
|
||||
user.Policy.IsHidden = (page.querySelector('.chkIsHidden') as HTMLInputElement).checked;
|
||||
user.Policy.IsDisabled = (page.querySelector('.chkDisabled') as HTMLInputElement).checked;
|
||||
|
|
|
@ -17,6 +17,7 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
|
|||
parentId,
|
||||
collectionType,
|
||||
itemType
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
}) => {
|
||||
const { isLoading, data: genresResult } = useGetGenres(itemType, parentId);
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import SectionContainer from 'components/common/SectionContainer';
|
|||
import { CardShape } from 'utils/card';
|
||||
import type { LibraryViewProps } from 'types/library';
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
const UpcomingView: FC<LibraryViewProps> = ({ parentId }) => {
|
||||
const { isLoading, data: groupsUpcomingEpisodes } =
|
||||
useGetGroupsUpcomingEpisodes(parentId);
|
||||
|
|
|
@ -46,6 +46,7 @@ const VideoPage: FC = () => {
|
|||
<AppToolbar
|
||||
isDrawerAvailable={false}
|
||||
isDrawerOpen={false}
|
||||
isFullscreen
|
||||
isUserMenuAvailable={false}
|
||||
buttons={
|
||||
<>
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface MovedItem {
|
|||
playlistItemId: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/redundant-type-aliases
|
||||
export type PlayerErrorCode = string;
|
||||
|
||||
export interface PlayerStopInfo {
|
||||
|
|
102
src/apps/stable/features/playback/utils/itemText.test.ts
Normal file
102
src/apps/stable/features/playback/utils/itemText.test.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
import { getItemTextLines } from './itemText';
|
||||
|
||||
describe('getItemTextLines', () => {
|
||||
it('Should return undefined if item is invalid', () => {
|
||||
let lines = getItemTextLines({});
|
||||
expect(lines).toBeUndefined();
|
||||
lines = getItemTextLines(null);
|
||||
expect(lines).toBeUndefined();
|
||||
lines = getItemTextLines(undefined);
|
||||
expect(lines).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should return the name and index number', () => {
|
||||
const item: ItemDto = {
|
||||
Name: 'Item Name'
|
||||
};
|
||||
let lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines?.[0]).toBe(item.Name);
|
||||
|
||||
item.MediaType = MediaType.Video;
|
||||
item.IndexNumber = 5;
|
||||
lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines?.[0]).toBe(`${item.IndexNumber} - ${item.Name}`);
|
||||
|
||||
item.ParentIndexNumber = 2;
|
||||
lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines?.[0]).toBe(`${item.ParentIndexNumber}.${item.IndexNumber} - ${item.Name}`);
|
||||
});
|
||||
|
||||
it('Should add artist names', () => {
|
||||
let item: ItemDto = {
|
||||
Name: 'Item Name',
|
||||
ArtistItems: [
|
||||
{ Name: 'Artist 1' },
|
||||
{ Name: 'Artist 2' }
|
||||
]
|
||||
};
|
||||
let lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toBe(item.Name);
|
||||
expect(lines?.[1]).toBe('Artist 1, Artist 2');
|
||||
|
||||
item = {
|
||||
Name: 'Item Name',
|
||||
Artists: [
|
||||
'Artist 1',
|
||||
'Artist 2'
|
||||
]
|
||||
};
|
||||
lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toBe(item.Name);
|
||||
expect(lines?.[1]).toBe('Artist 1, Artist 2');
|
||||
});
|
||||
|
||||
it('Should add album or series name', () => {
|
||||
let item: ItemDto = {
|
||||
Name: 'Item Name',
|
||||
SeriesName: 'Series'
|
||||
};
|
||||
let lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toBe(item.SeriesName);
|
||||
expect(lines?.[1]).toBe(item.Name);
|
||||
|
||||
item = {
|
||||
Name: 'Item Name',
|
||||
Album: 'Album'
|
||||
};
|
||||
lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toBe(item.Album);
|
||||
expect(lines?.[1]).toBe(item.Name);
|
||||
});
|
||||
|
||||
it('Should add production year', () => {
|
||||
const item = {
|
||||
Name: 'Item Name',
|
||||
ProductionYear: 2025
|
||||
};
|
||||
const lines = getItemTextLines(item);
|
||||
expect(lines).toBeDefined();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toBe(item.Name);
|
||||
expect(lines?.[1]).toBe(String(item.ProductionYear));
|
||||
});
|
||||
});
|
44
src/apps/stable/features/playback/utils/itemText.ts
Normal file
44
src/apps/stable/features/playback/utils/itemText.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
/**
|
||||
* Gets lines of text used to describe an item for display.
|
||||
* @param nowPlayingItem The item to describe
|
||||
* @param isYearIncluded Should the production year be included
|
||||
* @returns The list of strings describing the item for display
|
||||
*/
|
||||
export function getItemTextLines(
|
||||
nowPlayingItem: ItemDto | null | undefined,
|
||||
isYearIncluded = true
|
||||
) {
|
||||
let line1 = nowPlayingItem?.Name;
|
||||
if (nowPlayingItem?.MediaType === MediaType.Video) {
|
||||
if (nowPlayingItem.IndexNumber != null) {
|
||||
line1 = nowPlayingItem.IndexNumber + ' - ' + line1;
|
||||
}
|
||||
if (nowPlayingItem.ParentIndexNumber != null) {
|
||||
line1 = nowPlayingItem.ParentIndexNumber + '.' + line1;
|
||||
}
|
||||
}
|
||||
|
||||
let line2: string | null | undefined;
|
||||
if (nowPlayingItem?.ArtistItems?.length) {
|
||||
line2 = nowPlayingItem.ArtistItems.map(a => a.Name).join(', ');
|
||||
} else if (nowPlayingItem?.Artists?.length) {
|
||||
line2 = nowPlayingItem.Artists.join(', ');
|
||||
} else if (nowPlayingItem?.SeriesName || nowPlayingItem?.Album) {
|
||||
line2 = line1;
|
||||
line1 = nowPlayingItem.SeriesName || nowPlayingItem.Album;
|
||||
} else if (nowPlayingItem?.ProductionYear && isYearIncluded) {
|
||||
line2 = String(nowPlayingItem.ProductionYear);
|
||||
}
|
||||
|
||||
if (!line1) return;
|
||||
|
||||
const lines = [ line1 ];
|
||||
|
||||
if (line2) lines.push(line2);
|
||||
|
||||
return lines;
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
|
||||
import { getImageUrl } from 'apps/stable/features/playback/utils/image';
|
||||
import { getItemTextLines } from 'apps/stable/features/playback/utils/itemText';
|
||||
import { PlaybackSubscriber } from 'apps/stable/features/playback/utils/playbackSubscriber';
|
||||
import { getNowPlayingNames } from 'components/playback/nowplayinghelper';
|
||||
import type { PlaybackManager } from 'components/playback/playbackmanager';
|
||||
import { MILLISECONDS_PER_SECOND, TICKS_PER_MILLISECOND } from 'constants/time';
|
||||
import browser from 'scripts/browser';
|
||||
|
@ -110,11 +110,11 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
|
|||
}
|
||||
|
||||
const album = item.Album || undefined;
|
||||
const [ line1, line2 ] = getNowPlayingNames(item, false) || [];
|
||||
const [ line1, line2 ] = getItemTextLines(item, false) || [];
|
||||
// The artist will be the second line if present or the first line otherwise
|
||||
const artist = (line2 || line1)?.text;
|
||||
const artist = line2 || line1;
|
||||
// The title will be the first line if there are two lines
|
||||
const title = (line2 && line1)?.text;
|
||||
const title = line2 && line1;
|
||||
|
||||
if (hasNavigatorSession) {
|
||||
if (
|
||||
|
|
|
@ -16,6 +16,7 @@ const MarkdownBox: FC<MarkdownBoxProps> = ({
|
|||
<Box
|
||||
dangerouslySetInnerHTML={
|
||||
markdown ?
|
||||
// eslint-disable-next-line sonarjs/disabled-auto-escaping
|
||||
{ __html: DOMPurify.sanitize(markdownIt({ html: true }).render(markdown)) } :
|
||||
undefined
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { type FC, type PropsWithChildren, type HTMLAttributes, useEffect,
|
|||
|
||||
import viewManager from './viewManager/viewManager';
|
||||
|
||||
type PageProps = {
|
||||
type CustomPageProps = {
|
||||
id: string, // id is required for libraryMenu
|
||||
title?: string,
|
||||
isBackButtonEnabled?: boolean,
|
||||
|
@ -12,11 +12,13 @@ type PageProps = {
|
|||
backDropType?: string,
|
||||
};
|
||||
|
||||
export type PageProps = CustomPageProps & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* Page component that handles hiding active non-react views, triggering the required events for
|
||||
* navigation and appRouter state updates, and setting the correct classes and data attributes.
|
||||
*/
|
||||
const Page: FC<PropsWithChildren<PageProps & HTMLAttributes<HTMLDivElement>>> = ({
|
||||
const Page: FC<PropsWithChildren<PageProps>> = ({
|
||||
children,
|
||||
id,
|
||||
className = '',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import '../../styles/mixins';
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.alphaPicker {
|
||||
text-align: center;
|
||||
|
|
|
@ -9,6 +9,7 @@ interface CardTextProps {
|
|||
|
||||
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
|
||||
const { title, titleAction } = textLine;
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
const renderCardText = () => {
|
||||
if (titleAction) {
|
||||
return (
|
||||
|
|
|
@ -323,7 +323,7 @@ function shouldShowMediaTitle(
|
|||
}
|
||||
|
||||
function shouldShowExtraType(itemExtraType: NullableString) {
|
||||
return itemExtraType && itemExtraType !== 'Unknown';
|
||||
return !!(itemExtraType && itemExtraType !== 'Unknown');
|
||||
}
|
||||
|
||||
function shouldShowSeriesYearOrYear(
|
||||
|
@ -351,7 +351,7 @@ function shouldShowPersonRoleOrType(
|
|||
showPersonRoleOrType: boolean | undefined,
|
||||
item: ItemDto
|
||||
) {
|
||||
return showPersonRoleOrType && (item as BaseItemPerson).Role;
|
||||
return !!(showPersonRoleOrType && (item as BaseItemPerson).Role);
|
||||
}
|
||||
|
||||
function shouldShowParentTitle(
|
||||
|
|
|
@ -195,6 +195,7 @@ function buildCardsHtmlInternal(items, options) {
|
|||
if (isVertical) {
|
||||
html += '</div>';
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-dead-store
|
||||
hasOpenSection = false;
|
||||
}
|
||||
|
||||
|
@ -215,6 +216,7 @@ function buildCardsHtmlInternal(items, options) {
|
|||
if (options.rows && itemsInRow === 0) {
|
||||
if (hasOpenRow) {
|
||||
html += '</div>';
|
||||
// eslint-disable-next-line sonarjs/no-dead-store
|
||||
hasOpenRow = false;
|
||||
}
|
||||
|
||||
|
@ -704,7 +706,8 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
|||
if (item.Role) {
|
||||
if ([ PersonKind.Actor, PersonKind.GuestStar ].includes(item.Type)) {
|
||||
// List actor roles formatted like "as Character Name"
|
||||
lines.push(globalize.translate('PersonRole', escapeHtml(item.Role)));
|
||||
const roleText = globalize.translate('PersonRole', escapeHtml(item.Role));
|
||||
lines.push(`<span title="${roleText}">${roleText}</span>`);
|
||||
} else if (item.Role.toLowerCase() === item.Type.toLowerCase()) {
|
||||
// Role and Type are the same so use the localized Type
|
||||
lines.push(escapeHtml(globalize.translate(item.Type)));
|
||||
|
|
|
@ -4,7 +4,7 @@ import globalize from '../../../lib/globalize';
|
|||
import IconButtonElement from '../../../elements/IconButtonElement';
|
||||
|
||||
type AccessScheduleListProps = {
|
||||
index: number;
|
||||
index?: number;
|
||||
DayOfWeek?: string;
|
||||
StartHour?: number ;
|
||||
EndHour?: number;
|
||||
|
|
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