mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'jellyfin:master' into enable_hlsjs_with_webos
This commit is contained in:
commit
0318e1e1d8
201 changed files with 5941 additions and 4725 deletions
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"ecmaVersion": "es5",
|
||||
"modules": "false",
|
||||
"files": "./dist/**/*.js",
|
||||
"not": [
|
||||
"./dist/libraries/pdf.worker.js",
|
||||
|
|
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@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3.28.13
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
|
4
.github/workflows/__deploy.yml
vendored
4
.github/workflows/__deploy.yml
vendored
|
@ -29,13 +29,13 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Download workflow artifact ⬇️
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}
|
||||
path: dist
|
||||
|
||||
- name: Publish to Cloudflare Pages 📃
|
||||
uses: cloudflare/wrangler-action@392082e81ffbcb9ebdde27400634aa004b35ea37 # v3.14.0
|
||||
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
|
||||
id: cf
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
|
4
.github/workflows/__package.yml
vendored
4
.github/workflows/__package.yml
vendored
|
@ -19,7 +19,7 @@ jobs:
|
|||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
@ -39,7 +39,7 @@ jobs:
|
|||
mv dist/config.tmp.json dist/config.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: frontend
|
||||
path: dist
|
||||
|
|
2
.github/workflows/__quality_checks.yml
vendored
2
.github/workflows/__quality_checks.yml
vendored
|
@ -48,7 +48,7 @@ jobs:
|
|||
show-progress: false
|
||||
|
||||
- name: Setup node environment ⚙️
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
|
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
|
@ -85,7 +85,7 @@ jobs:
|
|||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
@ -95,6 +95,6 @@ jobs:
|
|||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@3ba53ce078667d5f60a73a8005627cf95ab57dce # v4.1.9
|
||||
uses: CatChen/eslint-suggestion-action@623ecbafb1dd3f127bc56596b1145e474fc26c6c # v4.1.11
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -98,7 +98,7 @@ export default tseslint.config(
|
|||
|
||||
'sonarjs/fixme-tag': 'warn',
|
||||
'sonarjs/todo-tag': 'off',
|
||||
'sonarjs/deprecation': 'warn',
|
||||
'sonarjs/deprecation': 'off',
|
||||
'sonarjs/no-alphabetical-sort': 'warn',
|
||||
'sonarjs/no-inverted-boolean-check': 'error',
|
||||
'sonarjs/no-selector-parameter': 'off',
|
||||
|
@ -329,6 +329,7 @@ export default tseslint.config(
|
|||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/no-deprecated': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': 'error'
|
||||
}
|
||||
|
@ -366,7 +367,6 @@ export default tseslint.config(
|
|||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'@typescript-eslint/no-this-alias': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
|
||||
'sonarjs/public-static-readonly': 'off',
|
||||
|
||||
|
|
5461
package-lock.json
generated
5461
package-lock.json
generated
File diff suppressed because it is too large
Load diff
87
package.json
87
package.json
|
@ -5,96 +5,97 @@
|
|||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@babel/core": "7.26.10",
|
||||
"@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",
|
||||
"@eslint/js": "9.20.0",
|
||||
"@stylistic/eslint-plugin": "3.1.0",
|
||||
"@eslint/js": "9.23.0",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/stylelint-plugin": "3.1.2",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/escape-html": "1.0.4",
|
||||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react": "18.3.19",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.27.0",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"@vitest/coverage-v8": "3.0.9",
|
||||
"autoprefixer": "10.4.21",
|
||||
"babel-loader": "10.0.0",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"confusing-browser-globals": "1.0.11",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.6",
|
||||
"es-check": "7.2.1",
|
||||
"eslint": "9.20.1",
|
||||
"es-check": "8.0.2",
|
||||
"eslint": "9.23.0",
|
||||
"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": "5.1.0",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-sonarjs": "3.0.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"fast-glob": "3.3.3",
|
||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||
"globals": "15.15.0",
|
||||
"globals": "16.0.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"jsdom": "25.0.1",
|
||||
"jsdom": "26.0.0",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss": "8.5.2",
|
||||
"postcss": "8.5.3",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "10.1.4",
|
||||
"postcss-preset-env": "10.1.5",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.85.0",
|
||||
"sass": "1.86.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.14.1",
|
||||
"stylelint": "16.16.0",
|
||||
"stylelint-config-rational-order": "0.1.2",
|
||||
"stylelint-no-browser-hacks": "1.3.0",
|
||||
"stylelint-order": "6.0.4",
|
||||
"stylelint-scss": "6.11.0",
|
||||
"stylelint-scss": "6.11.1",
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "8.24.1",
|
||||
"vitest": "3.0.5",
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.27.0",
|
||||
"vitest": "3.0.9",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/styled": "11.13.0",
|
||||
"@fontsource/noto-sans": "5.1.1",
|
||||
"@fontsource/noto-sans-hk": "5.1.1",
|
||||
"@fontsource/noto-sans-jp": "5.1.1",
|
||||
"@fontsource/noto-sans-kr": "5.1.1",
|
||||
"@fontsource/noto-sans-sc": "5.1.1",
|
||||
"@fontsource/noto-sans-tc": "5.1.1",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@fontsource/noto-sans": "5.2.6",
|
||||
"@fontsource/noto-sans-hk": "5.2.5",
|
||||
"@fontsource/noto-sans-jp": "5.2.5",
|
||||
"@fontsource/noto-sans-kr": "5.2.5",
|
||||
"@fontsource/noto-sans-sc": "5.2.5",
|
||||
"@fontsource/noto-sans-tc": "5.2.5",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@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",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202503260501",
|
||||
"@mui/icons-material": "6.4.8",
|
||||
"@mui/material": "6.4.8",
|
||||
"@mui/x-date-pickers": "7.28.0",
|
||||
"@react-hook/resize-observer": "2.0.2",
|
||||
"@tanstack/react-query": "5.62.16",
|
||||
"@tanstack/react-query-devtools": "5.62.16",
|
||||
"@tanstack/react-query": "5.69.0",
|
||||
"@tanstack/react-query-devtools": "5.69.0",
|
||||
"abortcontroller-polyfill": "1.7.8",
|
||||
"blurhash": "2.0.5",
|
||||
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.38.1",
|
||||
"core-js": "3.41.0",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "2.5.8",
|
||||
"epubjs": "0.3.93",
|
||||
|
@ -113,14 +114,14 @@
|
|||
"lodash-es": "4.17.21",
|
||||
"markdown-it": "14.1.0",
|
||||
"material-design-icons-iconfont": "6.7.0",
|
||||
"material-react-table": "2.13.3",
|
||||
"material-react-table": "3.2.1",
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-lazy-load-image-component": "1.6.2",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-lazy-load-image-component": "1.6.3",
|
||||
"react-router-dom": "6.30.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
|
@ -130,7 +131,7 @@
|
|||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.85.0"
|
||||
"sass-embedded": "1.86.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
|
|
|
@ -11,6 +11,7 @@ import AppBody from 'components/AppBody';
|
|||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import ThemeCss from 'components/ThemeCss';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
|
||||
|
@ -101,6 +102,7 @@ export const Component: FC = () => {
|
|||
</AppBody>
|
||||
</Box>
|
||||
</Box>
|
||||
<ThemeCss dashboard />
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
<div id="dashboardGeneralPage" data-role="page" class="page type-interior dashboardHomePage" data-title="${General}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<form class="dashboardGeneralForm">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${Settings}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" type="text" id="txtServerName" label="${LabelServerName}" />
|
||||
<div class="fieldDescription">${LabelServerNameHelp}</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectLocalizationLanguage" label="${LabelPreferredDisplayLanguage}"></select>
|
||||
<div class="fieldDescription">
|
||||
<div>${LabelDisplayLanguageHelp}</div>
|
||||
<div style="margin-top: .25em;">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org/docs/general/contributing/#translating" target="_blank">${LearnHowYouCanContribute}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<h2>${HeaderPaths}</h2>
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtCachePath" label="${LabelCachePath}" autocomplete="off" dir="ltr" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectCachePath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="fieldDescription">${LabelCachePathHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div style="flex-grow:1;">
|
||||
<input is="emby-input" id="txtMetadataPath" label="${LabelMetadataPath}" autocomplete="off" dir="ltr" />
|
||||
</div>
|
||||
<button type="button" is="paper-icon-button-light" id="btnSelectMetadataPath" title="${ButtonSelectDirectory}" class="emby-input-iconbutton"><span class="material-icons search" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="fieldDescription">${LabelMetadataPathHelp}</div>
|
||||
<input type="hidden" id="txtMetadataNetworkPath" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle">${QuickConnect}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxList paperList" style="padding:.5em 1em;">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkQuickConnectAvailable" />
|
||||
<span>${EnableQuickConnect}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2>${HeaderPerformance}</h2>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" id="txtLibraryScanFanoutConcurrency" label="${LibraryScanFanoutConcurrency}" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" />
|
||||
<div class="fieldDescription">${LibraryScanFanoutConcurrencyHelp}</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" id="txtParallelImageEncodingLimit" label="${LabelParallelImageEncodingLimit}" placeholder="0" type="number" pattern="[0-9]*" min="0" step="1" />
|
||||
<div class="fieldDescription">${LabelParallelImageEncodingLimitHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>${Save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,105 +0,0 @@
|
|||
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';
|
||||
|
||||
function loadPage(page, config, languageOptions, systemInfo) {
|
||||
page.querySelector('#txtServerName').value = systemInfo.ServerName;
|
||||
page.querySelector('#txtCachePath').value = systemInfo.CachePath || '';
|
||||
page.querySelector('#chkQuickConnectAvailable').checked = config.QuickConnectAvailable === true;
|
||||
page.querySelector('#txtMetadataPath').value = systemInfo.InternalMetadataPath || '';
|
||||
page.querySelector('#txtMetadataNetworkPath').value = systemInfo.MetadataNetworkPath || '';
|
||||
const localizationLanguageElem = page.querySelector('#selectLocalizationLanguage');
|
||||
localizationLanguageElem.innerHTML = languageOptions.map(function (language) {
|
||||
return '<option value="' + language.Value + '">' + language.Name + '</option>';
|
||||
}).join('');
|
||||
localizationLanguageElem.value = config.UICulture;
|
||||
page.querySelector('#txtLibraryScanFanoutConcurrency').value = config.LibraryScanFanoutConcurrency || '';
|
||||
page.querySelector('#txtParallelImageEncodingLimit').value = config.ParallelImageEncodingLimit || '';
|
||||
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
loading.show();
|
||||
const form = this;
|
||||
ApiClient.getServerConfiguration().then(function (config) {
|
||||
config.ServerName = form.querySelector('#txtServerName').value;
|
||||
config.UICulture = form.querySelector('#selectLocalizationLanguage').value;
|
||||
config.CachePath = form.querySelector('#txtCachePath').value;
|
||||
config.MetadataPath = form.querySelector('#txtMetadataPath').value;
|
||||
config.MetadataNetworkPath = form.querySelector('#txtMetadataNetworkPath').value;
|
||||
config.QuickConnectAvailable = form.querySelector('#chkQuickConnectAvailable').checked;
|
||||
config.LibraryScanFanoutConcurrency = parseInt(form.querySelector('#txtLibraryScanFanoutConcurrency').value || '0', 10);
|
||||
config.ParallelImageEncodingLimit = parseInt(form.querySelector('#txtParallelImageEncodingLimit').value || '0', 10);
|
||||
|
||||
return ApiClient.updateServerConfiguration(config)
|
||||
.then(() => {
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
}).catch(() => {
|
||||
loading.hide();
|
||||
alert(globalize.translate('ErrorDefault'));
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function (view) {
|
||||
$('#btnSelectCachePath', view).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
callback: function (path) {
|
||||
if (path) {
|
||||
view.querySelector('#txtCachePath').value = path;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectServerCachePath'),
|
||||
instruction: globalize.translate('HeaderSelectServerCachePathHelp')
|
||||
});
|
||||
});
|
||||
});
|
||||
$('#btnSelectMetadataPath', view).on('click.selectDirectory', function () {
|
||||
import('components/directorybrowser/directorybrowser').then(({ default: DirectoryBrowser }) => {
|
||||
const picker = new DirectoryBrowser();
|
||||
picker.show({
|
||||
path: view.querySelector('#txtMetadataPath').value,
|
||||
networkSharePath: view.querySelector('#txtMetadataNetworkPath').value,
|
||||
callback: function (path, networkPath) {
|
||||
if (path) {
|
||||
view.querySelector('#txtMetadataPath').value = path;
|
||||
}
|
||||
|
||||
if (networkPath) {
|
||||
view.querySelector('#txtMetadataNetworkPath').value = networkPath;
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectMetadataPath'),
|
||||
instruction: globalize.translate('HeaderSelectMetadataPathHelp')
|
||||
});
|
||||
});
|
||||
});
|
||||
$('.dashboardGeneralForm', view).off('submit', onSubmit).on('submit', onSubmit);
|
||||
view.addEventListener('viewshow', function () {
|
||||
const promiseConfig = ApiClient.getServerConfiguration();
|
||||
const promiseLanguageOptions = ApiClient.getJSON(ApiClient.getUrl('Localization/Options'));
|
||||
const promiseSystemInfo = ApiClient.getSystemInfo();
|
||||
Promise.all([promiseConfig, promiseLanguageOptions, promiseSystemInfo]).then(function (responses) {
|
||||
loadPage(view, responses[0], responses[1], responses[2]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<div id="metadataNfoPage" data-role="page" class="page type-interior metadataConfigurationPage" data-title="${TabNfoSettings}">
|
||||
|
||||
<div>
|
||||
|
||||
<div class="content-primary">
|
||||
<form class="metadataNfoForm">
|
||||
|
||||
<p>${HeaderKodiMetadataHelp}</p>
|
||||
<br />
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" name="selectUser" id="selectUser" label="${LabelKodiMetadataUser}"></select>
|
||||
<div class="fieldDescription">${LabelKodiMetadataUserHelp}</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" name="selectReleaseDateFormat" id="selectReleaseDateFormat" label="${LabelKodiMetadataDateFormat}">
|
||||
<option value="yyyy-MM-dd">yyyy-MM-dd</option>
|
||||
</select>
|
||||
<div class="fieldDescription">${LabelKodiMetadataDateFormatHelp}</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkSaveImagePaths" />
|
||||
<span>${LabelKodiMetadataSaveImagePaths}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelKodiMetadataSaveImagePathsHelp}</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkEnablePathSubstitution" />
|
||||
<span>${LabelKodiMetadataEnablePathSubstitution}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
<div>${LabelKodiMetadataEnablePathSubstitutionHelp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label>
|
||||
<input type="checkbox" is="emby-checkbox" id="chkEnableExtraThumbs" />
|
||||
<span>${LabelKodiMetadataEnableExtraThumbs}</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">${LabelKodiMetadataEnableExtraThumbsHelp}</div>
|
||||
</div>
|
||||
<div><button is="emby-button" type="submit" class="raised button-submit block"><span>${Save}</span></button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -1,61 +0,0 @@
|
|||
import escapeHtml from 'escape-html';
|
||||
import 'jquery';
|
||||
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import alert from 'components/alert';
|
||||
|
||||
function loadPage(page, config, users) {
|
||||
let html = '<option value="" selected="selected">' + globalize.translate('None') + '</option>';
|
||||
html += users.map(function (user) {
|
||||
return '<option value="' + user.Id + '">' + escapeHtml(user.Name) + '</option>';
|
||||
}).join('');
|
||||
const elem = page.querySelector('#selectUser');
|
||||
elem.innerHTML = html;
|
||||
elem.value = config.UserId || '';
|
||||
page.querySelector('#selectReleaseDateFormat').value = config.ReleaseDateFormat;
|
||||
page.querySelector('#chkSaveImagePaths').checked = config.SaveImagePathsInNfo;
|
||||
page.querySelector('#chkEnablePathSubstitution').checked = config.EnablePathSubstitution;
|
||||
page.querySelector('#chkEnableExtraThumbs').checked = config.EnableExtraThumbsDuplication;
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
loading.show();
|
||||
const form = this;
|
||||
ApiClient.getNamedConfiguration(metadataKey).then(function (config) {
|
||||
config.UserId = form.querySelector('#selectUser').value || null;
|
||||
config.ReleaseDateFormat = form.querySelector('#selectReleaseDateFormat').value;
|
||||
config.SaveImagePathsInNfo = form.querySelector('#chkSaveImagePaths').checked;
|
||||
config.EnablePathSubstitution = form.querySelector('#chkEnablePathSubstitution').checked;
|
||||
config.EnableExtraThumbsDuplication = form.querySelector('#chkEnableExtraThumbs').checked;
|
||||
ApiClient.updateNamedConfiguration(metadataKey, config).then(function () {
|
||||
Dashboard.processServerConfigurationUpdateResult();
|
||||
showConfirmMessage();
|
||||
});
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function showConfirmMessage() {
|
||||
const msg = [];
|
||||
msg.push(globalize.translate('MetadataSettingChangeHelp'));
|
||||
alert({
|
||||
text: msg.join('<br/><br/>')
|
||||
});
|
||||
}
|
||||
|
||||
const metadataKey = 'xbmcmetadata';
|
||||
$(document).on('pageinit', '#metadataNfoPage', function () {
|
||||
$('.metadataNfoForm').off('submit', onSubmit).on('submit', onSubmit);
|
||||
}).on('pageshow', '#metadataNfoPage', function () {
|
||||
loading.show();
|
||||
const page = this;
|
||||
const promise1 = ApiClient.getUsers();
|
||||
const promise2 = ApiClient.getNamedConfiguration(metadataKey);
|
||||
Promise.all([promise1, promise2]).then(function (responses) {
|
||||
loadPage(page, responses[1], responses[0]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<div id="scheduledTaskPage" data-role="page" class="page type-interior scheduledTasksConfigurationPage">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer flex align-items-center">
|
||||
<h2 class="sectionTitle taskName"></h2>
|
||||
</div>
|
||||
<p id="pTaskDescription"></p>
|
||||
</div>
|
||||
|
||||
<div class="readOnlyContent">
|
||||
<div>
|
||||
<h2 style="vertical-align: middle; display: inline-block;">${HeaderTaskTriggers}</h2>
|
||||
<button is="emby-button" type="button" class="fab fab-mini btnAddTrigger submit" style="margin-left: 1em;" title="${ButtonAddScheduledTaskTrigger}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="taskTriggers"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-role="popup" id="popupAddTrigger" class="dialog dialog-fixedSize dialog-medium hide" style="position: fixed; top: 10%; z-index: 999999;">
|
||||
<form class="addTriggerForm" style="padding:1em;">
|
||||
<div class="ui-bar-a">
|
||||
<h3>${ButtonAddScheduledTaskTrigger}</h3>
|
||||
</div>
|
||||
<div data-role="content">
|
||||
<div class="selectContainer">
|
||||
<select is="emby-select" id="selectTriggerType" class="selectTriggerType" label="${LabelTriggerType}">
|
||||
<option value="DailyTrigger">${OptionDaily}</option>
|
||||
<option value="WeeklyTrigger">${OptionWeekly}</option>
|
||||
<option value="IntervalTrigger">${OptionOnInterval}</option>
|
||||
<option value="StartupTrigger">${OnApplicationStartup}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="fldDayOfWeek" class="selectContainer">
|
||||
<select is="emby-select" id="selectDayOfWeek" name="selectDayOfWeek" label="${LabelDay}">
|
||||
<option value="Sunday">${Sunday}</option>
|
||||
<option value="Monday">${Monday}</option>
|
||||
<option value="Tuesday">${Tuesday}</option>
|
||||
<option value="Wednesday">${Wednesday}</option>
|
||||
<option value="Thursday">${Thursday}</option>
|
||||
<option value="Friday">${Friday}</option>
|
||||
<option value="Saturday">${Saturday}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="fldTimeOfDay" class="selectContainer">
|
||||
<select is="emby-select" id="selectTimeOfDay" label="${LabelTime}"></select>
|
||||
</div>
|
||||
<div id="fldSelectSystemEvent" class="selectContainer">
|
||||
<select is="emby-select" id="selectSystemEvent" name="selectSystemEvent" label="${LabelEvent}">
|
||||
<option value="WakeFromSleep">${OptionWakeFromSleep}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="fldSelectInterval" class="selectContainer">
|
||||
<select is="emby-select" id="selectInterval" label="${LabelEveryXMinutes}">
|
||||
<option value="9000000000">15 minutes</option>
|
||||
<option value="18000000000">30 minutes</option>
|
||||
<option value="27000000000">45 minutes</option>
|
||||
<option value="36000000000">1 hour</option>
|
||||
<option value="72000000000">2 hours</option>
|
||||
<option value="108000000000">3 hours</option>
|
||||
<option value="144000000000">4 hours</option>
|
||||
<option value="216000000000">6 hours</option>
|
||||
<option value="288000000000">8 hours</option>
|
||||
<option value="432000000000">12 hours</option>
|
||||
<option value="864000000000">24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<input is="emby-input" id="txtTimeLimit" type="number" pattern="[0-9]*" min="1" step=".5" label="${LabelTimeLimitHours}" />
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block" data-icon="check">
|
||||
<span>${Add}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" class="raised button-cancel block" data-icon="delete" onclick="$(this).parents('.dialog').addClass('hide');">
|
||||
<span>${ButtonCancel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -1,236 +0,0 @@
|
|||
import loading from 'components/loading/loading';
|
||||
import datetime from 'scripts/datetime';
|
||||
import dom from 'scripts/dom';
|
||||
import globalize from 'lib/globalize';
|
||||
import 'elements/emby-input/emby-input';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-select/emby-select';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getParameterByName } from 'utils/url.ts';
|
||||
|
||||
function fillTimeOfDay(select) {
|
||||
const options = [];
|
||||
|
||||
for (let i = 0; i < 86400000; i += 900000) {
|
||||
options.push({
|
||||
name: ScheduledTaskPage.getDisplayTime(i * 10000),
|
||||
value: i * 10000
|
||||
});
|
||||
}
|
||||
|
||||
select.innerHTML = options.map(function (o) {
|
||||
return '<option value="' + o.value + '">' + o.name + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const ScheduledTaskPage = {
|
||||
refreshScheduledTask: function (view) {
|
||||
loading.show();
|
||||
const id = getParameterByName('id');
|
||||
ApiClient.getScheduledTask(id).then(function (task) {
|
||||
ScheduledTaskPage.loadScheduledTask(view, task);
|
||||
});
|
||||
},
|
||||
loadScheduledTask: function (view, task) {
|
||||
view.querySelector('.taskName').innerHTML = task.Name;
|
||||
view.querySelector('#pTaskDescription').innerHTML = task.Description;
|
||||
|
||||
import('components/listview/listview.scss').then(() => {
|
||||
ScheduledTaskPage.loadTaskTriggers(view, task);
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
},
|
||||
loadTaskTriggers: function (context, task) {
|
||||
let html = '';
|
||||
html += '<div class="paperList">';
|
||||
|
||||
for (let i = 0, length = task.Triggers.length; i < length; i++) {
|
||||
const trigger = task.Triggers[i];
|
||||
|
||||
html += '<div class="listItem listItem-border">';
|
||||
html += '<span class="material-icons listItemIcon schedule" aria-hidden="true"></span>';
|
||||
if (trigger.MaxRuntimeTicks) {
|
||||
html += '<div class="listItemBody two-line">';
|
||||
} else {
|
||||
html += '<div class="listItemBody">';
|
||||
}
|
||||
html += "<div class='listItemBodyText'>" + ScheduledTaskPage.getTriggerFriendlyName(trigger) + '</div>';
|
||||
if (trigger.MaxRuntimeTicks) {
|
||||
html += '<div class="listItemBodyText secondary">';
|
||||
const hours = trigger.MaxRuntimeTicks / 36e9;
|
||||
if (hours == 1) {
|
||||
html += globalize.translate('ValueTimeLimitSingleHour');
|
||||
} else {
|
||||
html += globalize.translate('ValueTimeLimitMultiHour', hours);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '<button class="btnDeleteTrigger" data-index="' + i + '" type="button" is="paper-icon-button-light" title="' + globalize.translate('Delete') + '"><span class="material-icons delete" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
context.querySelector('.taskTriggers').innerHTML = html;
|
||||
},
|
||||
// TODO: Replace this mess with date-fns and remove datetime completely
|
||||
getTriggerFriendlyName: function (trigger) {
|
||||
if (trigger.Type == 'DailyTrigger') {
|
||||
return globalize.translate('DailyAt', ScheduledTaskPage.getDisplayTime(trigger.TimeOfDayTicks));
|
||||
}
|
||||
|
||||
if (trigger.Type == 'WeeklyTrigger') {
|
||||
// TODO: The day of week isn't localised as well
|
||||
return globalize.translate('WeeklyAt', trigger.DayOfWeek, ScheduledTaskPage.getDisplayTime(trigger.TimeOfDayTicks));
|
||||
}
|
||||
|
||||
if (trigger.Type == 'SystemEventTrigger' && trigger.SystemEvent == 'WakeFromSleep') {
|
||||
return globalize.translate('OnWakeFromSleep');
|
||||
}
|
||||
|
||||
if (trigger.Type == 'IntervalTrigger') {
|
||||
const hours = trigger.IntervalTicks / 36e9;
|
||||
|
||||
if (hours == 0.25) {
|
||||
return globalize.translate('EveryXMinutes', '15');
|
||||
}
|
||||
if (hours == 0.5) {
|
||||
return globalize.translate('EveryXMinutes', '30');
|
||||
}
|
||||
if (hours == 0.75) {
|
||||
return globalize.translate('EveryXMinutes', '45');
|
||||
}
|
||||
if (hours == 1) {
|
||||
return globalize.translate('EveryHour');
|
||||
}
|
||||
|
||||
return globalize.translate('EveryXHours', hours);
|
||||
}
|
||||
|
||||
if (trigger.Type == 'StartupTrigger') {
|
||||
return globalize.translate('OnApplicationStartup');
|
||||
}
|
||||
|
||||
return trigger.Type;
|
||||
},
|
||||
getDisplayTime: function (ticks) {
|
||||
const ms = ticks / 1e4;
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
now.setTime(now.getTime() + ms);
|
||||
return datetime.getDisplayTime(now);
|
||||
},
|
||||
showAddTriggerPopup: function (view) {
|
||||
view.querySelector('#selectTriggerType').value = 'DailyTrigger';
|
||||
view.querySelector('#selectTriggerType').dispatchEvent(new CustomEvent('change', {}));
|
||||
view.querySelector('#popupAddTrigger').classList.remove('hide');
|
||||
},
|
||||
confirmDeleteTrigger: function (view, index) {
|
||||
confirm(globalize.translate('MessageDeleteTaskTrigger'), globalize.translate('HeaderDeleteTaskTrigger')).then(function () {
|
||||
ScheduledTaskPage.deleteTrigger(view, index);
|
||||
});
|
||||
},
|
||||
deleteTrigger: function (view, index) {
|
||||
loading.show();
|
||||
const id = getParameterByName('id');
|
||||
ApiClient.getScheduledTask(id).then(function (task) {
|
||||
task.Triggers.splice(index, 1);
|
||||
ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () {
|
||||
ScheduledTaskPage.refreshScheduledTask(view);
|
||||
});
|
||||
});
|
||||
},
|
||||
refreshTriggerFields: function (page, triggerType) {
|
||||
if (triggerType == 'DailyTrigger') {
|
||||
page.querySelector('#fldTimeOfDay').classList.remove('hide');
|
||||
page.querySelector('#fldDayOfWeek').classList.add('hide');
|
||||
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
|
||||
page.querySelector('#fldSelectInterval').classList.add('hide');
|
||||
page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
|
||||
} else if (triggerType == 'WeeklyTrigger') {
|
||||
page.querySelector('#fldTimeOfDay').classList.remove('hide');
|
||||
page.querySelector('#fldDayOfWeek').classList.remove('hide');
|
||||
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
|
||||
page.querySelector('#fldSelectInterval').classList.add('hide');
|
||||
page.querySelector('#selectTimeOfDay').setAttribute('required', 'required');
|
||||
} else if (triggerType == 'SystemEventTrigger') {
|
||||
page.querySelector('#fldTimeOfDay').classList.add('hide');
|
||||
page.querySelector('#fldDayOfWeek').classList.add('hide');
|
||||
page.querySelector('#fldSelectSystemEvent').classList.remove('hide');
|
||||
page.querySelector('#fldSelectInterval').classList.add('hide');
|
||||
page.querySelector('#selectTimeOfDay').removeAttribute('required');
|
||||
} else if (triggerType == 'IntervalTrigger') {
|
||||
page.querySelector('#fldTimeOfDay').classList.add('hide');
|
||||
page.querySelector('#fldDayOfWeek').classList.add('hide');
|
||||
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
|
||||
page.querySelector('#fldSelectInterval').classList.remove('hide');
|
||||
page.querySelector('#selectTimeOfDay').removeAttribute('required');
|
||||
} else if (triggerType == 'StartupTrigger') {
|
||||
page.querySelector('#fldTimeOfDay').classList.add('hide');
|
||||
page.querySelector('#fldDayOfWeek').classList.add('hide');
|
||||
page.querySelector('#fldSelectSystemEvent').classList.add('hide');
|
||||
page.querySelector('#fldSelectInterval').classList.add('hide');
|
||||
page.querySelector('#selectTimeOfDay').removeAttribute('required');
|
||||
}
|
||||
},
|
||||
getTriggerToAdd: function (page) {
|
||||
const trigger = {
|
||||
Type: page.querySelector('#selectTriggerType').value
|
||||
};
|
||||
|
||||
if (trigger.Type == 'DailyTrigger') {
|
||||
trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
|
||||
} else if (trigger.Type == 'WeeklyTrigger') {
|
||||
trigger.DayOfWeek = page.querySelector('#selectDayOfWeek').value;
|
||||
trigger.TimeOfDayTicks = page.querySelector('#selectTimeOfDay').value;
|
||||
} else if (trigger.Type == 'SystemEventTrigger') {
|
||||
trigger.SystemEvent = page.querySelector('#selectSystemEvent').value;
|
||||
} else if (trigger.Type == 'IntervalTrigger') {
|
||||
trigger.IntervalTicks = page.querySelector('#selectInterval').value;
|
||||
}
|
||||
|
||||
let timeLimit = page.querySelector('#txtTimeLimit').value || '0';
|
||||
timeLimit = parseFloat(timeLimit) * 3600000;
|
||||
|
||||
trigger.MaxRuntimeTicks = timeLimit * 1e4 || null;
|
||||
|
||||
return trigger;
|
||||
}
|
||||
};
|
||||
export default function (view) {
|
||||
function onSubmit(e) {
|
||||
loading.show();
|
||||
const id = getParameterByName('id');
|
||||
ApiClient.getScheduledTask(id).then(function (task) {
|
||||
task.Triggers.push(ScheduledTaskPage.getTriggerToAdd(view));
|
||||
ApiClient.updateScheduledTaskTriggers(task.Id, task.Triggers).then(function () {
|
||||
document.querySelector('#popupAddTrigger').classList.add('hide');
|
||||
ScheduledTaskPage.refreshScheduledTask(view);
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
view.querySelector('.addTriggerForm').addEventListener('submit', onSubmit);
|
||||
fillTimeOfDay(view.querySelector('#selectTimeOfDay'));
|
||||
view.querySelector('#popupAddTrigger').parentNode.trigger(new Event('create'));
|
||||
view.querySelector('.selectTriggerType').addEventListener('change', function () {
|
||||
ScheduledTaskPage.refreshTriggerFields(view, this.value);
|
||||
});
|
||||
view.querySelector('.btnAddTrigger').addEventListener('click', function () {
|
||||
ScheduledTaskPage.showAddTriggerPopup(view);
|
||||
});
|
||||
view.addEventListener('click', function (e) {
|
||||
const btnDeleteTrigger = dom.parentWithClass(e.target, 'btnDeleteTrigger');
|
||||
|
||||
if (btnDeleteTrigger) {
|
||||
ScheduledTaskPage.confirmDeleteTrigger(view, parseInt(btnDeleteTrigger.getAttribute('data-index'), 10));
|
||||
}
|
||||
});
|
||||
view.addEventListener('viewshow', function () {
|
||||
ScheduledTaskPage.refreshScheduledTask(view);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 fetchLocalizationOptions = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLocalizationApi(api).getLocalizationOptions(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLocalizationOptions = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'LocalizationOptions' ],
|
||||
queryFn: ({ signal }) => fetchLocalizationOptions(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
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
|
||||
});
|
||||
};
|
|
@ -22,7 +22,7 @@ export const useTasks = (params?: ScheduledTasksApiGetTasksRequest) => {
|
|||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
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 ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
173
src/apps/dashboard/features/tasks/components/NewTriggerForm.tsx
Normal file
173
src/apps/dashboard/features/tasks/components/NewTriggerForm.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
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'
|
||||
label={globalize.translate('LabelTimeLimitHours')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
step: 0.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
color='error'
|
||||
>{globalize.translate('ButtonCancel')}</Button>
|
||||
<Button type='submit'>{globalize.translate('Add')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTriggerForm;
|
|
@ -2,11 +2,9 @@ import React, { FunctionComponent, useCallback } from 'react';
|
|||
import ListItem from '@mui/material/ListItem';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { TaskProps } from '../types/taskProps';
|
||||
import TaskProgress from './TaskProgress';
|
||||
import TaskLastRan from './TaskLastRan';
|
||||
|
@ -15,18 +13,12 @@ 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 navigateTaskEdit = useCallback(() => {
|
||||
Dashboard.navigate(`/dashboard/tasks/edit?id=${task.Id}`)
|
||||
.catch(err => {
|
||||
console.error('[Task] failed to navigate to task edit page', err);
|
||||
});
|
||||
}, [task]);
|
||||
|
||||
const handleStartTask = useCallback(() => {
|
||||
if (task.Id) {
|
||||
startTask.mutate({ taskId: task.Id });
|
||||
|
@ -48,7 +40,7 @@ const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
|||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemButton onClick={navigateTaskEdit}>
|
||||
<ListItemLink to={`/dashboard/tasks/${task.Id}`}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<AccessTimeIcon sx={{ color: '#fff' }} />
|
||||
|
@ -59,7 +51,7 @@ const Task: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
|||
secondary={task.State == 'Running' ? <TaskProgress task={task} /> : <TaskLastRan task={task} />}
|
||||
disableTypography
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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
|
||||
];
|
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;
|
||||
}
|
||||
}
|
|
@ -5,9 +5,11 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
|||
{ path: 'activity', type: AppType.Dashboard },
|
||||
{ path: 'branding', type: AppType.Dashboard },
|
||||
{ path: 'devices', type: AppType.Dashboard },
|
||||
{ path: 'settings', 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 },
|
||||
|
@ -15,6 +17,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
|||
{ 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 },
|
||||
|
|
|
@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
controller: 'dashboard',
|
||||
view: 'dashboard.html'
|
||||
}
|
||||
}, {
|
||||
path: 'settings',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'general',
|
||||
view: 'general.html'
|
||||
}
|
||||
}, {
|
||||
path: 'networking',
|
||||
pageProps: {
|
||||
|
@ -37,13 +30,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
controller: 'encodingsettings',
|
||||
view: 'encodingsettings.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries/nfo',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'metadatanfo',
|
||||
view: 'metadatanfo.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/catalog',
|
||||
pageProps: {
|
||||
|
@ -93,12 +79,5 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
controller: 'plugins/installed/index',
|
||||
view: 'plugins/installed/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tasks/edit',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'scheduledtasks/scheduledtask',
|
||||
view: 'scheduledtasks/scheduledtask.html'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
@ -123,14 +123,16 @@ export const Component = () => {
|
|||
multiline
|
||||
minRows={5}
|
||||
maxRows={5}
|
||||
InputProps={{
|
||||
className: 'textarea-mono'
|
||||
}}
|
||||
name={BrandingOption.LoginDisclaimer}
|
||||
label={globalize.translate('LabelLoginDisclaimer')}
|
||||
helperText={globalize.translate('LabelLoginDisclaimerHelp')}
|
||||
value={brandingOptions?.LoginDisclaimer}
|
||||
onChange={setBrandingOption}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'textarea-mono'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -138,14 +140,16 @@ export const Component = () => {
|
|||
multiline
|
||||
minRows={5}
|
||||
maxRows={20}
|
||||
InputProps={{
|
||||
className: 'textarea-mono'
|
||||
}}
|
||||
name={BrandingOption.CustomCss}
|
||||
label={globalize.translate('LabelCustomCss')}
|
||||
helperText={globalize.translate('LabelCustomCssHelp')}
|
||||
value={brandingOptions?.CustomCss}
|
||||
onChange={setBrandingOption}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'textarea-mono'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
|
@ -7,7 +7,7 @@ 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 Checkbox from '@mui/material/Checkbox';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
|
@ -21,6 +21,8 @@ import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'rea
|
|||
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');
|
||||
|
@ -43,13 +45,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
.updateConfiguration({ serverConfiguration: config });
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({ key: 'metadata', body: metadataConfig });
|
||||
.updateNamedConfiguration({ key: CONFIG_KEY, body: metadataConfig });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ CONFIG_QUERY_KEY ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ NAMED_CONFIG_QUERY_KEY, 'metadata' ]
|
||||
queryKey: [ NAMED_CONFIG_QUERY_KEY, CONFIG_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -67,7 +69,7 @@ export const Component = () => {
|
|||
data: namedConfig,
|
||||
isPending: isNamedConfigPending,
|
||||
isError: isNamedConfigError
|
||||
} = useNamedConfiguration('metadata');
|
||||
} = useNamedConfiguration(CONFIG_KEY);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
|
@ -109,7 +111,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name={'DisplayFolderView'}
|
||||
defaultChecked={config.EnableFolderView}
|
||||
/>
|
||||
|
@ -122,7 +124,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name={'DisplaySpecialsWithinSeasons'}
|
||||
defaultChecked={config.DisplaySpecialsWithinSeasons}
|
||||
/>
|
||||
|
@ -134,7 +136,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name={'GroupMoviesIntoCollections'}
|
||||
defaultChecked={config.EnableGroupingIntoCollections}
|
||||
/>
|
||||
|
@ -147,7 +149,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name={'EnableExternalContentInSuggestions'}
|
||||
defaultChecked={config.EnableExternalContentInSuggestions}
|
||||
/>
|
||||
|
|
|
@ -127,12 +127,14 @@ export const Component = () => {
|
|||
name={'DummyChapterDuration'}
|
||||
defaultValue={config.DummyChapterDuration}
|
||||
type='number'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
label={globalize.translate('LabelDummyChapterDuration')}
|
||||
helperText={globalize.translate('LabelDummyChapterDurationHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
|
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 Checkbox from '@mui/material/Checkbox';
|
||||
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={
|
||||
<Checkbox
|
||||
name={'SaveImagePathsInNfo'}
|
||||
defaultChecked={nfoConfig.SaveImagePathsInNfo}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataSaveImagePaths')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelKodiMetadataSaveImagePathsHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name={'EnablePathSubstitution'}
|
||||
defaultChecked={nfoConfig.EnablePathSubstitution}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelKodiMetadataEnablePathSubstitution')}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('LabelKodiMetadataEnablePathSubstitutionHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
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';
|
|
@ -8,7 +8,7 @@ import Box from '@mui/material/Box';
|
|||
import Button from '@mui/material/Button';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
|
@ -98,7 +98,7 @@ export const Component = () => {
|
|||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
checked={configuration?.EnableSlowResponseWarning}
|
||||
onChange={setLogWarningMessage}
|
||||
name={'EnableWarningMessage'}
|
||||
|
|
|
@ -81,12 +81,14 @@ export const Component = () => {
|
|||
name='MinResumePercentage'
|
||||
type='number'
|
||||
defaultValue={config?.MinResumePct}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinResumePercentageHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -94,12 +96,14 @@ export const Component = () => {
|
|||
name='MaxResumePercentage'
|
||||
type='number'
|
||||
defaultValue={config?.MaxResumePct}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMaxResumePercentageHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -107,12 +111,14 @@ export const Component = () => {
|
|||
name='MinAudiobookResume'
|
||||
type='number'
|
||||
defaultValue={config?.MinAudiobookResume}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinAudiobookResumeHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -120,12 +126,14 @@ export const Component = () => {
|
|||
name='MaxAudiobookResume'
|
||||
type='number'
|
||||
defaultValue={config?.MaxAudiobookResume}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMaxAudiobookResumeHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -133,11 +141,13 @@ export const Component = () => {
|
|||
name='MinResumeDuration'
|
||||
type='number'
|
||||
defaultValue={config?.MinResumeDurationSeconds}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelMinResumeDurationHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
|
@ -70,14 +70,16 @@ export const Component = () => {
|
|||
<TextField
|
||||
type='number'
|
||||
inputMode='decimal'
|
||||
inputProps={{
|
||||
min: 0,
|
||||
step: 0.25
|
||||
}}
|
||||
name='StreamingBitrateLimit'
|
||||
label={globalize.translate('LabelRemoteClientBitrateLimit')}
|
||||
helperText={globalize.translate('LabelRemoteClientBitrateLimitHelp')}
|
||||
defaultValue={defaultConfiguration?.RemoteClientBitrateLimit ? defaultConfiguration?.RemoteClientBitrateLimit / 1e6 : ''}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 0.25
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='submit'
|
||||
|
|
|
@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack';
|
|||
import Typography from '@mui/material/Typography';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
@ -92,7 +92,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name='HwAcceleration'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwAcceleration}
|
||||
/>
|
||||
|
@ -104,7 +104,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name='HwEncoding'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableHwEncoding}
|
||||
/>
|
||||
|
@ -117,7 +117,7 @@ export const Component = () => {
|
|||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
<Checkbox
|
||||
name='KeyFrameOnlyExtraction'
|
||||
defaultChecked={defaultConfig.TrickplayOptions?.EnableKeyFrameOnlyExtraction}
|
||||
/>
|
||||
|
@ -158,22 +158,26 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.Interval}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelImageIntervalHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('LabelWidthResolutions')}
|
||||
name='WidthResolutions'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.WidthResolutions}
|
||||
inputProps={{
|
||||
required: true,
|
||||
pattern: '[0-9,]*'
|
||||
}}
|
||||
helperText={globalize.translate('LabelWidthResolutionsHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
required: true,
|
||||
pattern: '[0-9,]*'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -182,11 +186,13 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.TileWidth}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTileWidthHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -195,11 +201,13 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.TileHeight}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTileHeightHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -208,12 +216,14 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.JpegQuality}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelJpegQualityHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 1,
|
||||
max: 100,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -222,12 +232,14 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.Qscale}
|
||||
inputProps={{
|
||||
min: 2,
|
||||
max: 31,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelQscaleHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 2,
|
||||
max: 31,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
@ -236,11 +248,13 @@ export const Component = () => {
|
|||
type='number'
|
||||
inputMode='numeric'
|
||||
defaultValue={defaultConfig.TrickplayOptions?.ProcessThreads}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
required: true
|
||||
}}
|
||||
helperText={globalize.translate('LabelTrickplayThreadsHelp')}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
required: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
277
src/apps/dashboard/routes/settings/index.tsx
Normal file
277
src/apps/dashboard/routes/settings/index.tsx
Normal file
|
@ -0,0 +1,277 @@
|
|||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
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 { useLocalizationOptions } from 'apps/dashboard/features/settings/api/useLocalizationOptions';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import { QUERY_KEY, useConfiguration } from 'hooks/useConfiguration';
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import DirectoryBrowser from 'components/directorybrowser/directorybrowser';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ActionData } from 'types/actionData';
|
||||
|
||||
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();
|
||||
|
||||
config.ServerName = formData.get('ServerName')?.toString();
|
||||
config.UICulture = formData.get('UICulture')?.toString();
|
||||
config.CachePath = formData.get('CachePath')?.toString();
|
||||
config.MetadataPath = formData.get('MetadataPath')?.toString();
|
||||
config.QuickConnectAvailable = formData.get('QuickConnectAvailable')?.toString() === 'on';
|
||||
config.LibraryScanFanoutConcurrency = parseInt(formData.get('LibraryScanFanoutConcurrency')?.toString() || '0', 10);
|
||||
config.ParallelImageEncodingLimit = parseInt(formData.get('ParallelImageEncodingLimit')?.toString() || '0', 10);
|
||||
|
||||
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: languageOptions,
|
||||
isPending: isLocalizationOptionsPending,
|
||||
isError: isLocalizationOptionsError
|
||||
} = useLocalizationOptions();
|
||||
const {
|
||||
data: systemInfo,
|
||||
isPending: isSystemInfoPending,
|
||||
isError: isSystemInfoError
|
||||
} = useSystemInfo();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const [ cachePath, setCachePath ] = useState<string | null | undefined>('');
|
||||
const [ metadataPath, setMetadataPath ] = useState<string | null | undefined>('');
|
||||
|
||||
const onCachePathChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
setCachePath(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onMetadataPathChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
setMetadataPath(event.target.value);
|
||||
}, []);
|
||||
|
||||
const showCachePathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
callback: function (path: string) {
|
||||
if (path) {
|
||||
setCachePath(path);
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectServerCachePath'),
|
||||
instruction: globalize.translate('HeaderSelectServerCachePathHelp')
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showMetadataPathPicker = useCallback(() => {
|
||||
const picker = new DirectoryBrowser();
|
||||
|
||||
picker.show({
|
||||
path: metadataPath,
|
||||
callback: function (path: string) {
|
||||
if (path) {
|
||||
setMetadataPath(path);
|
||||
}
|
||||
|
||||
picker.close();
|
||||
},
|
||||
validateWriteable: true,
|
||||
header: globalize.translate('HeaderSelectMetadataPath'),
|
||||
instruction: globalize.translate('HeaderSelectMetadataPathHelp')
|
||||
});
|
||||
}, [metadataPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSystemInfoPending && !isSystemInfoError) {
|
||||
setCachePath(systemInfo.CachePath);
|
||||
setMetadataPath(systemInfo.InternalMetadataPath);
|
||||
}
|
||||
}, [systemInfo, isSystemInfoPending, isSystemInfoError]);
|
||||
|
||||
if (isConfigPending || isLocalizationOptionsPending || isSystemInfoPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='dashboardGeneralPage'
|
||||
title={globalize.translate('General')}
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isConfigError || isLocalizationOptionsError || isSystemInfoError ? (
|
||||
<Alert severity='error'>{globalize.translate('SettingsPageLoadError')}</Alert>
|
||||
) : (
|
||||
<Form method='POST'>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>{globalize.translate('Settings')}</Typography>
|
||||
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='ServerName'
|
||||
label={globalize.translate('LabelServerName')}
|
||||
helperText={globalize.translate('LabelServerNameHelp')}
|
||||
defaultValue={systemInfo.ServerName}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
name='UICulture'
|
||||
label={globalize.translate('LabelPreferredDisplayLanguage')}
|
||||
helperText={(
|
||||
<>
|
||||
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
|
||||
<Link href='https://jellyfin.org/docs/general/contributing/#translating'>
|
||||
{globalize.translate('LearnHowYouCanContribute')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
defaultValue={config.UICulture}
|
||||
slotProps={{
|
||||
formHelperText: { component: Stack }
|
||||
}}
|
||||
>
|
||||
{languageOptions.map((language) =>
|
||||
<MenuItem key={language.Name} value={language.Value || ''}>{language.Name}</MenuItem>
|
||||
)}
|
||||
</TextField>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderPaths')}</Typography>
|
||||
|
||||
<TextField
|
||||
name='CachePath'
|
||||
label={globalize.translate('LabelCachePath')}
|
||||
helperText={globalize.translate('LabelCachePathHelp')}
|
||||
value={cachePath}
|
||||
onChange={onCachePathChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showCachePathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name={'MetadataPath'}
|
||||
label={globalize.translate('LabelMetadataPath')}
|
||||
helperText={globalize.translate('LabelMetadataPathHelp')}
|
||||
value={metadataPath}
|
||||
onChange={onMetadataPathChange}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton edge='end' onClick={showMetadataPathPicker}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('QuickConnect')}</Typography>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name='QuickConnectAvailable'
|
||||
defaultChecked={config.QuickConnectAvailable}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableQuickConnect')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderPerformance')}</Typography>
|
||||
|
||||
<TextField
|
||||
name='LibraryScanFanoutConcurrency'
|
||||
type='number'
|
||||
label={globalize.translate('LibraryScanFanoutConcurrency')}
|
||||
helperText={globalize.translate('LibraryScanFanoutConcurrencyHelp')}
|
||||
defaultValue={config.LibraryScanFanoutConcurrency || ''}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name='ParallelImageEncodingLimit'
|
||||
type='number'
|
||||
label={globalize.translate('LabelParallelImageEncodingLimit')}
|
||||
helperText={globalize.translate('LabelParallelImageEncodingLimitHelp')}
|
||||
defaultValue={config.ParallelImageEncodingLimit || ''}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type='submit' size='large'>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'SettingsPage';
|
|
@ -3,10 +3,10 @@ import Page from 'components/Page';
|
|||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { QUERY_KEY, useTasks } from '../../features/scheduledtasks/api/useTasks';
|
||||
import { getCategories, getTasksByCategory } from '../../features/scheduledtasks/utils/tasks';
|
||||
import { QUERY_KEY, useTasks } from '../../features/tasks/api/useTasks';
|
||||
import { getCategories, getTasksByCategory } from '../../features/tasks/utils/tasks';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Tasks from '../../features/scheduledtasks/components/Tasks';
|
||||
import Tasks from '../../features/tasks/components/Tasks';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
|
|
172
src/apps/dashboard/routes/tasks/task.tsx
Normal file
172
src/apps/dashboard/routes/tasks/task.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
|
||||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
|
||||
import { useUpdateTask } from 'apps/dashboard/features/tasks/api/useUpdateTask';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import TaskTriggerCell from 'apps/dashboard/features/tasks/components/TaskTriggerCell';
|
||||
import NewTriggerForm from 'apps/dashboard/features/tasks/components/NewTriggerForm';
|
||||
|
||||
export const Component = () => {
|
||||
const { id: taskId } = useParams();
|
||||
const updateTask = useUpdateTask();
|
||||
const { data: task, isLoading } = useTask({ taskId: taskId || '' });
|
||||
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
|
||||
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
|
||||
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
|
||||
|
||||
const onCloseRemoveConfirmDialog = useCallback(() => {
|
||||
setPendingDeleteTrigger(null);
|
||||
setIsRemoveConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
const onDeleteTrigger = useCallback((trigger: TaskTriggerInfo | null | undefined) => {
|
||||
if (trigger) {
|
||||
setPendingDeleteTrigger(trigger);
|
||||
setIsRemoveConfirmOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
const triggersRemaining = task?.Triggers?.filter(trigger => trigger != pendingDeleteTrigger);
|
||||
|
||||
if (task?.Id && triggersRemaining) {
|
||||
updateTask.mutate({
|
||||
taskId: task.Id,
|
||||
taskTriggerInfo: triggersRemaining
|
||||
});
|
||||
setIsRemoveConfirmOpen(false);
|
||||
}
|
||||
}, [task, pendingDeleteTrigger, updateTask]);
|
||||
|
||||
const showAddTriggerDialog = useCallback(() => {
|
||||
setIsAddTriggerDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleNewTriggerDialogClose = useCallback(() => {
|
||||
setIsAddTriggerDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onNewTriggerAdd = useCallback((trigger: TaskTriggerInfo) => {
|
||||
if (task?.Triggers && task?.Id) {
|
||||
const triggers = [...task.Triggers, trigger];
|
||||
|
||||
updateTask.mutate({
|
||||
taskId: task.Id,
|
||||
taskTriggerInfo: triggers
|
||||
});
|
||||
setIsAddTriggerDialogOpen(false);
|
||||
}
|
||||
}, [task, updateTask]);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<TaskTriggerInfo>[]>(() => [
|
||||
{
|
||||
id: 'TriggerTime',
|
||||
accessorFn: row => row,
|
||||
Cell: TaskTriggerCell,
|
||||
header: globalize.translate('LabelTime')
|
||||
}
|
||||
], []);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: task?.Triggers || [],
|
||||
|
||||
enableSorting: false,
|
||||
enableFilters: false,
|
||||
enableColumnActions: false,
|
||||
enablePagination: false,
|
||||
|
||||
state: {
|
||||
isLoading
|
||||
},
|
||||
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||
}
|
||||
},
|
||||
|
||||
// Custom actions
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
header: ''
|
||||
}
|
||||
},
|
||||
renderRowActions: ({ row }) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<Tooltip disableInteractive title={globalize.translate('ButtonRemove')}>
|
||||
<IconButton
|
||||
color='error'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => onDeleteTrigger(row.original)}
|
||||
>
|
||||
<RemoveCircleIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading || !task) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='scheduledTaskPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={isRemoveConfirmOpen}
|
||||
title={globalize.translate('HeaderDeleteTaskTrigger')}
|
||||
text={globalize.translate('MessageDeleteTaskTrigger')}
|
||||
onCancel={onCloseRemoveConfirmDialog}
|
||||
onConfirm={onConfirmDelete}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonRemove')}
|
||||
/>
|
||||
<NewTriggerForm
|
||||
open={isAddTriggerDialogOpen}
|
||||
title={globalize.translate('ButtonAddScheduledTaskTrigger')}
|
||||
onClose={handleNewTriggerDialogClose}
|
||||
onAdd={onNewTriggerAdd}
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
<Box className='readOnlyContent'>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h2'>{task.Name}</Typography>
|
||||
<Typography variant='body1'>{task.Description}</Typography>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={showAddTriggerDialog}
|
||||
>{globalize.translate('ButtonAddScheduledTaskTrigger')}</Button>
|
||||
<MRT_Table table={table} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'TaskPage';
|
|
@ -298,7 +298,7 @@ const UserEdit = () => {
|
|||
className='lnkEditUserPreferencesContainer'
|
||||
style={{ paddingBottom: '1em' }}
|
||||
>
|
||||
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu.html?userId=${userDto.Id}` : undefined}>
|
||||
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu?userId=${userDto.Id}` : undefined}>
|
||||
{globalize.translate('ButtonEditOtherUserPreferences')}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
|
|
@ -6,8 +6,10 @@ import useMediaQuery from '@mui/material/useMediaQuery';
|
|||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import AppBody from 'components/AppBody';
|
||||
import CustomCss from 'components/CustomCss';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import ThemeCss from 'components/ThemeCss';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import AppToolbar from './components/AppToolbar';
|
||||
|
@ -29,52 +31,56 @@ export const Component = () => {
|
|||
}, [ isDrawerActive, setIsDrawerActive ]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
|
||||
<StrictMode>
|
||||
<ElevationScroll elevate={false}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppToolbar
|
||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
/>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', height: '100%' }}>
|
||||
<StrictMode>
|
||||
<ElevationScroll elevate={false}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppToolbar
|
||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
/>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</StrictMode>
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</StrictMode>
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<ThemeCss />
|
||||
<CustomCss />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -17,6 +17,10 @@ $drawer-width: 240px;
|
|||
left: $drawer-width;
|
||||
}
|
||||
}
|
||||
// The fallback page has no drawer
|
||||
#fallbackPage {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
// Hide some items from the user "settings" page that are in the drawer
|
||||
#myPreferencesMenuPage {
|
||||
|
|
|
@ -35,13 +35,13 @@ const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
|
|||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isSearchPath = location.pathname === '/search.html';
|
||||
const isSearchPath = location.pathname === '/search';
|
||||
const createSearchLink = isTabsAvailable ?
|
||||
{
|
||||
pathname: '/search.html',
|
||||
pathname: '/search',
|
||||
search: `?${createSearchParams(getUrlParams(searchParams))}`
|
||||
} :
|
||||
'/search.html';
|
||||
'/search';
|
||||
|
||||
return (
|
||||
<Tooltip title={globalize.translate('Search')}>
|
||||
|
|
|
@ -14,11 +14,11 @@ interface AppToolbarProps {
|
|||
}
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/addserver.html',
|
||||
'/selectserver.html',
|
||||
'/login.html',
|
||||
'/forgotpassword.html',
|
||||
'/forgotpasswordpin.html'
|
||||
'/addserver',
|
||||
'/selectserver',
|
||||
'/login',
|
||||
'/forgotpassword',
|
||||
'/forgotpasswordpin'
|
||||
];
|
||||
|
||||
const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
|
|
|
@ -30,7 +30,7 @@ const MainDrawerContent = () => {
|
|||
const userViews = userViewsData?.Items || [];
|
||||
const webConfig = useWebConfig();
|
||||
|
||||
const isHomeSelected = location.pathname === '/home.html' && (!location.search || location.search === '?tab=0');
|
||||
const isHomeSelected = location.pathname === '/home' && (!location.search || location.search === '?tab=0');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -40,7 +40,7 @@ const MainDrawerContent = () => {
|
|||
<DrawerHeaderLink />
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/home.html' selected={isHomeSelected}>
|
||||
<ListItemLink to='/home' selected={isHomeSelected}>
|
||||
<ListItemIcon>
|
||||
<Home />
|
||||
</ListItemIcon>
|
||||
|
@ -48,7 +48,7 @@ const MainDrawerContent = () => {
|
|||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/home.html?tab=1'>
|
||||
<ListItemLink to='/home?tab=1'>
|
||||
<ListItemIcon>
|
||||
<Favorite />
|
||||
</ListItemIcon>
|
||||
|
|
|
@ -36,7 +36,7 @@ export const getDefaultTabIndex = (path: string, libraryId?: string | null) => {
|
|||
|
||||
const TabRoutes: TabRoute[] = [
|
||||
{
|
||||
path: '/livetv.html',
|
||||
path: '/livetv',
|
||||
tabs: [
|
||||
{
|
||||
index: 0,
|
||||
|
@ -72,7 +72,7 @@ const TabRoutes: TabRoute[] = [
|
|||
]
|
||||
},
|
||||
{
|
||||
path: '/movies.html',
|
||||
path: '/movies',
|
||||
tabs: [
|
||||
{
|
||||
index: 0,
|
||||
|
@ -108,7 +108,7 @@ const TabRoutes: TabRoute[] = [
|
|||
]
|
||||
},
|
||||
{
|
||||
path: '/music.html',
|
||||
path: '/music',
|
||||
tabs: [
|
||||
{
|
||||
index: 0,
|
||||
|
@ -149,7 +149,7 @@ const TabRoutes: TabRoute[] = [
|
|||
]
|
||||
},
|
||||
{
|
||||
path: '/tv.html',
|
||||
path: '/tv',
|
||||
tabs: [
|
||||
{
|
||||
index: 0,
|
||||
|
@ -185,7 +185,7 @@ const TabRoutes: TabRoute[] = [
|
|||
]
|
||||
},
|
||||
{
|
||||
path: '/homevideos.html',
|
||||
path: '/homevideos',
|
||||
tabs: [
|
||||
{
|
||||
index: 0,
|
||||
|
|
|
@ -36,7 +36,7 @@ const CancelSeriesTimerButton: FC<CancelSeriesTimerButtonProps> = ({
|
|||
onSuccess: async () => {
|
||||
toast(globalize.translate('SeriesCancelled'));
|
||||
loading.hide();
|
||||
navigate('/livetv.html');
|
||||
navigate('/livetv');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
loading.hide();
|
||||
|
|
|
@ -146,18 +146,20 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
|||
<TextField
|
||||
aria-describedby='display-settings-screensaver-interval-description'
|
||||
value={values.screensaverInterval}
|
||||
inputProps={{
|
||||
inputMode: 'numeric',
|
||||
max: '3600',
|
||||
min: '1',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1',
|
||||
type: 'number'
|
||||
}}
|
||||
label={globalize.translate('LabelBackdropScreensaverInterval')}
|
||||
name='screensaverInterval'
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: 'numeric',
|
||||
max: '3600',
|
||||
min: '1',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1',
|
||||
type: 'number'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText id='display-settings-screensaver-interval-description'>
|
||||
{globalize.translate('LabelBackdropScreensaverIntervalHelp')}
|
||||
|
|
|
@ -24,19 +24,21 @@ export function LibraryPreferences({ onChange, values }: Readonly<LibraryPrefere
|
|||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-lib-pagesize-description'
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}}
|
||||
value={values.libraryPageSize}
|
||||
label={globalize.translate('LabelLibraryPageSize')}
|
||||
name='libraryPageSize'
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText id='display-settings-lib-pagesize-description'>
|
||||
{globalize.translate('LabelLibraryPageSizeHelp')}
|
||||
|
|
|
@ -25,18 +25,20 @@ export function NextUpPreferences({ onChange, values }: Readonly<NextUpPreferenc
|
|||
<TextField
|
||||
aria-describedby='display-settings-max-days-next-up-description'
|
||||
value={values.maxDaysForNextUp}
|
||||
inputProps={{
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}}
|
||||
label={globalize.translate('LabelMaxDaysForNextUp')}
|
||||
name='maxDaysForNextUp'
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
type: 'number',
|
||||
inputMode: 'numeric',
|
||||
max: '1000',
|
||||
min: '0',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText id='display-settings-max-days-next-up-description'>
|
||||
{globalize.translate('LabelMaxDaysForNextUpHelp')}
|
||||
|
|
|
@ -2,14 +2,14 @@ import { AsyncRoute } from 'components/router/AsyncRoute';
|
|||
import { AppType } from 'constants/appType';
|
||||
|
||||
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'home.html', page: 'home', type: AppType.Experimental },
|
||||
{ path: 'home', page: 'home', type: AppType.Experimental },
|
||||
{ path: 'quickconnect', page: 'quickConnect' },
|
||||
{ path: 'search.html', page: 'search' },
|
||||
{ path: 'userprofile.html', page: 'user/userprofile' },
|
||||
{ path: 'movies.html', page: 'movies', type: AppType.Experimental },
|
||||
{ path: 'tv.html', page: 'shows', type: AppType.Experimental },
|
||||
{ path: 'music.html', page: 'music', type: AppType.Experimental },
|
||||
{ path: 'livetv.html', page: 'livetv', type: AppType.Experimental },
|
||||
{ path: 'mypreferencesdisplay.html', page: 'user/display', type: AppType.Experimental },
|
||||
{ path: 'homevideos.html', page: 'homevideos', type: AppType.Experimental }
|
||||
{ path: 'search', page: 'search' },
|
||||
{ path: 'userprofile', page: 'user/userprofile' },
|
||||
{ path: 'movies', page: 'movies', type: AppType.Experimental },
|
||||
{ path: 'tv', page: 'shows', type: AppType.Experimental },
|
||||
{ path: 'music', page: 'music', type: AppType.Experimental },
|
||||
{ path: 'livetv', page: 'livetv', type: AppType.Experimental },
|
||||
{ path: 'mypreferencesdisplay', page: 'user/display', type: AppType.Experimental },
|
||||
{ path: 'homevideos', page: 'homevideos', type: AppType.Experimental }
|
||||
];
|
||||
|
|
|
@ -2,77 +2,77 @@ import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
|||
|
||||
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
|
||||
{
|
||||
path: 'addserver.html',
|
||||
path: 'addserver',
|
||||
pageProps: {
|
||||
controller: 'session/addServer/index',
|
||||
view: 'session/addServer/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'selectserver.html',
|
||||
path: 'selectserver',
|
||||
pageProps: {
|
||||
controller: 'session/selectServer/index',
|
||||
view: 'session/selectServer/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'login.html',
|
||||
path: 'login',
|
||||
pageProps: {
|
||||
controller: 'session/login/index',
|
||||
view: 'session/login/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgotpassword.html',
|
||||
path: 'forgotpassword',
|
||||
pageProps: {
|
||||
controller: 'session/forgotPassword/index',
|
||||
view: 'session/forgotPassword/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgotpasswordpin.html',
|
||||
path: 'forgotpasswordpin',
|
||||
pageProps: {
|
||||
controller: 'session/resetPassword/index',
|
||||
view: 'session/resetPassword/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardremoteaccess.html',
|
||||
path: 'wizardremoteaccess',
|
||||
pageProps: {
|
||||
controller: 'wizard/remote/index',
|
||||
view: 'wizard/remote/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardfinish.html',
|
||||
path: 'wizardfinish',
|
||||
pageProps: {
|
||||
controller: 'wizard/finish/index',
|
||||
view: 'wizard/finish/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardlibrary.html',
|
||||
path: 'wizardlibrary',
|
||||
pageProps: {
|
||||
controller: 'wizard/library',
|
||||
view: 'wizard/library.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardsettings.html',
|
||||
path: 'wizardsettings',
|
||||
pageProps: {
|
||||
controller: 'wizard/settings/index',
|
||||
view: 'wizard/settings/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardstart.html',
|
||||
path: 'wizardstart',
|
||||
pageProps: {
|
||||
controller: 'wizard/start/index',
|
||||
view: 'wizard/start/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizarduser.html',
|
||||
path: 'wizarduser',
|
||||
pageProps: {
|
||||
controller: 'wizard/user/index',
|
||||
view: 'wizard/user/index.html'
|
||||
|
|
|
@ -8,7 +8,7 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
view: 'itemDetails/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'list.html',
|
||||
path: 'list',
|
||||
pageProps: {
|
||||
controller: 'list',
|
||||
view: 'list.html'
|
||||
|
@ -20,31 +20,31 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
view: 'lyrics.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesmenu.html',
|
||||
path: 'mypreferencesmenu',
|
||||
pageProps: {
|
||||
controller: 'user/menu/index',
|
||||
view: 'user/menu/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencescontrols.html',
|
||||
path: 'mypreferencescontrols',
|
||||
pageProps: {
|
||||
controller: 'user/controls/index',
|
||||
view: 'user/controls/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferenceshome.html',
|
||||
path: 'mypreferenceshome',
|
||||
pageProps: {
|
||||
controller: 'user/home/index',
|
||||
view: 'user/home/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesplayback.html',
|
||||
path: 'mypreferencesplayback',
|
||||
pageProps: {
|
||||
controller: 'user/playback/index',
|
||||
view: 'user/playback/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencessubtitles.html',
|
||||
path: 'mypreferencessubtitles',
|
||||
pageProps: {
|
||||
controller: 'user/subtitles/index',
|
||||
view: 'user/subtitles/index.html'
|
||||
|
|
|
@ -5,6 +5,7 @@ import ConnectionRequired from 'components/ConnectionRequired';
|
|||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||
import ErrorBoundary from 'components/router/ErrorBoundary';
|
||||
import FallbackRoute from 'components/router/FallbackRoute';
|
||||
|
||||
import { ASYNC_USER_ROUTES } from './asyncRoutes';
|
||||
import { LEGACY_PUBLIC_ROUTES, LEGACY_USER_ROUTES } from './legacyRoutes';
|
||||
|
@ -15,9 +16,11 @@ export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [
|
|||
path: '/*',
|
||||
lazy: () => import('../AppLayout'),
|
||||
children: [
|
||||
{ index: true, element: <Navigate replace to='/home' /> },
|
||||
|
||||
{
|
||||
/* User routes: Any child route of this layout is authenticated */
|
||||
element: <ConnectionRequired isUserRequired />,
|
||||
/* User routes */
|
||||
Component: ConnectionRequired,
|
||||
children: [
|
||||
...ASYNC_USER_ROUTES.map(toAsyncPageRoute),
|
||||
...LEGACY_USER_ROUTES.map(toViewManagerPageRoute),
|
||||
|
@ -25,15 +28,26 @@ export const EXPERIMENTAL_APP_ROUTES: RouteObject[] = [
|
|||
// The video page is special since it combines new controls with the legacy view
|
||||
{
|
||||
path: 'video',
|
||||
element: <VideoPage />
|
||||
Component: VideoPage
|
||||
}
|
||||
],
|
||||
ErrorBoundary
|
||||
},
|
||||
|
||||
/* Public routes */
|
||||
{ index: true, element: <Navigate replace to='/home.html' /> },
|
||||
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
|
||||
{
|
||||
/* Public routes */
|
||||
element: <ConnectionRequired isUserRequired={false} />,
|
||||
children: [
|
||||
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute),
|
||||
|
||||
/* Fallback route for invalid paths */
|
||||
{
|
||||
path: '*',
|
||||
Component: FallbackRoute
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
|
@ -2,11 +2,17 @@ import React from 'react';
|
|||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import AppBody from 'components/AppBody';
|
||||
import CustomCss from 'components/CustomCss';
|
||||
import ThemeCss from 'components/ThemeCss';
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
<ThemeCss />
|
||||
<CustomCss />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
23
src/apps/stable/features/search/api/fetchItemsByType.ts
Normal file
23
src/apps/stable/features/search/api/fetchItemsByType.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Api } from '@jellyfin/sdk/lib/api';
|
||||
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { QUERY_OPTIONS } from '../constants/queryOptions';
|
||||
|
||||
export const fetchItemsByType = async (
|
||||
api: Api,
|
||||
userId?: string,
|
||||
params?: ItemsApiGetItemsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getItemsApi(api).getItems(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId,
|
||||
recursive: true,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
49
src/apps/stable/features/search/api/useArtistsSearch.ts
Normal file
49
src/apps/stable/features/search/api/useArtistsSearch.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { ArtistsApiGetArtistsRequest } from '@jellyfin/sdk/lib/generated-client/api/artists-api';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { getArtistsApi } from '@jellyfin/sdk/lib/utils/api/artists-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_OPTIONS } from '../constants/queryOptions';
|
||||
import { isMusic } from '../utils/search';
|
||||
|
||||
const fetchArtists = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: ArtistsApiGetArtistsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getArtistsApi(api).getArtists(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useArtistsSearch = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'Artists', collectionType, parentId, searchTerm],
|
||||
queryFn: ({ signal }) => fetchArtists(
|
||||
api!,
|
||||
userId!,
|
||||
{
|
||||
parentId,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
),
|
||||
enabled: !!api && !!userId && (!collectionType || isMusic(collectionType))
|
||||
});
|
||||
};
|
150
src/apps/stable/features/search/api/useLiveTvSearch.ts
Normal file
150
src/apps/stable/features/search/api/useLiveTvSearch.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { addSection, isLivetv } from '../utils/search';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
|
||||
import { CardShape } from 'utils/card';
|
||||
import { Section } from '../types';
|
||||
import { fetchItemsByType } from './fetchItemsByType';
|
||||
|
||||
const fetchLiveTv = (api: Api, userId: string | undefined, searchTerm: string | undefined, signal: AbortSignal) => {
|
||||
const sections: Section[] = [];
|
||||
|
||||
// Movies row
|
||||
const movies = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isMovie: true,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(moviesData => {
|
||||
addSection(sections, 'Movies', moviesData.Items, {
|
||||
...LIVETV_CARD_OPTIONS,
|
||||
shape: CardShape.PortraitOverflow
|
||||
});
|
||||
});
|
||||
|
||||
// Episodes row
|
||||
const episodes = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isKids: false,
|
||||
isNews: false,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(episodesData => {
|
||||
addSection(sections, 'Episodes', episodesData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
});
|
||||
|
||||
// Sports row
|
||||
const sports = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isSports: true,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(sportsData => {
|
||||
addSection(sections, 'Sports', sportsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
});
|
||||
|
||||
// Kids row
|
||||
const kids = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isKids: true,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(kidsData => {
|
||||
addSection(sections, 'Kids', kidsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
});
|
||||
|
||||
// News row
|
||||
const news = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isNews: true,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(newsData => {
|
||||
addSection(sections, 'News', newsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
});
|
||||
|
||||
// Programs row
|
||||
const programs = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.LiveTvProgram ],
|
||||
isMovie: false,
|
||||
isSeries: false,
|
||||
isSports: false,
|
||||
isKids: false,
|
||||
isNews: false,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(programsData => {
|
||||
addSection(sections, 'Programs', programsData.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
});
|
||||
|
||||
// Channels row
|
||||
const channels = fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [ BaseItemKind.TvChannel ],
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
).then(channelsData => {
|
||||
addSection(sections, 'Channels', channelsData.Items);
|
||||
});
|
||||
|
||||
return Promise.all([ movies, episodes, sports, kids, news, programs, channels ]).then(() => sections);
|
||||
};
|
||||
|
||||
export const useLiveTvSearch = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'LiveTv', collectionType, parentId, searchTerm],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchLiveTv(api!, userId!, searchTerm, signal),
|
||||
enabled: !!api && !!userId && !!collectionType && !!isLivetv(collectionType)
|
||||
});
|
||||
};
|
50
src/apps/stable/features/search/api/usePeopleSearch.ts
Normal file
50
src/apps/stable/features/search/api/usePeopleSearch.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_OPTIONS } from '../constants/queryOptions';
|
||||
import { isMovies, isTVShows } from '../utils/search';
|
||||
import { PersonsApiGetPersonsRequest } from '@jellyfin/sdk/lib/generated-client/api/persons-api';
|
||||
import { getPersonsApi } from '@jellyfin/sdk/lib/utils/api/persons-api';
|
||||
|
||||
const fetchPeople = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: PersonsApiGetPersonsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getPersonsApi(api).getPersons(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId,
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const usePeopleSearch = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
const isPeopleEnabled = (!collectionType || isMovies(collectionType) || isTVShows(collectionType));
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'People', collectionType, parentId, searchTerm],
|
||||
queryFn: ({ signal }) => fetchPeople(
|
||||
api!,
|
||||
userId!,
|
||||
{
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
),
|
||||
enabled: !!api && !!userId && isPeopleEnabled
|
||||
});
|
||||
};
|
50
src/apps/stable/features/search/api/useProgramsSearch.ts
Normal file
50
src/apps/stable/features/search/api/useProgramsSearch.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
|
||||
import { fetchItemsByType } from './fetchItemsByType';
|
||||
|
||||
const fetchPrograms = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: ItemsApiGetItemsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await fetchItemsByType(
|
||||
api,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: [BaseItemKind.LiveTvProgram],
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const useProgramsSearch = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'Programs', collectionType, parentId, searchTerm],
|
||||
queryFn: ({ signal }) => fetchPrograms(
|
||||
api!,
|
||||
userId!,
|
||||
{
|
||||
parentId,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
),
|
||||
enabled: !!api && !!userId && !collectionType
|
||||
});
|
||||
};
|
98
src/apps/stable/features/search/api/useSearchItems.ts
Normal file
98
src/apps/stable/features/search/api/useSearchItems.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../../../../../hooks/useApi';
|
||||
import { addSection, getCardOptionsFromType, getItemTypesFromCollectionType, getTitleFromType, isLivetv, isMovies, isMusic, isTVShows, sortSections } from '../utils/search';
|
||||
import { useArtistsSearch } from './useArtistsSearch';
|
||||
import { usePeopleSearch } from './usePeopleSearch';
|
||||
import { useVideoSearch } from './useVideoSearch';
|
||||
import { Section } from '../types';
|
||||
import { useLiveTvSearch } from './useLiveTvSearch';
|
||||
import { fetchItemsByType } from './fetchItemsByType';
|
||||
import { useProgramsSearch } from './useProgramsSearch';
|
||||
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
|
||||
|
||||
export const useSearchItems = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { data: artists, isPending: isArtistsPending } = useArtistsSearch(parentId, collectionType, searchTerm);
|
||||
const { data: people, isPending: isPeoplePending } = usePeopleSearch(parentId, collectionType, searchTerm);
|
||||
const { data: videos, isPending: isVideosPending } = useVideoSearch(parentId, collectionType, searchTerm);
|
||||
const { data: programs, isPending: isProgramsPending } = useProgramsSearch(parentId, collectionType, searchTerm);
|
||||
const { data: liveTvSections, isPending: isLiveTvPending } = useLiveTvSearch(parentId, collectionType, searchTerm);
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
const isArtistsEnabled = !isArtistsPending || (collectionType && !isMusic(collectionType));
|
||||
const isPeopleEnabled = !isPeoplePending || (collectionType && !isMovies(collectionType) && !isTVShows(collectionType));
|
||||
const isVideosEnabled = !isVideosPending || collectionType;
|
||||
const isProgramsEnabled = !isProgramsPending || collectionType;
|
||||
const isLiveTvEnabled = !isLiveTvPending || !collectionType || !isLivetv(collectionType);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'Items', collectionType, parentId, searchTerm],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (liveTvSections && collectionType && isLivetv(collectionType)) {
|
||||
return sortSections(liveTvSections);
|
||||
}
|
||||
|
||||
const sections: Section[] = [];
|
||||
|
||||
addSection(sections, 'Artists', artists?.Items, {
|
||||
coverImage: true
|
||||
});
|
||||
|
||||
addSection(sections, 'Programs', programs?.Items, {
|
||||
...LIVETV_CARD_OPTIONS
|
||||
});
|
||||
|
||||
addSection(sections, 'People', people?.Items, {
|
||||
coverImage: true
|
||||
});
|
||||
|
||||
addSection(sections, 'HeaderVideos', videos?.Items, {
|
||||
showParentTitle: true
|
||||
});
|
||||
|
||||
const itemTypes: BaseItemKind[] = getItemTypesFromCollectionType(collectionType);
|
||||
|
||||
const searchData = await fetchItemsByType(
|
||||
api!,
|
||||
userId,
|
||||
{
|
||||
includeItemTypes: itemTypes,
|
||||
parentId,
|
||||
searchTerm,
|
||||
limit: 800
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (searchData.Items) {
|
||||
for (const itemType of itemTypes) {
|
||||
const items: BaseItemDto[] = [];
|
||||
for (const searchItem of searchData.Items) {
|
||||
if (searchItem.Type === itemType) {
|
||||
items.push(searchItem);
|
||||
}
|
||||
}
|
||||
addSection(sections, getTitleFromType(itemType), items, getCardOptionsFromType(itemType));
|
||||
}
|
||||
}
|
||||
|
||||
return sortSections(sections);
|
||||
},
|
||||
enabled: (
|
||||
!!api
|
||||
&& !!userId
|
||||
&& !!isArtistsEnabled
|
||||
&& !!isPeopleEnabled
|
||||
&& !!isVideosEnabled
|
||||
&& !!isLiveTvEnabled
|
||||
&& !!isProgramsEnabled
|
||||
)
|
||||
});
|
||||
};
|
|
@ -4,20 +4,17 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client';
|
|||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from '../useApi';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchGetItems = async (
|
||||
api?: Api,
|
||||
userId?: string,
|
||||
api: Api,
|
||||
userId: string,
|
||||
parentId?: string,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) throw new Error('No API instance available');
|
||||
if (!userId) throw new Error('No User ID provided');
|
||||
|
||||
const response = await getItemsApi(api).getItems(
|
||||
{
|
||||
userId: userId,
|
||||
userId,
|
||||
sortBy: [ItemSortBy.IsFavoriteOrLiked, ItemSortBy.Random],
|
||||
includeItemTypes: [
|
||||
BaseItemKind.Movie,
|
||||
|
@ -28,7 +25,7 @@ const fetchGetItems = async (
|
|||
recursive: true,
|
||||
imageTypeLimit: 0,
|
||||
enableImages: false,
|
||||
parentId: parentId,
|
||||
parentId,
|
||||
enableTotalRecordCount: false
|
||||
},
|
||||
options
|
||||
|
@ -43,7 +40,8 @@ export const useSearchSuggestions = (parentId?: string) => {
|
|||
return useQuery({
|
||||
queryKey: ['SearchSuggestions', { parentId }],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchGetItems(api, userId, parentId, { signal }),
|
||||
fetchGetItems(api!, userId!, parentId, { signal }),
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!api && !!userId
|
||||
});
|
||||
};
|
57
src/apps/stable/features/search/api/useVideoSearch.ts
Normal file
57
src/apps/stable/features/search/api/useVideoSearch.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Api } from '@jellyfin/sdk';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_OPTIONS } from '../constants/queryOptions';
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api';
|
||||
|
||||
const fetchVideos = async (
|
||||
api: Api,
|
||||
userId: string,
|
||||
params?: ItemsApiGetItemsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getItemsApi(api).getItems(
|
||||
{
|
||||
...QUERY_OPTIONS,
|
||||
userId,
|
||||
recursive: true,
|
||||
mediaTypes: [MediaType.Video],
|
||||
excludeItemTypes: [
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.TvChannel
|
||||
],
|
||||
...params
|
||||
},
|
||||
options
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useVideoSearch = (
|
||||
parentId?: string,
|
||||
collectionType?: CollectionType,
|
||||
searchTerm?: string
|
||||
) => {
|
||||
const { api, user } = useApi();
|
||||
const userId = user?.Id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['Search', 'Video', collectionType, parentId, searchTerm],
|
||||
queryFn: ({ signal }) => fetchVideos(
|
||||
api!,
|
||||
userId!,
|
||||
{
|
||||
parentId,
|
||||
searchTerm
|
||||
},
|
||||
{ signal }
|
||||
),
|
||||
enabled: !!api && !!userId && !collectionType
|
||||
});
|
||||
};
|
|
@ -1,11 +1,11 @@
|
|||
import React, { type ChangeEvent, type FC, useCallback, useRef } from 'react';
|
||||
import AlphaPicker from '../alphaPicker/AlphaPickerComponent';
|
||||
import AlphaPicker from 'components/alphaPicker/AlphaPickerComponent';
|
||||
import Input from 'elements/emby-input/Input';
|
||||
import globalize from '../../lib/globalize';
|
||||
import layoutManager from '../layoutManager';
|
||||
import browser from '../../scripts/browser';
|
||||
import globalize from 'lib/globalize';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import browser from 'scripts/browser';
|
||||
import 'material-design-icons-iconfont';
|
||||
import '../../styles/flexstyles.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import './searchfields.scss';
|
||||
|
||||
interface SearchFieldsProps {
|
|
@ -1,13 +1,16 @@
|
|||
import React, { type FC } from 'react';
|
||||
import { Section, useSearchItems } from 'hooks/searchHook';
|
||||
import globalize from '../../lib/globalize';
|
||||
import Loading from '../loading/LoadingComponent';
|
||||
import { useSearchItems } from '../api/useSearchItems';
|
||||
import globalize from 'lib/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import SearchResultsRow from './SearchResultsRow';
|
||||
import { CardShape } from 'utils/card';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { Section } from '../types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface SearchResultsProps {
|
||||
parentId?: string;
|
||||
collectionType?: string;
|
||||
collectionType?: CollectionType;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
|
@ -19,14 +22,22 @@ const SearchResults: FC<SearchResultsProps> = ({
|
|||
collectionType,
|
||||
query
|
||||
}) => {
|
||||
const { isLoading, data } = useSearchItems(parentId, collectionType, query);
|
||||
const { data, isPending } = useSearchItems(parentId, collectionType, query?.trim());
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (isPending) return <Loading />;
|
||||
|
||||
if (!data?.length) {
|
||||
return (
|
||||
<div className='noItemsMessage centerMessage'>
|
||||
{globalize.translate('SearchResultsEmpty', query)}
|
||||
{collectionType && (
|
||||
<div>
|
||||
<Link
|
||||
className='emby-button'
|
||||
to={`/search.html?query=${encodeURIComponent(query || '')}`}
|
||||
>{globalize.translate('RetryWithGlobalSearch')}</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -51,7 +62,7 @@ const SearchResults: FC<SearchResultsProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={'searchResults, padded-top, padded-bottom-page'}>
|
||||
<div className={'searchResults padded-top padded-bottom-page'}>
|
||||
{data.map((section, index) => renderSection(section, index))}
|
||||
</div>
|
||||
);
|
|
@ -1,10 +1,10 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { type FC, useEffect, useRef } from 'react';
|
||||
|
||||
import cardBuilder from '../cardbuilder/cardBuilder';
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
import '../../elements/emby-scroller/emby-scroller';
|
||||
import '../../elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'elements/emby-scroller/emby-scroller';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
|
||||
// There seems to be some compatibility issues here between
|
||||
// React and our legacy web components, so we need to inject
|
|
@ -1,21 +1,21 @@
|
|||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { appRouter } from '../router/appRouter';
|
||||
import { useSearchSuggestions } from 'hooks/searchHook/useSearchSuggestions';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import { useSearchSuggestions } from '../api/useSearchSuggestions';
|
||||
import globalize from 'lib/globalize';
|
||||
import LinkButton from '../../elements/emby-button/LinkButton';
|
||||
import LinkButton from 'elements/emby-button/LinkButton';
|
||||
|
||||
import '../../elements/emby-button/emby-button';
|
||||
import 'elements/emby-button/emby-button';
|
||||
|
||||
type SearchSuggestionsProps = {
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
const SearchSuggestions: FunctionComponent<SearchSuggestionsProps> = ({ parentId }) => {
|
||||
const { isLoading, data: suggestions } = useSearchSuggestions(parentId || undefined);
|
||||
const { data: suggestions, isPending } = useSearchSuggestions(parentId || undefined);
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (isPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<div
|
|
@ -0,0 +1,11 @@
|
|||
export const LIVETV_CARD_OPTIONS = {
|
||||
preferThumb: true,
|
||||
inheritThumb: false,
|
||||
showParentTitleOrTitle: true,
|
||||
showTitle: false,
|
||||
coverImage: true,
|
||||
overlayMoreButton: true,
|
||||
showAirTime: true,
|
||||
showAirDateTime: true,
|
||||
showChannelName: true
|
||||
};
|
12
src/apps/stable/features/search/constants/queryOptions.ts
Normal file
12
src/apps/stable/features/search/constants/queryOptions.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
|
||||
|
||||
export const QUERY_OPTIONS = {
|
||||
limit: 100,
|
||||
fields: [
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.CanDelete,
|
||||
ItemFields.MediaSourceCount
|
||||
],
|
||||
enableTotalRecordCount: false,
|
||||
imageTypeLimit: 1
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
export const SEARCH_SECTIONS_SORT_ORDER = [
|
||||
'Movies',
|
||||
'Shows',
|
||||
'Episodes',
|
||||
'People',
|
||||
'Playlists',
|
||||
'Artists',
|
||||
'Albums',
|
||||
'Songs',
|
||||
'HeaderVideos',
|
||||
'Programs',
|
||||
'Channels',
|
||||
'HeaderPhotoAlbums',
|
||||
'Photos',
|
||||
'HeaderAudioBooks',
|
||||
'Books',
|
||||
'Collections'
|
||||
];
|
8
src/apps/stable/features/search/types.ts
Normal file
8
src/apps/stable/features/search/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { CardOptions } from 'types/cardOptions';
|
||||
|
||||
export interface Section {
|
||||
title: string
|
||||
items: BaseItemDto[];
|
||||
cardOptions?: CardOptions;
|
||||
};
|
141
src/apps/stable/features/search/utils/search.ts
Normal file
141
src/apps/stable/features/search/utils/search.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { CardShape } from 'utils/card';
|
||||
import { Section } from '../types';
|
||||
import { CardOptions } from 'types/cardOptions';
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { LIVETV_CARD_OPTIONS } from '../constants/liveTvCardOptions';
|
||||
import { SEARCH_SECTIONS_SORT_ORDER } from '../constants/sectionSortOrder';
|
||||
|
||||
export const isMovies = (collectionType: string) =>
|
||||
collectionType === CollectionType.Movies;
|
||||
|
||||
export const isTVShows = (collectionType: string) =>
|
||||
collectionType === CollectionType.Tvshows;
|
||||
|
||||
export const isMusic = (collectionType: string) =>
|
||||
collectionType === CollectionType.Music;
|
||||
|
||||
export const isLivetv = (collectionType: string) =>
|
||||
collectionType === CollectionType.Livetv;
|
||||
|
||||
export function addSection(
|
||||
sections: Section[],
|
||||
title: string,
|
||||
items: BaseItemDto[] | null | undefined,
|
||||
cardOptions?: CardOptions
|
||||
) {
|
||||
if (items && items?.length > 0) {
|
||||
sections.push({ title, items, cardOptions });
|
||||
}
|
||||
}
|
||||
|
||||
export function sortSections(sections: Section[]) {
|
||||
return sections.sort((a, b) => {
|
||||
const indexA = SEARCH_SECTIONS_SORT_ORDER.indexOf(a.title);
|
||||
const indexB = SEARCH_SECTIONS_SORT_ORDER.indexOf(b.title);
|
||||
|
||||
if (indexA > indexB) {
|
||||
return 1;
|
||||
} else if (indexA < indexB) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getCardOptionsFromType(type: BaseItemKind) {
|
||||
switch (type) {
|
||||
case BaseItemKind.Movie:
|
||||
case BaseItemKind.Series:
|
||||
case BaseItemKind.MusicAlbum:
|
||||
return {
|
||||
showYear: true
|
||||
};
|
||||
case BaseItemKind.Episode:
|
||||
return {
|
||||
coverImage: true,
|
||||
showParentTitle: true
|
||||
};
|
||||
case BaseItemKind.MusicArtist:
|
||||
return {
|
||||
coverImage: true
|
||||
};
|
||||
case BaseItemKind.Audio:
|
||||
return {
|
||||
showParentTitle: true,
|
||||
shape: CardShape.SquareOverflow
|
||||
};
|
||||
case BaseItemKind.LiveTvProgram:
|
||||
return LIVETV_CARD_OPTIONS;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getTitleFromType(type: BaseItemKind) {
|
||||
switch (type) {
|
||||
case BaseItemKind.Movie:
|
||||
return 'Movies';
|
||||
case BaseItemKind.Series:
|
||||
return 'Shows';
|
||||
case BaseItemKind.Episode:
|
||||
return 'Episodes';
|
||||
case BaseItemKind.Playlist:
|
||||
return 'Playlists';
|
||||
case BaseItemKind.MusicAlbum:
|
||||
return 'Albums';
|
||||
case BaseItemKind.Audio:
|
||||
return 'Songs';
|
||||
case BaseItemKind.LiveTvProgram:
|
||||
return 'Programs';
|
||||
case BaseItemKind.TvChannel:
|
||||
return 'Channels';
|
||||
case BaseItemKind.PhotoAlbum:
|
||||
return 'HeaderPhotoAlbums';
|
||||
case BaseItemKind.Photo:
|
||||
return 'Photos';
|
||||
case BaseItemKind.AudioBook:
|
||||
return 'HeaderAudioBooks';
|
||||
case BaseItemKind.Book:
|
||||
return 'Books';
|
||||
case BaseItemKind.BoxSet:
|
||||
return 'Collections';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getItemTypesFromCollectionType(collectionType: CollectionType | undefined) {
|
||||
switch (collectionType) {
|
||||
case CollectionType.Movies:
|
||||
return [ BaseItemKind.Movie ];
|
||||
case CollectionType.Tvshows:
|
||||
return [
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.Episode
|
||||
];
|
||||
case CollectionType.Music:
|
||||
return [
|
||||
BaseItemKind.Playlist,
|
||||
BaseItemKind.MusicAlbum,
|
||||
BaseItemKind.Audio
|
||||
];
|
||||
default:
|
||||
return [
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.Series,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.Playlist,
|
||||
BaseItemKind.MusicAlbum,
|
||||
BaseItemKind.Audio,
|
||||
BaseItemKind.TvChannel,
|
||||
BaseItemKind.PhotoAlbum,
|
||||
BaseItemKind.Photo,
|
||||
BaseItemKind.AudioBook,
|
||||
BaseItemKind.Book,
|
||||
BaseItemKind.BoxSet
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,6 +2,6 @@ import { AsyncRoute } from '../../../../components/router/AsyncRoute';
|
|||
|
||||
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'quickconnect', page: 'quickConnect' },
|
||||
{ path: 'search.html', page: 'search' },
|
||||
{ path: 'userprofile.html', page: 'user/userprofile' }
|
||||
{ path: 'search', page: 'search' },
|
||||
{ path: 'userprofile', page: 'user/userprofile' }
|
||||
];
|
||||
|
|
|
@ -2,77 +2,77 @@ import { LegacyRoute } from '../../../../components/router/LegacyRoute';
|
|||
|
||||
export const LEGACY_PUBLIC_ROUTES: LegacyRoute[] = [
|
||||
{
|
||||
path: 'addserver.html',
|
||||
path: 'addserver',
|
||||
pageProps: {
|
||||
controller: 'session/addServer/index',
|
||||
view: 'session/addServer/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'selectserver.html',
|
||||
path: 'selectserver',
|
||||
pageProps: {
|
||||
controller: 'session/selectServer/index',
|
||||
view: 'session/selectServer/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'login.html',
|
||||
path: 'login',
|
||||
pageProps: {
|
||||
controller: 'session/login/index',
|
||||
view: 'session/login/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgotpassword.html',
|
||||
path: 'forgotpassword',
|
||||
pageProps: {
|
||||
controller: 'session/forgotPassword/index',
|
||||
view: 'session/forgotPassword/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgotpasswordpin.html',
|
||||
path: 'forgotpasswordpin',
|
||||
pageProps: {
|
||||
controller: 'session/resetPassword/index',
|
||||
view: 'session/resetPassword/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardremoteaccess.html',
|
||||
path: 'wizardremoteaccess',
|
||||
pageProps: {
|
||||
controller: 'wizard/remote/index',
|
||||
view: 'wizard/remote/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardfinish.html',
|
||||
path: 'wizardfinish',
|
||||
pageProps: {
|
||||
controller: 'wizard/finish/index',
|
||||
view: 'wizard/finish/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardlibrary.html',
|
||||
path: 'wizardlibrary',
|
||||
pageProps: {
|
||||
controller: 'wizard/library',
|
||||
view: 'wizard/library.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardsettings.html',
|
||||
path: 'wizardsettings',
|
||||
pageProps: {
|
||||
controller: 'wizard/settings/index',
|
||||
view: 'wizard/settings/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizardstart.html',
|
||||
path: 'wizardstart',
|
||||
pageProps: {
|
||||
controller: 'wizard/start/index',
|
||||
view: 'wizard/start/index.html'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wizarduser.html',
|
||||
path: 'wizarduser',
|
||||
pageProps: {
|
||||
controller: 'wizard/user/index',
|
||||
view: 'wizard/user/index.html'
|
||||
|
|
|
@ -8,13 +8,13 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
view: 'itemDetails/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'list.html',
|
||||
path: 'list',
|
||||
pageProps: {
|
||||
controller: 'list',
|
||||
view: 'list.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv.html',
|
||||
path: 'livetv',
|
||||
pageProps: {
|
||||
controller: 'livetv/livetvsuggested',
|
||||
view: 'livetv.html'
|
||||
|
@ -26,49 +26,49 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
view: 'lyrics.html'
|
||||
}
|
||||
}, {
|
||||
path: 'music.html',
|
||||
path: 'music',
|
||||
pageProps: {
|
||||
controller: 'music/musicrecommended',
|
||||
view: 'music/music.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesmenu.html',
|
||||
path: 'mypreferencesmenu',
|
||||
pageProps: {
|
||||
controller: 'user/menu/index',
|
||||
view: 'user/menu/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencescontrols.html',
|
||||
path: 'mypreferencescontrols',
|
||||
pageProps: {
|
||||
controller: 'user/controls/index',
|
||||
view: 'user/controls/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesdisplay.html',
|
||||
path: 'mypreferencesdisplay',
|
||||
pageProps: {
|
||||
controller: 'user/display/index',
|
||||
view: 'user/display/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferenceshome.html',
|
||||
path: 'mypreferenceshome',
|
||||
pageProps: {
|
||||
controller: 'user/home/index',
|
||||
view: 'user/home/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencesplayback.html',
|
||||
path: 'mypreferencesplayback',
|
||||
pageProps: {
|
||||
controller: 'user/playback/index',
|
||||
view: 'user/playback/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'mypreferencessubtitles.html',
|
||||
path: 'mypreferencessubtitles',
|
||||
pageProps: {
|
||||
controller: 'user/subtitles/index',
|
||||
view: 'user/subtitles/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'tv.html',
|
||||
path: 'tv',
|
||||
pageProps: {
|
||||
controller: 'shows/tvrecommended',
|
||||
view: 'shows/tvrecommended.html'
|
||||
|
@ -93,13 +93,13 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [
|
|||
isThemeMediaSupported: true
|
||||
}
|
||||
}, {
|
||||
path: 'home.html',
|
||||
path: 'home',
|
||||
pageProps: {
|
||||
controller: 'home',
|
||||
view: 'home.html'
|
||||
}
|
||||
}, {
|
||||
path: 'movies.html',
|
||||
path: 'movies',
|
||||
pageProps: {
|
||||
controller: 'movies/moviesrecommended',
|
||||
view: 'movies/movies.html'
|
||||
|
|
|
@ -84,7 +84,7 @@ const QuickConnectPage: FC = () => {
|
|||
<p>
|
||||
{globalize.translate('QuickConnectAuthorizeSuccess')}
|
||||
</p>
|
||||
<Link to='/home.html' className='button-link emby-button'>
|
||||
<Link to='/home' className='button-link emby-button'>
|
||||
{globalize.translate('GoHome')}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ import ConnectionRequired from 'components/ConnectionRequired';
|
|||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||
import ErrorBoundary from 'components/router/ErrorBoundary';
|
||||
import FallbackRoute from 'components/router/FallbackRoute';
|
||||
|
||||
import AppLayout from '../AppLayout';
|
||||
|
||||
|
@ -16,9 +17,11 @@ export const STABLE_APP_ROUTES: RouteObject[] = [
|
|||
path: '/*',
|
||||
Component: AppLayout,
|
||||
children: [
|
||||
{ index: true, element: <Navigate replace to='/home' /> },
|
||||
|
||||
{
|
||||
/* User routes */
|
||||
element: <ConnectionRequired isUserRequired />,
|
||||
Component: ConnectionRequired,
|
||||
children: [
|
||||
...ASYNC_USER_ROUTES.map(toAsyncPageRoute),
|
||||
...LEGACY_USER_ROUTES.map(toViewManagerPageRoute)
|
||||
|
@ -26,9 +29,19 @@ export const STABLE_APP_ROUTES: RouteObject[] = [
|
|||
ErrorBoundary
|
||||
},
|
||||
|
||||
/* Public routes */
|
||||
{ index: true, element: <Navigate replace to='/home.html' /> },
|
||||
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute)
|
||||
{
|
||||
/* Public routes */
|
||||
element: <ConnectionRequired isUserRequired={false} />,
|
||||
children: [
|
||||
...LEGACY_PUBLIC_ROUTES.map(toViewManagerPageRoute),
|
||||
/* Fallback route for invalid paths */
|
||||
{
|
||||
path: '*',
|
||||
Component: FallbackRoute
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
|
@ -4,9 +4,10 @@ import { useDebounceValue } from 'usehooks-ts';
|
|||
import { usePrevious } from 'hooks/usePrevious';
|
||||
import globalize from 'lib/globalize';
|
||||
import Page from 'components/Page';
|
||||
import SearchFields from 'components/search/SearchFields';
|
||||
import SearchSuggestions from 'components/search/SearchSuggestions';
|
||||
import SearchResults from 'components/search/SearchResults';
|
||||
import SearchFields from 'apps/stable/features/search/components/SearchFields';
|
||||
import SearchSuggestions from 'apps/stable/features/search/components/SearchSuggestions';
|
||||
import SearchResults from 'apps/stable/features/search/components/SearchResults';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
|
||||
const COLLECTION_TYPE_PARAM = 'collectionType';
|
||||
const PARENT_ID_PARAM = 'parentId';
|
||||
|
@ -15,7 +16,7 @@ const QUERY_PARAM = 'query';
|
|||
const Search: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined;
|
||||
const collectionTypeQuery = searchParams.get(COLLECTION_TYPE_PARAM) || undefined;
|
||||
const collectionTypeQuery = (searchParams.get(COLLECTION_TYPE_PARAM) || undefined) as CollectionType | undefined;
|
||||
const urlQuery = searchParams.get(QUERY_PARAM) || '';
|
||||
const [query, setQuery] = useState(urlQuery);
|
||||
const prevQuery = usePrevious(query, '');
|
||||
|
@ -50,7 +51,7 @@ const Search: FC = () => {
|
|||
className='mainAnimatedPage libraryPage allLibraryPage noSecondaryNavPage'
|
||||
>
|
||||
<SearchFields query={query} onSearch={setQuery} />
|
||||
{!query ? (
|
||||
{!debouncedQuery ? (
|
||||
<SearchSuggestions
|
||||
parentId={parentIdQuery}
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
|
|||
onConfirm,
|
||||
...dialogProps
|
||||
}) => (
|
||||
<Dialog {...dialogProps}>
|
||||
<Dialog onClose={onCancel} {...dialogProps}>
|
||||
<DialogTitle>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
|
|
@ -9,10 +9,10 @@ import globalize from '../lib/globalize';
|
|||
import { ConnectionState } from '../utils/jellyfin-apiclient/ConnectionState';
|
||||
|
||||
enum BounceRoutes {
|
||||
Home = '/home.html',
|
||||
Login = '/login.html',
|
||||
SelectServer = '/selectserver.html',
|
||||
StartWizard = '/wizardstart.html'
|
||||
Home = '/home',
|
||||
Login = '/login',
|
||||
SelectServer = '/selectserver',
|
||||
StartWizard = '/wizardstart'
|
||||
}
|
||||
|
||||
type ConnectionRequiredProps = {
|
||||
|
|
26
src/components/CustomCss.tsx
Normal file
26
src/components/CustomCss.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import React, { type FC } from 'react';
|
||||
|
||||
import { useUserSettings } from 'hooks/useUserSettings';
|
||||
import { useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
|
||||
|
||||
const CustomCss: FC = () => {
|
||||
const { data: brandingOptions } = useBrandingOptions();
|
||||
const { customCss: userCustomCss, disableCustomCss } = useUserSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disableCustomCss && brandingOptions?.CustomCss && (
|
||||
<style>
|
||||
{brandingOptions.CustomCss}
|
||||
</style>
|
||||
)}
|
||||
{userCustomCss && (
|
||||
<style>
|
||||
{userCustomCss}
|
||||
</style>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCss;
|
|
@ -1,5 +1,5 @@
|
|||
// NOTE: This is used for jsdoc return type
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { MINIMUM_VERSION } from '@jellyfin/sdk/lib/versions';
|
||||
import { ConnectionManager, Credentials, ApiClient } from 'jellyfin-apiclient';
|
||||
|
@ -18,7 +18,6 @@ const normalizeImageOptions = options => {
|
|||
};
|
||||
|
||||
const getMaxBandwidth = () => {
|
||||
/* eslint-disable compat/compat */
|
||||
if (navigator.connection) {
|
||||
let max = navigator.connection.downlinkMax;
|
||||
if (max && max > 0 && max < Number.POSITIVE_INFINITY) {
|
||||
|
@ -28,7 +27,6 @@ const getMaxBandwidth = () => {
|
|||
return parseInt(max, 10);
|
||||
}
|
||||
}
|
||||
/* eslint-enable compat/compat */
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
38
src/components/SimpleAlert.tsx
Normal file
38
src/components/SimpleAlert.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Button from '@mui/material/Button';
|
||||
import Dialog, { type DialogProps } from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import globalize from 'lib/globalize';
|
||||
import React from 'react';
|
||||
|
||||
interface SimpleAlertDialog extends DialogProps {
|
||||
title?: string;
|
||||
text: string;
|
||||
onClose: () => void
|
||||
};
|
||||
|
||||
const SimpleAlert = ({ open, title, text, onClose }: SimpleAlertDialog) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
{title && (
|
||||
<DialogTitle>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{text}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
{globalize.translate('ButtonGotIt')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleAlert;
|
34
src/components/ThemeCss.tsx
Normal file
34
src/components/ThemeCss.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React, { type FC, useEffect, useState } from 'react';
|
||||
|
||||
import { useUserTheme } from 'hooks/useUserTheme';
|
||||
import { getDefaultTheme } from 'scripts/settings/webSettings';
|
||||
|
||||
interface ThemeCssProps {
|
||||
dashboard?: boolean
|
||||
}
|
||||
|
||||
const getThemeUrl = (id: string) => `themes/${id}/theme.css`;;
|
||||
|
||||
const DEFAULT_THEME_URL = getThemeUrl(getDefaultTheme().id);
|
||||
|
||||
const ThemeCss: FC<ThemeCssProps> = ({
|
||||
dashboard = false
|
||||
}) => {
|
||||
const { theme, dashboardTheme } = useUserTheme();
|
||||
const [ themeUrl, setThemeUrl ] = useState(DEFAULT_THEME_URL);
|
||||
|
||||
useEffect(() => {
|
||||
const id = dashboard ? dashboardTheme : theme;
|
||||
if (id) setThemeUrl(getThemeUrl(id));
|
||||
}, [dashboard, dashboardTheme, theme]);
|
||||
|
||||
return (
|
||||
<link
|
||||
rel='stylesheet'
|
||||
type='text/css'
|
||||
href={themeUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCss;
|
|
@ -22,7 +22,9 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
|
|||
undefined
|
||||
}
|
||||
sx={{
|
||||
bgcolor: theme.palette.primary.dark,
|
||||
bgcolor: api && user.Id && user.PrimaryImageTag ?
|
||||
theme.palette.background.paper :
|
||||
theme.palette.primary.dark,
|
||||
color: 'inherit'
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -452,7 +452,7 @@ let isHidden = false;
|
|||
let hidden;
|
||||
let visibilityChange;
|
||||
|
||||
if (typeof document.hidden !== 'undefined') { /* eslint-disable-line compat/compat */
|
||||
if (typeof document.hidden !== 'undefined') {
|
||||
hidden = 'hidden';
|
||||
visibilityChange = 'visibilitychange';
|
||||
} else if (typeof document.webkitHidden !== 'undefined') {
|
||||
|
@ -461,7 +461,6 @@ if (typeof document.hidden !== 'undefined') { /* eslint-disable-line compat/comp
|
|||
}
|
||||
|
||||
document.addEventListener(visibilityChange, function () {
|
||||
/* eslint-disable-next-line compat/compat */
|
||||
if (document[hidden]) {
|
||||
onAppHidden();
|
||||
} else {
|
||||
|
|
|
@ -484,7 +484,7 @@ function getAirTimeText(item, showAirDateTime, showAirEndTime) {
|
|||
airTimeText += ' - ' + datetime.getDisplayTime(date);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error parsing date: ' + item.StartDate);
|
||||
console.error('error parsing date: ' + item.StartDate, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -617,7 +617,7 @@ function getCardFooterText(item, apiClient, options, footerClass, progressHtml,
|
|||
datetime.parseISO8601Date(item.PremiereDate),
|
||||
{ weekday: 'long', month: 'long', day: 'numeric' }
|
||||
));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
lines.push('');
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -678,6 +678,7 @@ describe('getDefaultBackgroundClass', () => {
|
|||
});
|
||||
|
||||
test('randomization string provided', () => {
|
||||
// eslint-disable-next-line sonarjs/pseudo-random
|
||||
const generateRandomString = (stringLength: number): string => (Math.random() + 1).toString(36).substring(stringLength);
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
|
|
|
@ -137,7 +137,7 @@ function loadSection(elem, userId, topParentId, section, isSingleSection) {
|
|||
html += '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
|
||||
|
||||
if (!layoutManager.tv && options.Limit && result.Items.length >= options.Limit) {
|
||||
html += '<a is="emby-linkbutton" href="' + ('#/list.html?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
|
||||
html += '<a is="emby-linkbutton" href="' + ('#/list?serverId=' + ApiClient.serverId() + '&type=' + section.types + '&IsFavorite=true') + '" class="more button-flat button-flat-mini sectionTitleTextButton">';
|
||||
html += '<h2 class="sectionTitle sectionTitle-cards">';
|
||||
html += globalize.translate(section.name);
|
||||
html += '</h2>';
|
||||
|
|
|
@ -14,6 +14,7 @@ function merge(resultItems, queryItems, delimiter) {
|
|||
if (!queryItems) {
|
||||
return resultItems;
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort
|
||||
return union(resultItems, queryItems.split(delimiter)).sort();
|
||||
}
|
||||
|
||||
|
|
|
@ -358,7 +358,7 @@ function Guide(options) {
|
|||
if ((typeof date).toString().toLowerCase() === 'string') {
|
||||
try {
|
||||
date = datetime.parseISO8601Date(date, { toLocal: true });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
@ -392,7 +392,7 @@ function Guide(options) {
|
|||
try {
|
||||
program.StartDateLocal = datetime.parseISO8601Date(program.StartDate, { toLocal: true });
|
||||
} catch (err) {
|
||||
console.error('error parsing timestamp for start date');
|
||||
console.error('error parsing timestamp for start date', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -400,7 +400,7 @@ function Guide(options) {
|
|||
try {
|
||||
program.EndDateLocal = datetime.parseISO8601Date(program.EndDate, { toLocal: true });
|
||||
} catch (err) {
|
||||
console.error('error parsing timestamp for end date');
|
||||
console.error('error parsing timestamp for end date', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -77,6 +77,7 @@ function setFiles(page, files) {
|
|||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-invariant-returns
|
||||
function onSubmit(e) {
|
||||
const file = currentFile;
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import Worker from './blurhash.worker.ts'; // eslint-disable-line import/default
|
|||
import * as lazyLoader from '../lazyLoader/lazyLoaderIntersectionObserver';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import './style.scss';
|
||||
// eslint-disable-next-line compat/compat
|
||||
|
||||
const worker = new Worker();
|
||||
const targetDic = {};
|
||||
worker.addEventListener(
|
||||
|
|
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