mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into double-tap-seek
This commit is contained in:
commit
7fc74f1bfa
256 changed files with 11659 additions and 7149 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@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
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 }}
|
||||
|
|
2
.github/workflows/__package.yml
vendored
2
.github/workflows/__package.yml
vendored
|
@ -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/pull_request.yml
vendored
2
.github/workflows/pull_request.yml
vendored
|
@ -95,6 +95,6 @@ jobs:
|
|||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@2e2f18e272ccd63a031778599523af90d122e25f # v4.1.8
|
||||
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.
|
||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -16,3 +16,8 @@ config.json
|
|||
# vim
|
||||
*.sw?
|
||||
|
||||
# direnv
|
||||
.direnv/
|
||||
|
||||
# environment related
|
||||
.envrc
|
||||
|
|
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'
|
||||
}
|
||||
}
|
||||
);
|
60
flake.lock
generated
Normal file
60
flake.lock
generated
Normal file
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1739874174,
|
||||
"narHash": "sha256-XGxSVtojlwjYRYGvGXex0Cw+/363EVJlbY9TPX9bARk=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d2ab2691c798f6b633be91d74b1626980ddaff30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
34
flake.nix
Normal file
34
flake.nix
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
description = "jellyfin-web nix flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system: let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in {
|
||||
devShell = with pkgs;
|
||||
mkShell rec {
|
||||
buildInputs = [
|
||||
nodejs_20
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
# Also see: https://github.com/sass/embedded-host-node/issues/334
|
||||
echo "Removing sass-embedded from node-modules as its broken on NixOS."
|
||||
rm -rf node_modules/sass-embedded*
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
5965
package-lock.json
generated
5965
package-lock.json
generated
File diff suppressed because it is too large
Load diff
60
package.json
60
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.13.0",
|
||||
"@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.21.0",
|
||||
"@typescript-eslint/parser": "8.21.0",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "3.0.4",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
|
@ -34,37 +34,39 @@
|
|||
"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.4",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-sonarjs": "0.25.1",
|
||||
"expose-loader": "5.0.0",
|
||||
"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.3",
|
||||
"jsdom": "25.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss": "8.4.49",
|
||||
"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.4",
|
||||
"sass-loader": "16.0.4",
|
||||
"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.13.2",
|
||||
"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.1",
|
||||
"stylelint-scss": "6.11.0",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "3.0.4",
|
||||
"webpack": "5.97.1",
|
||||
"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.2.0",
|
||||
|
@ -81,10 +83,10 @@
|
|||
"@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",
|
||||
|
@ -122,13 +124,13 @@
|
|||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
"swiper": "11.2.1",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"swiper": "11.2.3",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.83.4"
|
||||
"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,
|
383
src/apps/dashboard/controllers/library.js
Normal file
383
src/apps/dashboard/controllers/library.js
Normal file
|
@ -0,0 +1,383 @@
|
|||
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 { 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 }) => {
|
||||
new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
|
||||
return !f.hidden;
|
||||
}),
|
||||
refresh: shouldRefreshLibraryAfterChanges(page)
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editVirtualFolder(page, virtualFolder) {
|
||||
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
|
||||
new MediaLibraryEditor({
|
||||
refresh: shouldRefreshLibraryAfterChanges(page),
|
||||
library: virtualFolder
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteVirtualFolder(page, virtualFolder) {
|
||||
let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder');
|
||||
|
||||
if (virtualFolder.Locations.length) {
|
||||
msg += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
|
||||
msg += virtualFolder.Locations.join('<br/>');
|
||||
}
|
||||
|
||||
confirm({
|
||||
text: msg,
|
||||
title: globalize.translate('HeaderRemoveMediaFolder'),
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshVirtualFolder(page, virtualFolder) {
|
||||
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
|
||||
new RefreshDialog({
|
||||
itemIds: [virtualFolder.ItemId],
|
||||
serverId: ApiClient.serverId(),
|
||||
mode: 'scan'
|
||||
}).show();
|
||||
});
|
||||
}
|
||||
|
||||
function renameVirtualFolder(page, virtualFolder) {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelNewName'),
|
||||
description: globalize.translate('MessageRenameMediaFolder'),
|
||||
confirmText: globalize.translate('ButtonRename')
|
||||
}).then(function (newName) {
|
||||
if (newName && newName != virtualFolder.Name) {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showCardMenu(page, elem, virtualFolders) {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
name: globalize.translate('EditImages'),
|
||||
id: 'editimages',
|
||||
icon: 'photo'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ManageLibrary'),
|
||||
id: 'edit',
|
||||
icon: 'folder'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRename'),
|
||||
id: 'rename',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ScanLibrary'),
|
||||
id: 'refresh',
|
||||
icon: 'refresh'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRemove'),
|
||||
id: 'delete',
|
||||
icon: 'delete'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
||||
callback: function (resultId) {
|
||||
switch (resultId) {
|
||||
case 'edit':
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'editimages':
|
||||
editImages(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
renameVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'refresh':
|
||||
refreshVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reloadLibrary(page) {
|
||||
loading.show();
|
||||
ApiClient.getVirtualFolders().then(function (result) {
|
||||
reloadVirtualFolders(page, result);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRefreshLibraryAfterChanges(page) {
|
||||
return page.id === 'mediaLibraryPage';
|
||||
}
|
||||
|
||||
function reloadVirtualFolders(page, virtualFolders) {
|
||||
let html = '';
|
||||
virtualFolders.push({
|
||||
Name: globalize.translate('ButtonAddMediaLibrary'),
|
||||
icon: 'add_circle',
|
||||
Locations: [],
|
||||
showType: false,
|
||||
showLocations: false,
|
||||
showMenu: false,
|
||||
showNameWithIcon: false,
|
||||
elementId: 'addLibrary'
|
||||
});
|
||||
|
||||
for (let i = 0; i < virtualFolders.length; i++) {
|
||||
const virtualFolder = virtualFolders[i];
|
||||
html += getVirtualFolderHtml(page, virtualFolder, i);
|
||||
}
|
||||
|
||||
const divVirtualFolders = page.querySelector('#divVirtualFolders');
|
||||
divVirtualFolders.innerHTML = html;
|
||||
divVirtualFolders.classList.add('itemsContainer');
|
||||
divVirtualFolders.classList.add('vertical-wrap');
|
||||
const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
|
||||
btnCardMenuElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
showCardMenu(page, btn, virtualFolders);
|
||||
});
|
||||
});
|
||||
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
|
||||
addVirtualFolder(page);
|
||||
});
|
||||
|
||||
const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
|
||||
libraryEditElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const card = dom.parentWithClass(btn, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
|
||||
if (virtualFolder.ItemId) {
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
});
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function editImages(page, virtualFolder) {
|
||||
import('components/imageeditor/imageeditor').then((imageEditor) => {
|
||||
imageEditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ApiClient.serverId()
|
||||
}).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLink(text, url) {
|
||||
return globalize.translate(text, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
|
||||
}
|
||||
|
||||
function getCollectionTypeOptions() {
|
||||
return [{
|
||||
name: '',
|
||||
value: ''
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: 'movies',
|
||||
message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies')
|
||||
}, {
|
||||
name: globalize.translate('TabMusic'),
|
||||
value: 'music',
|
||||
message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music')
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: 'tvshows',
|
||||
message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows')
|
||||
}, {
|
||||
name: globalize.translate('Books'),
|
||||
value: 'books',
|
||||
message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books')
|
||||
}, {
|
||||
name: globalize.translate('HomeVideosPhotos'),
|
||||
value: 'homevideos'
|
||||
}, {
|
||||
name: globalize.translate('MusicVideos'),
|
||||
value: 'musicvideos'
|
||||
}, {
|
||||
name: globalize.translate('MixedMoviesShows'),
|
||||
value: 'mixed',
|
||||
message: globalize.translate('MessageUnsetContentHelp')
|
||||
}];
|
||||
}
|
||||
|
||||
function getVirtualFolderHtml(page, virtualFolder, index) {
|
||||
let html = '';
|
||||
|
||||
const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : '';
|
||||
html += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
|
||||
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
let imgUrl = '';
|
||||
|
||||
if (virtualFolder.PrimaryImageItemId) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
|
||||
let hasCardImageContainer;
|
||||
|
||||
if (imgUrl) {
|
||||
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
|
||||
html += `<img src="${imgUrl}" style="width:100%" />`;
|
||||
hasCardImageContainer = true;
|
||||
} else if (!virtualFolder.showNameWithIcon) {
|
||||
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
hasCardImageContainer = true;
|
||||
}
|
||||
|
||||
if (hasCardImageContainer) {
|
||||
html += '<div class="cardIndicators backdropCardIndicators">';
|
||||
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (!imgUrl && virtualFolder.showNameWithIcon) {
|
||||
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += '<div style="margin:1em 0;position:width:100%;">';
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</h3>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
|
||||
|
||||
if (virtualFolder.showMenu !== false) {
|
||||
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
|
||||
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += "<div class='cardText'>";
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
let typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0];
|
||||
typeName = typeName ? typeName.name : globalize.translate('Other');
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
|
||||
if (virtualFolder.showType === false) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += typeName;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (virtualFolder.showLocations === false) {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += ' ';
|
||||
html += '</div>';
|
||||
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
|
||||
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
|
||||
html += virtualFolder.Locations[0];
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
pageClassOn('pageshow', 'mediaLibraryPage', function () {
|
||||
reloadLibrary(this);
|
||||
});
|
||||
pageIdOn('pageshow', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
pageIdOn('pagebeforehide', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
|
@ -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 = [];
|
||||
|
|
@ -7,15 +7,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchLogEntries = async (
|
||||
api?: Api,
|
||||
api: Api,
|
||||
requestParams?: ActivityLogApiGetLogEntriesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchLogEntries] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getActivityLogApi(api).getLogEntries(requestParams, {
|
||||
signal: options?.signal
|
||||
});
|
||||
|
@ -30,7 +25,7 @@ export const useLogEntries = (
|
|||
return useQuery({
|
||||
queryKey: ['ActivityLogEntries', requestParams],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchLogEntries(api, requestParams, { signal }),
|
||||
fetchLogEntries(api!, requestParams, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
|
|
|
@ -8,14 +8,9 @@ import { useApi } from 'hooks/useApi';
|
|||
export const QUERY_KEY = 'BrandingOptions';
|
||||
|
||||
const fetchBrandingOptions = async (
|
||||
api?: Api,
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.error('[fetchBrandingOptions] no Api instance provided');
|
||||
throw new Error('No Api instance provided to fetchBrandingOptions');
|
||||
}
|
||||
|
||||
return getBrandingApi(api)
|
||||
.getBrandingOptions(options)
|
||||
.then(({ data }) => data);
|
||||
|
@ -25,7 +20,7 @@ export const getBrandingOptionsQuery = (
|
|||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryFn: ({ signal }) => fetchBrandingOptions(api, { signal }),
|
||||
queryFn: ({ signal }) => fetchBrandingOptions(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
|
|
23
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal file
23
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
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) => (
|
||||
getDevicesApi(api!)
|
||||
.deleteDevice(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
33
src/apps/dashboard/features/devices/api/useDevices.ts
Normal file
33
src/apps/dashboard/features/devices/api/useDevices.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
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
|
||||
) => {
|
||||
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
|
||||
});
|
||||
};
|
23
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal file
23
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
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) => (
|
||||
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>
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { getApiKeyApi } from '@jellyfin/sdk/lib/utils/api/api-key-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'ApiKeys';
|
||||
|
||||
const fetchApiKeys = async (api?: Api) => {
|
||||
if (!api) {
|
||||
console.error('[useApiKeys] Failed to create Api instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getApiKeyApi(api).getKeys();
|
||||
const fetchApiKeys = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getApiKeyApi(api).getKeys(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
@ -21,7 +17,7 @@ export const useApiKeys = () => {
|
|||
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryFn: () => fetchApiKeys(api),
|
||||
queryFn: ({ signal }) => fetchApiKeys(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
|
|
|
@ -10,7 +10,6 @@ export const useCreateKey = () => {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: (params: ApiKeyApiCreateKeyRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getApiKeyApi(api!)
|
||||
.createKey(params)
|
||||
),
|
||||
|
|
|
@ -10,7 +10,6 @@ export const useRevokeKey = () => {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: (params: ApiKeyApiRevokeKeyRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getApiKeyApi(api!)
|
||||
.revokeKey(params)
|
||||
),
|
||||
|
|
21
src/apps/dashboard/features/libraries/api/useCountries.ts
Normal file
21
src/apps/dashboard/features/libraries/api/useCountries.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchCountries = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLocalizationApi(api).getCountries(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useCountries = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'Countries' ],
|
||||
queryFn: ({ signal }) => fetchCountries(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
21
src/apps/dashboard/features/libraries/api/useCultures.ts
Normal file
21
src/apps/dashboard/features/libraries/api/useCultures.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchCultures = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLocalizationApi(api).getCultures(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useCultures = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'Cultures' ],
|
||||
queryFn: ({ signal }) => fetchCultures(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image-resolution';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
export function getImageResolutionOptions() {
|
||||
return [
|
||||
{
|
||||
name: globalize.translate('ResolutionMatchSource'),
|
||||
value: ImageResolution.MatchSource
|
||||
},
|
||||
{ name: '2160p', value: ImageResolution.P2160 },
|
||||
{ name: '1440p', value: ImageResolution.P1440 },
|
||||
{ name: '1080p', value: ImageResolution.P1080 },
|
||||
{ name: '720p', value: ImageResolution.P720 },
|
||||
{ name: '480p', value: ImageResolution.P480 },
|
||||
{ name: '360p', value: ImageResolution.P360 },
|
||||
{ name: '240p', value: ImageResolution.P240 },
|
||||
{ name: '144p', value: ImageResolution.P144 }
|
||||
];
|
||||
};
|
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
|
||||
});
|
||||
};
|
|
@ -4,13 +4,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchServerLogs = async (api?: Api, options?: AxiosRequestConfig) => {
|
||||
if (!api) {
|
||||
console.error('[useServerLogs] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getSystemApi(api).getServerLogs(options);
|
||||
const fetchServerLogs = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getSystemApi(api!).getServerLogs(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
@ -20,7 +15,7 @@ export const useServerLogs = () => {
|
|||
|
||||
return useQuery({
|
||||
queryKey: [ 'ServerLogs' ],
|
||||
queryFn: ({ signal }) => fetchServerLogs(api, { signal }),
|
||||
queryFn: ({ signal }) => fetchServerLogs(api!, { 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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -9,15 +9,10 @@ import { useApi } from 'hooks/useApi';
|
|||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchConfigurationPages = async (
|
||||
api?: Api,
|
||||
api: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchConfigurationPages] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getDashboardApi(api)
|
||||
.getConfigurationPages(params, options);
|
||||
return response.data;
|
||||
|
@ -28,7 +23,7 @@ const getConfigurationPagesQuery = (
|
|||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@ export const useDisablePlugin = () => {
|
|||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.disablePlugin(params)
|
||||
),
|
||||
|
|
|
@ -11,7 +11,6 @@ export const useEnablePlugin = () => {
|
|||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.enablePlugin(params)
|
||||
),
|
||||
|
|
|
@ -11,7 +11,6 @@ export const useInstallPackage = () => {
|
|||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPackageApi(api!)
|
||||
.installPackage(params)
|
||||
),
|
||||
|
|
|
@ -9,34 +9,24 @@ import { useApi } from 'hooks/useApi';
|
|||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest,
|
||||
api: Api,
|
||||
params: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPackageInfo] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
console.warn('[fetchPackageInfo] Missing request params');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackageInfoQuery = (
|
||||
api?: Api,
|
||||
api: Api | undefined,
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||
enabled: !!api && !!params?.name
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api!, params!, { signal }),
|
||||
enabled: !!params && !!api && !!params.name
|
||||
});
|
||||
|
||||
export const usePackageInfo = (
|
||||
|
|
|
@ -8,14 +8,9 @@ import { useApi } from 'hooks/useApi';
|
|||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPlugins = async (
|
||||
api?: Api,
|
||||
api: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPlugins] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getPluginsApi(api)
|
||||
.getPlugins(options);
|
||||
return response.data;
|
||||
|
@ -25,7 +20,7 @@ const getPluginsQuery = (
|
|||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Plugins ],
|
||||
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||
queryFn: ({ signal }) => fetchPlugins(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@ export const useUninstallPlugin = () => {
|
|||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.uninstallPluginByVersion(params)
|
||||
),
|
||||
|
|
22
src/apps/dashboard/features/tasks/api/useStartTask.ts
Normal file
22
src/apps/dashboard/features/tasks/api/useStartTask.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
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) => (
|
||||
getScheduledTasksApi(api!)
|
||||
.startTask(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
22
src/apps/dashboard/features/tasks/api/useStopTask.ts
Normal file
22
src/apps/dashboard/features/tasks/api/useStopTask.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
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) => (
|
||||
getScheduledTasksApi(api!)
|
||||
.stopTask(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
29
src/apps/dashboard/features/tasks/api/useTask.ts
Normal file
29
src/apps/dashboard/features/tasks/api/useTask.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { ScheduledTasksApiGetTaskRequest } 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';
|
||||
import { QUERY_KEY } from './useTasks';
|
||||
|
||||
const fetchTask = async (
|
||||
api: Api,
|
||||
params: ScheduledTasksApiGetTaskRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getScheduledTasksApi(api).getTask(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useTask = (params: ScheduledTasksApiGetTaskRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY, params.taskId ],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchTask(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
30
src/apps/dashboard/features/tasks/api/useTasks.ts
Normal file
30
src/apps/dashboard/features/tasks/api/useTasks.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
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
|
||||
) => {
|
||||
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
|
||||
});
|
||||
};
|
22
src/apps/dashboard/features/tasks/api/useUpdateTask.ts
Normal file
22
src/apps/dashboard/features/tasks/api/useUpdateTask.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { ScheduledTasksApiUpdateTaskRequest } 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 useUpdateTask = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: ScheduledTasksApiUpdateTaskRequest) => (
|
||||
getScheduledTasksApi(api!)
|
||||
.updateTask(params)
|
||||
),
|
||||
onSuccess: (_data, params) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY, params.taskId ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
171
src/apps/dashboard/features/tasks/components/NewTriggerForm.tsx
Normal file
171
src/apps/dashboard/features/tasks/components/NewTriggerForm.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import React, { FunctionComponent, useCallback, useMemo, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import { TaskTriggerInfoType } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info-type';
|
||||
import { DayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/day-of-week';
|
||||
import globalize from 'lib/globalize';
|
||||
import { getIntervalOptions, getTimeOfDayOptions } from '../utils/edit';
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
|
||||
type IProps = {
|
||||
open: boolean,
|
||||
title: string,
|
||||
onClose?: () => void,
|
||||
onAdd?: (trigger: TaskTriggerInfo) => void
|
||||
};
|
||||
|
||||
const NewTriggerForm: FunctionComponent<IProps> = ({ open, title, onClose, onAdd }: IProps) => {
|
||||
const { dateFnsLocale } = useLocale();
|
||||
const [triggerType, setTriggerType] = useState<TaskTriggerInfoType>(TaskTriggerInfoType.DailyTrigger);
|
||||
|
||||
const timeOfDayOptions = useMemo(() => getTimeOfDayOptions(dateFnsLocale), [dateFnsLocale]);
|
||||
const intervalOptions = useMemo(() => getIntervalOptions(dateFnsLocale), [dateFnsLocale]);
|
||||
|
||||
const onTriggerTypeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTriggerType(e.target.value as TaskTriggerInfoType);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const trigger: TaskTriggerInfo = {
|
||||
Type: data.TriggerType.toString() as TaskTriggerInfoType
|
||||
};
|
||||
|
||||
if (trigger.Type == TaskTriggerInfoType.WeeklyTrigger) {
|
||||
trigger.DayOfWeek = data.DayOfWeek.toString() as DayOfWeek;
|
||||
}
|
||||
|
||||
if (trigger.Type == TaskTriggerInfoType.DailyTrigger || trigger.Type == TaskTriggerInfoType.WeeklyTrigger) {
|
||||
trigger.TimeOfDayTicks = parseInt(data.TimeOfDay.toString(), 10);
|
||||
}
|
||||
|
||||
if (trigger.Type == TaskTriggerInfoType.IntervalTrigger) {
|
||||
trigger.IntervalTicks = parseInt(data.Interval.toString(), 10);
|
||||
}
|
||||
|
||||
if (data.TimeLimit.toString()) {
|
||||
trigger.MaxRuntimeTicks = parseFloat(data.TimeLimit.toString()) * 36e9;
|
||||
}
|
||||
|
||||
if (onAdd) {
|
||||
onAdd(trigger);
|
||||
}
|
||||
}, [ onAdd ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={'xs'}
|
||||
fullWidth
|
||||
onClose={onClose}
|
||||
PaperProps={{
|
||||
component: 'form',
|
||||
onSubmit: onSubmit
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
name='TriggerType'
|
||||
select
|
||||
fullWidth
|
||||
value={triggerType}
|
||||
onChange={onTriggerTypeChange}
|
||||
label={globalize.translate('LabelTriggerType')}
|
||||
>
|
||||
<MenuItem value={TaskTriggerInfoType.DailyTrigger}>{globalize.translate('OptionDaily')}</MenuItem>
|
||||
<MenuItem value={TaskTriggerInfoType.WeeklyTrigger}>{globalize.translate('OptionWeekly')}</MenuItem>
|
||||
<MenuItem value={TaskTriggerInfoType.IntervalTrigger}>{globalize.translate('OptionOnInterval')}</MenuItem>
|
||||
<MenuItem value={TaskTriggerInfoType.StartupTrigger}>{globalize.translate('OnApplicationStartup')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{triggerType == TaskTriggerInfoType.WeeklyTrigger && (
|
||||
<TextField
|
||||
name='DayOfWeek'
|
||||
select
|
||||
fullWidth
|
||||
defaultValue={DayOfWeek.Sunday}
|
||||
label={globalize.translate('LabelDay')}
|
||||
>
|
||||
<MenuItem value={DayOfWeek.Sunday}>{globalize.translate('Sunday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Monday}>{globalize.translate('Monday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Tuesday}>{globalize.translate('Tuesday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Wednesday}>{globalize.translate('Wednesday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Thursday}>{globalize.translate('Thursday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Friday}>{globalize.translate('Friday')}</MenuItem>
|
||||
<MenuItem value={DayOfWeek.Saturday}>{globalize.translate('Saturday')}</MenuItem>
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
{(triggerType == TaskTriggerInfoType.DailyTrigger || triggerType == TaskTriggerInfoType.WeeklyTrigger) && (
|
||||
<TextField
|
||||
name='TimeOfDay'
|
||||
select
|
||||
fullWidth
|
||||
defaultValue={'0'}
|
||||
label={globalize.translate('LabelTime')}
|
||||
>
|
||||
{timeOfDayOptions.map((option) => {
|
||||
return <MenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>{option.name}</MenuItem>;
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
{triggerType == TaskTriggerInfoType.IntervalTrigger && (
|
||||
<TextField
|
||||
name='Interval'
|
||||
select
|
||||
fullWidth
|
||||
defaultValue={intervalOptions[0].value}
|
||||
label={globalize.translate('LabelEveryXMinutes')}
|
||||
>
|
||||
{intervalOptions.map((option) => {
|
||||
return <MenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>{option.name}</MenuItem>;
|
||||
})}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='TimeLimit'
|
||||
fullWidth
|
||||
defaultValue={''}
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 1,
|
||||
step: 0.5
|
||||
}}
|
||||
label={globalize.translate('LabelTimeLimitHours')}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
color='error'
|
||||
>{globalize.translate('ButtonCancel')}</Button>
|
||||
<Button type='submit'>{globalize.translate('Add')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTriggerForm;
|
59
src/apps/dashboard/features/tasks/components/Task.tsx
Normal file
59
src/apps/dashboard/features/tasks/components/Task.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
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 ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
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';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
|
||||
const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const startTask = useStartTask();
|
||||
const stopTask = useStopTask();
|
||||
|
||||
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>
|
||||
}
|
||||
>
|
||||
<ListItemLink to={`/dashboard/tasks/${task.Id}`}>
|
||||
<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
|
||||
/>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default Task;
|
45
src/apps/dashboard/features/tasks/components/TaskLastRan.tsx
Normal file
45
src/apps/dashboard/features/tasks/components/TaskLastRan.tsx
Normal file
|
@ -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,34 @@
|
|||
import React, { FC } from 'react';
|
||||
import type { MRT_Cell, MRT_RowData } from 'material-react-table';
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { getTriggerFriendlyName } from '../utils/edit';
|
||||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface CellProps {
|
||||
cell: MRT_Cell<MRT_RowData>
|
||||
}
|
||||
|
||||
const TaskTriggerCell: FC<CellProps> = ({ cell }) => {
|
||||
const { dateFnsLocale } = useLocale();
|
||||
const trigger = cell.getValue<TaskTriggerInfo>();
|
||||
|
||||
const timeLimitHours = trigger.MaxRuntimeTicks ? trigger.MaxRuntimeTicks / 36e9 : false;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant='body1'>{getTriggerFriendlyName(trigger, dateFnsLocale)}</Typography>
|
||||
{timeLimitHours && (
|
||||
<Typography variant='body2' color={'text.secondary'}>
|
||||
{timeLimitHours == 1 ?
|
||||
globalize.translate('ValueTimeLimitSingleHour') :
|
||||
globalize.translate('ValueTimeLimitMultiHour', timeLimitHours)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTriggerCell;
|
29
src/apps/dashboard/features/tasks/components/Tasks.tsx
Normal file
29
src/apps/dashboard/features/tasks/components/Tasks.tsx
Normal file
|
@ -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,13 @@
|
|||
export const INTERVAL_DURATIONS: number[] = [
|
||||
9000000000, // 15 minutes
|
||||
18000000000, // 30 minutes
|
||||
27000000000, // 45 minutes
|
||||
36000000000, // 1 hour
|
||||
72000000000, // 2 hours
|
||||
108000000000, // 3 hours
|
||||
144000000000, // 4 hours
|
||||
216000000000, // 6 hours
|
||||
288000000000, // 8 hours
|
||||
432000000000, // 12 hours
|
||||
864000000000 // 24 hours
|
||||
];
|
5
src/apps/dashboard/features/tasks/types/taskProps.ts
Normal file
5
src/apps/dashboard/features/tasks/types/taskProps.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
|
||||
export type TaskProps = {
|
||||
task: TaskInfo;
|
||||
};
|
80
src/apps/dashboard/features/tasks/utils/edit.ts
Normal file
80
src/apps/dashboard/features/tasks/utils/edit.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import { format, formatDistanceStrict, Locale, parse } from 'date-fns';
|
||||
import globalize from 'lib/globalize';
|
||||
import { INTERVAL_DURATIONS } from '../constants/intervalDurations';
|
||||
|
||||
function getDisplayTime(ticks: number, locale: Locale) {
|
||||
const ms = ticks / 1e4;
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
now.setTime(now.getTime() + ms);
|
||||
return format(now, 'p', { locale: locale });
|
||||
}
|
||||
|
||||
export function getTimeOfDayOptions(locale: Locale) {
|
||||
const options = [];
|
||||
|
||||
for (let i = 0; i < 86400000; i += 900000) {
|
||||
options.push({
|
||||
name: getDisplayTime(i * 10000, locale),
|
||||
value: i * 10000
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getIntervalOptions(locale: Locale) {
|
||||
const options = [];
|
||||
|
||||
for (const ticksDuration of INTERVAL_DURATIONS) {
|
||||
const durationMs = Math.floor(ticksDuration / 1e4);
|
||||
const unit = durationMs < 36e5 ? 'minute' : 'hour';
|
||||
options.push({
|
||||
name: formatDistanceStrict(0, durationMs, { locale: locale, unit: unit }),
|
||||
value: ticksDuration
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function getIntervalTriggerTime(ticks: number) {
|
||||
const hours = ticks / 36e9;
|
||||
|
||||
switch (hours) {
|
||||
case 0.25:
|
||||
return globalize.translate('EveryXMinutes', '15');
|
||||
case 0.5:
|
||||
return globalize.translate('EveryXMinutes', '30');
|
||||
case 0.75:
|
||||
return globalize.translate('EveryXMinutes', '45');
|
||||
case 1:
|
||||
return globalize.translate('EveryHour');
|
||||
default:
|
||||
return globalize.translate('EveryXHours', hours);
|
||||
}
|
||||
}
|
||||
|
||||
function localizeDayOfWeek(dayOfWeek: string | null | undefined, locale: Locale) {
|
||||
if (!dayOfWeek) return '';
|
||||
|
||||
const parsedDayOfWeek = parse(dayOfWeek, 'cccc', new Date());
|
||||
|
||||
return format(parsedDayOfWeek, 'cccc', { locale: locale });
|
||||
}
|
||||
|
||||
export function getTriggerFriendlyName(trigger: TaskTriggerInfo, locale: Locale) {
|
||||
switch (trigger.Type) {
|
||||
case 'DailyTrigger':
|
||||
return globalize.translate('DailyAt', getDisplayTime(trigger.TimeOfDayTicks || 0, locale));
|
||||
case 'WeeklyTrigger':
|
||||
return globalize.translate('WeeklyAt', localizeDayOfWeek(trigger.DayOfWeek, locale), getDisplayTime(trigger.TimeOfDayTicks || 0, locale));
|
||||
case 'IntervalTrigger':
|
||||
return getIntervalTriggerTime(trigger.IntervalTicks || 0);
|
||||
case 'StartupTrigger':
|
||||
return globalize.translate('OnApplicationStartup');
|
||||
default:
|
||||
return trigger.Type;
|
||||
}
|
||||
}
|
27
src/apps/dashboard/features/tasks/utils/tasks.ts
Normal file
27
src/apps/dashboard/features/tasks/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,19 @@ 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: 'libraries/display', type: AppType.Dashboard },
|
||||
{ path: 'libraries/metadata', type: AppType.Dashboard },
|
||||
{ path: 'libraries/nfo', 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: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
|
||||
{ path: 'users', type: AppType.Dashboard },
|
||||
{ path: 'users/access', type: AppType.Dashboard },
|
||||
{ path: 'users/add', type: AppType.Dashboard },
|
||||
|
|
|
@ -6,92 +6,50 @@ 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'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/display',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/librarydisplay',
|
||||
view: 'dashboard/librarydisplay.html'
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'playback/transcoding',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/encodingsettings',
|
||||
view: 'dashboard/encodingsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/metadata',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/metadataImages',
|
||||
view: 'dashboard/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: 'encodingsettings',
|
||||
view: 'encodingsettings.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 +83,8 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
path: 'plugins',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard/plugins/installed/index',
|
||||
view: 'dashboard/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: 'plugins/installed/index',
|
||||
view: 'plugins/installed/index.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';
|
||||
|
|
176
src/apps/dashboard/routes/libraries/display.tsx
Normal file
176
src/apps/dashboard/routes/libraries/display.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
import React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
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 Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { QUERY_KEY as CONFIG_QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import { QUERY_KEY as NAMED_CONFIG_QUERY_KEY, NamedConfiguration, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import globalize from 'lib/globalize';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
const CONFIG_KEY = 'metadata';
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
|
||||
const metadataConfig: NamedConfiguration = {
|
||||
UseFileCreationTimeForDateAdded: data.DateAddedBehavior.toString() === '1'
|
||||
};
|
||||
|
||||
config.EnableFolderView = data.DisplayFolderView?.toString() === 'on';
|
||||
config.DisplaySpecialsWithinSeasons = data.DisplaySpecialsWithinSeasons?.toString() === 'on';
|
||||
config.EnableGroupingIntoCollections = data.GroupMoviesIntoCollections?.toString() === 'on';
|
||||
config.EnableExternalContentInSuggestions = data.EnableExternalContentInSuggestions?.toString() === 'on';
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({ key: CONFIG_KEY, body: metadataConfig });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ CONFIG_QUERY_KEY ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ NAMED_CONFIG_QUERY_KEY, CONFIG_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useConfiguration();
|
||||
const {
|
||||
data: namedConfig,
|
||||
isPending: isNamedConfigPending,
|
||||
isError: isNamedConfigError
|
||||
} = useNamedConfiguration(CONFIG_KEY);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
if (isConfigPending || isNamedConfigPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='libraryDisplayPage'
|
||||
title={globalize.translate('Display')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isConfigError || isNamedConfigError ? (
|
||||
<Alert severity='error'>{globalize.translate('DisplayLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant='h2'>{globalize.translate('Display')}</Typography>
|
||||
<TextField
|
||||
name={'DateAddedBehavior'}
|
||||
label={globalize.translate('LabelDateAddedBehavior')}
|
||||
select
|
||||
defaultValue={namedConfig.UseFileCreationTimeForDateAdded ? '1' : '0'}
|
||||
helperText={globalize.translate('LabelDateAddedBehaviorHelp')}
|
||||
>
|
||||
<MenuItem value={'0'}>{globalize.translate('OptionDateAddedImportTime')}</MenuItem>
|
||||
<MenuItem value={'1'}>{globalize.translate('OptionDateAddedFileTime')}</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'DisplayFolderView'}
|
||||
defaultChecked={config.EnableFolderView}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('OptionDisplayFolderView')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('OptionDisplayFolderViewHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'DisplaySpecialsWithinSeasons'}
|
||||
defaultChecked={config.DisplaySpecialsWithinSeasons}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelDisplaySpecialsWithinSeasons')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'GroupMoviesIntoCollections'}
|
||||
defaultChecked={config.EnableGroupingIntoCollections}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelGroupMoviesIntoCollections')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelGroupMoviesIntoCollectionsHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'EnableExternalContentInSuggestions'}
|
||||
defaultChecked={config.EnableExternalContentInSuggestions}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('OptionEnableExternalContentInSuggestions')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('OptionEnableExternalContentInSuggestionsHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'DisplayPage';
|
164
src/apps/dashboard/routes/libraries/metadata.tsx
Normal file
164
src/apps/dashboard/routes/libraries/metadata.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { ImageResolution } from '@jellyfin/sdk/lib/generated-client/models/image-resolution';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCountries } from 'apps/dashboard/features/libraries/api/useCountries';
|
||||
import { useCultures } from 'apps/dashboard/features/libraries/api/useCultures';
|
||||
import { getImageResolutionOptions } from 'apps/dashboard/features/libraries/utils/metadataOptions';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import globalize from 'lib/globalize';
|
||||
import React from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
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 formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const { data: config } = await getConfigurationApi(api).getConfiguration();
|
||||
|
||||
config.PreferredMetadataLanguage = data.Language.toString();
|
||||
config.MetadataCountryCode = data.Country.toString();
|
||||
config.DummyChapterDuration = parseInt(data.DummyChapterDuration.toString(), 10);
|
||||
config.ChapterImageResolution = data.ChapterImageResolution.toString() as ImageResolution;
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useConfiguration();
|
||||
const {
|
||||
data: cultures,
|
||||
isPending: isCulturesPending,
|
||||
isError: isCulturesError
|
||||
} = useCultures();
|
||||
const {
|
||||
data: countries,
|
||||
isPending: isCountriesPending,
|
||||
isError: isCountriesError
|
||||
} = useCountries();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const imageResolutions = getImageResolutionOptions();
|
||||
|
||||
if (isConfigPending || isCulturesPending || isCountriesPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='metadataImagesConfigurationPage'
|
||||
title={globalize.translate('LabelMetadata')}
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isConfigError || isCulturesError || isCountriesError ? (
|
||||
<Alert severity='error'>{globalize.translate('MetadataImagesLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant='h2'>{globalize.translate('HeaderPreferredMetadataLanguage')}</Typography>
|
||||
<Typography>{globalize.translate('DefaultMetadataLangaugeDescription')}</Typography>
|
||||
|
||||
<TextField
|
||||
name={'Language'}
|
||||
label={globalize.translate('LabelLanguage')}
|
||||
defaultValue={config.PreferredMetadataLanguage}
|
||||
select
|
||||
>
|
||||
{cultures.map(culture => {
|
||||
return <MenuItem
|
||||
key={culture.TwoLetterISOLanguageName}
|
||||
value={culture.TwoLetterISOLanguageName}
|
||||
>{culture.DisplayName}</MenuItem>;
|
||||
})}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
name={'Country'}
|
||||
label={globalize.translate('LabelCountry')}
|
||||
defaultValue={config.MetadataCountryCode}
|
||||
select
|
||||
>
|
||||
{countries.map(country => {
|
||||
return <MenuItem
|
||||
key={country.DisplayName}
|
||||
value={country.TwoLetterISORegionName || ''}
|
||||
>{country.DisplayName}</MenuItem>;
|
||||
})}
|
||||
</TextField>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderDummyChapter')}</Typography>
|
||||
|
||||
<TextField
|
||||
name={'DummyChapterDuration'}
|
||||
defaultValue={config.DummyChapterDuration}
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
label={globalize.translate('LabelDummyChapterDuration')}
|
||||
helperText={globalize.translate('LabelDummyChapterDurationHelp')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name={'ChapterImageResolution'}
|
||||
select
|
||||
defaultValue={config.ChapterImageResolution}
|
||||
label={globalize.translate('LabelChapterImageResolution')}
|
||||
helperText={globalize.translate('LabelChapterImageResolutionHelp')}
|
||||
>
|
||||
{imageResolutions.map(resolution => {
|
||||
return <MenuItem key={resolution.name} value={resolution.value}>{resolution.name}</MenuItem>;
|
||||
})}
|
||||
</TextField>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'MetadataImagesPage';
|
186
src/apps/dashboard/routes/libraries/nfo.tsx
Normal file
186
src/apps/dashboard/routes/libraries/nfo.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
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 Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import SimpleAlert from 'components/SimpleAlert';
|
||||
import { QUERY_KEY, useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import { ActionData } from 'types/actionData';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
const CONFIG_KEY = 'xbmcmetadata';
|
||||
|
||||
interface NFOSettingsConfig {
|
||||
UserId?: string;
|
||||
EnableExtraThumbsDuplication?: boolean;
|
||||
EnablePathSubstitution?: boolean;
|
||||
ReleaseDateFormat?: string;
|
||||
SaveImagePathsInNfo?: boolean;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const newConfig: NFOSettingsConfig = {
|
||||
UserId: data.UserId?.toString(),
|
||||
ReleaseDateFormat: 'yyyy-MM-dd',
|
||||
SaveImagePathsInNfo: data.SaveImagePathsInNfo?.toString() === 'on',
|
||||
EnablePathSubstitution: data.EnablePathSubstitution?.toString() === 'on',
|
||||
EnableExtraThumbsDuplication: data.EnableExtraThumbsDuplication?.toString() === 'on'
|
||||
};
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({ key: CONFIG_KEY, body: newConfig });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, CONFIG_KEY]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useNamedConfiguration(CONFIG_KEY);
|
||||
const {
|
||||
data: users,
|
||||
isPending: isUsersPending,
|
||||
isError: isUsersError
|
||||
} = useUsers();
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||||
|
||||
const nfoConfig = config as NFOSettingsConfig;
|
||||
|
||||
const onAlertClose = useCallback(() => {
|
||||
setIsAlertOpen(false);
|
||||
}, []);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
setIsAlertOpen(true);
|
||||
}, []);
|
||||
|
||||
if (isConfigPending || isUsersPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='metadataNfoPage'
|
||||
title={globalize.translate('TabNfoSettings')}
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<SimpleAlert
|
||||
open={isAlertOpen}
|
||||
text={globalize.translate('MetadataSettingChangeHelp')}
|
||||
onClose={onAlertClose}
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
{isConfigError || isUsersError ? (
|
||||
<Alert severity='error'>{globalize.translate('MetadataNfoLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST' onSubmit={onSubmit}>
|
||||
<Stack spacing={3}>
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography variant='h2'>{globalize.translate('TabNfoSettings')}</Typography>
|
||||
<Typography>{globalize.translate('HeaderKodiMetadataHelp')}</Typography>
|
||||
|
||||
<TextField
|
||||
name={'UserId'}
|
||||
label={globalize.translate('LabelKodiMetadataUser')}
|
||||
defaultValue={nfoConfig.UserId || ''}
|
||||
select
|
||||
SelectProps={{
|
||||
displayEmpty: true
|
||||
}}
|
||||
InputLabelProps={{
|
||||
shrink: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelKodiMetadataUserHelp')}
|
||||
>
|
||||
<MenuItem value=''>{globalize.translate('None')}</MenuItem>
|
||||
{users.map(user =>
|
||||
<MenuItem key={user.Id} value={user.Id}>{user.Name}</MenuItem>
|
||||
)}
|
||||
</TextField>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'SaveImagePathsInNfo'}
|
||||
defaultChecked={nfoConfig.SaveImagePathsInNfo}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataSaveImagePaths')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelKodiMetadataSaveImagePathsHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'EnablePathSubstitution'}
|
||||
defaultChecked={nfoConfig.EnablePathSubstitution}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataEnablePathSubstitution')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelKodiMetadataEnablePathSubstitutionHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={'EnableExtraThumbsDuplication'}
|
||||
defaultChecked={nfoConfig.EnableExtraThumbsDuplication}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataEnableExtraThumbs')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelKodiMetadataEnableExtraThumbsHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button type='submit' size='large'>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'NFOSettingsPage';
|
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';
|
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