Compare commits
189 Commits
noavc
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cae5c2646 | ||
|
|
5195006f7c | ||
|
|
6f203b9d1d | ||
|
|
2682098f61 | ||
|
|
455db67286 | ||
|
|
6b1352a855 | ||
|
|
a1721ddd17 | ||
|
|
5051ee2d8e | ||
|
|
7d30057c37 | ||
|
|
2b1f3470f4 | ||
|
|
d2e09f9cae | ||
|
|
b9925ebf73 | ||
|
|
2ebf0c9fe4 | ||
|
|
838e14e89f | ||
|
|
b9760eac75 | ||
|
|
61eb481d20 | ||
|
|
b1a6fd5d4e | ||
|
|
20ea6041a7 | ||
|
|
ef00d439b1 | ||
|
|
7807f8f062 | ||
|
|
7949ff4f0a | ||
|
|
d47023855e | ||
|
|
6a8f21e462 | ||
|
|
90236c25ee | ||
|
|
2305240cb9 | ||
|
|
8bc954468a | ||
|
|
76a28e125e | ||
|
|
2e4e4050cd | ||
|
|
4071c44437 | ||
|
|
44afbc2357 | ||
|
|
a0e6da790c | ||
|
|
f1ecb967bf | ||
|
|
fc4f396808 | ||
|
|
7f575d724e | ||
|
|
0c5a433bbf | ||
|
|
219cda9c06 | ||
|
|
4598d66688 | ||
|
|
3eeebf9bd2 | ||
|
|
4de2a05264 | ||
|
|
7acdb66e14 | ||
|
|
665678d5d7 | ||
|
|
7991d15177 | ||
|
|
f6acb157c6 | ||
|
|
8ddd9ecd9d | ||
|
|
1adaf00cb3 | ||
|
|
3235e2e594 | ||
|
|
b6844e61e2 | ||
|
|
91d40a0c4e | ||
|
|
c98822a7c6 | ||
|
|
83503936cc | ||
|
|
ebe3f0feb7 | ||
|
|
f3bb9f2eef | ||
|
|
8c06742d2d | ||
|
|
2d68f94ec6 | ||
|
|
9501c5097b | ||
|
|
86ff77924e | ||
|
|
9e7ad28eaf | ||
|
|
765394b6f2 | ||
|
|
798b408bd7 | ||
|
|
e669a9be02 | ||
|
|
e0a0c92b43 | ||
|
|
2d2d5bef94 | ||
|
|
0b34c1812e | ||
|
|
edd32297ee | ||
|
|
7372e837ee | ||
|
|
71d4f7083d | ||
|
|
bc8b83be5e | ||
|
|
c89846c039 | ||
|
|
4ffa90cdd7 | ||
|
|
21ced03987 | ||
|
|
18061ce247 | ||
|
|
f507bfb016 | ||
|
|
98207228d6 | ||
|
|
30c1926e4e | ||
|
|
84e7b59e03 | ||
|
|
cb085ff955 | ||
|
|
4bb0c67340 | ||
|
|
4ec0e2f086 | ||
|
|
674b0b118f | ||
|
|
aed4ffa2cd | ||
|
|
a031aab622 | ||
|
|
fa4b109037 | ||
|
|
3bb9d44f85 | ||
|
|
7e20d3032f | ||
|
|
a0c2202e64 | ||
|
|
5495ef220a | ||
|
|
7a88d5f02d | ||
|
|
24f4833742 | ||
|
|
d898afdf10 | ||
|
|
238b44f1bb | ||
|
|
52aa8ebd49 | ||
|
|
7865170eb6 | ||
|
|
2a110f6b5d | ||
|
|
5680c18ade | ||
|
|
07bbe67927 | ||
|
|
611922d260 | ||
|
|
c35cec5c77 | ||
|
|
ab781678c1 | ||
|
|
574eddada8 | ||
|
|
7d057c58cf | ||
|
|
4dd44dfd9f | ||
|
|
74a3bd8768 | ||
|
|
7854c4b20b | ||
|
|
ba36747dbb | ||
|
|
cbedc384b3 | ||
|
|
d9c5440864 | ||
|
|
60af8a68f8 | ||
|
|
2a9892db85 | ||
|
|
4129676ed8 | ||
|
|
75ef961530 | ||
|
|
4959a777c9 | ||
|
|
003bc94e02 | ||
|
|
40e7dc9007 | ||
|
|
c135be012c | ||
|
|
ee6909325d | ||
|
|
3a0be7d345 | ||
|
|
a67fd2e5ac | ||
|
|
472fc09a50 | ||
|
|
4eeb79b3e1 | ||
|
|
a35c81a0eb | ||
|
|
adcea4467d | ||
|
|
6feb46fecb | ||
|
|
20e29b81b5 | ||
|
|
2b59a9f998 | ||
|
|
a66e4d6d1a | ||
|
|
ea1cadf4b6 | ||
|
|
788ce37c43 | ||
|
|
c1db082629 | ||
|
|
a51d700eff | ||
|
|
61976b8101 | ||
|
|
35e4fe497e | ||
|
|
1ea598968c | ||
|
|
167515dbf0 | ||
|
|
f7f5ac99b0 | ||
|
|
a88d03fe8f | ||
|
|
3630ac0436 | ||
|
|
a71fe63684 | ||
|
|
b49eb09a08 | ||
|
|
bd03c43716 | ||
|
|
e56c46a913 | ||
|
|
9e34ae8b42 | ||
|
|
7342e43bd4 | ||
|
|
b5fda71a27 | ||
|
|
14075c641a | ||
|
|
e53c78a8ff | ||
|
|
adb662eb0b | ||
|
|
bb9b4ce8bb | ||
|
|
6da3dd7c86 | ||
|
|
9d9b69edd5 | ||
|
|
fb87dfbf5e | ||
|
|
1342bedad0 | ||
|
|
292240df46 | ||
|
|
add01e332b | ||
|
|
2280d98785 | ||
|
|
11e3bf395e | ||
|
|
0271ae42c0 | ||
|
|
68bac17a46 | ||
|
|
7eb54e029f | ||
|
|
70b9aa4611 | ||
|
|
3dcb42daac | ||
|
|
b8a7cf214d | ||
|
|
c0b86a39c7 | ||
|
|
a806eeb3a7 | ||
|
|
ab70cc07a8 | ||
|
|
ed321c4cdb | ||
|
|
7ce8c070b3 | ||
|
|
3402f1beba | ||
|
|
1e035d5867 | ||
|
|
91961de0ce | ||
|
|
ea1d069e90 | ||
|
|
bdce41c3ae | ||
|
|
b17ca028f8 | ||
|
|
37b1d5cbea | ||
|
|
1a172bdb1b | ||
|
|
7de4ebf33a | ||
|
|
32a91eabf1 | ||
|
|
25b1bcab50 | ||
|
|
703ec1b488 | ||
|
|
c0467b1f13 | ||
|
|
0fcb1ff983 | ||
|
|
1ad7dfb5c0 | ||
|
|
a358d34ea9 | ||
|
|
ea8ceaa727 | ||
|
|
d17c35acc3 | ||
|
|
df26f36a09 | ||
|
|
f980e38530 | ||
|
|
1d883f445b | ||
|
|
ac8c2239ca | ||
|
|
7dc9c1d7aa |
@@ -5,6 +5,7 @@
|
||||
"not": [
|
||||
"./dist/libraries/pdf.worker.js",
|
||||
"./dist/libraries/worker-bundle.js",
|
||||
"./dist/libraries/wasm-gen/libarchive.js",
|
||||
"./dist/serviceworker.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
89
.eslintrc.js
89
.eslintrc.js
@@ -4,10 +4,10 @@ module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@stylistic',
|
||||
'@typescript-eslint',
|
||||
'react',
|
||||
'import',
|
||||
'eslint-comments',
|
||||
'sonarjs'
|
||||
],
|
||||
env: {
|
||||
@@ -20,14 +20,23 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:import/errors',
|
||||
'plugin:@eslint-community/eslint-comments/recommended',
|
||||
'plugin:eslint-comments/recommended',
|
||||
'plugin:compat/recommended',
|
||||
'plugin:sonarjs/recommended'
|
||||
],
|
||||
rules: {
|
||||
'array-callback-return': ['error', { 'checkForEach': true }],
|
||||
'block-spacing': ['error'],
|
||||
'brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'comma-spacing': ['error'],
|
||||
'curly': ['error', 'multi-line', 'consistent'],
|
||||
'default-case-last': ['error'],
|
||||
'eol-last': ['error'],
|
||||
'indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'keyword-spacing': ['error'],
|
||||
'max-statements-per-line': ['error'],
|
||||
'max-params': ['error', 7],
|
||||
'new-cap': [
|
||||
'error',
|
||||
@@ -39,7 +48,10 @@ module.exports = {
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extend-native': ['error'],
|
||||
'no-floating-decimal': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'no-nested-ternary': ['error'],
|
||||
'no-redeclare': ['off'],
|
||||
'@typescript-eslint/no-redeclare': ['error', { builtinGlobals: false }],
|
||||
@@ -50,6 +62,7 @@ module.exports = {
|
||||
'no-shadow': ['off'],
|
||||
'@typescript-eslint/no-shadow': ['error'],
|
||||
'no-throw-literal': ['error'],
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-undef-init': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unused-expressions': ['off'],
|
||||
@@ -61,12 +74,18 @@ module.exports = {
|
||||
'no-var': ['error'],
|
||||
'no-void': ['error', { 'allowAsStatement': true }],
|
||||
'no-warning-comments': ['warn', { 'terms': ['fixme', 'hack', 'xxx'] }],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'one-var': ['error', 'never'],
|
||||
'operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'prefer-const': ['error', { 'destructuring': 'all' }],
|
||||
'prefer-promise-reject-errors': ['warn', { 'allowEmptyReject': true }],
|
||||
'@typescript-eslint/prefer-for-of': ['error'],
|
||||
'@typescript-eslint/prefer-optional-chain': ['error'],
|
||||
'quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||
'radix': ['error'],
|
||||
'@typescript-eslint/semi': ['error'],
|
||||
'space-before-blocks': ['error'],
|
||||
'space-infix-ops': 'error',
|
||||
'yoda': 'error',
|
||||
|
||||
'react/jsx-filename-extension': ['error', { 'extensions': ['.jsx', '.tsx'] }],
|
||||
@@ -78,28 +97,7 @@ module.exports = {
|
||||
'sonarjs/no-inverted-boolean-check': ['error'],
|
||||
// TODO: Enable the following rules and fix issues
|
||||
'sonarjs/cognitive-complexity': ['off'],
|
||||
'sonarjs/no-duplicate-string': ['off'],
|
||||
|
||||
'@stylistic/block-spacing': ['error'],
|
||||
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/comma-spacing': ['error'],
|
||||
'@stylistic/eol-last': ['error'],
|
||||
'@stylistic/indent': ['error', 4, { 'SwitchCase': 1 }],
|
||||
'@stylistic/jsx-quotes': ['error', 'prefer-single'],
|
||||
'@stylistic/keyword-spacing': ['error'],
|
||||
'@stylistic/max-statements-per-line': ['error'],
|
||||
'@stylistic/no-floating-decimal': ['error'],
|
||||
'@stylistic/no-multi-spaces': ['error'],
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { 'max': 1 }],
|
||||
'@stylistic/no-trailing-spaces': ['error'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/operator-linebreak': ['error', 'before', { overrides: { '?': 'after', ':': 'after', '=': 'after' } }],
|
||||
'@stylistic/padded-blocks': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single', { 'avoidEscape': true, 'allowTemplateLiterals': false }],
|
||||
'@stylistic/semi': ['error'],
|
||||
'@stylistic/space-before-blocks': ['error'],
|
||||
'@stylistic/space-infix-ops': ['error']
|
||||
'sonarjs/no-duplicate-string': ['off']
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -265,7 +263,6 @@ module.exports = {
|
||||
'UserParentalControlPage': 'writable',
|
||||
'Windows': 'readonly',
|
||||
// Build time definitions
|
||||
__COMMIT_SHA__: 'readonly',
|
||||
__JF_BUILD_VERSION__: 'readonly',
|
||||
__PACKAGE_JSON_NAME__: 'readonly',
|
||||
__PACKAGE_JSON_VERSION__: 'readonly',
|
||||
@@ -273,44 +270,6 @@ module.exports = {
|
||||
__WEBPACK_SERVE__: 'readonly'
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allow'
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: [ 'camelCase', 'PascalCase', 'UPPER_CASE' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: [ 'PascalCase' ]
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: [ 'PascalCase', 'UPPER_CASE' ]
|
||||
},
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: [ 'camelCase', 'PascalCase' ],
|
||||
leadingUnderscore: 'allowSingleOrDouble',
|
||||
trailingUnderscore: 'allowSingleOrDouble'
|
||||
},
|
||||
// Ignore numbers, locale strings (en-us), aria/data attributes, CSS selectors,
|
||||
// and api_key parameter
|
||||
{
|
||||
selector: [ 'objectLiteralProperty', 'typeProperty' ],
|
||||
format: null,
|
||||
filter: {
|
||||
regex: '[ &\\-]|^([0-9]+)$|^api_key$',
|
||||
match: true
|
||||
}
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/prefer-string-starts-ends-with': ['error']
|
||||
}
|
||||
},
|
||||
@@ -323,7 +282,7 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@eslint-community/eslint-comments/recommended',
|
||||
'plugin:eslint-comments/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended'
|
||||
|
||||
14
.github/renovate.json
vendored
14
.github/renovate.json
vendored
@@ -2,19 +2,7 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>jellyfin/.github//renovate-presets/nodejs",
|
||||
":semanticCommitsDisabled",
|
||||
":dependencyDashboard"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": [ "@jellyfin/sdk" ],
|
||||
"followTag": "unstable",
|
||||
"minimumReleaseAge": null,
|
||||
"schedule": [ "after 7:00 am" ]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["dompurify"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
.github/workflows/__automation.yml
vendored
15
.github/workflows/__automation.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Automation 🎛️
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
conflicts:
|
||||
name: Merge conflict labeling 🏷️
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
40
.github/workflows/__codeql.yml
vendored
40
.github/workflows/__codeql.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: GitHub CodeQL 🔬
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze ${{ matrix.language }} 🔬
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language:
|
||||
- javascript-typescript
|
||||
|
||||
steps:
|
||||
- name: Checkout repository ⬇️
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
59
.github/workflows/__deploy.yml
vendored
59
.github/workflows/__deploy.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Deploy 🏗️
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
branch:
|
||||
required: true
|
||||
type: string
|
||||
commit:
|
||||
required: false
|
||||
type: string
|
||||
comment:
|
||||
required: false
|
||||
type: boolean
|
||||
artifact_name:
|
||||
required: false
|
||||
type: string
|
||||
default: frontend
|
||||
|
||||
jobs:
|
||||
cf-pages:
|
||||
name: CloudFlare Pages 📃
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ inputs.branch == 'master' && 'Production' || 'Preview' }}
|
||||
url: ${{ steps.cf.outputs.deployment-url }}
|
||||
outputs:
|
||||
url: ${{ steps.cf.outputs.deployment-url }}
|
||||
|
||||
steps:
|
||||
- name: Download workflow artifact ⬇️
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}
|
||||
path: dist
|
||||
|
||||
- name: Publish to Cloudflare Pages 📃
|
||||
uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3.13.0
|
||||
id: cf
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy dist --project-name=jellyfin-web --branch=${{ inputs.branch }}
|
||||
|
||||
compose-comment:
|
||||
name: Compose and push comment 📝
|
||||
# Always run so the comment is composed for the workflow summary
|
||||
if: ${{ always() }}
|
||||
uses: ./.github/workflows/__job_messages.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
- cf-pages
|
||||
|
||||
with:
|
||||
branch: ${{ inputs.branch }}
|
||||
commit: ${{ inputs.commit }}
|
||||
preview_url: ${{ needs.cf-pages.outputs.url }}
|
||||
in_progress: false
|
||||
comment: ${{ inputs.comment }}
|
||||
61
.github/workflows/__quality_checks.yml
vendored
61
.github/workflows/__quality_checks.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name: Quality checks 👌🧪
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Vulnerable dependencies 🔎
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Scan
|
||||
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
||||
with:
|
||||
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||
## TODO: Remove when necessary
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
|
||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
quality:
|
||||
name: Run ${{ matrix.command }} 🕵️♂️
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- build:es-check
|
||||
- lint
|
||||
- stylelint
|
||||
- build:check
|
||||
- test
|
||||
|
||||
steps:
|
||||
- name: Checkout ⬇️
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Setup node environment ⚙️
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
check-latest: true
|
||||
|
||||
- name: Install dependencies 📦
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run ${{ matrix.command }} ⚙️
|
||||
run: npm run ${{ matrix.command }}
|
||||
21
.github/workflows/automation.yml
vendored
Normal file
21
.github/workflows/automation.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: 'Automation'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: 'Merge conflict labeling'
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
steps:
|
||||
- uses: eps1lon/actions-label-merge-conflict@6d74047dcef155976a15e4a124dde2c7fe0c5522 # v3.0.1
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -1,36 +1,38 @@
|
||||
name: Packaging 📦
|
||||
name: Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
commit:
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request:
|
||||
branches: [ master, release* ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run-build-prod:
|
||||
name: Run production build 🏗️
|
||||
name: Run production build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.commit || github.sha }}
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run a production build
|
||||
env:
|
||||
JELLYFIN_VERSION: ${{ inputs.commit || github.sha }}
|
||||
JELLYFIN_VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: npm run build:production
|
||||
|
||||
- name: Update config.json for testing
|
||||
@@ -39,7 +41,8 @@ jobs:
|
||||
mv dist/config.tmp.json dist/config.json
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
with:
|
||||
name: frontend
|
||||
path: dist
|
||||
name: jellyfin-web__prod
|
||||
path: |
|
||||
dist
|
||||
34
.github/workflows/codeql.yml
vendored
Normal file
34
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: CodeQL
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request:
|
||||
branches: [ master, release* ]
|
||||
schedule:
|
||||
- cron: '30 7 * * 6'
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
name: Run CodeQL
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
with:
|
||||
languages: javascript
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
36
.github/workflows/commands.yml
vendored
Normal file
36
.github/workflows/commands.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
- name: Comment on failure
|
||||
if: failure()
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
I'm sorry @${{ github.event.comment.user.login }}, I'm afraid I can't do that.
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Job messages ⚙️
|
||||
name: Job messages
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -12,26 +12,32 @@ on:
|
||||
preview_url:
|
||||
required: false
|
||||
type: string
|
||||
build_workflow_run_id:
|
||||
required: false
|
||||
type: number
|
||||
commenting_workflow_run_id:
|
||||
required: true
|
||||
type: string
|
||||
in_progress:
|
||||
required: true
|
||||
type: boolean
|
||||
comment:
|
||||
required: false
|
||||
type: boolean
|
||||
outputs:
|
||||
msg:
|
||||
description: The composed message
|
||||
value: ${{ jobs.msg.outputs.msg }}
|
||||
marker:
|
||||
description: Hidden marker to detect PR comments composed by the bot
|
||||
required: false
|
||||
type: string
|
||||
default: "CFPages-deployment"
|
||||
|
||||
value: "CFPages-deployment"
|
||||
|
||||
jobs:
|
||||
cf_pages_msg:
|
||||
name: CloudFlare Pages deployment 📃🚀
|
||||
msg:
|
||||
name: Deployment status
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
msg: ${{ env.msg }}
|
||||
|
||||
steps:
|
||||
- name: Compose message 📃
|
||||
- name: Compose message
|
||||
if: ${{ always() }}
|
||||
id: compose
|
||||
env:
|
||||
@@ -39,7 +45,8 @@ jobs:
|
||||
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
|
||||
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
|
||||
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
|
||||
WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', github.repository, github.run_id) || '' }}
|
||||
BUILD_WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.build_workflow_run_id) || '' }}
|
||||
COMMENTING_WORKFLOW_RUN: ${{ format('**[View bot logs](https://github.com/{0}/actions/runs/{1})**', 'jellyfin/jellyfin-web', inputs.commenting_workflow_run_id) }}
|
||||
# EOF is needed for multiline environment variables in a GitHub Actions context
|
||||
run: |
|
||||
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
|
||||
@@ -50,16 +57,9 @@ jobs:
|
||||
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$BUILD_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$COMMENTING_WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
|
||||
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
|
||||
echo "msg<<EOF" >> $GITHUB_ENV
|
||||
echo "$COMPOSED_MSG" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Push comment to Pull Request 🔼
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
|
||||
if: ${{ inputs.comment && steps.compose.conclusion == 'success' }}
|
||||
with:
|
||||
github-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: ${{ env.msg }}
|
||||
comment-tag: ${{ inputs.marker }}
|
||||
38
.github/workflows/pr-suggestions.yml
vendored
Normal file
38
.github/workflows/pr-suggestions.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: PR suggestions
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [ master, release* ]
|
||||
types:
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
run-eslint:
|
||||
name: Run eslint suggestions
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: CatChen/eslint-suggestion-action@b110ac684564c7b73e47cc223eb7a5266ec83fd3 # v4.1.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
84
.github/workflows/publish.yml
vendored
Normal file
84
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Build
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Deploy to Cloudflare Pages
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
# We set the environment variable here (and as an output) because,
|
||||
# given no real runner is dispatched in compose-comment job (it's dispatched in the reusable workflow) in this workflow definition,
|
||||
# the env. context is not valid.
|
||||
env:
|
||||
TARGET_BRANCH: |
|
||||
${{
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
&& github.event.workflow_run.head_branch
|
||||
|| format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch)
|
||||
}}
|
||||
outputs:
|
||||
url: ${{ steps.cf.outputs.url }}
|
||||
branch: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
steps:
|
||||
- name: Download workflow artifact
|
||||
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe # v3.1.4
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: jellyfin-web__prod
|
||||
path: dist
|
||||
|
||||
- name: Publish
|
||||
id: cf
|
||||
uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # 1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: jellyfin-web
|
||||
branch: ${{ env.TARGET_BRANCH }}
|
||||
directory: dist
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
compose-comment:
|
||||
name: Compose comment
|
||||
if: ${{ always() }}
|
||||
uses: ./.github/workflows/job-messages.yml
|
||||
needs:
|
||||
- publish
|
||||
|
||||
with:
|
||||
branch: ${{ needs.publish.outputs.branch }}
|
||||
commit: ${{ github.event.workflow_run.head_commit.id }}
|
||||
preview_url: ${{ needs.publish.outputs.url }}
|
||||
build_workflow_run_id: ${{ github.event.workflow_run.id }}
|
||||
commenting_workflow_run_id: ${{ github.run_id }}
|
||||
in_progress: false
|
||||
|
||||
comment-status:
|
||||
name: Create comment status
|
||||
if: |
|
||||
always() &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.pull_requests[0].number != ''
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- compose-comment
|
||||
|
||||
steps:
|
||||
- name: Update job summary in PR comment
|
||||
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
message: ${{ needs.compose-comment.outputs.msg }}
|
||||
pr_number: ${{ github.event.workflow_run.pull_requests[0].number }}
|
||||
comment_tag: ${{ needs.compose-comment.outputs.marker }}
|
||||
mode: recreate
|
||||
100
.github/workflows/pull_request.yml
vendored
100
.github/workflows/pull_request.yml
vendored
@@ -1,100 +0,0 @@
|
||||
name: Pull Request 📥
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- master
|
||||
- release*
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
push-comment:
|
||||
name: Create comments ✍️
|
||||
if: ${{ always() && !cancelled() && github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: ./.github/workflows/__job_messages.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
in_progress: true
|
||||
comment: true
|
||||
|
||||
build:
|
||||
name: Build 🏗️
|
||||
if: ${{ always() && !cancelled() }}
|
||||
uses: ./.github/workflows/__package.yml
|
||||
with:
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
automation:
|
||||
name: Automation 🎛️
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: ./.github/workflows/__automation.yml
|
||||
secrets: inherit
|
||||
|
||||
quality_checks:
|
||||
name: Quality checks 👌🧪
|
||||
if: ${{ always() && !cancelled() }}
|
||||
uses: ./.github/workflows/__quality_checks.yml
|
||||
permissions: {}
|
||||
with:
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
codeql:
|
||||
name: GitHub CodeQL 🔬
|
||||
if: ${{ always() && !cancelled() }}
|
||||
uses: ./.github/workflows/__codeql.yml
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
with:
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
deploy:
|
||||
name: Deploy 🚀
|
||||
uses: ./.github/workflows/__deploy.yml
|
||||
if: ${{ always() && !cancelled() && needs.build.result == 'success' && github.repository == 'jellyfin/jellyfin-web' }}
|
||||
needs:
|
||||
- push-comment
|
||||
- build
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
secrets: inherit
|
||||
with:
|
||||
# If the PR is from the master branch of a fork, append the fork's name to the branch name
|
||||
branch: ${{ github.event.pull_request.head.repo.full_name != github.repository && github.event.pull_request.head.ref == 'master' && format('{0}/{1}', github.event.pull_request.head.repo.full_name, github.event.pull_request.head.ref) || github.event.pull_request.head.ref }}
|
||||
comment: true
|
||||
commit: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
run-eslint:
|
||||
name: Run eslint suggestions
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
check-latest: true
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@9c12109c4943f26f0676b71c9c10e456748872cf # v4.1.7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
58
.github/workflows/push.yml
vendored
58
.github/workflows/push.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Push & Release 🌍
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release*
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
|
||||
jobs:
|
||||
automation:
|
||||
name: Automation 🎛️
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: ./.github/workflows/__automation.yml
|
||||
secrets: inherit
|
||||
|
||||
main:
|
||||
name: 'Unstable release 🚀⚠️'
|
||||
uses: ./.github/workflows/__package.yml
|
||||
with:
|
||||
commit: ${{ github.sha }}
|
||||
|
||||
quality_checks:
|
||||
name: Quality checks 👌🧪
|
||||
if: ${{ always() && !cancelled() }}
|
||||
uses: ./.github/workflows/__quality_checks.yml
|
||||
permissions: {}
|
||||
with:
|
||||
commit: ${{ github.sha }}
|
||||
|
||||
codeql:
|
||||
name: GitHub CodeQL 🔬
|
||||
uses: ./.github/workflows/__codeql.yml
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
with:
|
||||
commit: ${{ github.sha }}
|
||||
|
||||
deploy:
|
||||
name: Deploy 🚀
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
uses: ./.github/workflows/__deploy.yml
|
||||
needs:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
secrets: inherit
|
||||
with:
|
||||
branch: ${{ github.ref_name }}
|
||||
comment: false
|
||||
123
.github/workflows/quality.yml
vendored
Normal file
123
.github/workflows/quality.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
name: Quality checks
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, release* ]
|
||||
pull_request:
|
||||
branches: [ master, release* ]
|
||||
|
||||
jobs:
|
||||
run-escheck:
|
||||
name: Run es-check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run a production build
|
||||
run: npm run build:production
|
||||
|
||||
- name: Run es-check
|
||||
run: npm run escheck
|
||||
|
||||
run-eslint:
|
||||
name: Run eslint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
run: npx eslint --quiet "."
|
||||
|
||||
run-stylelint:
|
||||
name: Run stylelint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Set up stylelint matcher
|
||||
uses: xt0rted/stylelint-problem-matcher@34db1b874c0452909f0696aedef70b723870a583 # tag=v1
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run stylelint
|
||||
run: npm run stylelint
|
||||
|
||||
run-tsc:
|
||||
name: Run TypeScript build check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run tsc
|
||||
run: npm run build:check
|
||||
|
||||
run-test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Setup node environment
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run test suite
|
||||
run: npm run test
|
||||
@@ -1,9 +1,10 @@
|
||||
name: Scheduled tasks 🕑
|
||||
name: Stale Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
52
.github/workflows/update-sdk.yml
vendored
Normal file
52
.github/workflows/update-sdk.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Update the Jellyfin SDK
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unstable-sdk-pr
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: master
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
cache: npm
|
||||
|
||||
- name: Install latest unstable SDK
|
||||
run: |
|
||||
npm i --save @jellyfin/sdk@unstable
|
||||
VERSION=$(jq -r '.dependencies["@jellyfin/sdk"]' package.json)
|
||||
echo "JF_SDK_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Open a pull request
|
||||
uses: peter-evans/create-pull-request@6d6857d36972b65feb161a90e484f2984215f83e # v6.0.5
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
commit-message: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
committer: jellyfin-bot <team@jellyfin.org>
|
||||
author: jellyfin-bot <team@jellyfin.org>
|
||||
branch: update-jf-sdk
|
||||
delete-branch: true
|
||||
title: Update @jellyfin/sdk to ${{env.JF_SDK_VERSION}}
|
||||
body: |
|
||||
**Changes**
|
||||
Updates to the latest unstable @jellyfin/sdk build
|
||||
labels: |
|
||||
dependencies
|
||||
npm
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,9 +3,6 @@ dist
|
||||
web
|
||||
node_modules
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
||||
# config
|
||||
config.json
|
||||
|
||||
@@ -13,6 +10,12 @@ config.json
|
||||
.idea
|
||||
.vs
|
||||
|
||||
# log
|
||||
yarn-error.log
|
||||
|
||||
# vim
|
||||
*.sw?
|
||||
|
||||
# build artifacts
|
||||
fedora/jellyfin-web-*.src.rpm
|
||||
fedora/jellyfin-web-*.tar.gz
|
||||
|
||||
@@ -87,12 +87,9 @@
|
||||
- [JPUC1143](https://github.com/Jpuc1143)
|
||||
- [David Angel](https://github.com/davidangel)
|
||||
- [Pithaya](https://github.com/Pithaya)
|
||||
- [Peter Santos](https://github.com/prsantos-com)
|
||||
- [Chaitanya Shahare](https://github.com/Chaitanya-Shahare)
|
||||
- [Venkat Karasani](https://github.com/venkat-karasani)
|
||||
- [Connor Smith](https://github.com/ConnorS1110)
|
||||
- [iFraan](https://github.com/iFraan)
|
||||
- [Ali](https://github.com/bu3alwa)
|
||||
- [Venkat Karasani](https://github.com/venkat-karasani)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
||||
@@ -85,11 +85,8 @@ Jellyfin Web is the frontend used for most of the clients available for end user
|
||||
├── controllers # Legacy page views and controllers 🧹
|
||||
├── elements # Basic webcomponents and React wrappers 🧹
|
||||
├── hooks # Custom React hooks
|
||||
├── lib # Reusable libraries
|
||||
│ ├── globalize # Custom localization library
|
||||
│ ├── legacy # Polyfills for legacy browsers
|
||||
│ ├── navdrawer # Navigation drawer library for classic layout
|
||||
│ └── scroller # Content scrolling library
|
||||
├── legacy # Polyfills for legacy browsers
|
||||
├── libraries # Third party libraries 🧹
|
||||
├── plugins # Client plugins
|
||||
├── scripts # Random assortment of visual components and utilities 🐉
|
||||
├── strings # Translation files
|
||||
|
||||
@@ -15,5 +15,8 @@ module.exports = {
|
||||
'@babel/preset-react'
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
'@babel/plugin-proposal-private-methods',
|
||||
'babel-plugin-dynamic-import-polyfill'
|
||||
]
|
||||
};
|
||||
|
||||
17339
package-lock.json
generated
17339
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
145
package.json
145
package.json
@@ -1,134 +1,130 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.9.11",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.25.8",
|
||||
"@babel/plugin-transform-modules-umd": "7.25.7",
|
||||
"@babel/preset-env": "7.25.8",
|
||||
"@babel/preset-react": "7.25.7",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.4.0",
|
||||
"@stylistic/eslint-plugin": "2.9.0",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@babel/core": "7.24.3",
|
||||
"@babel/plugin-proposal-class-properties": "7.18.6",
|
||||
"@babel/plugin-proposal-private-methods": "7.18.6",
|
||||
"@babel/plugin-transform-modules-umd": "7.24.1",
|
||||
"@babel/preset-env": "7.24.3",
|
||||
"@babel/preset-react": "7.24.1",
|
||||
"@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-lazy-load-image-component": "1.6.4",
|
||||
"@types/markdown-it": "13.0.7",
|
||||
"@types/react": "17.0.79",
|
||||
"@types/react-dom": "17.0.25",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"@uupaa/dynamic-import-polyfill": "1.0.2",
|
||||
"@vitest/coverage-v8": "2.1.8",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-dynamic-import-polyfill": "1.0.0",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"confusing-browser-globals": "1.0.11",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "7.1.2",
|
||||
"cssnano": "7.0.6",
|
||||
"es-check": "7.2.1",
|
||||
"eslint": "8.57.1",
|
||||
"css-loader": "6.10.0",
|
||||
"cssnano": "6.1.2",
|
||||
"es-check": "7.1.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-sonarjs": "0.25.1",
|
||||
"expose-loader": "5.0.0",
|
||||
"eslint-plugin-eslint-comments": "3.2.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "6.8.0",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-sonarjs": "0.24.0",
|
||||
"expose-loader": "4.1.0",
|
||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||
"html-loader": "5.1.0",
|
||||
"html-loader": "4.2.0",
|
||||
"html-webpack-plugin": "5.6.0",
|
||||
"jsdom": "25.0.1",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"postcss": "8.4.47",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-preset-env": "10.0.7",
|
||||
"jsdom": "23.2.0",
|
||||
"mini-css-extract-plugin": "2.8.1",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-loader": "7.3.4",
|
||||
"postcss-preset-env": "9.5.2",
|
||||
"postcss-scss": "4.0.9",
|
||||
"sass": "1.79.5",
|
||||
"sass-loader": "16.0.2",
|
||||
"source-map-loader": "5.0.0",
|
||||
"sass": "1.72.0",
|
||||
"sass-loader": "13.3.3",
|
||||
"source-map-loader": "4.0.2",
|
||||
"speed-measure-webpack-plugin": "1.5.0",
|
||||
"style-loader": "4.0.0",
|
||||
"style-loader": "3.3.4",
|
||||
"stylelint": "15.11.0",
|
||||
"stylelint-config-rational-order": "0.1.2",
|
||||
"stylelint-no-browser-hacks": "1.3.0",
|
||||
"stylelint-order": "6.0.4",
|
||||
"stylelint-scss": "5.3.2",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.6.3",
|
||||
"vitest": "2.1.8",
|
||||
"webpack": "5.95.0",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"typescript": "5.4.3",
|
||||
"vitest": "1.4.0",
|
||||
"webpack": "5.91.0",
|
||||
"webpack-bundle-analyzer": "4.10.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.1.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"webpack-dev-server": "4.15.2",
|
||||
"webpack-merge": "5.10.0",
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/styled": "11.13.0",
|
||||
"@fontsource/noto-sans": "5.1.0",
|
||||
"@fontsource/noto-sans-hk": "5.1.0",
|
||||
"@fontsource/noto-sans-jp": "5.1.0",
|
||||
"@fontsource/noto-sans-kr": "5.1.0",
|
||||
"@fontsource/noto-sans-sc": "5.1.0",
|
||||
"@fontsource/noto-sans-tc": "5.1.0",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202501020501",
|
||||
"@mui/icons-material": "5.16.7",
|
||||
"@mui/material": "5.16.7",
|
||||
"@mui/x-date-pickers": "7.20.0",
|
||||
"@react-hook/resize-observer": "2.0.2",
|
||||
"@tanstack/react-query": "5.59.13",
|
||||
"@tanstack/react-query-devtools": "5.59.13",
|
||||
"@emotion/react": "11.11.4",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@fontsource/noto-sans": "5.0.21",
|
||||
"@fontsource/noto-sans-hk": "5.0.18",
|
||||
"@fontsource/noto-sans-jp": "5.0.18",
|
||||
"@fontsource/noto-sans-kr": "5.0.18",
|
||||
"@fontsource/noto-sans-sc": "5.0.18",
|
||||
"@fontsource/noto-sans-tc": "5.0.18",
|
||||
"@jellyfin/libass-wasm": "4.2.1",
|
||||
"@jellyfin/sdk": "0.9.0",
|
||||
"@loadable/component": "5.16.3",
|
||||
"@mui/icons-material": "5.15.11",
|
||||
"@mui/material": "5.15.11",
|
||||
"@mui/x-data-grid": "6.19.5",
|
||||
"@react-hook/resize-observer": "1.2.6",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@tanstack/react-query-devtools": "4.36.1",
|
||||
"@types/react-lazy-load-image-component": "1.6.3",
|
||||
"abortcontroller-polyfill": "1.7.5",
|
||||
"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.36.1",
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "2.5.7",
|
||||
"dompurify": "3.0.1",
|
||||
"epubjs": "0.3.93",
|
||||
"escape-html": "1.0.3",
|
||||
"fast-text-encoding": "1.0.6",
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.5.18",
|
||||
"hls.js": "1.5.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
"jstree": "3.3.17",
|
||||
"libarchive.js": "2.0.2",
|
||||
"libpgs": "0.8.1",
|
||||
"jstree": "3.3.16",
|
||||
"libarchive.js": "1.3.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"markdown-it": "14.1.0",
|
||||
"material-design-icons-iconfont": "6.7.0",
|
||||
"material-react-table": "2.13.3",
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react": "17.0.2",
|
||||
"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-dom": "17.0.2",
|
||||
"react-lazy-load-image-component": "1.6.0",
|
||||
"react-router-dom": "6.22.3",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.3",
|
||||
"swiper": "11.1.14",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"sortablejs": "1.15.2",
|
||||
"swiper": "11.0.7",
|
||||
"usehooks-ts": "2.16.0",
|
||||
"webcomponents.js": "0.7.24",
|
||||
"whatwg-fetch": "3.6.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.79.5"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Firefox versions",
|
||||
"last 2 Chrome versions",
|
||||
@@ -152,7 +148,6 @@
|
||||
"build:development": "webpack --config webpack.dev.js",
|
||||
"build:production": "cross-env NODE_ENV=\"production\" webpack --config webpack.prod.js",
|
||||
"build:check": "tsc --noEmit",
|
||||
"build:es-check": "npm run build:production && npm run escheck",
|
||||
"escheck": "es-check",
|
||||
"lint": "eslint \"./\"",
|
||||
"test": "vitest --watch=false --config vite.config.ts",
|
||||
|
||||
@@ -3,29 +3,22 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import React from 'react';
|
||||
|
||||
import { ApiProvider } from 'hooks/useApi';
|
||||
import { UserSettingsProvider } from 'hooks/useUserSettings';
|
||||
import { WebConfigProvider } from 'hooks/useWebConfig';
|
||||
import browser from 'scripts/browser';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import RootAppRouter from 'RootAppRouter';
|
||||
import RootAppRouter from './RootAppRouter';
|
||||
|
||||
const useReactQueryDevtools = window.Proxy // '@tanstack/query-devtools' requires 'Proxy', which cannot be polyfilled for legacy browsers
|
||||
&& !browser.tv; // Don't use devtools on the TV as the navigation is weird
|
||||
|
||||
const RootApp = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider>
|
||||
<UserSettingsProvider>
|
||||
const RootApp = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider>
|
||||
<WebConfigProvider>
|
||||
<RootAppRouter />
|
||||
</WebConfigProvider>
|
||||
</UserSettingsProvider>
|
||||
</ApiProvider>
|
||||
{useReactQueryDevtools && (
|
||||
</ApiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootApp;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { EXPERIMENTAL_APP_ROUTES } from 'apps/experimental/routes/routes';
|
||||
import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
|
||||
import AppHeader from 'components/AppHeader';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import BangRedirect from 'components/router/BangRedirect';
|
||||
import { createRouterHistory } from 'components/router/routerHistory';
|
||||
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||
|
||||
@@ -24,11 +23,7 @@ const router = createHashRouter([
|
||||
element: <RootAppLayout />,
|
||||
children: [
|
||||
...(isExperimentalLayout ? EXPERIMENTAL_APP_ROUTES : STABLE_APP_ROUTES),
|
||||
...DASHBOARD_APP_ROUTES,
|
||||
{
|
||||
path: '!/*',
|
||||
Component: BangRedirect
|
||||
}
|
||||
...DASHBOARD_APP_ROUTES
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
6
src/apiclient.d.ts
vendored
6
src/apiclient.d.ts
vendored
@@ -182,7 +182,7 @@ declare module 'jellyfin-apiclient' {
|
||||
getPluginConfiguration(id: string): Promise<any>;
|
||||
getPublicSystemInfo(): Promise<PublicSystemInfo>;
|
||||
getPublicUsers(): Promise<UserDto[]>;
|
||||
getQuickConnect(verb: string): Promise<void | boolean | number | QuickConnectResult | QuickConnectState>;
|
||||
getQuickConnect(verb: string): Promise<void|boolean|number|QuickConnectResult|QuickConnectState>;
|
||||
getReadySyncItems(deviceId: string): Promise<any>;
|
||||
getRecordingFolders(userId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getRegistrationInfo(feature: string): Promise<any>;
|
||||
@@ -308,7 +308,7 @@ declare module 'jellyfin-apiclient' {
|
||||
class AppStore {
|
||||
constructor();
|
||||
|
||||
getItem(name: string): string | null;
|
||||
getItem(name: string): string|null;
|
||||
removeItem(name: string): void;
|
||||
setItem(name: string, value: string): void;
|
||||
}
|
||||
@@ -329,7 +329,7 @@ declare module 'jellyfin-apiclient' {
|
||||
connectToServer(server: any, options?: any): Promise<any>;
|
||||
connectToServers(servers: any[], options?: any): Promise<any>;
|
||||
deleteServer(serverId: string): Promise<void>;
|
||||
getApiClient(item: BaseItemDto | string): ApiClient;
|
||||
getApiClient(item: BaseItemDto|string): ApiClient;
|
||||
getApiClients(): ApiClient[];
|
||||
getAvailableServers(): any[];
|
||||
getOrCreateApiClient(serverId: string): ApiClient;
|
||||
|
||||
@@ -2,9 +2,7 @@ import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { type Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import React, { FC, StrictMode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import AppBody from 'components/AppBody';
|
||||
@@ -12,26 +10,26 @@ import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import ElevationScroll from 'components/ElevationScroll';
|
||||
import { DRAWER_WIDTH } from 'components/ResponsiveDrawer';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocale } from 'hooks/useLocale';
|
||||
|
||||
import AppTabs from './components/AppTabs';
|
||||
import AppDrawer from './components/drawer/AppDrawer';
|
||||
import HelpButton from './components/toolbar/HelpButton';
|
||||
import { DASHBOARD_APP_PATHS } from './routes/routes';
|
||||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
const DRAWERLESS_PATHS = [ DASHBOARD_APP_PATHS.MetadataManager ];
|
||||
interface AppLayoutProps {
|
||||
drawerlessPaths: string[]
|
||||
}
|
||||
|
||||
export const Component: FC = () => {
|
||||
const AppLayout: FC<AppLayoutProps> = ({
|
||||
drawerlessPaths
|
||||
}) => {
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||
const location = useLocation();
|
||||
const { user } = useApi();
|
||||
const { dateFnsLocale } = useLocale();
|
||||
|
||||
const isMediumScreen = useMediaQuery((t: Theme) => t.breakpoints.up('md'));
|
||||
const isDrawerAvailable = Boolean(user)
|
||||
&& !DRAWERLESS_PATHS.some(path => location.pathname.startsWith(`/${path}`));
|
||||
&& !drawerlessPaths.some(path => location.pathname.startsWith(`/${path}`));
|
||||
const isDrawerOpen = isDrawerActive && isDrawerAvailable;
|
||||
|
||||
const onToggleDrawer = useCallback(() => {
|
||||
@@ -48,59 +46,54 @@ export const Component: FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={dateFnsLocale}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<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}
|
||||
buttons={
|
||||
<HelpButton />
|
||||
}
|
||||
>
|
||||
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||
</AppToolbar>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</StrictMode>
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<ElevationScroll elevate={false}>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: isDrawerAvailable ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%'
|
||||
},
|
||||
ml: {
|
||||
xs: 0,
|
||||
md: isDrawerAvailable ? DRAWER_WIDTH : 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
</Box>
|
||||
<AppToolbar
|
||||
isDrawerAvailable={!isMediumScreen && isDrawerAvailable}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
onDrawerButtonClick={onToggleDrawer}
|
||||
>
|
||||
<AppTabs isDrawerOpen={isDrawerOpen} />
|
||||
</AppToolbar>
|
||||
</AppBar>
|
||||
</ElevationScroll>
|
||||
|
||||
{
|
||||
isDrawerAvailable && (
|
||||
<AppDrawer
|
||||
open={isDrawerOpen}
|
||||
onClose={onToggleDrawer}
|
||||
onOpen={onToggleDrawer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
<AppBody>
|
||||
<Outlet />
|
||||
</AppBody>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -9,7 +9,7 @@ $drawer-width: 240px;
|
||||
|
||||
// Fix dashboard pages layout to work with drawer
|
||||
.dashboardDocument {
|
||||
.mainAnimatedPage:not(.metadataEditorPage) {
|
||||
.mainAnimatedPage {
|
||||
@media all and (min-width: $mui-bp-md) {
|
||||
left: $drawer-width;
|
||||
}
|
||||
@@ -31,8 +31,4 @@ $drawer-width: 240px;
|
||||
padding-top: 3.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataEditorPage {
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import React from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const LogLevelChip = ({ level }: { level: LogLevel }) => {
|
||||
let color: 'info' | 'warning' | 'error' | undefined;
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import Info from '@mui/icons-material/Info';
|
||||
import Box from '@mui/material/Box';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { type FC, useCallback, useState } from 'react';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
|
||||
import type { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
|
||||
|
||||
const OverviewCell: FC<ActivityLogEntryCell> = ({ row }) => {
|
||||
const { ShortOverview, Overview } = row.original;
|
||||
const OverviewCell: FC<ActivityLogEntry> = ({ Overview, ShortOverview }) => {
|
||||
const displayValue = ShortOverview ?? Overview;
|
||||
const [ open, setOpen ] = useState(false);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { type RefAttributes } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GridActionsCellItem, type GridActionsCellItemProps } from '@mui/x-data-grid';
|
||||
|
||||
type GridActionsCellLinkProps = { to: string } & GridActionsCellItemProps & RefAttributes<HTMLButtonElement>;
|
||||
|
||||
/**
|
||||
* Link component to use in mui's data-grid action column due to a current bug with passing props to custom link components.
|
||||
* @see https://github.com/mui/mui-x/issues/4654
|
||||
*/
|
||||
const GridActionsCellLink = ({ to, ...props }: GridActionsCellLinkProps) => (
|
||||
<Link to={to}>
|
||||
<GridActionsCellItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default GridActionsCellLink;
|
||||
@@ -29,8 +29,8 @@ const AppDrawer: FC<ResponsiveDrawerProps> = ({
|
||||
<ServerDrawerSection />
|
||||
<DevicesDrawerSection />
|
||||
<LiveTvDrawerSection />
|
||||
<PluginDrawerSection />
|
||||
<AdvancedDrawerSection />
|
||||
<PluginDrawerSection />
|
||||
</ResponsiveDrawer>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import Article from '@mui/icons-material/Article';
|
||||
import EditNotifications from '@mui/icons-material/EditNotifications';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import Extension from '@mui/icons-material/Extension';
|
||||
import Lan from '@mui/icons-material/Lan';
|
||||
import Schedule from '@mui/icons-material/Schedule';
|
||||
import VpnKey from '@mui/icons-material/VpnKey';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const PLUGIN_PATHS = [
|
||||
'/dashboard/plugins',
|
||||
'/dashboard/plugins/catalog',
|
||||
'/dashboard/plugins/repositories',
|
||||
'/dashboard/plugins/add',
|
||||
'/configurationpage'
|
||||
];
|
||||
|
||||
const AdvancedDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
aria-labelledby='advanced-subheader'
|
||||
@@ -46,6 +64,36 @@ const AdvancedDrawerSection = () => {
|
||||
<ListItemText primary={globalize.translate('TabLogs')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/notifications'>
|
||||
<ListItemIcon>
|
||||
<EditNotifications />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('Notifications')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/plugins' selected={false}>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabPlugins')} />
|
||||
{isPluginSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isPluginSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
<ListItemLink to='/dashboard/plugins' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/plugins/catalog' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabCatalog')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/plugins/repositories' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabRepositories')} />
|
||||
</ListItemLink>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/tasks'>
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Devices, Analytics } from '@mui/icons-material';
|
||||
import { Devices, Analytics, Input } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
@@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const DevicesDrawerSection = () => {
|
||||
return (
|
||||
@@ -35,6 +35,14 @@ const DevicesDrawerSection = () => {
|
||||
<ListItemText primary={globalize.translate('HeaderActivity')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/dlna'>
|
||||
<ListItemIcon>
|
||||
<Input />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'DLNA'} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const LiveTvDrawerSection = () => {
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import Extension from '@mui/icons-material/Extension';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import Public from '@mui/icons-material/Public';
|
||||
import { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { Folder } from '@mui/icons-material';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
|
||||
const PluginDrawerSection = () => {
|
||||
const {
|
||||
data: pagesInfo,
|
||||
error
|
||||
} = useConfigurationPages({ enableInMainMenu: true });
|
||||
const { api } = useApi();
|
||||
const [ pagesInfo, setPagesInfo ] = useState<ConfigurationPageInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) console.error('[PluginDrawerSection] unable to fetch plugin config pages', error);
|
||||
}, [ error ]);
|
||||
const fetchPluginPages = async () => {
|
||||
if (!api) return;
|
||||
|
||||
const pagesResponse = await getDashboardApi(api)
|
||||
.getConfigurationPages({ enableInMainMenu: true });
|
||||
|
||||
setPagesInfo(pagesResponse.data);
|
||||
};
|
||||
|
||||
fetchPluginPages()
|
||||
.catch(err => {
|
||||
console.error('[PluginDrawerSection] unable to fetch plugin config pages', err);
|
||||
});
|
||||
}, [ api ]);
|
||||
|
||||
if (!api || pagesInfo.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
@@ -31,39 +46,19 @@ const PluginDrawerSection = () => {
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins'
|
||||
includePaths={[ '/configurationpage' ]}
|
||||
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins/catalog'
|
||||
includePaths={[ '/dashboard/plugins/repositories' ]}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Public />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabCatalog')} />
|
||||
</ListItemLink>
|
||||
|
||||
{pagesInfo?.map(pageInfo => (
|
||||
<ListItemLink
|
||||
key={pageInfo.PluginId}
|
||||
to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{/* TODO: Support different icons? */}
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={pageInfo.DisplayName} />
|
||||
</ListItemLink>
|
||||
))}
|
||||
{
|
||||
pagesInfo.map(pageInfo => (
|
||||
<ListItem key={pageInfo.PluginId} disablePadding>
|
||||
<ListItemLink to={`/${Dashboard.getPluginUrl(pageInfo.Name)}`}>
|
||||
<ListItemIcon>
|
||||
{/* TODO: Support different icons? */}
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={pageInfo.DisplayName} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
))
|
||||
}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Dashboard, ExpandLess, ExpandMore, LibraryAdd, People, PlayCircle, Settings } from '@mui/icons-material';
|
||||
import Palette from '@mui/icons-material/Palette';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const LIBRARY_PATHS = [
|
||||
'/dashboard/libraries',
|
||||
@@ -30,16 +28,8 @@ const PLAYBACK_PATHS = [
|
||||
const ServerDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const [ isLibrarySectionOpen, setIsLibrarySectionOpen ] = useState(LIBRARY_PATHS.includes(location.pathname));
|
||||
const [ isPlaybackSectionOpen, setIsPlaybackSectionOpen ] = useState(PLAYBACK_PATHS.includes(location.pathname));
|
||||
|
||||
const onLibrarySectionClick = useCallback(() => {
|
||||
setIsLibrarySectionOpen(isOpen => !isOpen);
|
||||
}, []);
|
||||
|
||||
const onPlaybackSectionClick = useCallback(() => {
|
||||
setIsPlaybackSectionOpen(isOpen => !isOpen);
|
||||
}, []);
|
||||
const isLibrarySectionOpen = LIBRARY_PATHS.includes(location.pathname);
|
||||
const isPlaybackSectionOpen = PLAYBACK_PATHS.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
@@ -66,12 +56,6 @@ const ServerDrawerSection = () => {
|
||||
<ListItemText primary={globalize.translate('General')} />
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItemLink to='/dashboard/branding'>
|
||||
<ListItemIcon>
|
||||
<Palette />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderBranding')} />
|
||||
</ListItemLink>
|
||||
<ListItem disablePadding>
|
||||
<ListItemLink to='/dashboard/users'>
|
||||
<ListItemIcon>
|
||||
@@ -81,13 +65,13 @@ const ServerDrawerSection = () => {
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={onLibrarySectionClick}>
|
||||
<ListItemLink to='/dashboard/libraries' selected={false}>
|
||||
<ListItemIcon>
|
||||
<LibraryAdd />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('HeaderLibraries')} />
|
||||
{isLibrarySectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isLibrarySectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
@@ -98,7 +82,7 @@ const ServerDrawerSection = () => {
|
||||
<ListItemText inset primary={globalize.translate('Display')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/libraries/metadata' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('LabelMetadata')} />
|
||||
<ListItemText inset primary={globalize.translate('Metadata')} />
|
||||
</ListItemLink>
|
||||
<ListItemLink to='/dashboard/libraries/nfo' sx={{ pl: 4 }}>
|
||||
<ListItemText inset primary={globalize.translate('TabNfoSettings')} />
|
||||
@@ -106,13 +90,13 @@ const ServerDrawerSection = () => {
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={onPlaybackSectionClick}>
|
||||
<ListItemLink to='/dashboard/playback/transcoding' selected={false}>
|
||||
<ListItemIcon>
|
||||
<PlayCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TitlePlayback')} />
|
||||
{isPlaybackSectionOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
<Collapse in={isPlaybackSectionOpen} timeout='auto' unmountOnExit>
|
||||
<List component='div' disablePadding>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import HelpOutline from '@mui/icons-material/HelpOutline';
|
||||
import IconButton from '@mui/material/IconButton/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip/Tooltip';
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { HelpLinks } from 'apps/dashboard/constants/helpLinks';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const HelpButton = () => (
|
||||
<Routes>
|
||||
{
|
||||
HelpLinks.map(({ paths, url }) => paths.map(path => (
|
||||
<Route
|
||||
key={[url, path].join('-')}
|
||||
path={path}
|
||||
element={
|
||||
<Tooltip title={globalize.translate('Help')}>
|
||||
<IconButton
|
||||
href={url}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
size='large'
|
||||
color='inherit'
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
))).flat()
|
||||
}
|
||||
</Routes>
|
||||
);
|
||||
|
||||
export default HelpButton;
|
||||
@@ -1,54 +0,0 @@
|
||||
export const HelpLinks = [
|
||||
{
|
||||
paths: ['/dashboard/devices'],
|
||||
url: 'https://jellyfin.org/docs/general/server/devices'
|
||||
}, {
|
||||
paths: ['/dashboard/libraries'],
|
||||
url: 'https://jellyfin.org/docs/general/server/libraries'
|
||||
}, {
|
||||
paths: [
|
||||
'/dashboard/livetv',
|
||||
'/dashboard/livetv/tuner',
|
||||
'/dashboard/recordings'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/live-tv/'
|
||||
}, {
|
||||
paths: ['/dashboard/livetv/guide'],
|
||||
url: 'https://jellyfin.org/docs/general/server/live-tv/setup-guide#adding-guide-data'
|
||||
}, {
|
||||
paths: ['/dashboard/networking'],
|
||||
url: 'https://jellyfin.org/docs/general/networking/'
|
||||
}, {
|
||||
paths: ['/dashboard/playback/transcoding'],
|
||||
url: 'https://jellyfin.org/docs/general/server/transcoding'
|
||||
}, {
|
||||
paths: [
|
||||
'/dashboard/plugins',
|
||||
'/dashboard/plugins/catalog'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/plugins/'
|
||||
}, {
|
||||
paths: ['/dashboard/plugins/repositories'],
|
||||
url: 'https://jellyfin.org/docs/general/server/plugins/#repositories'
|
||||
}, {
|
||||
paths: [
|
||||
'/dashboard/branding',
|
||||
'/dashboard/settings'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/settings'
|
||||
}, {
|
||||
paths: ['/dashboard/tasks'],
|
||||
url: 'https://jellyfin.org/docs/general/server/tasks'
|
||||
}, {
|
||||
paths: ['/dashboard/users'],
|
||||
url: 'https://jellyfin.org/docs/general/server/users/adding-managing-users'
|
||||
}, {
|
||||
paths: [
|
||||
'/dashboard/users/access',
|
||||
'/dashboard/users/parentalcontrol',
|
||||
'/dashboard/users/password',
|
||||
'/dashboard/users/profile'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/users/'
|
||||
}
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ActivityLogApiGetLogEntriesRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const fetchLogEntries = async (
|
||||
api?: Api,
|
||||
requestParams?: ActivityLogApiGetLogEntriesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchLogEntries] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getActivityLogApi(api).getLogEntries(requestParams, {
|
||||
signal: options?.signal
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLogEntires = (
|
||||
requestParams: ActivityLogApiGetLogEntriesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery({
|
||||
queryKey: ['LogEntries', requestParams],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchLogEntries(api, requestParams, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import IconButton from '@mui/material/IconButton/IconButton';
|
||||
import PermMedia from '@mui/icons-material/PermMedia';
|
||||
import React, { type FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const ActionsCell: FC<ActivityLogEntryCell> = ({ row }) => (
|
||||
row.original.ItemId ? (
|
||||
<IconButton
|
||||
size='large'
|
||||
title={globalize.translate('LabelMediaDetails')}
|
||||
component={Link}
|
||||
to={`/details?id=${row.original.ItemId}`}
|
||||
>
|
||||
<PermMedia fontSize='inherit' />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
);
|
||||
|
||||
export default ActionsCell;
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import { ActivityLogEntryCell } from '../types/ActivityLogEntryCell';
|
||||
import LogLevelChip from './LogLevelChip';
|
||||
|
||||
const LogLevelCell: FC<ActivityLogEntryCell> = ({ cell }) => {
|
||||
const level = cell.getValue<LogLevel | undefined>();
|
||||
return level ? (
|
||||
<LogLevelChip level={level} />
|
||||
) : undefined;
|
||||
};
|
||||
|
||||
export default LogLevelCell;
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import IconButton from '@mui/material/IconButton/IconButton';
|
||||
import React, { type FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
|
||||
interface UserAvatarButtonProps {
|
||||
user?: UserDto
|
||||
}
|
||||
|
||||
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
|
||||
user?.Id ? (
|
||||
<IconButton
|
||||
size='large'
|
||||
color='inherit'
|
||||
sx={{ padding: 0 }}
|
||||
title={user.Name || undefined}
|
||||
component={Link}
|
||||
to={`/dashboard/users/profile?userId=${user.Id}`}
|
||||
>
|
||||
<UserAvatar user={user} />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
);
|
||||
|
||||
export default UserAvatarButton;
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import type { MRT_Cell, MRT_Row } from 'material-react-table';
|
||||
|
||||
export interface ActivityLogEntryCell {
|
||||
cell: MRT_Cell<ActivityLogEntry>
|
||||
row: MRT_Row<ActivityLogEntry>
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { getBrandingApi } from '@jellyfin/sdk/lib/utils/api/branding-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'BrandingOptions';
|
||||
|
||||
const fetchBrandingOptions = async (
|
||||
api?: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.error('[fetchBrandingOptions] no Api instance provided');
|
||||
throw new Error('No Api instance provided to fetchBrandingOptions');
|
||||
}
|
||||
|
||||
return getBrandingApi(api)
|
||||
.getBrandingOptions(options)
|
||||
.then(({ data }) => data);
|
||||
};
|
||||
|
||||
export const getBrandingOptionsQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QUERY_KEY ],
|
||||
queryFn: ({ signal }) => fetchBrandingOptions(api, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useBrandingOptions = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getBrandingOptionsQuery(api));
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||
|
||||
export const findBestConfigurationPage = (
|
||||
configurationPages: ConfigurationPageInfo[],
|
||||
pluginId: string
|
||||
) => {
|
||||
// Find candidates matching the plugin id
|
||||
const candidates = configurationPages.filter(c => c.PluginId === pluginId);
|
||||
|
||||
// If none are found, return undefined
|
||||
if (candidates.length === 0) return;
|
||||
// If only one is found, return it
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Prefer the first candidate with the EnableInMainMenu flag for consistency
|
||||
const menuCandidate = candidates.find(c => !!c.EnableInMainMenu);
|
||||
if (menuCandidate) return menuCandidate;
|
||||
|
||||
// Fallback to the first match
|
||||
return candidates[0];
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
|
||||
/**
|
||||
* HACK: The Plugins API is returning garbage data in some cases,
|
||||
* so we need to try to find the "best" match if multiple exist.
|
||||
*/
|
||||
export const findBestPluginInfo = (
|
||||
pluginId: string,
|
||||
plugins?: PluginInfo[]
|
||||
) => {
|
||||
if (!plugins) return;
|
||||
// Find all plugin entries with a matching ID
|
||||
const matches = plugins.filter(p => p.Id === pluginId);
|
||||
// Get the first match (or undefined if none)
|
||||
const firstMatch = matches?.[0];
|
||||
|
||||
if (matches.length > 1) {
|
||||
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|
||||
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|
||||
|| firstMatch; // Fallback to the first match
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Plugins = 'Plugins'
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchConfigurationPages = async (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchConfigurationPages] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getDashboardApi(api)
|
||||
.getConfigurationPages(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getConfigurationPagesQuery = (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useConfigurationPages = (
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getConfigurationPagesQuery(api, params));
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useDisablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.disablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useEnablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.enablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useInstallPackage = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPackageApi(api!)
|
||||
.installPackage(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPackageInfo] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
console.warn('[fetchPackageInfo] Missing request params');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackageInfoQuery = (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||
enabled: !!api && !!params?.name
|
||||
});
|
||||
|
||||
export const usePackageInfo = (
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackageInfoQuery(api, params));
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPlugins = async (
|
||||
api?: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPlugins] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getPluginsApi(api)
|
||||
.getPlugins(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPluginsQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Plugins ],
|
||||
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const usePlugins = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPluginsQuery(api));
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useUninstallPlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.uninstallPluginByVersion(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import Link from '@mui/material/Link/Link';
|
||||
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Table from '@mui/material/Table/Table';
|
||||
import TableBody from '@mui/material/TableBody/TableBody';
|
||||
import TableCell from '@mui/material/TableCell/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer/TableContainer';
|
||||
import TableRow from '@mui/material/TableRow/TableRow';
|
||||
import React, { FC } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
interface PluginDetailsTableProps extends PaperProps {
|
||||
isPluginLoading: boolean
|
||||
isRepositoryLoading: boolean
|
||||
pluginDetails?: PluginDetails
|
||||
}
|
||||
|
||||
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
isPluginLoading,
|
||||
isRepositoryLoading,
|
||||
pluginDetails,
|
||||
...paperProps
|
||||
}) => (
|
||||
<TableContainer component={Paper} {...paperProps}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelStatus')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.status
|
||||
|| globalize.translate('LabelNotInstalled')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelVersion')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.version?.version
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelDeveloper')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| pluginDetails?.owner
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelRepository')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={pluginDetails.version.repositoryUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{pluginDetails.version.repositoryName}
|
||||
</Link>
|
||||
))
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
export default PluginDetailsTable;
|
||||
@@ -1,34 +0,0 @@
|
||||
import Paper from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
interface PluginImageProps {
|
||||
isLoading: boolean
|
||||
alt?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const PluginImage: FC<PluginImageProps> = ({
|
||||
isLoading,
|
||||
alt,
|
||||
url
|
||||
}) => (
|
||||
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
variant='rectangular'
|
||||
width='100%'
|
||||
height='100%'
|
||||
/>
|
||||
)}
|
||||
{url && (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
width='100%'
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default PluginImage;
|
||||
@@ -1,67 +0,0 @@
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import DownloadDone from '@mui/icons-material/DownloadDone';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import Accordion from '@mui/material/Accordion/Accordion';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import MarkdownBox from 'components/MarkdownBox';
|
||||
import { getDisplayDateTime } from 'scripts/datetime';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
interface PluginRevisionsProps {
|
||||
pluginDetails?: PluginDetails,
|
||||
onInstall: (version?: VersionInfo) => () => void
|
||||
}
|
||||
|
||||
const PluginRevisions: FC<PluginRevisionsProps> = ({
|
||||
pluginDetails,
|
||||
onInstall
|
||||
}) => (
|
||||
pluginDetails?.versions?.map(version => (
|
||||
<Accordion key={version.checksum}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
>
|
||||
{version.version}
|
||||
{version.timestamp && (<>
|
||||
—
|
||||
{getDisplayDateTime(version.timestamp)}
|
||||
</>)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<MarkdownBox
|
||||
fallback={globalize.translate('LabelNoChangelog')}
|
||||
markdown={version.changelog}
|
||||
/>
|
||||
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
|
||||
<Button
|
||||
disabled
|
||||
startIcon={<DownloadDone />}
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('LabelInstalled')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
variant='outlined'
|
||||
onClick={onInstall(version)}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
);
|
||||
|
||||
export default PluginRevisions;
|
||||
@@ -1,15 +0,0 @@
|
||||
/** A mapping of category names used by the plugin repository to translation keys. */
|
||||
export const CATEGORY_LABELS = {
|
||||
Administration: 'HeaderAdmin',
|
||||
Anime: 'Anime',
|
||||
Authentication: 'LabelAuthProvider', // Legacy
|
||||
Books: 'Books',
|
||||
Channel: 'Channels', // Unused?
|
||||
General: 'General',
|
||||
LiveTV: 'LiveTV',
|
||||
Metadata: 'LabelMetadata', // Legacy
|
||||
MoviesAndShows: 'MoviesAndShows',
|
||||
Music: 'TabMusic',
|
||||
Subtitles: 'Subtitles',
|
||||
Other: 'Other'
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
isEnabled: boolean
|
||||
name?: string
|
||||
owner?: string
|
||||
configurationPage?: ConfigurationPageInfo
|
||||
status?: PluginStatus
|
||||
version?: VersionInfo
|
||||
versions: VersionInfo[]
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
||||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'branding', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'dlna', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'notifications', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/add', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/password', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
|
||||
];
|
||||
|
||||
@@ -31,6 +31,12 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'dashboard/devices/device',
|
||||
view: 'dashboard/devices/device.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/add',
|
||||
pageProps: {
|
||||
controller: 'dashboard/plugins/add/index',
|
||||
view: 'dashboard/plugins/add/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
|
||||
40
src/apps/dashboard/routes/_redirects.ts
Normal file
40
src/apps/dashboard/routes/_redirects.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Redirect } from 'components/router/Redirect';
|
||||
|
||||
export const REDIRECTS: Redirect[] = [
|
||||
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
|
||||
{ from: 'apikeys.html', to: '/dashboard/keys' },
|
||||
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
|
||||
{ from: 'dashboard.html', to: '/dashboard' },
|
||||
{ from: 'dashboardgeneral.html', to: '/dashboard/settings' },
|
||||
{ from: 'device.html', to: '/dashboard/devices/edit' },
|
||||
{ from: 'devices.html', to: '/dashboard/devices' },
|
||||
{ from: 'dlnaprofile.html', to: '/dashboard/dlna' },
|
||||
{ from: 'dlnaprofiles.html', to: '/dashboard/dlna' },
|
||||
{ from: 'dlnasettings.html', to: '/dashboard/dlna' },
|
||||
{ from: 'edititemmetadata.html', to: '/metadata' },
|
||||
{ from: 'encodingsettings.html', to: '/dashboard/playback/transcoding' },
|
||||
{ from: 'installedplugins.html', to: '/dashboard/plugins' },
|
||||
{ from: 'library.html', to: '/dashboard/libraries' },
|
||||
{ from: 'librarydisplay.html', to: '/dashboard/libraries/display' },
|
||||
{ from: 'livetvguideprovider.html', to: '/dashboard/livetv/guide' },
|
||||
{ from: 'livetvsettings.html', to: '/dashboard/recordings' },
|
||||
{ from: 'livetvstatus.html', to: '/dashboard/livetv' },
|
||||
{ from: 'livetvtuner.html', to: '/dashboard/livetv/tuner' },
|
||||
{ from: 'log.html', to: '/dashboard/logs' },
|
||||
{ from: 'metadataimages.html', to: '/dashboard/libraries/metadata' },
|
||||
{ from: 'metadatanfo.html', to: '/dashboard/libraries/nfo' },
|
||||
{ from: 'networking.html', to: '/dashboard/networking' },
|
||||
{ from: 'notificationsettings.html', to: '/dashboard/notifications' },
|
||||
{ from: 'playbackconfiguration.html', to: '/dashboard/playback/resume' },
|
||||
{ from: 'repositories.html', to: '/dashboard/plugins/repositories' },
|
||||
{ from: 'scheduledtask.html', to: '/dashboard/tasks/edit' },
|
||||
{ from: 'scheduledtasks.html', to: '/dashboard/tasks' },
|
||||
{ from: 'serveractivity.html', to: '/dashboard/activity' },
|
||||
{ from: 'streamingsettings.html', to: '/dashboard/playback/streaming' },
|
||||
{ from: 'useredit.html', to: '/dashboard/users/profile' },
|
||||
{ from: 'userlibraryaccess.html', to: '/dashboard/users/access' },
|
||||
{ from: 'usernew.html', to: '/dashboard/users/add' },
|
||||
{ from: 'userparentalcontrol.html', to: '/dashboard/users/parentalcontrol' },
|
||||
{ from: 'userpassword.html', to: '/dashboard/users/password' },
|
||||
{ from: 'userprofiles.html', to: '/dashboard/users' }
|
||||
];
|
||||
@@ -1,35 +1,35 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
||||
import PermMedia from '@mui/icons-material/PermMedia';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useLogEntires } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
|
||||
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
|
||||
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
|
||||
import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton';
|
||||
import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||
import Page from 'components/Page';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||
import globalize from 'lib/globalize';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { parseISO8601Date, toLocaleDateString, toLocaleTimeString } from 'scripts/datetime';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { toBoolean } from 'utils/string';
|
||||
|
||||
type UsersRecords = Record<string, UserDto>;
|
||||
import LogLevelChip from '../components/activityTable/LogLevelChip';
|
||||
import OverviewCell from '../components/activityTable/OverviewCell';
|
||||
import GridActionsCellLink from '../components/dataGrid/GridActionsCellLink';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const VIEW_PARAM = 'useractivity';
|
||||
|
||||
const enum ActivityView {
|
||||
All = 'All',
|
||||
User = 'User',
|
||||
System = 'System'
|
||||
All,
|
||||
User,
|
||||
System
|
||||
}
|
||||
|
||||
const getActivityView = (param: string | null) => {
|
||||
@@ -38,132 +38,173 @@ const getActivityView = (param: string | null) => {
|
||||
return ActivityView.System;
|
||||
};
|
||||
|
||||
const getUserCell = (users: UsersRecords) => function UserCell({ row }: ActivityLogEntryCell) {
|
||||
return (
|
||||
<UserAvatarButton user={row.original.UserId && users[row.original.UserId] || undefined} />
|
||||
);
|
||||
};
|
||||
const getRowId = (row: ActivityLogEntry) => row.Id ?? -1;
|
||||
|
||||
const Activity = () => {
|
||||
const { api } = useApi();
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
|
||||
const [ activityView, setActivityView ] = useState(
|
||||
getActivityView(searchParams.get(VIEW_PARAM)));
|
||||
|
||||
const [ pagination, setPagination ] = useState({
|
||||
pageIndex: 0,
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ paginationModel, setPaginationModel ] = useState({
|
||||
page: 0,
|
||||
pageSize: DEFAULT_PAGE_SIZE
|
||||
});
|
||||
const [ rowCount, setRowCount ] = useState(0);
|
||||
const [ rows, setRows ] = useState<ActivityLogEntry[]>([]);
|
||||
const [ users, setUsers ] = useState<Record<string, UserDto>>({});
|
||||
|
||||
const { data: usersData, isLoading: isUsersLoading } = useUsers();
|
||||
|
||||
const users: UsersRecords = useMemo(() => {
|
||||
if (!usersData) return {};
|
||||
|
||||
return usersData.reduce<UsersRecords>((acc, user) => {
|
||||
const userId = user.Id;
|
||||
if (!userId) return acc;
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[userId]: user
|
||||
};
|
||||
}, {});
|
||||
}, [ usersData ]);
|
||||
|
||||
const userNames = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
usersData?.forEach(user => {
|
||||
if (user.Name) names.push(user.Name);
|
||||
});
|
||||
return names;
|
||||
}, [ usersData ]);
|
||||
|
||||
const UserCell = getUserCell(users);
|
||||
|
||||
const activityParams = useMemo(() => ({
|
||||
startIndex: pagination.pageIndex * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
hasUserId: activityView !== ActivityView.All ? activityView === ActivityView.User : undefined
|
||||
}), [activityView, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const { data: logEntries, isLoading: isLogEntriesLoading } = useLogEntires(activityParams);
|
||||
|
||||
const isLoading = isUsersLoading || isLogEntriesLoading;
|
||||
|
||||
const userColumn: MRT_ColumnDef<ActivityLogEntry>[] = useMemo(() =>
|
||||
(activityView === ActivityView.System) ? [] : [{
|
||||
id: 'User',
|
||||
accessorFn: row => row.UserId && users[row.UserId]?.Name,
|
||||
header: globalize.translate('LabelUser'),
|
||||
size: 75,
|
||||
Cell: UserCell,
|
||||
enableResizing: false,
|
||||
muiTableBodyCellProps: {
|
||||
align: 'center'
|
||||
},
|
||||
filterVariant: 'multi-select',
|
||||
filterSelectOptions: userNames
|
||||
}], [ activityView, userNames, users, UserCell ]);
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
|
||||
const userColDef: GridColDef[] = activityView !== ActivityView.System ? [
|
||||
{
|
||||
id: 'Date',
|
||||
accessorFn: row => parseISO8601Date(row.Date),
|
||||
header: globalize.translate('LabelTime'),
|
||||
size: 160,
|
||||
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
|
||||
filterVariant: 'datetime-range'
|
||||
},
|
||||
{
|
||||
accessorKey: 'Severity',
|
||||
header: globalize.translate('LabelLevel'),
|
||||
size: 90,
|
||||
Cell: LogLevelCell,
|
||||
enableResizing: false,
|
||||
muiTableBodyCellProps: {
|
||||
align: 'center'
|
||||
},
|
||||
filterVariant: 'multi-select',
|
||||
filterSelectOptions: Object.values(LogLevel).map(level => globalize.translate(`LogLevel.${level}`))
|
||||
},
|
||||
...userColumn,
|
||||
{
|
||||
accessorKey: 'Name',
|
||||
header: globalize.translate('LabelName'),
|
||||
size: 270
|
||||
},
|
||||
{
|
||||
id: 'Overview',
|
||||
accessorFn: row => row.ShortOverview || row.Overview,
|
||||
header: globalize.translate('LabelOverview'),
|
||||
size: 170,
|
||||
Cell: OverviewCell
|
||||
},
|
||||
{
|
||||
accessorKey: 'Type',
|
||||
header: globalize.translate('LabelType'),
|
||||
size: 150
|
||||
},
|
||||
{
|
||||
id: 'Actions',
|
||||
accessorFn: row => row.ItemId,
|
||||
header: '',
|
||||
size: 60,
|
||||
Cell: ActionsCell,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilter: false,
|
||||
enableResizing: false,
|
||||
enableSorting: false
|
||||
field: 'User',
|
||||
headerName: globalize.translate('LabelUser'),
|
||||
width: 60,
|
||||
valueGetter: ({ row }) => users[row.UserId]?.Name,
|
||||
renderCell: ({ row }) => (
|
||||
<IconButton
|
||||
size='large'
|
||||
color='inherit'
|
||||
sx={{ padding: 0 }}
|
||||
title={users[row.UserId]?.Name ?? undefined}
|
||||
component={Link}
|
||||
to={`/dashboard/users/profile?userId=${row.UserId}`}
|
||||
>
|
||||
<UserAvatar user={users[row.UserId]} />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
], [ userColumn ]);
|
||||
] : [];
|
||||
|
||||
const onViewChange = useCallback((_e: React.MouseEvent<HTMLElement, MouseEvent>, newView: ActivityView | null) => {
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'Date',
|
||||
headerName: globalize.translate('LabelDate'),
|
||||
width: 90,
|
||||
type: 'date',
|
||||
valueGetter: ({ value }) => parseISO8601Date(value),
|
||||
valueFormatter: ({ value }) => toLocaleDateString(value)
|
||||
},
|
||||
{
|
||||
field: 'Time',
|
||||
headerName: globalize.translate('LabelTime'),
|
||||
width: 100,
|
||||
type: 'dateTime',
|
||||
valueGetter: ({ row }) => parseISO8601Date(row.Date),
|
||||
valueFormatter: ({ value }) => toLocaleTimeString(value)
|
||||
},
|
||||
{
|
||||
field: 'Severity',
|
||||
headerName: globalize.translate('LabelLevel'),
|
||||
width: 110,
|
||||
renderCell: ({ value }) => (
|
||||
value ? (
|
||||
<LogLevelChip level={value} />
|
||||
) : undefined
|
||||
)
|
||||
},
|
||||
...userColDef,
|
||||
{
|
||||
field: 'Name',
|
||||
headerName: globalize.translate('LabelName'),
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
field: 'Overview',
|
||||
headerName: globalize.translate('LabelOverview'),
|
||||
width: 200,
|
||||
valueGetter: ({ row }) => row.ShortOverview ?? row.Overview,
|
||||
renderCell: ({ row }) => (
|
||||
<OverviewCell {...row} />
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'Type',
|
||||
headerName: globalize.translate('LabelType'),
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
width: 50,
|
||||
getActions: ({ row }) => {
|
||||
const actions = [];
|
||||
|
||||
if (row.ItemId) {
|
||||
actions.push(
|
||||
<GridActionsCellLink
|
||||
size='large'
|
||||
icon={<PermMedia />}
|
||||
label={globalize.translate('LabelMediaDetails')}
|
||||
title={globalize.translate('LabelMediaDetails')}
|
||||
to={`/details?id=${row.ItemId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const onViewChange = useCallback((_e, newView: ActivityView | null) => {
|
||||
if (newView !== null) {
|
||||
setActivityView(newView);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (api) {
|
||||
const fetchUsers = async () => {
|
||||
const { data } = await getUserApi(api).getUsers();
|
||||
const usersById: Record<string, UserDto> = {};
|
||||
data.forEach(user => {
|
||||
if (user.Id) {
|
||||
usersById[user.Id] = user;
|
||||
}
|
||||
});
|
||||
|
||||
setUsers(usersById);
|
||||
};
|
||||
|
||||
fetchUsers()
|
||||
.catch(err => {
|
||||
console.error('[activity] failed to fetch users', err);
|
||||
});
|
||||
}
|
||||
}, [ api ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (api) {
|
||||
const fetchActivity = async () => {
|
||||
const params: {
|
||||
startIndex: number,
|
||||
limit: number,
|
||||
hasUserId?: boolean
|
||||
} = {
|
||||
startIndex: paginationModel.page * paginationModel.pageSize,
|
||||
limit: paginationModel.pageSize
|
||||
};
|
||||
if (activityView !== ActivityView.All) {
|
||||
params.hasUserId = activityView === ActivityView.User;
|
||||
}
|
||||
|
||||
const { data } = await getActivityLogApi(api)
|
||||
.getLogEntries(params);
|
||||
|
||||
setRowCount(data.TotalRecordCount ?? 0);
|
||||
setRows(data.Items ?? []);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
fetchActivity()
|
||||
.catch(err => {
|
||||
console.error('[activity] failed to fetch activity log entries', err);
|
||||
});
|
||||
}
|
||||
}, [ activityView, api, paginationModel ]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentViewParam = getActivityView(searchParams.get(VIEW_PARAM));
|
||||
if (currentViewParam !== activityView) {
|
||||
@@ -176,83 +217,56 @@ const Activity = () => {
|
||||
}
|
||||
}, [ activityView, searchParams, setSearchParams ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: logEntries?.Items || [],
|
||||
|
||||
// Enable custom features
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
// Sticky header/footer
|
||||
enableStickyFooter: true,
|
||||
enableStickyHeader: true,
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer
|
||||
}
|
||||
},
|
||||
|
||||
// State
|
||||
initialState: {
|
||||
density: 'compact'
|
||||
},
|
||||
state: {
|
||||
isLoading,
|
||||
pagination
|
||||
},
|
||||
|
||||
// Server pagination
|
||||
manualPagination: true,
|
||||
onPaginationChange: setPagination,
|
||||
rowCount: logEntries?.TotalRecordCount || 0,
|
||||
|
||||
// Custom toolbar contents
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<ToggleButtonGroup
|
||||
size='small'
|
||||
value={activityView}
|
||||
onChange={onViewChange}
|
||||
exclusive
|
||||
>
|
||||
<ToggleButton value={ActivityView.All}>
|
||||
{globalize.translate('All')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.User}>
|
||||
{globalize.translate('LabelUser')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.System}>
|
||||
{globalize.translate('LabelSystem')}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='serverActivityPage'
|
||||
title={globalize.translate('HeaderActivity')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box
|
||||
className='content-primary'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<div className='content-primary'>
|
||||
<Box
|
||||
sx={{
|
||||
marginBottom: 1
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
marginY: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('HeaderActivity')}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant='h2'>
|
||||
{globalize.translate('HeaderActivity')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={activityView}
|
||||
onChange={onViewChange}
|
||||
exclusive
|
||||
>
|
||||
<ToggleButton value={ActivityView.All}>
|
||||
{globalize.translate('All')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.User}>
|
||||
{globalize.translate('LabelUser')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value={ActivityView.System}>
|
||||
{globalize.translate('LabelSystem')}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pageSizeOptions={[ 10, 25, 50, 100 ]}
|
||||
paginationMode='server'
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
rowCount={rowCount}
|
||||
getRowId={getRowId}
|
||||
loading={isLoading}
|
||||
sx={{
|
||||
minHeight: 500
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { BrandingOptions } from '@jellyfin/sdk/lib/generated-client/models/branding-options';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { type ActionFunctionArgs, Form, useActionData } from 'react-router-dom';
|
||||
|
||||
import { getBrandingOptionsQuery, QUERY_KEY, useBrandingOptions } from 'apps/dashboard/features/branding/api/useBrandingOptions';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
import globalize from 'lib/globalize';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
interface ActionData {
|
||||
isSaved: boolean
|
||||
}
|
||||
|
||||
const BRANDING_CONFIG_KEY = 'branding';
|
||||
const BrandingOption = {
|
||||
CustomCss: 'CustomCss',
|
||||
LoginDisclaimer: 'LoginDisclaimer',
|
||||
SplashscreenEnabled: 'SplashscreenEnabled'
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const api = ServerConnections.getCurrentApi();
|
||||
if (!api) throw new Error('No Api instance available');
|
||||
|
||||
const formData = await request.formData();
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const brandingOptions: BrandingOptions = {
|
||||
CustomCss: data.CustomCss?.toString(),
|
||||
LoginDisclaimer: data.LoginDisclaimer?.toString(),
|
||||
SplashscreenEnabled: data.SplashscreenEnabled?.toString() === 'on'
|
||||
};
|
||||
|
||||
await getConfigurationApi(api)
|
||||
.updateNamedConfiguration({
|
||||
key: BRANDING_CONFIG_KEY,
|
||||
body: JSON.stringify(brandingOptions)
|
||||
});
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
|
||||
return {
|
||||
isSaved: true
|
||||
};
|
||||
};
|
||||
|
||||
export const loader = () => {
|
||||
return queryClient.ensureQueryData(
|
||||
getBrandingOptionsQuery(ServerConnections.getCurrentApi()));
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
const actionData = useActionData() as ActionData | undefined;
|
||||
const [ isSubmitting, setIsSubmitting ] = useState(false);
|
||||
|
||||
const {
|
||||
data: defaultBrandingOptions,
|
||||
isPending
|
||||
} = useBrandingOptions();
|
||||
const [ brandingOptions, setBrandingOptions ] = useState(defaultBrandingOptions || {});
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmitting(false);
|
||||
}, [ actionData ]);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
setIsSubmitting(true);
|
||||
}, []);
|
||||
|
||||
const setSplashscreenEnabled = useCallback((_: React.ChangeEvent<HTMLInputElement>, isEnabled: boolean) => {
|
||||
setBrandingOptions({
|
||||
...brandingOptions,
|
||||
[BrandingOption.SplashscreenEnabled]: isEnabled
|
||||
});
|
||||
}, [ brandingOptions ]);
|
||||
|
||||
const setBrandingOption = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
if (Object.keys(BrandingOption).includes(event.target.name)) {
|
||||
setBrandingOptions({
|
||||
...brandingOptions,
|
||||
[event.target.name]: event.target.value
|
||||
});
|
||||
}
|
||||
}, [ brandingOptions ]);
|
||||
|
||||
if (isPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='brandingPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Form
|
||||
method='POST'
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('HeaderBranding')}
|
||||
</Typography>
|
||||
|
||||
{!isSubmitting && actionData?.isSaved && (
|
||||
<Alert severity='success'>
|
||||
{globalize.translate('SettingsSaved')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name={BrandingOption.SplashscreenEnabled}
|
||||
checked={brandingOptions?.SplashscreenEnabled}
|
||||
onChange={setSplashscreenEnabled}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('EnableSplashScreen')}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={5}
|
||||
maxRows={5}
|
||||
InputProps={{
|
||||
className: 'textarea-mono'
|
||||
}}
|
||||
name={BrandingOption.LoginDisclaimer}
|
||||
label={globalize.translate('LabelLoginDisclaimer')}
|
||||
helperText={globalize.translate('LabelLoginDisclaimerHelp')}
|
||||
value={brandingOptions?.LoginDisclaimer}
|
||||
onChange={setBrandingOption}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={5}
|
||||
maxRows={20}
|
||||
InputProps={{
|
||||
className: 'textarea-mono'
|
||||
}}
|
||||
name={BrandingOption.CustomCss}
|
||||
label={globalize.translate('LabelCustomCss')}
|
||||
helperText={globalize.translate('LabelCustomCssHelp')}
|
||||
value={brandingOptions?.CustomCss}
|
||||
onChange={setBrandingOption}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='large'
|
||||
>
|
||||
{globalize.translate('Save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Form>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'BrandingPage';
|
||||
33
src/apps/dashboard/routes/dlna.tsx
Normal file
33
src/apps/dashboard/routes/dlna.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const DlnaPage = () => (
|
||||
<Page
|
||||
id='dlnaSettingsPage'
|
||||
title='DLNA'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div className='content-primary'>
|
||||
<h2>DLNA</h2>
|
||||
<Alert severity='info'>
|
||||
<Box sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('DlnaMovedMessage')}
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/add?name=DLNA&guid=33eba9cd7da14720967fdd7dae7b74a1'
|
||||
>
|
||||
{globalize.translate('GetThePlugin')}
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
|
||||
export default DlnaPage;
|
||||
34
src/apps/dashboard/routes/notifications.tsx
Normal file
34
src/apps/dashboard/routes/notifications.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const NotificationsPage = () => (
|
||||
<Page
|
||||
id='notificationSettingPage'
|
||||
title={globalize.translate('Notifications')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<div className='content-primary'>
|
||||
<h2>{globalize.translate('Notifications')}</h2>
|
||||
|
||||
<Alert severity='info'>
|
||||
<Box sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('NotificationsMovedMessage')}
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/add?name=Webhook&guid=71552a5a5c5c4350a2aeebe451a30173'
|
||||
>
|
||||
{globalize.translate('GetThePlugin')}
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ServerConfiguration } from '@jellyfin/sdk/lib/generated-client/models/server-configuration';
|
||||
import { TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client/models/trickplay-scan-behavior';
|
||||
import { ProcessPriorityClass } from '@jellyfin/sdk/lib/generated-client/models/process-priority-class';
|
||||
import React, { type FC, useCallback, useEffect, useRef } from 'react';
|
||||
import type { ProcessPriorityClass, ServerConfiguration, TrickplayScanBehavior } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { type FunctionComponent, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import Page from '../../../../components/Page';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
@@ -19,10 +17,10 @@ function onSaveComplete() {
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const PlaybackTrickplay: FC = () => {
|
||||
const PlaybackTrickplay: FunctionComponent = () => {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadConfig = useCallback((config: ServerConfiguration) => {
|
||||
const loadConfig = useCallback((config) => {
|
||||
const page = element.current;
|
||||
const options = config.TrickplayOptions;
|
||||
|
||||
@@ -31,18 +29,17 @@ const PlaybackTrickplay: FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options?.EnableHwAcceleration || false;
|
||||
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options?.EnableHwEncoding || false;
|
||||
(page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked = options?.EnableKeyFrameOnlyExtraction || false;
|
||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = (options?.ScanBehavior || TrickplayScanBehavior.NonBlocking);
|
||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = (options?.ProcessPriority || ProcessPriorityClass.Normal);
|
||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options?.Interval?.toString() || '10000';
|
||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options?.WidthResolutions?.join(',') || '';
|
||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options?.TileWidth?.toString() || '10';
|
||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options?.TileHeight?.toString() || '10';
|
||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options?.Qscale?.toString() || '4';
|
||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options?.JpegQuality?.toString() || '90';
|
||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options?.ProcessThreads?.toString() || '1';
|
||||
(page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked = options.EnableHwAcceleration;
|
||||
(page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked = options.EnableHwEncoding;
|
||||
(page.querySelector('#selectScanBehavior') as HTMLSelectElement).value = options.ScanBehavior;
|
||||
(page.querySelector('#selectProcessPriority') as HTMLSelectElement).value = options.ProcessPriority;
|
||||
(page.querySelector('#txtInterval') as HTMLInputElement).value = options.Interval;
|
||||
(page.querySelector('#txtWidthResolutions') as HTMLInputElement).value = options.WidthResolutions.join(',');
|
||||
(page.querySelector('#txtTileWidth') as HTMLInputElement).value = options.TileWidth;
|
||||
(page.querySelector('#txtTileHeight') as HTMLInputElement).value = options.TileHeight;
|
||||
(page.querySelector('#txtQscale') as HTMLInputElement).value = options.Qscale;
|
||||
(page.querySelector('#txtJpegQuality') as HTMLInputElement).value = options.JpegQuality;
|
||||
(page.querySelector('#txtProcessThreads') as HTMLInputElement).value = options.ProcessThreads;
|
||||
|
||||
loading.hide();
|
||||
}, []);
|
||||
@@ -80,7 +77,6 @@ const PlaybackTrickplay: FC = () => {
|
||||
const options = config.TrickplayOptions;
|
||||
options.EnableHwAcceleration = (page.querySelector('.chkEnableHwAcceleration') as HTMLInputElement).checked;
|
||||
options.EnableHwEncoding = (page.querySelector('.chkEnableHwEncoding') as HTMLInputElement).checked;
|
||||
options.EnableKeyFrameOnlyExtraction = (page.querySelector('.chkEnableKeyFrameOnlyExtraction') as HTMLInputElement).checked;
|
||||
options.ScanBehavior = (page.querySelector('#selectScanBehavior') as HTMLSelectElement).value as TrickplayScanBehavior;
|
||||
options.ProcessPriority = (page.querySelector('#selectProcessPriority') as HTMLSelectElement).value as ProcessPriorityClass;
|
||||
options.Interval = Math.max(1, parseInt((page.querySelector('#txtInterval') as HTMLInputElement).value || '10000', 10));
|
||||
@@ -150,6 +146,7 @@ const PlaybackTrickplay: FC = () => {
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('Trickplay')}
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -171,17 +168,6 @@ const PlaybackTrickplay: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='checkboxContainer checkboxContainer-withDescription'>
|
||||
<CheckBoxElement
|
||||
className='chkEnableKeyFrameOnlyExtraction'
|
||||
title='LabelTrickplayKeyFrameOnlyExtraction'
|
||||
/>
|
||||
<div className='fieldDescription checkboxFieldDescription'>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('LabelTrickplayKeyFrameOnlyExtractionHelp')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='verticalSection'>
|
||||
<div className='selectContainer fldSelectScanBehavior'>
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Container from '@mui/material/Container/Container';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup/FormGroup';
|
||||
import Grid from '@mui/material/Grid/Grid';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import Switch from '@mui/material/Switch/Switch';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import React, { type FC, useState, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
|
||||
|
||||
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
|
||||
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
|
||||
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
|
||||
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
|
||||
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
|
||||
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
|
||||
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
|
||||
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
|
||||
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
|
||||
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import Page from 'components/Page';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import { getPluginUrl } from 'utils/dashboard';
|
||||
import { getUri } from 'utils/api';
|
||||
|
||||
interface AlertMessage {
|
||||
severity?: 'success' | 'info' | 'warning' | 'error'
|
||||
messageKey: string
|
||||
}
|
||||
|
||||
// Plugins from this url will be trusted and not prompt for confirmation when installing
|
||||
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
|
||||
|
||||
const PluginPage: FC = () => {
|
||||
const { api } = useApi();
|
||||
const { pluginId } = useParams();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const installPlugin = useInstallPackage();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
|
||||
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||
|
||||
const pluginName = searchParams.get('name') ?? undefined;
|
||||
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isLoading: isConfigurationPagesLoading
|
||||
} = useConfigurationPages();
|
||||
|
||||
const {
|
||||
data: packageInfo,
|
||||
isError: isPackageInfoError,
|
||||
isLoading: isPackageInfoLoading
|
||||
} = usePackageInfo(pluginName ? {
|
||||
name: pluginName,
|
||||
assemblyGuid: pluginId
|
||||
} : undefined);
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isLoading: isPluginsLoading,
|
||||
isError: isPluginsError
|
||||
} = usePlugins();
|
||||
|
||||
const isLoading =
|
||||
isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading;
|
||||
|
||||
const pluginDetails = useMemo<PluginDetails | undefined>(() => {
|
||||
if (pluginId && !isPluginsLoading) {
|
||||
const pluginInfo = findBestPluginInfo(pluginId, plugins);
|
||||
|
||||
let version;
|
||||
if (pluginInfo) {
|
||||
// Find the installed version
|
||||
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
|
||||
version = repoVersion || {
|
||||
version: pluginInfo.Version,
|
||||
VersionNumber: pluginInfo.Version
|
||||
};
|
||||
} else {
|
||||
// Use the latest version
|
||||
version = packageInfo?.versions?.[0];
|
||||
}
|
||||
|
||||
let imageUrl;
|
||||
if (pluginInfo?.HasImage) {
|
||||
imageUrl = getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`, api);
|
||||
}
|
||||
|
||||
return {
|
||||
canUninstall: !!pluginInfo?.CanUninstall,
|
||||
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||
id: pluginId,
|
||||
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
|
||||
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
|
||||
?? pluginInfo?.Status !== PluginStatus.Disabled,
|
||||
name: pluginName || pluginInfo?.Name || packageInfo?.name,
|
||||
owner: packageInfo?.owner,
|
||||
status: pluginInfo?.Status,
|
||||
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
|
||||
version,
|
||||
versions: packageInfo?.versions || []
|
||||
};
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
configurationPages,
|
||||
isEnabledOverride,
|
||||
isPluginsLoading,
|
||||
packageInfo?.description,
|
||||
packageInfo?.imageUrl,
|
||||
packageInfo?.name,
|
||||
packageInfo?.overview,
|
||||
packageInfo?.owner,
|
||||
packageInfo?.versions,
|
||||
pluginId,
|
||||
pluginName,
|
||||
plugins
|
||||
]);
|
||||
|
||||
const alertMessages = useMemo(() => {
|
||||
const alerts: AlertMessage[] = [];
|
||||
|
||||
if (disablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginDisableError' });
|
||||
}
|
||||
|
||||
if (enablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginEnableError' });
|
||||
}
|
||||
|
||||
if (installPlugin.isSuccess) {
|
||||
alerts.push({
|
||||
severity: 'success',
|
||||
messageKey: 'MessagePluginInstalled'
|
||||
});
|
||||
}
|
||||
|
||||
if (installPlugin.isError) {
|
||||
alerts.push({ messageKey: 'MessagePluginInstallError' });
|
||||
}
|
||||
|
||||
if (uninstallPlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginUninstallError' });
|
||||
}
|
||||
|
||||
if (isConfigurationPagesError) {
|
||||
alerts.push({ messageKey: 'PluginLoadConfigError' });
|
||||
}
|
||||
|
||||
if (isPackageInfoError) {
|
||||
alerts.push({
|
||||
severity: 'warning',
|
||||
messageKey: 'PluginLoadRepoError'
|
||||
});
|
||||
}
|
||||
|
||||
if (isPluginsError) {
|
||||
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}, [
|
||||
disablePlugin.isError,
|
||||
enablePlugin.isError,
|
||||
installPlugin.isError,
|
||||
installPlugin.isSuccess,
|
||||
isConfigurationPagesError,
|
||||
isPackageInfoError,
|
||||
isPluginsError,
|
||||
uninstallPlugin.isError
|
||||
]);
|
||||
|
||||
/** Enable/disable the plugin */
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
|
||||
|
||||
if (pluginDetails.isEnabled) {
|
||||
disablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
enablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
disablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Install the plugin or prompt for confirmation if untrusted */
|
||||
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
|
||||
if (!pluginDetails?.name) return;
|
||||
const installVersion = version || pluginDetails.version;
|
||||
if (!installVersion) return;
|
||||
|
||||
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
|
||||
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
|
||||
setPendingInstallVersion(installVersion);
|
||||
setIsInstallConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[PluginPage] installing plugin', installVersion);
|
||||
|
||||
installPlugin.mutate({
|
||||
name: pluginDetails.name,
|
||||
assemblyGuid: pluginDetails.id,
|
||||
version: installVersion.version,
|
||||
repositoryUrl: installVersion.repositoryUrl
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setPendingInstallVersion(undefined);
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Confirm and install the plugin */
|
||||
const onConfirmInstall = useCallback(() => {
|
||||
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
|
||||
setIsInstallConfirmOpen(false);
|
||||
onInstall(pendingInstallVersion, true)();
|
||||
}, [ onInstall, pendingInstallVersion ]);
|
||||
|
||||
/** Close the install confirmation dialog */
|
||||
const onCloseInstallConfirmDialog = useCallback(() => {
|
||||
setPendingInstallVersion(undefined);
|
||||
setIsInstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
/** Show the uninstall confirmation dialog */
|
||||
const onConfirmUninstall = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
/** Uninstall the plugin */
|
||||
const onUninstall = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
|
||||
|
||||
setIsUninstallConfirmOpen(false);
|
||||
|
||||
uninstallPlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSettled: () => {
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
installPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Close the uninstall confirmation dialog */
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='addPluginPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
|
||||
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||
<Alert key={messageKey} severity={severity}>
|
||||
{globalize.translate(messageKey)}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h1'>
|
||||
{pluginDetails?.name || pluginName}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ maxWidth: '80ch' }}>
|
||||
{isLoading && !pluginDetails?.description ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
pluginDetails?.description
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||
<PluginImage
|
||||
isLoading={isLoading}
|
||||
alt={pluginDetails?.name}
|
||||
url={pluginDetails?.imageUrl}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||
{!!pluginDetails?.versions.length && (
|
||||
<>
|
||||
<Typography variant='h3' sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('HeaderRevisionHistory')}
|
||||
</Typography>
|
||||
<PluginRevisions
|
||||
pluginDetails={pluginDetails}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
|
||||
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
|
||||
{!isLoading && !pluginDetails?.status && (
|
||||
<>
|
||||
<Alert severity='info'>
|
||||
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={onInstall()}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={pluginDetails.isEnabled}
|
||||
onChange={toggleEnabled}
|
||||
disabled={pluginDetails.status === PluginStatus.Restart}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelEnablePlugin')}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.configurationPage?.Name && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/${getPluginUrl(pluginDetails.configurationPage.Name)}`}
|
||||
startIcon={<Settings />}
|
||||
>
|
||||
{globalize.translate('Settings')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<Button
|
||||
color='error'
|
||||
startIcon={<Delete />}
|
||||
onClick={onConfirmUninstall}
|
||||
>
|
||||
{globalize.translate('ButtonUninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<PluginDetailsTable
|
||||
isPluginLoading={isPluginsLoading}
|
||||
isRepositoryLoading={isPackageInfoLoading}
|
||||
pluginDetails={pluginDetails}
|
||||
sx={{ flexBasis: '50%' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isInstallConfirmOpen}
|
||||
title={globalize.translate('HeaderConfirmPluginInstallation')}
|
||||
text={globalize.translate('MessagePluginInstallDisclaimer')}
|
||||
onCancel={onCloseInstallConfirmDialog}
|
||||
onConfirm={onConfirmInstall}
|
||||
confirmButtonText={globalize.translate('HeaderInstall')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginPage;
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import AppLayout from '../AppLayout';
|
||||
import ConnectionRequired from 'components/ConnectionRequired';
|
||||
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
|
||||
import { toAsyncPageRoute } from 'components/router/AsyncRoute';
|
||||
import { toViewManagerPageRoute } from 'components/router/LegacyRoute';
|
||||
import { LEGACY_ADMIN_ROUTES } from './_legacyRoutes';
|
||||
import ServerContentPage from 'components/ServerContentPage';
|
||||
import ErrorBoundary from 'components/router/ErrorBoundary';
|
||||
|
||||
export const DASHBOARD_APP_PATHS = {
|
||||
Dashboard: 'dashboard',
|
||||
@@ -19,15 +19,14 @@ export const DASHBOARD_APP_ROUTES: RouteObject[] = [
|
||||
element: <ConnectionRequired isAdminRequired />,
|
||||
children: [
|
||||
{
|
||||
lazy: () => import('../AppLayout'),
|
||||
element: <AppLayout drawerlessPaths={[ DASHBOARD_APP_PATHS.MetadataManager ]} />,
|
||||
children: [
|
||||
{
|
||||
path: DASHBOARD_APP_PATHS.Dashboard,
|
||||
children: [
|
||||
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
|
||||
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
|
||||
],
|
||||
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
|
||||
]
|
||||
},
|
||||
|
||||
/* NOTE: The metadata editor might deserve a dedicated app in the future */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { BaseItemDto, DeviceInfoDto, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import libraryMenu from '../../../../scripts/libraryMenu';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
@@ -13,21 +14,19 @@ import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
Id?: string | null;
|
||||
AppName?: string | null;
|
||||
CustomName?: string | null;
|
||||
Name?: string;
|
||||
Id?: string;
|
||||
AppName?: string;
|
||||
checkedAttribute?: string
|
||||
};
|
||||
|
||||
const UserLibraryAccess = () => {
|
||||
const UserLibraryAccess: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
||||
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
||||
const [devicesItems, setDevicesItems] = useState<ItemsArr[]>([]);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -36,7 +35,7 @@ const UserLibraryAccess = () => {
|
||||
select.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const loadMediaFolders = useCallback((user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
const loadMediaFolders = useCallback((user, mediaFolders) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -47,7 +46,7 @@ const UserLibraryAccess = () => {
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const folder of mediaFolders) {
|
||||
const isChecked = user.Policy?.EnableAllFolders || user.Policy?.EnabledFolders?.indexOf(folder.Id || '') != -1;
|
||||
const isChecked = user.Policy.EnableAllFolders || user.Policy.EnabledFolders.indexOf(folder.Id) != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: folder.Id,
|
||||
@@ -59,11 +58,11 @@ const UserLibraryAccess = () => {
|
||||
setMediaFoldersItems(itemsArr);
|
||||
|
||||
const chkEnableAllFolders = page.querySelector('.chkEnableAllFolders') as HTMLInputElement;
|
||||
chkEnableAllFolders.checked = Boolean(user.Policy?.EnableAllFolders);
|
||||
chkEnableAllFolders.checked = user.Policy.EnableAllFolders;
|
||||
triggerChange(chkEnableAllFolders);
|
||||
}, []);
|
||||
|
||||
const loadChannels = useCallback((user: UserDto, channels: BaseItemDto[]) => {
|
||||
const loadChannels = useCallback((user, channels) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -74,7 +73,7 @@ const UserLibraryAccess = () => {
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const folder of channels) {
|
||||
const isChecked = user.Policy?.EnableAllChannels || user.Policy?.EnabledChannels?.indexOf(folder.Id || '') != -1;
|
||||
const isChecked = user.Policy.EnableAllChannels || user.Policy.EnabledChannels.indexOf(folder.Id) != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: folder.Id,
|
||||
@@ -92,11 +91,11 @@ const UserLibraryAccess = () => {
|
||||
}
|
||||
|
||||
const chkEnableAllChannels = page.querySelector('.chkEnableAllChannels') as HTMLInputElement;
|
||||
chkEnableAllChannels.checked = Boolean(user.Policy?.EnableAllChannels);
|
||||
chkEnableAllChannels.checked = user.Policy.EnableAllChannels;
|
||||
triggerChange(chkEnableAllChannels);
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback((user: UserDto, devices: DeviceInfoDto[]) => {
|
||||
const loadDevices = useCallback((user, devices) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -107,13 +106,12 @@ const UserLibraryAccess = () => {
|
||||
const itemsArr: ItemsArr[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const isChecked = user.Policy?.EnableAllDevices || user.Policy?.EnabledDevices?.indexOf(device.Id || '') != -1;
|
||||
const isChecked = user.Policy.EnableAllDevices || user.Policy.EnabledDevices.indexOf(device.Id) != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
Id: device.Id,
|
||||
Name: device.Name,
|
||||
AppName: device.AppName,
|
||||
CustomName: device.CustomName,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
@@ -121,19 +119,19 @@ const UserLibraryAccess = () => {
|
||||
setDevicesItems(itemsArr);
|
||||
|
||||
const chkEnableAllDevices = page.querySelector('.chkEnableAllDevices') as HTMLInputElement;
|
||||
chkEnableAllDevices.checked = Boolean(user.Policy?.EnableAllDevices);
|
||||
chkEnableAllDevices.checked = user.Policy.EnableAllDevices;
|
||||
triggerChange(chkEnableAllDevices);
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
if (user.Policy.IsAdministrator) {
|
||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.add('hide');
|
||||
} else {
|
||||
(page.querySelector('.deviceAccessContainer') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto, mediaFolders: BaseItemDto[], channels: BaseItemDto[], devices: DeviceInfoDto[]) => {
|
||||
setUserName(user.Name || '');
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
const loadUser = useCallback((user, mediaFolders, channels, devices) => {
|
||||
setUserName(user.Name);
|
||||
libraryMenu.setTitle(user.Name);
|
||||
loadChannels(user, channels);
|
||||
loadMediaFolders(user, mediaFolders);
|
||||
loadDevices(user, devices);
|
||||
@@ -247,6 +245,7 @@ const UserLibraryAccess = () => {
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userlibraryaccess'/>
|
||||
@@ -308,7 +307,7 @@ const UserLibraryAccess = () => {
|
||||
key={Item.Id}
|
||||
className='chkDevice'
|
||||
itemId={Item.Id}
|
||||
itemName={Item.CustomName || Item.Name}
|
||||
itemName={Item.Name}
|
||||
itemAppName={Item.AppName}
|
||||
itemCheckedAttribute={Item.checkedAttribute}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
@@ -12,22 +11,22 @@ import AccessContainer from '../../../../components/dashboard/users/AccessContai
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type UserInput = {
|
||||
type userInput = {
|
||||
Name?: string;
|
||||
Password?: string;
|
||||
};
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
Name?: string;
|
||||
Id?: string;
|
||||
};
|
||||
|
||||
const UserNew = () => {
|
||||
const UserNew: FunctionComponent = () => {
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getItemsResult = (items: BaseItemDto[]) => {
|
||||
const getItemsResult = (items: ItemsArr[]) => {
|
||||
return items.map(item =>
|
||||
({
|
||||
Id: item.Id,
|
||||
@@ -36,7 +35,7 @@ const UserNew = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const loadMediaFolders = useCallback((result: BaseItemDto[]) => {
|
||||
const loadMediaFolders = useCallback((result) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -54,7 +53,7 @@ const UserNew = () => {
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
|
||||
const loadChannels = useCallback((result: BaseItemDto[]) => {
|
||||
const loadChannels = useCallback((result) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -110,7 +109,7 @@ const UserNew = () => {
|
||||
loadUser();
|
||||
|
||||
const saveUser = () => {
|
||||
const userInput: UserInput = {};
|
||||
const userInput: userInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value;
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
@@ -188,6 +187,7 @@ const UserNew = () => {
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={globalize.translate('HeaderAddUser')}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { FunctionComponent, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import dom from '../../../../scripts/dom';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
@@ -21,7 +21,7 @@ type MenuEntry = {
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const UserProfiles = () => {
|
||||
const UserProfiles: FunctionComponent = () => {
|
||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
@@ -170,6 +170,7 @@ const UserProfiles = () => {
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='ButtonAddUser'
|
||||
btnIcon='add'
|
||||
url='https://jellyfin.org/docs/general/server/users/adding-managing-users'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AccessSchedule, ParentalRating, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { UnratedItem } from '@jellyfin/sdk/lib/generated-client/models/unrated-item';
|
||||
import { DynamicDayOfWeek } from '@jellyfin/sdk/lib/generated-client/models/dynamic-day-of-week';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||
import AccessScheduleList from '../../../../components/dashboard/users/AccessScheduleList';
|
||||
import TagList from '../../../../components/dashboard/users/TagList';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
@@ -19,12 +19,9 @@ import Page from '../../../../components/Page';
|
||||
import prompt from '../../../../components/prompt/prompt';
|
||||
import ServerConnections from 'components/ServerConnections';
|
||||
|
||||
type NamedItem = {
|
||||
type UnratedItem = {
|
||||
name: string;
|
||||
value: UnratedItem;
|
||||
};
|
||||
|
||||
type UnratedNamedItem = NamedItem & {
|
||||
value: string;
|
||||
checkedAttribute: string
|
||||
};
|
||||
|
||||
@@ -59,20 +56,19 @@ function handleSaveUser(
|
||||
};
|
||||
}
|
||||
|
||||
const UserParentalControl = () => {
|
||||
const UserParentalControl: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [ parentalRatings, setParentalRatings ] = useState<ParentalRating[]>([]);
|
||||
const [ unratedItems, setUnratedItems ] = useState<UnratedNamedItem[]>([]);
|
||||
const [ unratedItems, setUnratedItems ] = useState<UnratedItem[]>([]);
|
||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
|
||||
const populateRatings = useCallback((allParentalRatings) => {
|
||||
let rating;
|
||||
const ratings: ParentalRating[] = [];
|
||||
|
||||
@@ -97,7 +93,7 @@ const UserParentalControl = () => {
|
||||
setParentalRatings(ratings);
|
||||
}, []);
|
||||
|
||||
const loadUnratedItems = useCallback((user: UserDto) => {
|
||||
const loadUnratedItems = useCallback((user) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -105,47 +101,111 @@ const UserParentalControl = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const items: NamedItem[] = [{
|
||||
const items = [{
|
||||
name: globalize.translate('Books'),
|
||||
value: UnratedItem.Book
|
||||
value: 'Book'
|
||||
}, {
|
||||
name: globalize.translate('Channels'),
|
||||
value: UnratedItem.ChannelContent
|
||||
value: 'ChannelContent'
|
||||
}, {
|
||||
name: globalize.translate('LiveTV'),
|
||||
value: UnratedItem.LiveTvChannel
|
||||
value: 'LiveTvChannel'
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: UnratedItem.Movie
|
||||
value: 'Movie'
|
||||
}, {
|
||||
name: globalize.translate('Music'),
|
||||
value: UnratedItem.Music
|
||||
value: 'Music'
|
||||
}, {
|
||||
name: globalize.translate('Trailers'),
|
||||
value: UnratedItem.Trailer
|
||||
value: 'Trailer'
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: UnratedItem.Series
|
||||
value: 'Series'
|
||||
}];
|
||||
|
||||
const unratedNamedItem: UnratedNamedItem[] = [];
|
||||
const itemsArr: UnratedItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const isChecked = user.Policy?.BlockUnratedItems?.indexOf(item.value) != -1;
|
||||
const isChecked = user.Policy.BlockUnratedItems.indexOf(item.value) != -1;
|
||||
const checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
unratedNamedItem.push({
|
||||
itemsArr.push({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
setUnratedItems(unratedNamedItem);
|
||||
setUnratedItems(itemsArr);
|
||||
|
||||
const blockUnratedItems = page.querySelector('.blockUnratedItems') as HTMLDivElement;
|
||||
blockUnratedItems.dispatchEvent(new CustomEvent('create'));
|
||||
}, []);
|
||||
|
||||
const loadAllowedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setAllowedTags(tags);
|
||||
|
||||
const allowedTagsElem = page.querySelector('.allowedTags') as HTMLDivElement;
|
||||
|
||||
for (const btnDeleteTag of allowedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadAllowedTags(newTags);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBlockedTags = useCallback((tags: string[]) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setBlockedTags(tags);
|
||||
|
||||
const blockedTagsElem = page.querySelector('.blockedTags') as HTMLDivElement;
|
||||
|
||||
for (const btnDeleteTag of blockedTagsElem.querySelectorAll('.btnDeleteTag')) {
|
||||
btnDeleteTag.addEventListener('click', function () {
|
||||
const tag = btnDeleteTag.getAttribute('data-tag');
|
||||
const newTags = tags.filter(t => t !== tag);
|
||||
loadBlockedTags(newTags);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderAccessSchedule = useCallback((schedules) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[userparentalcontrol] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setAccessSchedules(schedules);
|
||||
|
||||
const accessScheduleList = page.querySelector('.accessScheduleList') as HTMLDivElement;
|
||||
|
||||
for (const btnDelete of accessScheduleList.querySelectorAll('.btnDelete')) {
|
||||
btnDelete.addEventListener('click', function () {
|
||||
const index = parseInt(btnDelete.getAttribute('data-index') ?? '0', 10);
|
||||
schedules.splice(index, 1);
|
||||
const newindex = schedules.filter((i: number) => i != index);
|
||||
renderAccessSchedule(newindex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto, allParentalRatings: ParentalRating[]) => {
|
||||
const page = element.current;
|
||||
|
||||
@@ -155,11 +215,11 @@ const UserParentalControl = () => {
|
||||
}
|
||||
|
||||
setUserName(user.Name || '');
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
LibraryMenu.setTitle(user.Name);
|
||||
loadUnratedItems(user);
|
||||
|
||||
setAllowedTags(user.Policy?.AllowedTags || []);
|
||||
setBlockedTags(user.Policy?.BlockedTags || []);
|
||||
loadAllowedTags(user.Policy?.AllowedTags || []);
|
||||
loadBlockedTags(user.Policy?.BlockedTags || []);
|
||||
populateRatings(allParentalRatings);
|
||||
|
||||
let ratingValue = '';
|
||||
@@ -171,16 +231,16 @@ const UserParentalControl = () => {
|
||||
});
|
||||
}
|
||||
|
||||
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = String(ratingValue);
|
||||
(page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value = ratingValue;
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||
} else {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.remove('hide');
|
||||
}
|
||||
setAccessSchedules(user.Policy?.AccessSchedules || []);
|
||||
renderAccessSchedule(user.Policy?.AccessSchedules || []);
|
||||
loading.hide();
|
||||
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
|
||||
}, [loadAllowedTags, loadBlockedTags, loadUnratedItems, populateRatings, renderAccessSchedule]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userId) {
|
||||
@@ -221,7 +281,7 @@ const UserParentalControl = () => {
|
||||
}
|
||||
|
||||
schedules[index] = updatedSchedule;
|
||||
setAccessSchedules(schedules);
|
||||
renderAccessSchedule(schedules);
|
||||
}).catch(() => {
|
||||
// access schedule closed
|
||||
});
|
||||
@@ -254,7 +314,7 @@ const UserParentalControl = () => {
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
setAllowedTags(tags);
|
||||
loadAllowedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
@@ -275,7 +335,7 @@ const UserParentalControl = () => {
|
||||
|
||||
if (tags.indexOf(value) == -1) {
|
||||
tags.push(value);
|
||||
setBlockedTags(tags);
|
||||
loadBlockedTags(tags);
|
||||
}
|
||||
}).catch(() => {
|
||||
// prompt closed
|
||||
@@ -306,8 +366,7 @@ const UserParentalControl = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// The following is still hacky and should migrate to pure react implementation for callbacks in the future
|
||||
const accessSchedulesPopupCallback = function () {
|
||||
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showSchedulePopup({
|
||||
Id: 0,
|
||||
UserId: '',
|
||||
@@ -315,19 +374,18 @@ const UserParentalControl = () => {
|
||||
StartHour: 0,
|
||||
EndHour: 0
|
||||
}, -1);
|
||||
};
|
||||
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).addEventListener('click', accessSchedulesPopupCallback);
|
||||
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', showAllowedTagPopup);
|
||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', showBlockedTagPopup);
|
||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
});
|
||||
|
||||
return () => {
|
||||
(page.querySelector('#btnAddSchedule') as HTMLButtonElement).removeEventListener('click', accessSchedulesPopupCallback);
|
||||
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).removeEventListener('click', showAllowedTagPopup);
|
||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).removeEventListener('click', showBlockedTagPopup);
|
||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
};
|
||||
}, [setAllowedTags, setBlockedTags, loadData, userId]);
|
||||
(page.querySelector('#btnAddAllowedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showAllowedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('#btnAddBlockedTag') as HTMLButtonElement).addEventListener('click', function () {
|
||||
showBlockedTagPopup();
|
||||
});
|
||||
|
||||
(page.querySelector('.userParentalControlForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
}, [loadAllowedTags, loadBlockedTags, loadData, renderAccessSchedule]);
|
||||
|
||||
const optionMaxParentalRating = () => {
|
||||
let content = '';
|
||||
@@ -338,21 +396,6 @@ const UserParentalControl = () => {
|
||||
return content;
|
||||
};
|
||||
|
||||
const removeAllowedTagsCallback = useCallback((tag: string) => {
|
||||
const newTags = allowedTags.filter(t => t !== tag);
|
||||
setAllowedTags(newTags);
|
||||
}, [allowedTags, setAllowedTags]);
|
||||
|
||||
const removeBlockedTagsTagsCallback = useCallback((tag: string) => {
|
||||
const newTags = blockedTags.filter(t => t !== tag);
|
||||
setBlockedTags(newTags);
|
||||
}, [blockedTags, setBlockedTags]);
|
||||
|
||||
const removeScheduleCallback = useCallback((index: number) => {
|
||||
const newSchedules = accessSchedules.filter((_e, i) => i != index);
|
||||
setAccessSchedules(newSchedules);
|
||||
}, [accessSchedules, setAccessSchedules]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='userParentalControlPage'
|
||||
@@ -362,6 +405,7 @@ const UserParentalControl = () => {
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userparentalcontrol'/>
|
||||
@@ -405,6 +449,7 @@ const UserParentalControl = () => {
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('AllowContentWithTagsHelp')}
|
||||
@@ -415,7 +460,6 @@ const UserParentalControl = () => {
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='allowedTag'
|
||||
removeTagCallback={removeAllowedTagsCallback}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
@@ -429,6 +473,7 @@ const UserParentalControl = () => {
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<div className='fieldDescription'>
|
||||
{globalize.translate('BlockContentWithTagsHelp')}
|
||||
@@ -439,7 +484,6 @@ const UserParentalControl = () => {
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagType='blockedTag'
|
||||
removeTagCallback={removeBlockedTagsTagsCallback}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
@@ -452,17 +496,17 @@ const UserParentalControl = () => {
|
||||
btnClassName='fab submit sectionTitleButton'
|
||||
btnTitle='Add'
|
||||
btnIcon='add'
|
||||
isLinkVisible={false}
|
||||
/>
|
||||
<p>{globalize.translate('HeaderAccessScheduleHelp')}</p>
|
||||
<div className='accessScheduleList paperList'>
|
||||
{accessSchedules.map((accessSchedule, index) => {
|
||||
return <AccessScheduleList
|
||||
key={`${accessSchedule.DayOfWeek}${accessSchedule.StartHour}${accessSchedule.EndHour}`}
|
||||
key={accessSchedule.Id}
|
||||
index={index}
|
||||
DayOfWeek={accessSchedule.DayOfWeek}
|
||||
StartHour={accessSchedule.StartHour}
|
||||
EndHour={accessSchedule.EndHour}
|
||||
removeScheduleCallback={removeScheduleCallback}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
@@ -7,7 +7,7 @@ import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Page from '../../../../components/Page';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
|
||||
const UserPassword = () => {
|
||||
const UserPassword: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
@@ -42,6 +42,7 @@ const UserPassword = () => {
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userpassword'/>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import globalize from '../../../../scripts/globalize';
|
||||
import LibraryMenu from '../../../../scripts/libraryMenu';
|
||||
import ButtonElement from '../../../../elements/ButtonElement';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import InputElement from '../../../../elements/InputElement';
|
||||
import LinkButton from '../../../../elements/emby-button/LinkButton';
|
||||
import LinkEditUserPreferences from '../../../../components/dashboard/users/LinkEditUserPreferences';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
@@ -16,10 +17,15 @@ import toast from '../../../../components/toast/toast';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
|
||||
type ResetProvider = BaseItemDto & {
|
||||
type ResetProvider = AuthProvider & {
|
||||
checkedAttribute: string
|
||||
};
|
||||
|
||||
type AuthProvider = {
|
||||
Name?: string;
|
||||
Id?: string;
|
||||
};
|
||||
|
||||
const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||
Array.prototype.filter.call(elements, e => e.checked)
|
||||
.map(e => e.getAttribute('data-id'))
|
||||
@@ -34,14 +40,13 @@ function onSaveComplete() {
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const UserEdit = () => {
|
||||
const UserEdit: FunctionComponent = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userDto, setUserDto ] = useState<UserDto>();
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
||||
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
|
||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
const [ authProviders, setAuthProviders ] = useState<AuthProvider[]>([]);
|
||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<ResetProvider[]>([]);
|
||||
|
||||
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
||||
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
||||
@@ -58,27 +63,48 @@ const UserEdit = () => {
|
||||
return window.ApiClient.getUser(userId);
|
||||
};
|
||||
|
||||
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const loadAuthProviders = useCallback((user, providers) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setAuthProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
|
||||
const currentProviderId = user.Policy.AuthenticationProviderId;
|
||||
setAuthenticationProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadPasswordResetProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const loadPasswordResetProviders = useCallback((user, providers) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setPasswordResetProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
|
||||
const currentProviderId = user.Policy.PasswordResetProviderId;
|
||||
setPasswordResetProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
const loadDeleteFolders = useCallback((user, mediaFolders) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||
SupportsMediaDeletion: true
|
||||
})).then(function (channelsResult) {
|
||||
@@ -86,20 +112,22 @@ const UserEdit = () => {
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
|
||||
for (const mediaFolder of mediaFolders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
for (const folder of mediaFolders) {
|
||||
isChecked = user.Policy.EnableContentDeletion || user.Policy.EnableContentDeletionFromFolders.indexOf(folder.Id) != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
Id: folder.Id,
|
||||
Name: folder.Name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
for (const channel of channelsResult.Items) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||
for (const folder of channelsResult.Items) {
|
||||
isChecked = user.Policy.EnableContentDeletion || user.Policy.EnableContentDeletionFromFolders.indexOf(folder.Id) != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...channel,
|
||||
Id: folder.Id,
|
||||
Name: folder.Name,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
@@ -107,14 +135,14 @@ const UserEdit = () => {
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy.EnableContentDeletion;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch channels', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback((user: UserDto) => {
|
||||
const loadUser = useCallback((user) => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
@@ -123,55 +151,58 @@ const UserEdit = () => {
|
||||
}
|
||||
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
||||
loadAuthProviders(page, user, providers);
|
||||
loadAuthProviders(user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch auth providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
||||
loadPasswordResetProviders(page, user, providers);
|
||||
loadPasswordResetProviders(user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch password reset providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
})).then(function (folders) {
|
||||
loadDeleteFolders(page, user, folders.Items);
|
||||
loadDeleteFolders(user, folders.Items);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch media folders', err);
|
||||
});
|
||||
|
||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
|
||||
disabledUserBanner.classList.toggle('hide', !user.Policy.IsDisabled);
|
||||
|
||||
const txtUserName = page.querySelector('#txtUserName') as HTMLInputElement;
|
||||
txtUserName.disabled = false;
|
||||
txtUserName.removeAttribute('disabled');
|
||||
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
setUserDto(user);
|
||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
|
||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
|
||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
|
||||
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = !!user.Policy?.IsHidden;
|
||||
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = !!user.Policy?.EnableCollectionManagement;
|
||||
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = !!user.Policy?.EnableSubtitleManagement;
|
||||
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = !!user.Policy?.EnableSharedDeviceControl;
|
||||
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = !!user.Policy?.EnableRemoteControlOfOtherUsers;
|
||||
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = !!user.Policy?.EnableContentDownloading;
|
||||
(page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvManagement;
|
||||
(page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked = !!user.Policy?.EnableLiveTvAccess;
|
||||
(page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked = !!user.Policy?.EnableMediaPlayback;
|
||||
(page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableAudioPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked = !!user.Policy?.EnableVideoPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked = !!user.Policy?.EnablePlaybackRemuxing;
|
||||
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = !!user.Policy?.ForceRemoteSourceTranscoding;
|
||||
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy?.EnableRemoteAccess == null || user.Policy?.EnableRemoteAccess;
|
||||
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy?.RemoteClientBitrateLimit && user.Policy?.RemoteClientBitrateLimit > 0 ?
|
||||
(user.Policy?.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
|
||||
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = String(user.Policy?.LoginAttemptsBeforeLockout) || '-1';
|
||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
|
||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
|
||||
const lnkEditUserPreferences = page.querySelector('.lnkEditUserPreferences') as HTMLDivElement;
|
||||
lnkEditUserPreferences.setAttribute('href', 'mypreferencesmenu.html?userId=' + user.Id);
|
||||
LibraryMenu.setTitle(user.Name);
|
||||
setUserName(user.Name);
|
||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name;
|
||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = user.Policy.IsAdministrator;
|
||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = user.Policy.IsDisabled;
|
||||
(page.querySelector('.chkIsHidden') as HTMLInputElement).checked = user.Policy.IsHidden;
|
||||
(page.querySelector('.chkEnableCollectionManagement') as HTMLInputElement).checked = user.Policy.EnableCollectionManagement;
|
||||
(page.querySelector('.chkEnableSubtitleManagement') as HTMLInputElement).checked = user.Policy.EnableSubtitleManagement;
|
||||
(page.querySelector('.chkRemoteControlSharedDevices') as HTMLInputElement).checked = user.Policy.EnableSharedDeviceControl;
|
||||
(page.querySelector('.chkEnableRemoteControlOtherUsers') as HTMLInputElement).checked = user.Policy.EnableRemoteControlOfOtherUsers;
|
||||
(page.querySelector('.chkEnableDownloading') as HTMLInputElement).checked = user.Policy.EnableContentDownloading;
|
||||
(page.querySelector('.chkManageLiveTv') as HTMLInputElement).checked = user.Policy.EnableLiveTvManagement;
|
||||
(page.querySelector('.chkEnableLiveTvAccess') as HTMLInputElement).checked = user.Policy.EnableLiveTvAccess;
|
||||
(page.querySelector('.chkEnableMediaPlayback') as HTMLInputElement).checked = user.Policy.EnableMediaPlayback;
|
||||
(page.querySelector('.chkEnableAudioPlaybackTranscoding') as HTMLInputElement).checked = user.Policy.EnableAudioPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackTranscoding') as HTMLInputElement).checked = user.Policy.EnableVideoPlaybackTranscoding;
|
||||
(page.querySelector('.chkEnableVideoPlaybackRemuxing') as HTMLInputElement).checked = user.Policy.EnablePlaybackRemuxing;
|
||||
(page.querySelector('.chkForceRemoteSourceTranscoding') as HTMLInputElement).checked = user.Policy.ForceRemoteSourceTranscoding;
|
||||
(page.querySelector('.chkRemoteAccess') as HTMLInputElement).checked = user.Policy.EnableRemoteAccess == null || user.Policy.EnableRemoteAccess;
|
||||
(page.querySelector('#txtRemoteClientBitrateLimit') as HTMLInputElement).value = user.Policy.RemoteClientBitrateLimit > 0 ?
|
||||
(user.Policy.RemoteClientBitrateLimit / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }) : '';
|
||||
(page.querySelector('#txtLoginAttemptsBeforeLockout') as HTMLInputElement).value = user.Policy.LoginAttemptsBeforeLockout || '0';
|
||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = user.Policy.MaxActiveSessions || '0';
|
||||
if (window.ApiClient.isMinServerVersion('10.6.0')) {
|
||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = user.Policy.SyncPlayAccess;
|
||||
}
|
||||
loading.hide();
|
||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||
|
||||
@@ -289,7 +320,8 @@ const UserEdit = () => {
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userDto?.Name || ''}
|
||||
title={userName}
|
||||
url='https://jellyfin.org/docs/general/server/users/'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -298,9 +330,10 @@ const UserEdit = () => {
|
||||
className='lnkEditUserPreferencesContainer'
|
||||
style={{ paddingBottom: '1em' }}
|
||||
>
|
||||
<LinkButton className='lnkEditUserPreferences button-link' href={userDto?.Id ? `mypreferencesmenu.html?userId=${userDto.Id}` : undefined}>
|
||||
{globalize.translate('ButtonEditOtherUserPreferences')}
|
||||
</LinkButton>
|
||||
<LinkEditUserPreferences
|
||||
className= 'lnkEditUserPreferences button-link'
|
||||
title= 'ButtonEditOtherUserPreferences'
|
||||
/>
|
||||
</div>
|
||||
<form className='editUserProfileForm'>
|
||||
<div className='disabledUserBanner hide'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { StrictMode, useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { type Theme } from '@mui/material/styles';
|
||||
@@ -15,7 +15,7 @@ import AppDrawer, { isDrawerPath } from './components/drawers/AppDrawer';
|
||||
|
||||
import './AppOverrides.scss';
|
||||
|
||||
export const Component = () => {
|
||||
const AppLayout = () => {
|
||||
const [ isDrawerActive, setIsDrawerActive ] = useState(false);
|
||||
const { user } = useApi();
|
||||
const location = useLocation();
|
||||
@@ -30,39 +30,37 @@ export const Component = () => {
|
||||
|
||||
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>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<Box
|
||||
component='main'
|
||||
@@ -78,3 +76,5 @@ export const Component = () => {
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Events from 'utils/events';
|
||||
|
||||
import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||
@@ -33,7 +33,7 @@ const RemotePlayButton = () => {
|
||||
const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl);
|
||||
|
||||
const onRemotePlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
const onRemotePlayButtonClick = useCallback((event) => {
|
||||
setRemotePlayMenuAnchorEl(event.currentTarget);
|
||||
}, [ setRemotePlayMenuAnchorEl ]);
|
||||
|
||||
@@ -44,7 +44,7 @@ const RemotePlayButton = () => {
|
||||
const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl);
|
||||
|
||||
const onRemotePlayActiveButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
const onRemotePlayActiveButtonClick = useCallback((event) => {
|
||||
setRemotePlayActiveMenuAnchorEl(event.currentTarget);
|
||||
}, [ setRemotePlayActiveMenuAnchorEl ]);
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { type FC } from 'react';
|
||||
import {
|
||||
Link,
|
||||
URLSearchParamsInit,
|
||||
createSearchParams,
|
||||
useLocation,
|
||||
useSearchParams
|
||||
} from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const getUrlParams = (searchParams: URLSearchParams) => {
|
||||
const parentId =
|
||||
searchParams.get('parentId') || searchParams.get('topParentId');
|
||||
const collectionType = searchParams.get('collectionType');
|
||||
const params: URLSearchParamsInit = {};
|
||||
|
||||
if (parentId) {
|
||||
params.parentId = parentId;
|
||||
}
|
||||
|
||||
if (collectionType) {
|
||||
params.collectionType = collectionType;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
interface SearchButtonProps {
|
||||
isTabsAvailable: boolean;
|
||||
}
|
||||
|
||||
const SearchButton: FC<SearchButtonProps> = ({ isTabsAvailable }) => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isSearchPath = location.pathname === '/search.html';
|
||||
const createSearchLink = isTabsAvailable ?
|
||||
{
|
||||
pathname: '/search.html',
|
||||
search: `?${createSearchParams(getUrlParams(searchParams))}`
|
||||
} :
|
||||
'/search.html';
|
||||
|
||||
return (
|
||||
<Tooltip title={globalize.translate('Search')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
aria-label={globalize.translate('Search')}
|
||||
color='inherit'
|
||||
component={Link}
|
||||
disabled={isSearchPath}
|
||||
to={createSearchLink}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchButton;
|
||||
@@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { PluginType } from 'types/plugin';
|
||||
|
||||
import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu';
|
||||
@@ -17,7 +17,7 @@ const SyncPlayButton = () => {
|
||||
const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState<null | HTMLElement>(null);
|
||||
const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl);
|
||||
|
||||
const onSyncPlayButtonClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
const onSyncPlayButtonClick = useCallback((event) => {
|
||||
setSyncPlayMenuAnchorEl(event.currentTarget);
|
||||
}, [ setSyncPlayMenuAnchorEl ]);
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { FC } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import AppToolbar from 'components/toolbar/AppToolbar';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import AppTabs from '../tabs/AppTabs';
|
||||
import RemotePlayButton from './RemotePlayButton';
|
||||
import SyncPlayButton from './SyncPlayButton';
|
||||
import SearchButton from './SearchButton';
|
||||
import { isTabPath } from '../tabs/tabRoutes';
|
||||
|
||||
interface AppToolbarProps {
|
||||
@@ -40,7 +45,18 @@ const ExperimentalAppToolbar: FC<AppToolbarProps> = ({
|
||||
<>
|
||||
<SyncPlayButton />
|
||||
<RemotePlayButton />
|
||||
<SearchButton isTabsAvailable={isTabsAvailable} />
|
||||
|
||||
<Tooltip title={globalize.translate('Search')}>
|
||||
<IconButton
|
||||
size='large'
|
||||
aria-label={globalize.translate('Search')}
|
||||
color='inherit'
|
||||
component={Link}
|
||||
to='/search.html'
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
isDrawerAvailable={isDrawerAvailable}
|
||||
|
||||
@@ -13,7 +13,7 @@ import React, { FC, useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { enable, isEnabled } from 'scripts/autocast';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
interface RemotePlayActiveMenuProps extends MenuProps {
|
||||
onMenuClose: () => void
|
||||
|
||||
@@ -6,7 +6,7 @@ import Menu, { type MenuProps } from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import type { PlayTarget } from 'types/playTarget';
|
||||
@@ -65,20 +65,17 @@ const RemotePlayMenu: FC<RemotePlayMenuProps> = ({
|
||||
open={open}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{!isChromecastPluginLoaded && (
|
||||
<MenuItem disabled>
|
||||
{!isChromecastPluginLoaded && ([
|
||||
<MenuItem key='cast-unsupported-item' disabled>
|
||||
<ListItemIcon>
|
||||
<Warning />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{globalize.translate('GoogleCastUnsupported')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{!isChromecastPluginLoaded && playbackTargets.length > 0 && (
|
||||
<Divider />
|
||||
)}
|
||||
</MenuItem>,
|
||||
<Divider key='cast-unsupported-divider' />
|
||||
])}
|
||||
|
||||
{playbackTargets.map(target => (
|
||||
<MenuItem
|
||||
|
||||
@@ -20,9 +20,9 @@ import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { pluginManager } from 'components/pluginManager';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useSyncPlayGroups } from 'hooks/useSyncPlayGroups';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { PluginType } from 'types/plugin';
|
||||
import Events, { Event } from 'utils/events';
|
||||
import Events from 'utils/events';
|
||||
|
||||
export const ID = 'app-sync-play-menu';
|
||||
|
||||
@@ -136,7 +136,7 @@ const SyncPlayMenu: FC<SyncPlayMenuProps> = ({
|
||||
}
|
||||
}, [ __legacyApiClient__, onMenuClose, syncPlay ]);
|
||||
|
||||
const updateSyncPlayGroup = useCallback((_e: Event, enabled: boolean) => {
|
||||
const updateSyncPlayGroup = useCallback((_e, enabled) => {
|
||||
if (syncPlay && enabled) {
|
||||
setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import Movie from '@mui/icons-material/Movie';
|
||||
import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import Photo from '@mui/icons-material/Photo';
|
||||
@@ -8,11 +7,11 @@ import Tv from '@mui/icons-material/Tv';
|
||||
import Theaters from '@mui/icons-material/Theaters';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Book from '@mui/icons-material/Book';
|
||||
import Collections from '@mui/icons-material/Collections';
|
||||
import Queue from '@mui/icons-material/Queue';
|
||||
import Quiz from '@mui/icons-material/Quiz';
|
||||
import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import React, { FC } from 'react';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
|
||||
interface LibraryIconProps {
|
||||
item: BaseItemDto
|
||||
@@ -40,11 +39,9 @@ const LibraryIcon: FC<LibraryIconProps> = ({
|
||||
case CollectionType.Books:
|
||||
return <Book />;
|
||||
case CollectionType.Boxsets:
|
||||
return <VideoLibrary />;
|
||||
return <Collections />;
|
||||
case CollectionType.Playlists:
|
||||
return <Queue />;
|
||||
case undefined:
|
||||
return <Quiz />;
|
||||
default:
|
||||
return <Folder />;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { appRouter } from 'components/router/appRouter';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useUserViews } from 'hooks/useUserViews';
|
||||
import { useWebConfig } from 'hooks/useWebConfig';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import LibraryIcon from '../LibraryIcon';
|
||||
import DrawerHeaderLink from './DrawerHeaderLink';
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/bas
|
||||
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import React, { FC } from 'react';
|
||||
import { useGetGenres } from 'hooks/useFetchItems';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import GenresSectionContainer from './GenresSectionContainer';
|
||||
import type { ParentId } from 'types/library';
|
||||
@@ -25,18 +25,27 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
|
||||
}
|
||||
|
||||
if (!genresResult?.Items?.length) {
|
||||
return <NoItemsMessage message='MessageNoGenresAvailable' />;
|
||||
return (
|
||||
<div className='noItemsMessage centerMessage'>
|
||||
<h1>{globalize.translate('MessageNothingHere')}</h1>
|
||||
<p>{globalize.translate('MessageNoGenresAvailable')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return genresResult.Items.map((genre) => (
|
||||
<GenresSectionContainer
|
||||
key={genre.Id}
|
||||
collectionType={collectionType}
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
genre={genre}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
{genresResult.Items.map((genre) => (
|
||||
<GenresSectionContainer
|
||||
key={genre.Id}
|
||||
collectionType={collectionType}
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
genre={genre}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenresItemsContainer;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models/item-fields';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||
import React, { type FC } from 'react';
|
||||
import { useGetItems } from 'hooks/useFetchItems';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import SectionContainer from 'components/common/SectionContainer';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface GenresSectionContainerProps {
|
||||
parentId: ParentId;
|
||||
collectionType: CollectionType | undefined;
|
||||
itemType: BaseItemKind[];
|
||||
genre: ItemDto;
|
||||
genre: BaseItemDto;
|
||||
}
|
||||
|
||||
const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
||||
@@ -47,7 +47,7 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
||||
|
||||
const { isLoading, data: itemsResult } = useGetItems(getParametersOptions());
|
||||
|
||||
const getRouteUrl = (item: ItemDto) => {
|
||||
const getRouteUrl = (item: BaseItemDto) => {
|
||||
return appRouter.getRouteUrl(item, {
|
||||
context: collectionType,
|
||||
parentId: parentId
|
||||
@@ -59,12 +59,9 @@ const GenresSectionContainer: FC<GenresSectionContainerProps> = ({
|
||||
}
|
||||
|
||||
return <SectionContainer
|
||||
key={genre.Name}
|
||||
sectionHeaderProps={{
|
||||
title: genre.Name || '',
|
||||
url: getRouteUrl(genre)
|
||||
}}
|
||||
items={itemsResult?.Items}
|
||||
sectionTitle={genre.Name || ''}
|
||||
items={itemsResult?.Items || []}
|
||||
url={getRouteUrl(genre)}
|
||||
cardOptions={{
|
||||
scalable: true,
|
||||
overlayPlayButton: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { FC, useCallback } from 'react';
|
||||
import { ButtonGroup, IconButton } from '@mui/material';
|
||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { LibraryViewSettings, ViewMode } from 'types/library';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import ViewSettingsButton from './ViewSettingsButton';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/models/api/item-sort-by';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import classNames from 'classnames';
|
||||
@@ -72,7 +72,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
const {
|
||||
isLoading,
|
||||
data: itemsResult,
|
||||
isPlaceholderData,
|
||||
isPreviousData,
|
||||
refetch
|
||||
} = useGetItemsViewByType(
|
||||
viewType,
|
||||
@@ -92,7 +92,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
listOptions.showParentTitle = true;
|
||||
listOptions.action = 'playallfromhere';
|
||||
listOptions.smallIcon = true;
|
||||
listOptions.showArtist = true;
|
||||
listOptions.artist = true;
|
||||
listOptions.addToListButton = true;
|
||||
} else if (viewType === LibraryTab.Albums) {
|
||||
listOptions.sortBy = libraryViewSettings.SortBy;
|
||||
@@ -181,7 +181,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
|
||||
const getItems = useCallback(() => {
|
||||
if (!itemsResult?.Items?.length) {
|
||||
return <NoItemsMessage message={noItemsMessage} />;
|
||||
return <NoItemsMessage noItemsMessage={noItemsMessage} />;
|
||||
}
|
||||
|
||||
if (libraryViewSettings.ViewMode === ViewMode.ListView) {
|
||||
@@ -228,7 +228,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
isPreviousData={isPreviousData}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
)}
|
||||
@@ -312,7 +312,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
<Pagination
|
||||
totalRecordCount={totalRecordCount}
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
isPlaceholderData={isPlaceholderData}
|
||||
isPreviousData={isPreviousData}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const NewCollectionButton: FC = () => {
|
||||
const showCollectionEditor = useCallback(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import Box from '@mui/material/Box';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import * as userSettings from 'scripts/settings/userSettings';
|
||||
import { LibraryViewSettings } from 'types/library';
|
||||
|
||||
@@ -13,14 +13,14 @@ interface PaginationProps {
|
||||
libraryViewSettings: LibraryViewSettings;
|
||||
setLibraryViewSettings: React.Dispatch<React.SetStateAction<LibraryViewSettings>>;
|
||||
totalRecordCount: number;
|
||||
isPlaceholderData: boolean
|
||||
isPreviousData: boolean
|
||||
}
|
||||
|
||||
const Pagination: FC<PaginationProps> = ({
|
||||
libraryViewSettings,
|
||||
setLibraryViewSettings,
|
||||
totalRecordCount,
|
||||
isPlaceholderData
|
||||
isPreviousData
|
||||
}) => {
|
||||
const limit = userSettings.libraryPageSize(undefined);
|
||||
const startIndex = libraryViewSettings.StartIndex ?? 0;
|
||||
@@ -65,7 +65,7 @@ const Pagination: FC<PaginationProps> = ({
|
||||
<IconButton
|
||||
title={globalize.translate('Previous')}
|
||||
className='paper-icon-button-light btnPreviousPage autoSize'
|
||||
disabled={startIndex == 0 || isPlaceholderData}
|
||||
disabled={startIndex == 0 || isPreviousData}
|
||||
onClick={onPreviousPageClick}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
@@ -74,7 +74,7 @@ const Pagination: FC<PaginationProps> = ({
|
||||
<IconButton
|
||||
title={globalize.translate('Next')}
|
||||
className='paper-icon-button-light btnNextPage autoSize'
|
||||
disabled={startIndex + limit >= totalRecordCount || isPlaceholderData }
|
||||
disabled={startIndex + limit >= totalRecordCount || isPreviousData }
|
||||
onClick={onNextPageClick}
|
||||
>
|
||||
<ArrowForwardIcon />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { BaseItemDto, SeriesTimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { IconButton } from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { getFiltersQuery } from 'utils/items';
|
||||
import { LibraryViewSettings } from 'types/library';
|
||||
import { LibraryTab } from 'types/libraryTab';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
|
||||
interface PlayAllButtonProps {
|
||||
item: ItemDto | undefined;
|
||||
items: ItemDto[];
|
||||
item: BaseItemDto | null | undefined;
|
||||
items: BaseItemDto[] | SeriesTimerInfoDto[];
|
||||
viewType: LibraryTab;
|
||||
hasFilters: boolean;
|
||||
libraryViewSettings: LibraryViewSettings
|
||||
@@ -27,12 +27,10 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
|
||||
SortBy: [libraryViewSettings.SortBy],
|
||||
SortOrder: [libraryViewSettings.SortOrder]
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[PlayAllButton] failed to play', err);
|
||||
});
|
||||
} else {
|
||||
playbackManager.play({
|
||||
items,
|
||||
items: items,
|
||||
autoplay: true,
|
||||
queryOptions: {
|
||||
ParentId: item?.Id ?? undefined,
|
||||
@@ -40,8 +38,7 @@ const PlayAllButton: FC<PlayAllButtonProps> = ({ item, items, viewType, hasFilte
|
||||
SortBy: [libraryViewSettings.SortBy],
|
||||
SortOrder: [libraryViewSettings.SortOrder]
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[PlayAllButton] failed to play', err);
|
||||
|
||||
});
|
||||
}
|
||||
}, [hasFilters, item, items, libraryViewSettings, viewType]);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'lib/globalize';
|
||||
import globalize from 'scripts/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import SectionContainer from 'components/common/SectionContainer';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import { CardShape } from 'utils/card';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
@@ -31,7 +30,14 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||
}
|
||||
|
||||
if (!sectionsWithItems?.length && !upcomingRecordings?.length) {
|
||||
return <NoItemsMessage />;
|
||||
return (
|
||||
<div className='noItemsMessage centerMessage'>
|
||||
<h1>{globalize.translate('MessageNothingHere')}</h1>
|
||||
<p>
|
||||
{globalize.translate('MessageNoItemsAvailable')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getRouteUrl = (section: Section) => {
|
||||
@@ -52,33 +58,23 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||
{sectionsWithItems?.map(({ section, items }) => (
|
||||
<SectionContainer
|
||||
key={section.type}
|
||||
sectionHeaderProps={{
|
||||
title: globalize.translate(section.name),
|
||||
url: getRouteUrl(section)
|
||||
}}
|
||||
itemsContainerProps={{
|
||||
queryKey: ['ProgramSectionWithItems'],
|
||||
reloadItems: refetch
|
||||
}}
|
||||
items={items}
|
||||
sectionTitle={globalize.translate(section.name)}
|
||||
items={items ?? []}
|
||||
url={getRouteUrl(section)}
|
||||
reloadItems={refetch}
|
||||
cardOptions={{
|
||||
...section.cardOptions,
|
||||
queryKey: ['ProgramSectionWithItems']
|
||||
}}
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
{upcomingRecordings?.map((group) => (
|
||||
<SectionContainer
|
||||
key={group.name}
|
||||
sectionHeaderProps={{
|
||||
title: group.name
|
||||
}}
|
||||
itemsContainerProps={{
|
||||
queryKey: ['Timers'],
|
||||
reloadItems: refetch
|
||||
}}
|
||||
items={group.timerInfo}
|
||||
sectionTitle={group.name}
|
||||
items={group.timerInfo ?? []}
|
||||
cardOptions={{
|
||||
queryKey: ['Timers'],
|
||||
shape: CardShape.BackdropOverflow,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { BaseItemDto, SeriesTimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { IconButton } from '@mui/material';
|
||||
import QueueIcon from '@mui/icons-material/Queue';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
import globalize from 'lib/globalize';
|
||||
import type { ItemDto } from 'types/base/models/item-dto';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
interface QueueButtonProps {
|
||||
item: ItemDto | undefined
|
||||
items: ItemDto[];
|
||||
item: BaseItemDto | undefined
|
||||
items: BaseItemDto[] | SeriesTimerInfoDto[];
|
||||
hasFilters: boolean;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,10 @@ const QueueButton: FC<QueueButtonProps> = ({ item, items, hasFilters }) => {
|
||||
if (item && !hasFilters) {
|
||||
playbackManager.queue({
|
||||
items: [item]
|
||||
}).catch(err => {
|
||||
console.error('[QueueButton] failed to add to queue', err);
|
||||
});
|
||||
} else {
|
||||
playbackManager.queue({
|
||||
items
|
||||
}).catch(err => {
|
||||
console.error('[QueueButton] failed to add to queue', err);
|
||||
items: items
|
||||
});
|
||||
}
|
||||
}, [hasFilters, item, items]);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BaseItemDto, TimerInfoDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import ItemsContainer from 'elements/emby-itemscontainer/ItemsContainer';
|
||||
import Scroller from 'elements/emby-scroller/Scroller';
|
||||
import LinkButton from 'elements/emby-button/LinkButton';
|
||||
import Cards from 'components/cardbuilder/Card/Cards';
|
||||
import type { CardOptions } from 'types/cardOptions';
|
||||
|
||||
interface SectionContainerProps {
|
||||
url?: string;
|
||||
sectionTitle: string;
|
||||
items: BaseItemDto[] | TimerInfoDto[];
|
||||
cardOptions: CardOptions;
|
||||
reloadItems?: () => void;
|
||||
}
|
||||
|
||||
const SectionContainer: FC<SectionContainerProps> = ({
|
||||
sectionTitle,
|
||||
url,
|
||||
items,
|
||||
cardOptions,
|
||||
reloadItems
|
||||
}) => {
|
||||
return (
|
||||
<div className='verticalSection'>
|
||||
<div className='sectionTitleContainer sectionTitleContainer-cards padded-left'>
|
||||
{url && items.length > 5 ? (
|
||||
<LinkButton
|
||||
className='more button-flat button-flat-mini sectionTitleTextButton btnMoreFromGenre'
|
||||
href={url}
|
||||
>
|
||||
<h2 className='sectionTitle sectionTitle-cards'>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
<span
|
||||
className='material-icons chevron_right'
|
||||
aria-hidden='true'
|
||||
></span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<h2 className='sectionTitle sectionTitle-cards'>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Scroller
|
||||
className='padded-top-focusscale padded-bottom-focusscale'
|
||||
isMouseWheelEnabled={false}
|
||||
isCenterFocusEnabled={true}
|
||||
>
|
||||
<ItemsContainer
|
||||
className='itemsContainer scrollSlider focuscontainer-x'
|
||||
reloadItems={reloadItems}
|
||||
queryKey={cardOptions.queryKey}
|
||||
>
|
||||
<Cards items={items} cardOptions={cardOptions} />
|
||||
</ItemsContainer>
|
||||
</Scroller>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionContainer;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user