Compare commits

..

2 Commits

Author SHA1 Message Date
1hitsong
c01e1a22d3 Remove trailing extra empty line 2025-09-09 15:22:39 -04:00
1hitsong
c342e38858 Add a Reset Filters button to bottom of filter dropdown
- If no filters are applied, disable button
- Apply to both standard and experimental layouts
2025-09-09 13:45:28 -04:00
285 changed files with 5224 additions and 9617 deletions

4
.github/CODEOWNERS vendored
View File

@@ -1,5 +1 @@
* @jellyfin/web
# Joshua must review all changes to bump_version
bump_version @joshuaboniface
# Core must approve all changes within the repo config
.github/ @jellyfin/core

View File

@@ -20,21 +20,21 @@ jobs:
steps:
- name: Checkout repository ⬇️
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
with:
category: '/language:${{matrix.language}}'

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ inputs.commit || github.sha }}

View File

@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Scan
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
with:
## Workaround from https://github.com/actions/dependency-review-action/issues/456
## TODO: Remove when necessary
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout ⬇️
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ inputs.commit }}
show-progress: false

View File

@@ -80,7 +80,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -95,6 +95,6 @@ jobs:
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@4ee415529307a8ca0260b4a3775484802523e5af # v4.1.19
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -101,8 +101,6 @@
- [diegoeche](https://github.com/diegoeche)
- [Free O'Toole](https://github.com/freeotoole)
- [TheBosZ](https://github.com/thebosz)
- [qm3jp](https://github.com/qm3jp)
- [johnnyg](https://github.com/johnnyg)
## Emby Contributors

204
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.0",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "11.14.0",
@@ -18,14 +18,14 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
"@mui/x-date-pickers": "7.29.4",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.90.15",
"@tanstack/react-query-devtools": "5.91.2",
"@tanstack/react-query": "5.80.10",
"@tanstack/react-query-devtools": "5.80.10",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
@@ -40,7 +40,7 @@
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.6.13",
"hls.js": "1.6.9",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@@ -129,7 +129,6 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -140,7 +139,7 @@
},
"engines": {
"node": ">=20.0.0",
"npm": ">=9.6.4 <11.0.0",
"npm": ">=9.6.4",
"yarn": "YARN NO LONGER USED - use npm instead."
},
"optionalDependencies": {
@@ -4130,12 +4129,12 @@
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0"
},
"node_modules/@jellyfin/sdk": {
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"version": "0.0.0-unstable.202508300501",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
"license": "MPL-2.0",
"peerDependencies": {
"axios": "^1.12.0"
"axios": "^1.3.4"
}
},
"node_modules/@jellyfin/ux-web": {
@@ -5489,9 +5488,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
"integrity": "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g==",
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.10.tgz",
"integrity": "sha512-mUNQOtzxkjL6jLbyChZoSBP6A5gQDVRUiPvW+/zw/9ftOAz+H754zCj3D8PwnzPKyHzGkQ9JbH48ukhym9LK1Q==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5499,9 +5498,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"version": "5.80.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz",
"integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5509,12 +5508,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.15.tgz",
"integrity": "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ==",
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.10.tgz",
"integrity": "sha512-6zM098J8sLy9oU60XAdzUlAH4wVzoMVsWUWiiE/Iz4fd67PplxeyL4sw/MPcVJJVhbwGGXCsHn9GrQt2mlAzig==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.15"
"@tanstack/query-core": "5.80.10"
},
"funding": {
"type": "github",
@@ -5525,19 +5524,19 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.10.tgz",
"integrity": "sha512-6JL63fSc7kxyGOLV2w466SxhMn/m7LZk/ximQciy6OpVt+n2A8Mq3S0QwhIzfm4WEwLK/F3OELfzRToQburnYA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.92.0"
"@tanstack/query-devtools": "5.80.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-query": "^5.80.10",
"react": "^18 || ^19"
}
},
@@ -7234,7 +7233,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"peer": true
},
"node_modules/atob": {
@@ -7314,14 +7312,14 @@
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -8422,7 +8420,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -9512,7 +9509,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
@@ -11966,16 +11962,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -12369,13 +12364,6 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true,
"license": "MIT"
},
"node_modules/gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -12632,9 +12620,9 @@
}
},
"node_modules/hls.js": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g==",
"license": "Apache-2.0"
},
"node_modules/hoist-non-react-statics": {
@@ -23194,27 +23182,6 @@
"node": ">=8"
}
},
"node_modules/tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"license": "MIT",
"bin": {
"tsconfck": "bin/tsconfck.js"
},
"engines": {
"node": "^18 || >=20"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -23865,26 +23832,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
},
"peerDependencies": {
"vite": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -27150,9 +27097,9 @@
"integrity": "sha512-C0OlBxIr9NdeFESMTA/OVDqNSWtog6Mi7wwzwH12xbZpxsMD0RgCupUcIP7zZgcpTNecW3fZq5d6xVo7Q8HEJw=="
},
"@jellyfin/sdk": {
"version": "0.0.0-unstable.202512091852",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202512091852.tgz",
"integrity": "sha512-N+QEsrKk4KculkV6KMBb7XpzTLWcXEzqTHbS+b4rov0VYVwR6DIsJkmUzB3hM2YZsrLIHEFKhFRy/r4itkFeHw==",
"version": "0.0.0-unstable.202508300501",
"resolved": "https://registry.npmjs.org/@jellyfin/sdk/-/sdk-0.0.0-unstable.202508300501.tgz",
"integrity": "sha512-7pYdZxIQn/JTPmkEGiYmiGFbJ8S/fgA1EATz+lhOxL4nsCzf7b53DfNcL7eiYsUOFFThmDwJspUt6Y9FnimPPA==",
"requires": {}
},
"@jellyfin/ux-web": {
@@ -27840,29 +27787,29 @@
}
},
"@tanstack/query-core": {
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
"integrity": "sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g=="
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.10.tgz",
"integrity": "sha512-mUNQOtzxkjL6jLbyChZoSBP6A5gQDVRUiPvW+/zw/9ftOAz+H754zCj3D8PwnzPKyHzGkQ9JbH48ukhym9LK1Q=="
},
"@tanstack/query-devtools": {
"version": "5.92.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
"integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="
"version": "5.80.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz",
"integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA=="
},
"@tanstack/react-query": {
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.15.tgz",
"integrity": "sha512-uQvnDDcTOgJouNtAyrgRej+Azf0U5WDov3PXmHFUBc+t1INnAYhIlpZtCGNBLwCN41b43yO7dPNZu8xWkUFBwQ==",
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.10.tgz",
"integrity": "sha512-6zM098J8sLy9oU60XAdzUlAH4wVzoMVsWUWiiE/Iz4fd67PplxeyL4sw/MPcVJJVhbwGGXCsHn9GrQt2mlAzig==",
"requires": {
"@tanstack/query-core": "5.90.15"
"@tanstack/query-core": "5.80.10"
}
},
"@tanstack/react-query-devtools": {
"version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
"integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
"version": "5.80.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.10.tgz",
"integrity": "sha512-6JL63fSc7kxyGOLV2w466SxhMn/m7LZk/ximQciy6OpVt+n2A8Mq3S0QwhIzfm4WEwLK/F3OELfzRToQburnYA==",
"requires": {
"@tanstack/query-devtools": "5.92.0"
"@tanstack/query-devtools": "5.80.0"
}
},
"@tanstack/react-table": {
@@ -29150,13 +29097,13 @@
"dev": true
},
"axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"peer": true,
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -32443,15 +32390,14 @@
}
},
"form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"peer": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
@@ -32732,12 +32678,6 @@
"integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
"dev": true
},
"globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true
},
"gonzales-pe": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz",
@@ -32919,9 +32859,9 @@
}
},
"hls.js": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.9.tgz",
"integrity": "sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g=="
},
"hoist-non-react-statics": {
"version": "3.3.2",
@@ -40172,13 +40112,6 @@
}
}
},
"tsconfck": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
"integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
"dev": true,
"requires": {}
},
"tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -40620,17 +40553,6 @@
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
}
},
"vite-tsconfig-paths": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
"integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
"dev": true,
"requires": {
"debug": "^4.1.1",
"globrex": "^0.1.2",
"tsconfck": "^3.0.3"
}
},
"vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "jellyfin-web",
"version": "10.12.0",
"version": "10.11.0",
"description": "Web interface for Jellyfin",
"repository": "https://github.com/jellyfin/jellyfin-web",
"license": "GPL-2.0-or-later",
@@ -66,7 +66,6 @@
"ts-loader": "9.5.2",
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.99.9",
"webpack-bundle-analyzer": "4.10.2",
@@ -85,14 +84,14 @@
"@fontsource/noto-sans-sc": "5.2.6",
"@fontsource/noto-sans-tc": "5.2.6",
"@jellyfin/libass-wasm": "4.2.3",
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
"@jellyfin/sdk": "0.0.0-unstable.202508300501",
"@jellyfin/ux-web": "1.0.0",
"@mui/icons-material": "6.4.12",
"@mui/material": "6.4.12",
"@mui/x-date-pickers": "7.29.4",
"@react-hook/resize-observer": "2.0.2",
"@tanstack/react-query": "5.90.15",
"@tanstack/react-query-devtools": "5.91.2",
"@tanstack/react-query": "5.80.10",
"@tanstack/react-query-devtools": "5.80.10",
"abortcontroller-polyfill": "1.7.8",
"blurhash": "2.0.5",
"classlist.js": "https://github.com/eligrey/classList.js/archive/1.2.20180112.tar.gz",
@@ -107,7 +106,7 @@
"flv.js": "1.6.2",
"headroom.js": "0.12.0",
"history": "5.3.0",
"hls.js": "1.6.13",
"hls.js": "1.6.9",
"intersection-observer": "0.12.2",
"jellyfin-apiclient": "1.11.0",
"jquery": "3.7.1",
@@ -162,14 +161,14 @@
"build:check": "tsc --noEmit",
"build:es-check": "npm run build:production && npm run escheck",
"escheck": "es-check",
"lint": "eslint",
"lint": "eslint \"./\"",
"test": "vitest --watch=false --config vite.config.ts",
"test:watch": "vitest --config vite.config.ts",
"stylelint": "stylelint \"src/**/*.{css,scss}\""
},
"engines": {
"node": ">=20.0.0",
"npm": ">=9.6.4 <11.0.0",
"npm": ">=9.6.4",
"yarn": "YARN NO LONGER USED - use npm instead."
}
}

View File

@@ -13,16 +13,12 @@ import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
import { WIZARD_APP_ROUTES } from 'apps/wizard/routes/routes';
import AppHeader from 'components/AppHeader';
import Backdrop from 'components/Backdrop';
import { SETTING_KEY as LAYOUT_SETTING_KEY } from 'components/layoutManager';
import BangRedirect from 'components/router/BangRedirect';
import { createRouterHistory } from 'components/router/routerHistory';
import { LayoutMode } from 'constants/layoutMode';
import browser from 'scripts/browser';
import appTheme from 'themes';
import { ThemeStorageManager } from 'themes/themeStorageManager';
import appTheme from 'themes/themes';
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
const layoutMode = localStorage.getItem('layout');
const isExperimentalLayout = layoutMode === 'experimental';
const router = createHashRouter([
{
@@ -58,7 +54,8 @@ function RootAppLayout() {
<ThemeProvider
theme={appTheme}
defaultMode='dark'
storageManager={ThemeStorageManager}
// Disable mui's default saving to local storage
storageManager={null}
>
<Backdrop />
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />

1
src/apiclient.d.ts vendored
View File

@@ -136,7 +136,6 @@ declare module 'jellyfin-apiclient' {
getInstantMixFromItem(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
getIntros(itemId: string): Promise<BaseItemDtoQueryResult>;
getItemCounts(userId?: string): Promise<ItemCounts>;
/** @deprecated This function returns a URL with a legacy auth parameter.*/
getItemDownloadUrl(itemId: string): string;
getItemImageInfos(itemId: string): Promise<ImageInfo[]>;
getItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;

View File

@@ -7,7 +7,7 @@ import isEqual from 'lodash-es/isEqual';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { EventType } from 'constants/eventType';
import { EventType } from 'types/eventType';
import Events, { type Event } from 'utils/events';
interface AppTabsParams {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
@@ -13,6 +13,7 @@ import { Link, To } from 'react-router-dom';
interface BaseCardProps {
title?: string;
secondaryTitle?: string;
text?: string;
image?: string | null;
icon?: React.ReactNode;
@@ -21,30 +22,15 @@ interface BaseCardProps {
action?: boolean;
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
onActionClick?: () => void;
height?: number;
width?: number;
};
const BaseCard = ({
title,
text,
image,
icon,
to,
onClick,
action,
actionRef,
onActionClick,
height,
width
}: BaseCardProps) => {
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: BaseCardProps) => {
return (
<Card
sx={{
display: 'flex',
flexDirection: 'column',
height: height || 240,
width: width
height: 240
}}
>
<CardActionArea
@@ -76,44 +62,30 @@ const BaseCard = ({
</Box>
)}
</CardActionArea>
<CardContent
sx={{
minHeight: 50,
'&:last-child': {
paddingBottom: 2,
paddingRight: 1
}
}}>
<Stack flexGrow={1} direction='row'>
<Stack flexGrow={1}>
<Typography gutterBottom sx={{
<CardHeader
title={
<Stack direction='row' spacing={1} alignItems='center'>
<Typography sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis'
}}>
{title}
</Typography>
{text && (
<Typography
variant='body2'
color='text.secondary'
sx={{
lineBreak: 'anywhere'
}}
>
{text}
</Typography>
{secondaryTitle && (
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
)}
</Stack>
<Box>
{action ? (
<IconButton ref={actionRef} onClick={onActionClick}>
<MoreVertIcon />
</IconButton>
) : null}
</Box>
</Stack>
</CardContent>
}
subheader={text}
action={
action ? (
<IconButton ref={actionRef} onClick={onActionClick}>
<MoreVertIcon />
</IconButton>
) : null
}
/>
</Card>
);
};

View File

@@ -1,30 +0,0 @@
import React, { useCallback } from 'react';
import Snackbar, { SnackbarProps } from '@mui/material/Snackbar';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
const Toast = (props: SnackbarProps) => {
const onCloseClick = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
props.onClose?.(e, 'clickaway');
}, [ props ]);
const action = (
<IconButton
size='small'
color='inherit'
onClick={onCloseClick}
>
<CloseIcon fontSize='small' />
</IconButton>
);
return (
<Snackbar
autoHideDuration={3300}
action={action}
{ ...props }
/>
);
};
export default Toast;

View File

@@ -1,8 +1,7 @@
import Box from '@mui/material/Box/Box';
import Stack from '@mui/material/Stack/Stack';
import type {} from '@mui/material/themeCssVarsAugmentation';
import Typography from '@mui/material/Typography/Typography';
import { type MRT_RowData, type MRT_TableInstance, type MRT_TableOptions, MaterialReactTable } from 'material-react-table';
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
import React from 'react';
import Page, { type PageProps } from 'components/Page';
@@ -13,7 +12,7 @@ interface TablePageProps<T extends MRT_RowData> extends PageProps {
table: MRT_TableInstance<T>
}
export const DEFAULT_TABLE_OPTIONS: Partial<MRT_TableOptions<MRT_RowData>> = {
export const DEFAULT_TABLE_OPTIONS = {
// Enable custom features
enableColumnPinning: true,
enableColumnResizing: true,

View File

@@ -39,7 +39,6 @@ const ActivityLogWidget = () => {
key={entry.Id}
item={entry}
displayShortOverview={true}
to='/dashboard/activity?useractivity=true'
/>
))}
</List>

View File

@@ -31,7 +31,6 @@ const AlertsLogWidget = () => {
key={entry.Id}
item={entry}
displayShortOverview={false}
to='/dashboard/activity?useractivity=false'
/>
))}
</List>

View File

@@ -5,14 +5,13 @@ import MusicNote from '@mui/icons-material/MusicNote';
import MusicVideo from '@mui/icons-material/MusicVideo';
import Tv from '@mui/icons-material/Tv';
import VideoLibrary from '@mui/icons-material/VideoLibrary';
import Grid from '@mui/material/Grid';
import Grid from '@mui/material/Grid2';
import SvgIcon from '@mui/material/SvgIcon';
import React, { useMemo } from 'react';
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
import MetricCard, { type MetricCardProps } from 'apps/dashboard/features/metrics/components/MetricCard';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
interface MetricDefinition {
key: keyof ItemCounts
@@ -76,27 +75,23 @@ const ItemCountsWidget = () => {
}, [ counts, isPending ]);
return (
<Box>
<Grid
container
spacing={2}
sx={{
alignItems: 'stretch'
}}
>
{cards.map(card => (
<Grid
key={card.metrics.map(metric => metric.label).join('-')}
item
xs={12}
sm={6}
lg={4}
>
<MetricCard {...card} />
</Grid>
))}
</Grid>
</Box>
<Grid
container
spacing={2}
sx={{
alignItems: 'stretch',
marginTop: 2
}}
>
{cards.map(card => (
<Grid
key={card.metrics.map(metric => metric.label).join('-')}
size={{ xs: 12, sm: 6, lg: 4 }}
>
<MetricCard {...card} />
</Grid>
))}
</Grid>
);
};

View File

@@ -15,15 +15,9 @@ type ServerInfoWidgetProps = {
onScanLibrariesClick?: () => void;
onRestartClick?: () => void;
onShutdownClick?: () => void;
isScanning?: boolean;
};
const ServerInfoWidget = ({
onScanLibrariesClick,
onRestartClick,
onShutdownClick,
isScanning
}: ServerInfoWidgetProps) => {
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
const { data: systemInfo, isPending } = useSystemInfo();
return (
@@ -69,7 +63,6 @@ const ServerInfoWidget = ({
sx={{
fontWeight: 'bold'
}}
disabled={isScanning}
>
{globalize.translate('ButtonScanAllLibraries')}
</Button>

View File

@@ -47,8 +47,5 @@ export const HelpLinks = [
'/dashboard/users/profile'
],
url: 'https://jellyfin.org/docs/general/server/users/'
}, {
paths: ['/dashboard/backups'],
url: 'https://jellyfin.org/docs/general/administration/backup-and-restore/'
}
];

View File

@@ -0,0 +1,14 @@
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
<div>
<div class="content-primary">
<div class="padded-top padded-bottom">
<button is="emby-button" type="button" class="raised btnRefresh">
<span>${ButtonScanAllLibraries}</span>
</button>
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
</div>
<div id="divVirtualFolders"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,383 @@
import escapeHtml from 'escape-html';
import taskButton from 'scripts/taskbutton';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import dom from 'utils/dom';
import imageHelper from 'utils/image';
import 'components/cardbuilder/card.scss';
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
import { pageClassOn, pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function addVirtualFolder(page) {
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
return !f.hidden;
}),
refresh: shouldRefreshLibraryAfterChanges(page)
}).then(function (hasChanges) {
if (hasChanges) {
reloadLibrary(page);
}
});
});
}
function editVirtualFolder(page, virtualFolder) {
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
new MediaLibraryEditor({
refresh: shouldRefreshLibraryAfterChanges(page),
library: virtualFolder
}).then(function (hasChanges) {
if (hasChanges) {
reloadLibrary(page);
}
});
});
}
function deleteVirtualFolder(page, virtualFolder) {
let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder');
if (virtualFolder.Locations.length) {
msg += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
msg += virtualFolder.Locations.join('<br/>');
}
confirm({
text: msg,
title: globalize.translate('HeaderRemoveMediaFolder'),
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () {
reloadLibrary(page);
});
});
}
function refreshVirtualFolder(page, virtualFolder) {
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
new RefreshDialog({
itemIds: [virtualFolder.ItemId],
serverId: ApiClient.serverId(),
mode: 'scan'
}).show();
});
}
function renameVirtualFolder(page, virtualFolder) {
import('components/prompt/prompt').then(({ default: prompt }) => {
prompt({
label: globalize.translate('LabelNewName'),
description: globalize.translate('MessageRenameMediaFolder'),
confirmText: globalize.translate('ButtonRename')
}).then(function (newName) {
if (newName && newName != virtualFolder.Name) {
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () {
reloadLibrary(page);
});
}
});
});
}
function showCardMenu(page, elem, virtualFolders) {
const card = dom.parentWithClass(elem, 'card');
const index = parseInt(card.getAttribute('data-index'), 10);
const virtualFolder = virtualFolders[index];
const menuItems = [];
menuItems.push({
name: globalize.translate('EditImages'),
id: 'editimages',
icon: 'photo'
});
menuItems.push({
name: globalize.translate('ManageLibrary'),
id: 'edit',
icon: 'folder'
});
menuItems.push({
name: globalize.translate('ButtonRename'),
id: 'rename',
icon: 'mode_edit'
});
menuItems.push({
name: globalize.translate('ScanLibrary'),
id: 'refresh',
icon: 'refresh'
});
menuItems.push({
name: globalize.translate('ButtonRemove'),
id: 'delete',
icon: 'delete'
});
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
positionTo: elem,
callback: function (resultId) {
switch (resultId) {
case 'edit':
editVirtualFolder(page, virtualFolder);
break;
case 'editimages':
editImages(page, virtualFolder);
break;
case 'rename':
renameVirtualFolder(page, virtualFolder);
break;
case 'delete':
deleteVirtualFolder(page, virtualFolder);
break;
case 'refresh':
refreshVirtualFolder(page, virtualFolder);
}
}
});
});
}
function reloadLibrary(page) {
loading.show();
ApiClient.getVirtualFolders().then(function (result) {
reloadVirtualFolders(page, result);
});
}
function shouldRefreshLibraryAfterChanges(page) {
return page.id === 'mediaLibraryPage';
}
function reloadVirtualFolders(page, virtualFolders) {
let html = '';
virtualFolders.push({
Name: globalize.translate('ButtonAddMediaLibrary'),
icon: 'add_circle',
Locations: [],
showType: false,
showLocations: false,
showMenu: false,
showNameWithIcon: false,
elementId: 'addLibrary'
});
for (let i = 0; i < virtualFolders.length; i++) {
const virtualFolder = virtualFolders[i];
html += getVirtualFolderHtml(page, virtualFolder, i);
}
const divVirtualFolders = page.querySelector('#divVirtualFolders');
divVirtualFolders.innerHTML = html;
divVirtualFolders.classList.add('itemsContainer');
divVirtualFolders.classList.add('vertical-wrap');
const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
btnCardMenuElements.forEach(function (btn) {
btn.addEventListener('click', function () {
showCardMenu(page, btn, virtualFolders);
});
});
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
addVirtualFolder(page);
});
const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
libraryEditElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const card = dom.parentWithClass(btn, 'card');
const index = parseInt(card.getAttribute('data-index'), 10);
const virtualFolder = virtualFolders[index];
if (virtualFolder.ItemId) {
editVirtualFolder(page, virtualFolder);
}
});
});
loading.hide();
}
function editImages(page, virtualFolder) {
import('components/imageeditor/imageeditor').then((imageEditor) => {
imageEditor.show({
itemId: virtualFolder.ItemId,
serverId: ApiClient.serverId()
}).then(function () {
reloadLibrary(page);
});
});
}
function getLink(text, url) {
return globalize.translate(text, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
}
function getCollectionTypeOptions() {
return [{
name: '',
value: ''
}, {
name: globalize.translate('Movies'),
value: 'movies',
message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies')
}, {
name: globalize.translate('TabMusic'),
value: 'music',
message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music')
}, {
name: globalize.translate('Shows'),
value: 'tvshows',
message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows')
}, {
name: globalize.translate('Books'),
value: 'books',
message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books')
}, {
name: globalize.translate('HomeVideosPhotos'),
value: 'homevideos'
}, {
name: globalize.translate('MusicVideos'),
value: 'musicvideos'
}, {
name: globalize.translate('MixedMoviesShows'),
value: 'mixed',
message: globalize.translate('MessageUnsetContentHelp')
}];
}
function getVirtualFolderHtml(page, virtualFolder, index) {
let html = '';
const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : '';
html += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
html += '<div class="cardBox visualCardBox">';
html += '<div class="cardScalable visualCardBox-cardScalable">';
html += '<div class="cardPadder cardPadder-backdrop"></div>';
html += '<div class="cardContent">';
let imgUrl = '';
if (virtualFolder.PrimaryImageItemId) {
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
type: 'Primary'
});
}
let hasCardImageContainer;
if (imgUrl) {
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
html += `<img src="${imgUrl}" style="width:100%" />`;
hasCardImageContainer = true;
} else if (!virtualFolder.showNameWithIcon) {
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
hasCardImageContainer = true;
}
if (hasCardImageContainer) {
html += '<div class="cardIndicators backdropCardIndicators">';
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
html += '</div>';
html += '</div>';
}
if (!imgUrl && virtualFolder.showNameWithIcon) {
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
if (virtualFolder.showNameWithIcon) {
html += '<div style="margin:1em 0;position:width:100%;">';
html += escapeHtml(virtualFolder.Name);
html += '</div>';
}
html += '</h3>';
}
html += '</div>';
html += '</div>';
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
if (virtualFolder.showMenu !== false) {
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += '</div>';
}
html += "<div class='cardText'>";
if (virtualFolder.showNameWithIcon) {
html += '&nbsp;';
} else {
html += escapeHtml(virtualFolder.Name);
}
html += '</div>';
let typeName = getCollectionTypeOptions().filter(function (t) {
return t.value == virtualFolder.CollectionType;
})[0];
typeName = typeName ? typeName.name : globalize.translate('Other');
html += "<div class='cardText cardText-secondary'>";
if (virtualFolder.showType === false) {
html += '&nbsp;';
} else {
html += typeName;
}
html += '</div>';
if (virtualFolder.showLocations === false) {
html += "<div class='cardText cardText-secondary'>";
html += '&nbsp;';
html += '</div>';
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
html += virtualFolder.Locations[0];
html += '</div>';
} else {
html += "<div class='cardText cardText-secondary'>";
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
html += '</div>';
}
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
pageClassOn('pageshow', 'mediaLibraryPage', function () {
reloadLibrary(this);
});
pageIdOn('pageshow', 'mediaLibraryPage', function () {
const page = this;
taskButton({
mode: 'on',
progressElem: page.querySelector('.refreshProgress'),
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
});
pageIdOn('pagebeforehide', 'mediaLibraryPage', function () {
const page = this;
taskButton({
mode: 'off',
progressElem: page.querySelector('.refreshProgress'),
taskKey: 'RefreshLibrary',
button: page.querySelector('.btnRefresh')
});
});

View File

@@ -0,0 +1,40 @@
<div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage" data-title="${LiveTV}">
<div>
<div class="content-primary">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer sectionTitleContainer-cards">
<h2 class="sectionTitle sectionTitle-cards">
<span>${HeaderTunerDevices}</span>
</h2>
<button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
</div>
</div>
<div class="readOnlyContent">
<div class="verticalSection">
<div class="sectionTitleContainer">
<h2 class="sectionTitle">${HeaderGuideProviders}</h2>
<button is="emby-button" type="button" class="fab btnAddProvider submit" style="margin-left:1em;" title="${Add}">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>
<div class="providerList">
</div>
<div>
<button is="emby-button" type="button" class="raised btnRefresh block button-cancel">
<span>${ButtonRefreshGuideData}</span>
</button>
<progress max="100" min="0" style="width: 100%;" class="refreshGuideProgress"></progress>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,338 @@
import 'jquery';
import globalize from 'lib/globalize';
import taskButton from 'scripts/taskbutton';
import dom from 'utils/dom';
import layoutManager from 'components/layoutManager';
import loading from 'components/loading/loading';
import browser from 'scripts/browser';
import 'components/listview/listview.scss';
import 'styles/flexstyles.scss';
import 'elements/emby-itemscontainer/emby-itemscontainer';
import 'components/cardbuilder/card.scss';
import 'material-design-icons-iconfont';
import 'elements/emby-button/emby-button';
import Dashboard from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
const enableFocusTransform = !browser.slow && !browser.edge;
function getDeviceHtml(device) {
const padderClass = 'cardPadder-backdrop';
let cssClass = 'card scalableCard backdropCard backdropCard-scalable';
const cardBoxCssClass = 'cardBox visualCardBox';
let html = '';
// TODO move card creation code to Card component
if (layoutManager.tv) {
cssClass += ' show-focus';
if (enableFocusTransform) {
cssClass += ' show-animation';
}
}
html += '<div type="button" class="' + cssClass + '" data-id="' + device.Id + '">';
html += '<div class="' + cardBoxCssClass + '">';
html += '<div class="cardScalable visualCardBox-cardScalable">';
html += '<div class="' + padderClass + '"></div>';
html += '<div class="cardContent searchImage">';
html += `<div class="cardImageContainer coveredImage ${getDefaultBackgroundClass()}"><span class="cardImageIcon material-icons dvr" aria-hidden="true"></span></div>`;
html += '</div>';
html += '</div>';
html += '<div class="cardFooter visualCardBox-cardFooter">';
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions autoSize" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += '<div class="cardText">' + (device.FriendlyName || getTunerName(device.Type)) + '</div>';
html += '<div class="cardText cardText-secondary">';
html += device.Url || '&nbsp;';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
function renderDevices(page, devices) {
page.querySelector('.devicesList').innerHTML = devices.map(getDeviceHtml).join('');
}
function deleteDevice(page, id) {
const message = globalize.translate('MessageConfirmDeleteTunerDevice');
confirm(message, globalize.translate('HeaderDeleteDevice')).then(function () {
loading.show();
ApiClient.ajax({
type: 'DELETE',
url: ApiClient.getUrl('LiveTv/TunerHosts', {
Id: id
})
}).then(function () {
reload(page);
});
});
}
function reload(page) {
loading.show();
ApiClient.getNamedConfiguration('livetv').then(function (config) {
renderDevices(page, config.TunerHosts);
renderProviders(page, config.ListingProviders);
});
loading.hide();
}
function submitAddDeviceForm(page) {
page.querySelector('.dlgAddDevice').close();
loading.show();
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('LiveTv/TunerHosts'),
data: JSON.stringify({
Type: page.querySelector('#selectTunerDeviceType').value,
Url: page.querySelector('#txtDevicePath').value
}),
contentType: 'application/json'
}).then(function () {
reload(page);
}, function () {
Dashboard.alert({
message: globalize.translate('ErrorAddingTunerDevice')
});
});
}
function renderProviders(page, providers) {
let html = '';
if (providers.length) {
html += '<div class="paperList">';
for (let i = 0, length = providers.length; i < length; i++) {
const provider = providers[i];
html += '<div class="listItem">';
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true"></span>';
html += '<div class="listItemBody two-line">';
html += '<a is="emby-linkbutton" style="display:block;padding:0;margin:0;text-align:left;" class="clearLink" href="' + getProviderConfigurationUrl(provider.Type) + '&id=' + provider.Id + '">';
html += '<h3 class="listItemBodyText">';
html += getProviderName(provider.Type);
html += '</h3>';
html += '<div class="listItemBodyText secondary">';
html += provider.Path || provider.ListingsId || '';
html += '</div>';
html += '</a>';
html += '</div>';
html += '<button type="button" is="paper-icon-button-light" class="btnOptions" data-id="' + provider.Id + '"><span class="material-icons listItemAside more_vert" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '</div>';
}
const elem = page.querySelector('.providerList');
elem.innerHTML = html;
if (elem.querySelector('.btnOptions')) {
const btnOptionElements = elem.querySelectorAll('.btnOptions');
btnOptionElements.forEach(function (btn) {
btn.addEventListener('click', function () {
const id = this.getAttribute('data-id');
showProviderOptions(page, id, btn);
});
});
}
}
function showProviderOptions(page, providerId, button) {
const items = [];
items.push({
name: globalize.translate('Delete'),
id: 'delete'
});
items.push({
name: globalize.translate('MapChannels'),
id: 'map'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button
}).then(function (id) {
switch (id) {
case 'delete':
deleteProvider(page, providerId);
break;
case 'map':
mapChannels(page, providerId);
}
});
});
}
function mapChannels(page, providerId) {
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
new ChannelMapper({
serverId: ApiClient.serverInfo().Id,
providerId: providerId
}).show();
});
}
function deleteProvider(page, id) {
const message = globalize.translate('MessageConfirmDeleteGuideProvider');
confirm(message, globalize.translate('HeaderDeleteProvider')).then(function () {
loading.show();
ApiClient.ajax({
type: 'DELETE',
url: ApiClient.getUrl('LiveTv/ListingProviders', {
Id: id
})
}).then(function () {
reload(page);
}, function () {
reload(page);
});
});
}
function getTunerName(providerId) {
switch (providerId.toLowerCase()) {
case 'm3u':
return 'M3U';
case 'hdhomerun':
return 'HDHomeRun';
case 'hauppauge':
return 'Hauppauge';
case 'satip':
return 'DVB';
default:
return 'Unknown';
}
}
function getProviderName(providerId) {
switch (providerId.toLowerCase()) {
case 'schedulesdirect':
return 'Schedules Direct';
case 'xmltv':
return 'XMLTV';
default:
return 'Unknown';
}
}
function getProviderConfigurationUrl(providerId) {
switch (providerId.toLowerCase()) {
case 'xmltv':
return '#/dashboard/livetv/guide?type=xmltv';
case 'schedulesdirect':
return '#/dashboard/livetv/guide?type=schedulesdirect';
}
}
function addProvider(button) {
const menuItems = [];
menuItems.push({
name: 'Schedules Direct',
id: 'SchedulesDirect'
});
menuItems.push({
name: 'XMLTV',
id: 'xmltv'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: menuItems,
positionTo: button,
callback: function (id) {
Dashboard.navigate(getProviderConfigurationUrl(id));
}
});
});
}
function addDevice() {
Dashboard.navigate('dashboard/livetv/tuner');
}
function showDeviceMenu(button, tunerDeviceId) {
const items = [];
items.push({
name: globalize.translate('Delete'),
id: 'delete'
});
items.push({
name: globalize.translate('Edit'),
id: 'edit'
});
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
actionsheet.show({
items: items,
positionTo: button
}).then(function (id) {
switch (id) {
case 'delete':
deleteDevice(dom.parentWithClass(button, 'page'), tunerDeviceId);
break;
case 'edit':
Dashboard.navigate('dashboard/livetv/tuner?id=' + tunerDeviceId);
}
});
});
}
function onDevicesListClick(e) {
const card = dom.parentWithClass(e.target, 'card');
if (card) {
const id = card.getAttribute('data-id');
const btnCardOptions = dom.parentWithClass(e.target, 'btnCardOptions');
if (btnCardOptions) {
showDeviceMenu(btnCardOptions, id);
} else {
Dashboard.navigate('dashboard/livetv/tuner?id=' + id);
}
}
}
$(document).on('pageinit', '#liveTvStatusPage', function () {
const page = this;
page.querySelector('.btnAddDevice').addEventListener('click', function () {
addDevice();
});
if (page.querySelector('.formAddDevice')) {
// NOTE: unused?
page.querySelector('.formAddDevice').addEventListener('submit', function (e) {
e.preventDefault();
submitAddDeviceForm(page);
});
}
page.querySelector('.btnAddProvider').addEventListener('click', function () {
addProvider(this);
});
page.querySelector('.devicesList').addEventListener('click', onDevicesListClick);
}).on('pageshow', '#liveTvStatusPage', function () {
const page = this;
reload(page);
taskButton({
mode: 'on',
progressElem: page.querySelector('.refreshGuideProgress'),
taskKey: 'RefreshGuide',
button: page.querySelector('.btnRefresh')
});
}).on('pagehide', '#liveTvStatusPage', function () {
const page = this;
taskButton({
mode: 'off',
progressElem: page.querySelector('.refreshGuideProgress'),
taskKey: 'RefreshGuide',
button: page.querySelector('.btnRefresh')
});
});

View File

@@ -4,6 +4,7 @@ import Notifications from '@mui/icons-material/Notifications';
import Avatar from '@mui/material/Avatar';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
import formatRelative from 'date-fns/formatRelative';
@@ -11,15 +12,13 @@ import { getLocale } from 'utils/dateFnsLocale';
import Stack from '@mui/material/Stack';
import getLogLevelColor from '../utils/getLogLevelColor';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import ListItemLink from 'components/ListItemLink';
type ActivityListItemProps = {
item: ActivityLogEntry;
displayShortOverview: boolean;
to: string;
};
const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemProps) => {
const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => {
const relativeDate = useMemo(() => {
if (item.Date) {
return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() });
@@ -30,7 +29,7 @@ const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemPr
return (
<ListItem disablePadding>
<ListItemLink to={to}>
<ListItemButton>
<ListItemAvatar>
<Avatar sx={{ bgcolor: getLogLevelColor(item.Severity || LogLevel.Information) + '.main' }}>
<Notifications sx={{ color: '#fff' }} />
@@ -38,28 +37,14 @@ const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemPr
</ListItemAvatar>
<ListItemText
primary={<Typography sx={{ whiteSpace: 'pre-wrap' }}>{item.Name}</Typography>}
primary={<Typography>{item.Name}</Typography>}
secondary={(
<Stack>
<Typography
sx={{
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
variant='body1'
color='text.secondary'
>
<Typography variant='body1' color='text.secondary'>
{relativeDate}
</Typography>
{displayShortOverview && (
<Typography
sx={{
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
variant='body1'
color='text.secondary'
>
<Typography variant='body1' color='text.secondary'>
{item.ShortOverview}
</Typography>
)}
@@ -67,7 +52,7 @@ const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemPr
)}
disableTypography
/>
</ListItemLink>
</ListItemButton>
</ListItem>
);
};

View File

@@ -7,7 +7,7 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Box from '@mui/material/Box';
import globalize from 'lib/globalize';
import React, { FunctionComponent, useCallback, useState } from 'react';
import React, { FunctionComponent, useCallback } from 'react';
import Stack from '@mui/material/Stack';
import FormGroup from '@mui/material/FormGroup';
import FormControl from '@mui/material/FormControl';
@@ -16,7 +16,7 @@ import Checkbox from '@mui/material/Checkbox';
import ContentCopy from '@mui/icons-material/ContentCopy';
import IconButton from '@mui/material/IconButton';
import { copy } from 'scripts/clipboard';
import Toast from 'apps/dashboard/components/Toast';
import toast from 'components/toast/toast';
type IProps = {
backup: BackupManifestDto;
@@ -25,16 +25,10 @@ type IProps = {
};
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
const handleToastClose = useCallback(() => {
setIsCopiedToastOpen(false);
}, []);
const copyPath = useCallback(async () => {
if (backup.Path) {
await copy(backup.Path);
setIsCopiedToastOpen(true);
toast({ text: globalize.translate('Copied') });
}
}, [ backup.Path ]);
@@ -45,11 +39,6 @@ const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }:
maxWidth={'sm'}
fullWidth
>
<Toast
open={isCopiedToastOpen}
onClose={handleToastClose}
message={globalize.translate('Copied')}
/>
<DialogTitle>
{backup.DateCreated}
</DialogTitle>

View File

@@ -1,21 +0,0 @@
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
export const useRemoveVirtualFolder = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.removeVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
}
});
};

View File

@@ -1,21 +0,0 @@
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
export const useRenameVirtualFolder = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => (
getLibraryStructureApi(api!)
.renameVirtualFolder(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'VirtualFolders' ]
});
}
});
};

View File

@@ -1,21 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLibraryStructureApi(api).getVirtualFolders(options);
return response.data;
};
export const useVirtualFolders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'VirtualFolders' ],
queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -1,237 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info';
import BaseCard from 'apps/dashboard/components/BaseCard';
import getCollectionTypeOptions from '../utils/collectionTypeOptions';
import globalize from 'lib/globalize';
import Icon from '@mui/material/Icon';
import { getLibraryIcon } from 'utils/image';
import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor';
import { queryClient } from 'utils/query/queryClient';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import Folder from '@mui/icons-material/Folder';
import ImageIcon from '@mui/icons-material/Image';
import EditIcon from '@mui/icons-material/Edit';
import RefreshIcon from '@mui/icons-material/Refresh';
import DeleteIcon from '@mui/icons-material/Delete';
import ListItemText from '@mui/material/ListItemText';
import imageeditor from 'components/imageeditor/imageeditor';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import InputDialog from 'components/InputDialog';
import { useRenameVirtualFolder } from '../api/useRenameVirtualFolder';
import RefreshDialog from 'components/refreshdialog/refreshdialog';
import ConfirmDialog from 'components/ConfirmDialog';
import { useRemoveVirtualFolder } from '../api/useRemoveVirtualFolder';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { useApi } from 'hooks/useApi';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import dom from 'utils/dom';
type LibraryCardProps = {
virtualFolder: VirtualFolderInfo;
};
const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
const { api } = useApi();
const actionRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isRenameLibraryDialogOpen, setIsRenameLibraryDialogOpen ] = useState(false);
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
const renameVirtualFolder = useRenameVirtualFolder();
const removeVirtualFolder = useRemoveVirtualFolder();
const imageUrl = useMemo(() => {
if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) {
return getImageApi(api)
.getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, {
maxWidth: Math.round(dom.getScreenWidth() * 0.40)
});
}
}, [ api, virtualFolder ]);
const typeName = getCollectionTypeOptions().filter(function (t) {
return t.value == virtualFolder.CollectionType;
})[0]?.name || globalize.translate('Other');
const openRenameDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsRenameLibraryDialogOpen(true);
}, []);
const hideRenameLibraryDialog = useCallback(() => {
setIsRenameLibraryDialogOpen(false);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
const renameLibrary = useCallback((newName: string) => {
if (virtualFolder.Name) {
renameVirtualFolder.mutate({
refreshLibrary: true,
newName: newName,
name: virtualFolder.Name
}, {
onSettled: () => {
hideRenameLibraryDialog();
}
});
}
}, [ renameVirtualFolder, virtualFolder, hideRenameLibraryDialog ]);
const showRefreshDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void new RefreshDialog({
itemIds: [ virtualFolder.ItemId ],
serverId: ServerConnections.currentApiClient()?.serverId(),
mode: 'scan'
}).show();
}, [ virtualFolder ]);
const showMediaLibraryEditor = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
const mediaLibraryEditor = new MediaLibraryEditor({
library: virtualFolder
}) as Promise<boolean>;
void mediaLibraryEditor.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
});
}, [ virtualFolder ]);
const showImageEditor = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void imageeditor.show({
itemId: virtualFolder.ItemId,
serverId: ServerConnections.currentApiClient()?.serverId()
}).then(() => {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}).catch(() => {
/* pop up closed */
});
}, [ virtualFolder ]);
const showDeleteLibraryDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsConfirmDeleteDialogOpen(true);
}, []);
const onCancelDeleteLibrary = useCallback(() => {
setIsConfirmDeleteDialogOpen(false);
}, []);
const onConfirmDeleteLibrary = useCallback(() => {
if (virtualFolder.Name) {
removeVirtualFolder.mutate({
name: virtualFolder.Name,
refreshLibrary: true
}, {
onSettled: () => {
setIsConfirmDeleteDialogOpen(false);
}
});
}
}, [ virtualFolder, removeVirtualFolder ]);
return (
<>
<InputDialog
title={globalize.translate('ButtonRename')}
open={isRenameLibraryDialogOpen}
onClose={hideRenameLibraryDialog}
label={globalize.translate('LabelNewName')}
helperText={globalize.translate('MessageRenameMediaFolder')}
initialText={virtualFolder.Name || ''}
confirmButtonText={globalize.translate('ButtonRename')}
onConfirm={renameLibrary}
/>
<ConfirmDialog
open={isConfirmDeleteDialogOpen}
title={globalize.translate('HeaderRemoveMediaFolder')}
text={
globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder') + '\n\n'
+ globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '\n\n'
+ virtualFolder.Locations?.join('\n')
}
confirmButtonText={globalize.translate('Delete')}
confirmButtonColor='error'
onConfirm={onConfirmDeleteLibrary}
onCancel={onCancelDeleteLibrary}
/>
<BaseCard
title={virtualFolder.Name || ''}
text={typeName}
image={imageUrl}
icon={<Icon sx={{ fontSize: 70 }}>{getLibraryIcon(virtualFolder.CollectionType)}</Icon>}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
onClick={showMediaLibraryEditor}
height={260}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={showImageEditor}>
<ListItemIcon>
<ImageIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('EditImages')}</ListItemText>
</MenuItem>
<MenuItem onClick={showMediaLibraryEditor}>
<ListItemIcon>
<Folder />
</ListItemIcon>
<ListItemText>{globalize.translate('ManageLibrary')}</ListItemText>
</MenuItem>
<MenuItem onClick={openRenameDialog}>
<ListItemIcon>
<EditIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonRename')}</ListItemText>
</MenuItem>
<MenuItem onClick={showRefreshDialog}>
<ListItemIcon>
<RefreshIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ScanLibrary')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteLibraryDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonRemove')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default LibraryCard;

View File

@@ -1,31 +0,0 @@
import globalize from 'lib/globalize';
const getCollectionTypeOptions = () => {
return [{
name: '',
value: ''
}, {
name: globalize.translate('Movies'),
value: 'movies'
}, {
name: globalize.translate('TabMusic'),
value: 'music'
}, {
name: globalize.translate('Shows'),
value: 'tvshows'
}, {
name: globalize.translate('Books'),
value: 'books'
}, {
name: globalize.translate('HomeVideosPhotos'),
value: 'homevideos'
}, {
name: globalize.translate('MusicVideos'),
value: 'musicvideos'
}, {
name: globalize.translate('MixedMoviesShows'),
value: 'mixed'
}];
};
export default getCollectionTypeOptions;

View File

@@ -1,22 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
import { LiveTvApiDeleteListingProviderRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
export const useDeleteProvider = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LiveTvApiDeleteListingProviderRequest) => (
getLiveTvApi(api!)
.deleteListingProvider(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'NamedConfiguration', 'livetv' ]
});
}
});
};

View File

@@ -1,22 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
import { LiveTvApiDeleteTunerHostRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
export const useDeleteTuner = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: LiveTvApiDeleteTunerHostRequest) => (
getLiveTvApi(api!)
.deleteTunerHost(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ 'NamedConfiguration', 'livetv' ]
});
}
});
};

View File

@@ -1,138 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import type { ListingsProviderInfo } from '@jellyfin/sdk/lib/generated-client/models/listings-provider-info';
import Avatar from '@mui/material/Avatar';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemLink from 'components/ListItemLink';
import DvrIcon from '@mui/icons-material/Dvr';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import getProviderConfigurationUrl from '../utils/getProviderConfigurationUrl';
import ListItemText from '@mui/material/ListItemText';
import getProviderName from '../utils/getProviderName';
import IconButton from '@mui/material/IconButton';
import ConfirmDialog from 'components/ConfirmDialog';
import globalize from 'lib/globalize';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
import DeleteIcon from '@mui/icons-material/Delete';
import ChannelMapper from 'components/channelMapper/channelMapper';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import { useDeleteProvider } from '../api/useDeleteProvider';
interface ProviderProps {
provider: ListingsProviderInfo
}
const Provider = ({ provider }: ProviderProps) => {
const [ isDeleteProviderDialogOpen, setIsDeleteProviderDialogOpen ] = useState(false);
const actionsRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const deleteProvider = useDeleteProvider();
const showChannelMapper = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
void new ChannelMapper({
serverId: ServerConnections.currentApiClient()?.serverId(),
providerId: provider.Id
}).show();
}, [ provider ]);
const showContextMenu = useCallback(() => {
setAnchorEl(actionsRef.current);
setIsMenuOpen(true);
}, []);
const showDeleteDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsDeleteProviderDialogOpen(true);
}, []);
const onDeleteProviderDialogCancel = useCallback(() => {
setIsDeleteProviderDialogOpen(false);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onConfirmDelete = useCallback(() => {
if (provider.Id) {
deleteProvider.mutate({
id: provider.Id
}, {
onSettled: () => {
setIsDeleteProviderDialogOpen(false);
}
});
}
}, [ deleteProvider, provider ]);
return (
<>
<ConfirmDialog
open={isDeleteProviderDialogOpen}
title={globalize.translate('HeaderDeleteProvider')}
text={globalize.translate('MessageConfirmDeleteGuideProvider')}
onCancel={onDeleteProviderDialogCancel}
onConfirm={onConfirmDelete}
confirmButtonText={globalize.translate('Delete')}
confirmButtonColor='error'
/>
<ListItem
disablePadding key={provider.Id}
secondaryAction={
<IconButton ref={actionsRef} onClick={showContextMenu}>
<MoreVertIcon />
</IconButton>
}
>
<ListItemLink to={getProviderConfigurationUrl(provider.Type || '') + '&id=' + provider.Id}>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<DvrIcon sx={{ color: '#fff' }} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={getProviderName(provider.Type)}
secondary={provider.Path || provider.ListingsId}
slotProps={{
primary: {
variant: 'h3'
},
secondary: {
variant: 'body1'
}
}}
/>
</ListItemLink>
</ListItem>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={showChannelMapper}>
<ListItemIcon>
<LocationSearchingIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('MapChannels')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Delete')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default Provider;

View File

@@ -1,109 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import type { TunerHostInfo } from '@jellyfin/sdk/lib/generated-client/models/tuner-host-info';
import BaseCard from 'apps/dashboard/components/BaseCard';
import DvrIcon from '@mui/icons-material/Dvr';
import getTunerName from '../utils/getTunerName';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import ListItemText from '@mui/material/ListItemText';
import globalize from 'lib/globalize';
import { useNavigate } from 'react-router-dom';
import ConfirmDialog from 'components/ConfirmDialog';
import { useDeleteTuner } from '../api/useDeleteTuner';
interface TunerDeviceCardProps {
tunerHost: TunerHostInfo;
}
const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
const navigate = useNavigate();
const actionRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
const deleteTuner = useDeleteTuner();
const navigateToEditPage = useCallback(() => {
navigate(`/dashboard/livetv/tuner?id=${tunerHost.Id}`);
}, [ navigate, tunerHost ]);
const onDelete = useCallback(() => {
if (tunerHost.Id) {
deleteTuner.mutate({
id: tunerHost.Id
}, {
onSettled: () => {
setIsConfirmDeleteDialogOpen(false);
}
});
}
}, [ deleteTuner, tunerHost ]);
const showDeleteDialog = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
setIsConfirmDeleteDialogOpen(true);
}, []);
const onDeleteDialogClose = useCallback(() => {
setIsConfirmDeleteDialogOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
return (
<>
<ConfirmDialog
open={isConfirmDeleteDialogOpen}
title={globalize.translate('HeaderDeleteDevice')}
text={globalize.translate('MessageConfirmDeleteTunerDevice')}
onCancel={onDeleteDialogClose}
onConfirm={onDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
<BaseCard
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
text={tunerHost.Url || ''}
icon={<DvrIcon sx={{ fontSize: 70 }} />}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
onClick={navigateToEditPage}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToEditPage}>
<ListItemIcon>
<EditIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Edit')}</ListItemText>
</MenuItem>
<MenuItem onClick={showDeleteDialog}>
<ListItemIcon>
<DeleteIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('Delete')}</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default TunerDeviceCard;

View File

@@ -1,10 +0,0 @@
const getProviderConfigurationUrl = (providerId: string) => {
switch (providerId?.toLowerCase()) {
case 'xmltv':
return '/dashboard/livetv/guide?type=xmltv';
case 'schedulesdirect':
return '/dashboard/livetv/guide?type=schedulesdirect';
}
};
export default getProviderConfigurationUrl;

View File

@@ -1,12 +0,0 @@
const getProviderName = (providerId: string | null | undefined) => {
switch (providerId?.toLowerCase()) {
case 'schedulesdirect':
return 'Schedules Direct';
case 'xmltv':
return 'XMLTV';
default:
return 'Unknown';
}
};
export default getProviderName;

View File

@@ -1,16 +0,0 @@
const getTunerName = (providerId: string | null | undefined) => {
switch (providerId?.toLowerCase()) {
case 'm3u':
return 'M3U';
case 'hdhomerun':
return 'HDHomeRun';
case 'hauppauge':
return 'Hauppauge';
case 'satip':
return 'DVB';
default:
return 'Unknown';
}
};
export default getTunerName;

View File

@@ -12,13 +12,7 @@ const fetchServerLog = async (
const response = await getSystemApi(api).getLogFile({ name }, options);
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
const data = response.data as never as string | object;
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
} else {
return data;
}
return response.data as never as string;
};
export const useServerLog = (name: string) => {
const { api } = useApi();

View File

@@ -8,15 +8,7 @@ const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
const progress = task.CurrentProgressPercentage;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
height: '1.2rem',
mr: 2,
minWidth: '170px'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
{progress != null ? (
<>
<Box sx={{ width: '100%', mr: 1 }}>

View File

@@ -1,21 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
const fetchAuthProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getAuthProviders(options);
return response.data;
};
export const useAuthProviders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'AuthProviders' ],
queryFn: ({ signal }) => fetchAuthProviders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -1,22 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getChannelsApi } from '@jellyfin/sdk/lib/utils/api/channels-api';
import { ChannelsApiGetChannelsRequest } from '@jellyfin/sdk/lib/generated-client/api/channels-api';
import type { AxiosRequestConfig } from 'axios';
const fetchChannels = async (api: Api, params?: ChannelsApiGetChannelsRequest, options?: AxiosRequestConfig) => {
const response = await getChannelsApi(api).getChannels(params, options);
return response.data;
};
export const useChannels = (params?: ChannelsApiGetChannelsRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ 'Channels' ],
queryFn: ({ signal }) => fetchChannels(api!, params, { signal }),
enabled: !!api
});
};

View File

@@ -1,15 +0,0 @@
import { UserApiCreateUserByNameRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const useCreateUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiCreateUserByNameRequest) => (
getUserApi(api!)
.createUserByName(params)
)
});
};

View File

@@ -1,22 +0,0 @@
import { UserApiDeleteUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { QUERY_KEY } from 'hooks/useUsers';
import { queryClient } from 'utils/query/queryClient';
export const useDeleteUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiDeleteUserRequest) => (
getUserApi(api!)
.deleteUser(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};

View File

@@ -1,22 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { LibraryApiGetMediaFoldersRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchLibraryMediaFolders = async (api: Api, params?: LibraryApiGetMediaFoldersRequest, options?: AxiosRequestConfig) => {
const response = await getLibraryApi(api).getMediaFolders(params, options);
return response.data;
};
export const useLibraryMediaFolders = (params?: LibraryApiGetMediaFoldersRequest) => {
const { api } = useApi();
return useQuery({
queryKey: ['LibraryMediaFolders'],
queryFn: ({ signal }) => fetchLibraryMediaFolders(api!, params, { signal }),
enabled: !!api
});
};

View File

@@ -1,22 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
import type { AxiosRequestConfig } from 'axios';
import type { NetworkConfiguration } from '@jellyfin/sdk/lib/generated-client/models/network-configuration';
const fetchNetworkConfig = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getConfigurationApi(api).getNamedConfiguration({ key: 'network' }, options);
return response.data as NetworkConfiguration;
};
export const useNetworkConfig = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'NetConfig' ],
queryFn: ({ signal }) => fetchNetworkConfig(api!, { signal }),
enabled: !!api
});
};

View File

@@ -1,21 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
const fetchParentalRatings = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getLocalizationApi(api).getParentalRatings(options);
return response.data;
};
export const useParentalRatings = () => {
const { api } = useApi();
return useQuery({
queryKey: ['ParentalRatings'],
queryFn: ({ signal }) => fetchParentalRatings(api!, { signal }),
enabled: !!api
});
};

View File

@@ -1,21 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
import type { AxiosRequestConfig } from 'axios';
const fetchPasswordResetProviders = async (api: Api, options?: AxiosRequestConfig) => {
const response = await getSessionApi(api).getPasswordResetProviders(options);
return response.data;
};
export const usePasswordResetProviders = () => {
const { api } = useApi();
return useQuery({
queryKey: [ 'PasswordResetProviders' ],
queryFn: ({ signal }) => fetchPasswordResetProviders(api!, { signal }),
enabled: !!api
});
};

View File

@@ -1,22 +0,0 @@
import { UserApiUpdateUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
export const useUpdateUser = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiUpdateUserRequest) => (
getUserApi(api!)
.updateUser(params)
),
onSuccess: (_, params) => {
void queryClient.invalidateQueries({
queryKey: [QUERY_KEY, params.userId]
});
}
});
};

View File

@@ -1,23 +0,0 @@
import { UserApiUpdateUserPolicyRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QUERY_KEY } from './useUser';
export const useUpdateUserPolicy = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: UserApiUpdateUserPolicyRequest) => (
getUserApi(api!)
.updateUserPolicy(params)
),
onSuccess: (_, params) => {
void queryClient.invalidateQueries({
queryKey: [QUERY_KEY, params.userId]
});
}
});
};

View File

@@ -1,24 +0,0 @@
import { Api } from '@jellyfin/sdk';
import { UserApiGetUserByIdRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { useQuery } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import type { AxiosRequestConfig } from 'axios';
export const QUERY_KEY = 'User';
const fetchUser = async (api: Api, params: UserApiGetUserByIdRequest, options?: AxiosRequestConfig) => {
const response = await getUserApi(api).getUserById(params, options);
return response.data;
};
export const useUser = (params?: UserApiGetUserByIdRequest) => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY, params?.userId ],
queryFn: ({ signal }) => fetchUser(api!, params!, { signal }),
enabled: !!api && !!params
});
};

View File

@@ -9,11 +9,9 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'devices', type: AppType.Dashboard },
{ path: 'settings', type: AppType.Dashboard },
{ path: 'keys', type: AppType.Dashboard },
{ path: 'libraries', type: AppType.Dashboard },
{ path: 'libraries/display', type: AppType.Dashboard },
{ path: 'libraries/metadata', type: AppType.Dashboard },
{ path: 'libraries/nfo', type: AppType.Dashboard },
{ path: 'livetv', type: AppType.Dashboard },
{ path: 'livetv/recordings', type: AppType.Dashboard },
{ path: 'logs', type: AppType.Dashboard },
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },

View File

@@ -9,6 +9,13 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'networking',
view: 'networking.html'
}
}, {
path: 'libraries',
pageProps: {
appType: AppType.Dashboard,
controller: 'library',
view: 'library.html'
}
}, {
path: 'livetv/guide',
pageProps: {
@@ -16,6 +23,13 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'livetvguideprovider',
view: 'livetvguideprovider.html'
}
}, {
path: 'livetv',
pageProps: {
appType: AppType.Dashboard,
controller: 'livetvstatus',
view: 'livetvstatus.html'
}
}, {
path: 'livetv/tuner',
pageProps: {

View File

@@ -2,10 +2,9 @@ import parseISO from 'date-fns/parseISO';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
import { useTheme } from '@mui/material/styles';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import { useSearchParams } from 'react-router-dom';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
@@ -54,8 +53,6 @@ export const Component = () => {
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const theme = useTheme();
const UserCell = getUserCell(users);
const activityParams = useMemo(() => ({
@@ -159,15 +156,8 @@ export const Component = () => {
}
}, [ activityView, searchParams, setSearchParams ]);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: logEntries,

View File

@@ -102,7 +102,7 @@ export const Component = () => {
}).catch(() => {
// Server is still down
});
}, 45000);
}, 5000);
return () => {
clearInterval(serverCheckInterval);

View File

@@ -4,10 +4,9 @@ import Edit from '@mui/icons-material/Edit';
import Box from '@mui/material/Box/Box';
import Button from '@mui/material/Button/Button';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip/Tooltip';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo, useState } from 'react';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
@@ -42,7 +41,6 @@ export const Component = () => {
data?.Items || []
), [ data ]);
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
const theme = useTheme();
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
@@ -139,15 +137,8 @@ export const Component = () => {
}
], [ UserCell, userNames ]);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const mrTable = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: devices,
@@ -193,25 +184,16 @@ export const Component = () => {
positionActionsColumn: 'last',
displayColumnDefOptions: {
'mrt-row-actions': {
header: '',
size: 100
header: ''
}
},
renderRowActions: ({ row, table }) => {
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
return (
<Box
sx={{
display: 'flex',
gap: 1,
'&&': {
backgroundColor: 'transparent !important'
}
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={globalize.translate('Edit')}>
<IconButton
// eslint-disable-next-line react/jsx-no-bind
// eslint-disable-next-line react/jsx-no-bind
onClick={() => table.setEditingRow(row)}
>
<Edit />

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
@@ -16,7 +16,6 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
import DevicesWidget from '../components/widgets/DevicesWidget';
import { useStartTask } from '../features/tasks/api/useStartTask';
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
export const Component = () => {
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
@@ -27,10 +26,6 @@ export const Component = () => {
const { data: tasks } = useLiveTasks({ isHidden: false });
const librariesTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshLibrary')
), [ tasks ]);
const promptRestart = useCallback(() => {
setIsRestartConfirmDialogOpen(true);
}, []);
@@ -99,7 +94,6 @@ export const Component = () => {
onScanLibrariesClick={onScanLibraries}
onRestartClick={promptRestart}
onShutdownClick={promptShutdown}
isScanning={librariesTask?.State !== TaskState.Idle}
/>
<ItemCountsWidget />
<RunningTasksWidget tasks={tasks} />

View File

@@ -2,34 +2,31 @@ import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/mode
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import parseISO from 'date-fns/parseISO';
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo, useState } from 'react';
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import React, { useCallback, useMemo } from 'react';
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
import confirm from 'components/confirm/confirm';
import prompt from 'components/prompt/prompt';
import { useApi } from 'hooks/useApi';
import globalize from 'lib/globalize';
import InputDialog from 'components/InputDialog';
import ConfirmDialog from 'components/ConfirmDialog';
export const Component = () => {
const [ isCreateApiKeyPromptOpen, setIsCreateApiKeyPromptOpen ] = useState(false);
const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false);
const [ apiKeyToDelete, setApiKeyToDelete ] = useState('');
const { api } = useApi();
const { data, isLoading } = useApiKeys();
const keys = useMemo(() => (
data?.Items || []
), [ data ]);
const revokeKey = useRevokeKey();
const createKey = useCreateKey();
const theme = useTheme();
const columns = useMemo<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
{
@@ -52,15 +49,8 @@ export const Component = () => {
}
], []);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
...DEFAULT_TABLE_OPTIONS,
mrtTheme,
columns,
data: keys,
@@ -106,72 +96,41 @@ export const Component = () => {
});
const onRevokeKey = useCallback((accessToken: string) => {
setApiKeyToDelete(accessToken);
setIsConfirmDeleteOpen(true);
}, []);
if (!api) return;
confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () {
revokeKey.mutate({
key: accessToken
});
}).catch(err => {
console.error('[apikeys] failed to show confirmation dialog', err);
});
}, [api, revokeKey]);
const showNewKeyPopup = useCallback(() => {
setIsCreateApiKeyPromptOpen(true);
}, []);
if (!api) return;
const onCreateApiKeyPromptClose = useCallback(() => {
setIsCreateApiKeyPromptOpen(false);
}, []);
const onConfirmDelete = useCallback(() => {
revokeKey.mutate({
key: apiKeyToDelete
}, {
onSettled: () => {
setApiKeyToDelete('');
setIsConfirmDeleteOpen(false);
}
prompt({
title: globalize.translate('HeaderNewApiKey'),
label: globalize.translate('LabelAppName'),
description: globalize.translate('LabelAppNameExample')
}).then((value) => {
createKey.mutate({
app: value
});
}).catch(() => {
// popup closed
});
}, [ revokeKey, apiKeyToDelete ]);
const onConfirmDeleteCancel = useCallback(() => {
setApiKeyToDelete('');
setIsConfirmDeleteOpen(false);
}, []);
const onConfirmCreate = useCallback((name: string) => {
createKey.mutate({
app: name
}, {
onSettled: () => {
setIsCreateApiKeyPromptOpen(false);
}
});
}, [ createKey ]);
}, [api, createKey]);
return (
<>
<ConfirmDialog
open={isConfirmDeleteOpen}
title={globalize.translate('HeaderConfirmRevokeApiKey')}
text={globalize.translate('MessageConfirmRevokeApiKey')}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onConfirmDeleteCancel}
/>
<InputDialog
open={isCreateApiKeyPromptOpen}
title={globalize.translate('HeaderNewApiKey')}
label={globalize.translate('LabelAppName')}
helperText={globalize.translate('LabelAppNameExample')}
confirmButtonText={globalize.translate('Create')}
onConfirm={onConfirmCreate}
onClose={onCreateApiKeyPromptClose}
/>
<TablePage
id='apiKeysPage'
title={globalize.translate('HeaderApiKeys')}
subtitle={globalize.translate('HeaderApiKeysHelp')}
className='mainAnimatedPage type-interior'
table={table}
/>
</>
<TablePage
id='apiKeysPage'
title={globalize.translate('HeaderApiKeys')}
subtitle={globalize.translate('HeaderApiKeysHelp')}
className='mainAnimatedPage type-interior'
table={table}
/>
);
};

View File

@@ -1,108 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { useVirtualFolders } from 'apps/dashboard/features/libraries/api/useVirtualFolders';
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import Grid from '@mui/material/Grid';
import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCard';
import Loading from 'components/loading/LoadingComponent';
import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator';
import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions';
import { queryClient } from 'utils/query/queryClient';
import RefreshIcon from '@mui/icons-material/Refresh';
import Add from '@mui/icons-material/Add';
export const Component = () => {
const { data: virtualFolders, isPending: isVirtualFoldersPending } = useVirtualFolders();
const startTask = useStartTask();
const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false });
const librariesTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshLibrary')
), [ tasks ]);
const showMediaLibraryCreator = useCallback(() => {
const mediaLibraryCreator = new MediaLibraryCreator({
collectionTypeOptions: getCollectionTypeOptions(),
refresh: true
}) as Promise<boolean>;
void mediaLibraryCreator.then((hasChanges: boolean) => {
if (hasChanges) {
void queryClient.invalidateQueries({
queryKey: ['VirtualFolders']
});
}
});
}, []);
const onScanLibraries = useCallback(() => {
if (librariesTask?.Id) {
startTask.mutate({
taskId: librariesTask.Id
});
}
}, [ startTask, librariesTask ]);
if (isVirtualFoldersPending || isLiveTasksPending) return <Loading />;
return (
<Page
id='mediaLibraryPage'
title={globalize.translate('HeaderLibraries')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
<Stack spacing={3} mt={2}>
<Stack direction='row' alignItems={'center'} spacing={1.5}>
<Button
startIcon={<Add />}
onClick={showMediaLibraryCreator}
>
{globalize.translate('ButtonAddMediaLibrary')}
</Button>
<Button
onClick={onScanLibraries}
startIcon={<RefreshIcon />}
loading={librariesTask && librariesTask.State !== TaskState.Idle}
loadingPosition='start'
variant='outlined'
>
{globalize.translate('ButtonScanAllLibraries')}
</Button>
{(librariesTask && librariesTask.State == TaskState.Running) && (
<TaskProgress task={librariesTask} />
)}
</Stack>
<Box>
<Grid container spacing={2}>
{virtualFolders?.map(virtualFolder => (
<Grid
key={virtualFolder?.ItemId}
item
xs={12}
sm={6}
md={3}
lg={2.4}
>
<LibraryCard
virtualFolder={virtualFolder}
/>
</Grid>
))}
</Grid>
</Box>
</Stack>
</Box>
</Page>
);
};
Component.displayName = 'LibrariesPage';

View File

@@ -1,179 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Page from 'components/Page';
import { useNamedConfiguration } from 'hooks/useNamedConfiguration';
import type { LiveTvOptions } from '@jellyfin/sdk/lib/generated-client/models/live-tv-options';
import globalize from 'lib/globalize';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Loading from 'components/loading/LoadingComponent';
import TunerDeviceCard from 'apps/dashboard/features/livetv/components/TunerDeviceCard';
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
import Button from '@mui/material/Button';
import RefreshIcon from '@mui/icons-material/Refresh';
import AddIcon from '@mui/icons-material/Add';
import { Link, useNavigate } from 'react-router-dom';
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemText from '@mui/material/ListItemText';
import Alert from '@mui/material/Alert';
import List from '@mui/material/List';
import Provider from 'apps/dashboard/features/livetv/components/Provider';
import Grid from '@mui/material/Grid';
const CONFIG_KEY = 'livetv';
export const Component = () => {
const navigate = useNavigate();
const {
data: config,
isPending: isConfigPending,
isError: isConfigError
} = useNamedConfiguration<LiveTvOptions>(CONFIG_KEY);
const {
data: tasks,
isPending: isTasksPending,
isError: isTasksError
} = useLiveTasks({ isHidden: false });
const providerButtonRef = useRef<HTMLButtonElement | null>(null);
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const startTask = useStartTask();
const navigateToSchedulesDirect = useCallback(() => {
navigate('/dashboard/livetv/guide?type=schedulesdirect');
}, [ navigate ]);
const navigateToXMLTV = useCallback(() => {
navigate('/dashboard/livetv/guide?type=xmltv');
}, [ navigate ]);
const showProviderMenu = useCallback(() => {
setAnchorEl(providerButtonRef.current);
setIsMenuOpen(true);
}, []);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const refreshGuideTask = useMemo(() => (
tasks?.find((value) => value.Key === 'RefreshGuide')
), [ tasks ]);
const refreshGuideData = useCallback(() => {
if (refreshGuideTask?.Id) {
startTask.mutate({
taskId: refreshGuideTask.Id
});
}
}, [ startTask, refreshGuideTask ]);
if (isConfigPending || isTasksPending) return <Loading />;
return (
<Page
id='liveTvStatusPage'
title={globalize.translate('LiveTV')}
className='mainAnimatedPage type-interior'
>
<Box className='content-primary'>
{(isConfigError || isTasksError) ? (
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
component={Link}
to='/dashboard/livetv/tuner'
>
{globalize.translate('ButtonAddTunerDevice')}
</Button>
<Box>
<Grid container spacing={2}>
{config.TunerHosts?.map(tunerHost => (
<Grid
key={tunerHost.Id}
item
xs={12}
sm={6}
md={3}
lg={2.4}
>
<TunerDeviceCard
key={tunerHost.Id}
tunerHost={tunerHost}
/>
</Grid>
))}
</Grid>
</Box>
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
<Stack direction='row' spacing={1.5}>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
onClick={showProviderMenu}
ref={providerButtonRef}
>
{globalize.translate('ButtonAddProvider')}
</Button>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<RefreshIcon />}
variant='outlined'
onClick={refreshGuideData}
loading={refreshGuideTask && refreshGuideTask.State === TaskState.Running}
loadingPosition='start'
>
{globalize.translate('ButtonRefreshGuideData')}
</Button>
</Stack>
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
<TaskProgress task={refreshGuideTask} />
)}
</Stack>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
<MenuItem onClick={navigateToSchedulesDirect}>
<ListItemText>Schedules Direct</ListItemText>
</MenuItem>
<MenuItem onClick={navigateToXMLTV}>
<ListItemText>XMLTV</ListItemText>
</MenuItem>
</Menu>
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
<List sx={{ backgroundColor: 'background.paper' }}>
{config.ListingProviders?.map(provider => (
<Provider
key={provider.Id}
provider={provider}
/>
))}
</List>
)}
</Stack>
)}
</Box>
</Page>
);
};
Component.displayName = 'LiveTvPage';

View File

@@ -1,6 +1,6 @@
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
import Alert from '@mui/material/Alert';
@@ -13,8 +13,8 @@ import Typography from '@mui/material/Typography';
import ContentCopy from '@mui/icons-material/ContentCopy';
import FileDownload from '@mui/icons-material/FileDownload';
import globalize from 'lib/globalize';
import toast from 'components/toast/toast';
import { copy } from 'scripts/clipboard';
import Toast from 'apps/dashboard/components/Toast';
export const Component = () => {
const { file: fileName } = useParams();
@@ -24,18 +24,13 @@ export const Component = () => {
data: log,
refetch
} = useServerLog(fileName ?? '');
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
const retry = useCallback(() => refetch(), [refetch]);
const handleToastClose = useCallback(() => {
setIsCopiedToastOpen(false);
}, []);
const copyToClipboard = useCallback(async () => {
if (log) {
await copy(log);
setIsCopiedToastOpen(true);
toast({ text: globalize.translate('CopyLogSuccess') });
}
}, [log]);
@@ -57,12 +52,7 @@ export const Component = () => {
title={fileName}
className='mainAnimatedPage type-interior'
>
<Toast
open={isCopiedToastOpen}
onClose={handleToastClose}
message={globalize.translate('CopyLogSuccess')}
/>
<Container className='content-primary' maxWidth={false}>
<Container className='content-primary'>
<Box>
<Typography variant='h1'>{fileName}</Typography>
@@ -106,14 +96,7 @@ export const Component = () => {
<Paper sx={{ mt: 2 }}>
<code>
<pre style={{
overflow:'auto',
margin: 0,
padding: '16px',
whiteSpace: 'pre-wrap'
}}>
{log}
</pre>
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
</code>
</Paper>
</>

View File

@@ -471,21 +471,19 @@ export const Component = () => {
{(hardwareAccelType === 'none' || isHwaSelected) && (
<>
{isHwaSelected && (
<FormControl>
<FormControlLabel
label={globalize.translate('EnableTonemapping')}
control={
<Checkbox
name='EnableTonemapping'
checked={config.EnableTonemapping}
onChange={onCheckboxChange}
/>
}
/>
<FormHelperText>{globalize.translate('AllowTonemappingHelp')}</FormHelperText>
</FormControl>
)}
<FormControl>
<FormControlLabel
label={globalize.translate('EnableTonemapping')}
control={
<Checkbox
name='EnableTonemapping'
checked={config.EnableTonemapping}
onChange={onCheckboxChange}
/>
}
/>
<FormHelperText>{globalize.translate(isHwaSelected ? 'AllowTonemappingHelp' : 'AllowTonemappingSoftwareHelp')}</FormHelperText>
</FormControl>
<TextField
name='TonemappingAlgorithm'

View File

@@ -7,11 +7,10 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
import Loading from 'components/loading/LoadingComponent';
import { type MRT_ColumnDef, MRT_Table, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
import globalize from '../../../../lib/globalize';
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
@@ -27,7 +26,6 @@ export const Component = () => {
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
const theme = useTheme();
const onCloseRemoveConfirmDialog = useCallback(() => {
setPendingDeleteTrigger(null);
@@ -82,15 +80,7 @@ export const Component = () => {
}
], []);
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
// https://github.com/KevinVandy/material-react-table/issues/1429
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
baseBackgroundColor: theme.palette.background.paper
}), [ theme ]);
const table = useMaterialReactTable({
mrtTheme,
columns,
data: task?.Triggers || [],

View File

@@ -4,13 +4,13 @@ import { useSearchParams } from 'react-router-dom';
import loading from '../../../../components/loading/loading';
import globalize from '../../../../lib/globalize';
import toast from '../../../../components/toast/toast';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import Button from '../../../../elements/emby-button/Button';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
import Toast from 'apps/dashboard/components/Toast';
type ItemsArr = {
Name?: string | null;
@@ -23,7 +23,6 @@ type ItemsArr = {
const UserLibraryAccess = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const [ userName, setUserName ] = useState('');
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
@@ -32,10 +31,6 @@ const UserLibraryAccess = () => {
const element = useRef<HTMLDivElement>(null);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const triggerChange = (select: HTMLInputElement) => {
const evt = new Event('change', { bubbles: false, cancelable: true });
select.dispatchEvent(evt);
@@ -225,7 +220,7 @@ const UserLibraryAccess = () => {
const onSaveComplete = () => {
loading.hide();
setIsSettingsSavedToastOpen(true);
toast(globalize.translate('SettingsSaved'));
};
(page.querySelector('.chkEnableAllDevices') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
@@ -248,11 +243,6 @@ const UserLibraryAccess = () => {
id='userLibraryAccessPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -1,21 +1,21 @@
import type { BaseItemDto, CreateUserByName } from '@jellyfin/sdk/lib/generated-client';
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import Input from '../../../../elements/emby-input/Input';
import Button from '../../../../elements/emby-button/Button';
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import Page from '../../../../components/Page';
import Toast from 'apps/dashboard/components/Toast';
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
import { useCreateUser } from 'apps/dashboard/features/users/api/useCreateUser';
import { useNavigate } from 'react-router-dom';
type UserInput = {
Name?: string;
Password?: string;
};
type ItemsArr = {
Name?: string | null;
@@ -23,21 +23,10 @@ type ItemsArr = {
};
const UserNew = () => {
const navigate = useNavigate();
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
const element = useRef<HTMLDivElement>(null);
const handleToastClose = useCallback(() => {
setIsErrorToastOpen(false);
}, []);
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders();
const { data: channels, isSuccess: isChannelsSuccess } = useChannels();
const createUser = useCreateUser();
const updateUserPolicy = useUpdateUserPolicy();
const getItemsResult = (items: BaseItemDto[]) => {
return items.map(item =>
({
@@ -55,7 +44,9 @@ const UserNew = () => {
return;
}
setMediaFoldersItems(getItemsResult(result));
const mediaFolders = getItemsResult(result);
setMediaFoldersItems(mediaFolders);
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
folderAccess.dispatchEvent(new CustomEvent('create'));
@@ -71,15 +62,15 @@ const UserNew = () => {
return;
}
const channelItems = getItemsResult(result);
const channels = getItemsResult(result);
setChannelsItems(channelItems);
setChannelsItems(channels);
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
channelAccess.dispatchEvent(new CustomEvent('create'));
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
channelItems.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
}, []);
@@ -91,26 +82,22 @@ const UserNew = () => {
console.error('Unexpected null reference');
return;
}
if (!mediaFolders?.Items) {
console.error('[add] mediaFolders not available');
return;
}
if (!channels?.Items) {
console.error('[add] channels not available');
return;
}
loadMediaFolders(mediaFolders?.Items);
loadChannels(channels?.Items);
loading.hide();
}, [loadChannels, loadMediaFolders, mediaFolders, channels]);
useEffect(() => {
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
loading.show();
if (isMediaFoldersSuccess && isChannelsSuccess) {
loadUser();
}
}, [loadUser, isMediaFoldersSuccess, isChannelsSuccess]);
const promiseFolders = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
IsHidden: false
}));
const promiseChannels = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
Promise.all([promiseFolders, promiseChannels]).then(function (responses) {
loadMediaFolders(responses[0].Items);
loadChannels(responses[1].Items);
loading.hide();
}).catch(err => {
console.error('[usernew] failed to load data', err);
});
}, [loadChannels, loadMediaFolders]);
useEffect(() => {
const page = element.current;
@@ -120,54 +107,51 @@ const UserNew = () => {
return;
}
loadUser();
const saveUser = () => {
const userInput: CreateUserByName = {
Name: (page.querySelector('#txtUsername') as HTMLInputElement).value,
Password: (page.querySelector('#txtPassword') as HTMLInputElement).value
};
createUser.mutate({ createUserByName: userInput }, {
onSuccess: (response) => {
const user = response.data;
const userInput: UserInput = {};
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
window.ApiClient.createUser(userInput).then(function (user) {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
}
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
user.Policy.EnabledFolders = [];
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
user.Policy.EnabledFolders = [];
if (!user.Policy.EnableAllFolders) {
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
user.Policy.EnabledChannels = [];
if (!user.Policy.EnableAllChannels) {
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
updateUserPolicy.mutate({
userId: user.Id,
userPolicy: user.Policy
}, {
onSuccess: () => {
navigate(`/dashboard/users/profile?userId=${user.Id}`);
},
onError: () => {
console.error('[usernew] failed to update user policy');
setIsErrorToastOpen(true);
}
if (!user.Policy.EnableAllFolders) {
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
user.Policy.EnabledChannels = [];
if (!user.Policy.EnableAllChannels) {
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
return i.checked;
}).map(function (i) {
return i.getAttribute('data-id');
});
}
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
.catch(err => {
console.error('[usernew] failed to navigate to edit user page', err);
});
}).catch(err => {
console.error('[usernew] failed to update user policy', err);
});
}, function () {
toast(globalize.translate('ErrorDefault'));
loading.hide();
});
};
@@ -179,43 +163,28 @@ const UserNew = () => {
return false;
};
const enableAllChannelsChange = function (this: HTMLInputElement) {
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
};
});
const enableAllFoldersChange = function (this: HTMLInputElement) {
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
};
});
const onCancelClick = () => {
window.history.back();
};
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', enableAllChannelsChange);
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', enableAllFoldersChange);
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onCancelClick);
return () => {
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).removeEventListener('change', enableAllChannelsChange);
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).removeEventListener('change', enableAllFoldersChange);
(page.querySelector('.newUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onCancelClick);
};
}, [loadUser, createUser, updateUserPolicy, navigate]);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
window.history.back();
});
}, [loadUser]);
return (
<Page
id='newUserPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isErrorToastOpen}
onClose={handleToastClose}
message={globalize.translate('ErrorDefault')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import React, { useEffect, useState, useRef } from 'react';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import loading from '../../../../components/loading/loading';
import dom from '../../../../utils/dom';
import confirm from '../../../../components/confirm/confirm';
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
@@ -9,12 +14,6 @@ import '../../../../components/cardbuilder/card.scss';
import '../../../../components/indicators/indicators.scss';
import '../../../../styles/flexstyles.scss';
import Page from '../../../../components/Page';
import { useLocation, useNavigate } from 'react-router-dom';
import Toast from 'apps/dashboard/components/Toast';
import { useUsers } from 'hooks/useUsers';
import Loading from 'components/loading/LoadingComponent';
import { useDeleteUser } from 'apps/dashboard/features/users/api/useDeleteUser';
import dom from 'utils/dom';
type MenuEntry = {
name?: string;
@@ -23,30 +22,30 @@ type MenuEntry = {
};
const UserProfiles = () => {
const location = useLocation();
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const element = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { data: users, isPending } = useUsers();
const deleteUser = useDeleteUser();
const [ users, setUsers ] = useState<UserDto[]>([]);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
}, []);
const element = useRef<HTMLDivElement>(null);
const loadData = () => {
loading.show();
window.ApiClient.getUsers().then(function (result) {
setUsers(result);
loading.hide();
}).catch(err => {
console.error('[userprofiles] failed to fetch users', err);
});
};
useEffect(() => {
const page = element.current;
if (location.state?.openSavedToast) {
setIsSettingsSavedToastOpen(true);
window.history.replaceState({}, '');
}
if (!page) {
console.error('Unexpected null reference');
return;
}
loadData();
const showUserMenu = (elem: HTMLElement) => {
const card = dom.parentWithClass(elem, 'card');
const userId = card?.getAttribute('data-userid');
@@ -87,19 +86,28 @@ const UserProfiles = () => {
callback: function (id: string) {
switch (id) {
case 'open':
navigate(`/dashboard/users/profile?userId=${userId}`);
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user edit page', err);
});
break;
case 'access':
navigate(`/dashboard/users/access?userId=${userId}`);
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to user library page', err);
});
break;
case 'parentalcontrol':
navigate(`/dashboard/users/parentalcontrol?userId=${userId}`);
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
.catch(err => {
console.error('[userprofiles] failed to navigate to parental control page', err);
});
break;
case 'delete':
confirmDeleteUser(userId, username);
deleteUser(userId, username);
}
}
}).catch(() => {
@@ -110,7 +118,7 @@ const UserProfiles = () => {
});
};
const confirmDeleteUser = (id: string, username?: string | null) => {
const deleteUser = (id: string, username?: string | null) => {
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
const text = globalize.translate('DeleteUserConfirmation');
@@ -120,38 +128,32 @@ const UserProfiles = () => {
confirmText: globalize.translate('Delete'),
primary: 'delete'
}).then(function () {
deleteUser.mutate({
userId: id
loading.show();
window.ApiClient.deleteUser(id).then(function () {
loadData();
}).catch(err => {
console.error('[userprofiles] failed to delete user', err);
});
}).catch(() => {
// confirm dialog closed
});
};
const onPageClick = function (e: MouseEvent) {
page.addEventListener('click', function (e) {
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
if (btnUserMenu) {
showUserMenu(btnUserMenu);
}
};
});
const onAddUserClick = function() {
navigate('/dashboard/users/add');
};
page.addEventListener('click', onPageClick);
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', onAddUserClick);
return () => {
page.removeEventListener('click', onPageClick);
(page.querySelector('#btnAddUser') as HTMLButtonElement).removeEventListener('click', onAddUserClick);
};
}, [navigate, deleteUser, location.state?.openSavedToast]);
if (isPending) {
return <Loading />;
}
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
Dashboard.navigate('/dashboard/users/add')
.catch(err => {
console.error('[userprofiles] failed to navigate to new user page', err);
});
});
}, []);
return (
<Page
@@ -159,11 +161,6 @@ const UserProfiles = () => {
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
title={globalize.translate('HeaderUsers')}
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
@@ -177,7 +174,7 @@ const UserProfiles = () => {
</div>
<div className='localUsers itemsContainer vertical-wrap'>
{users?.map(user => {
{users.map(user => {
return <UserCardBox key={user.Id} user={user} />;
})}
</div>

View File

@@ -12,12 +12,12 @@ import Button from '../../../../elements/emby-button/Button';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
import prompt from '../../../../components/prompt/prompt';
import { ServerConnections } from 'lib/jellyfin-apiclient';
import Toast from 'apps/dashboard/components/Toast';
type NamedItem = {
name: string;
@@ -30,7 +30,6 @@ type UnratedNamedItem = NamedItem & {
function handleSaveUser(
page: HTMLDivElement,
parentalRatingsRef: React.MutableRefObject<ParentalRating[]>,
getSchedulesFromPage: () => AccessSchedule[],
getAllowedTagsFromPage: () => string[],
getBlockedTagsFromPage: () => string[],
@@ -43,12 +42,8 @@ function handleSaveUser(
throw new Error('Unexpected null user id or policy');
}
const parentalRatingIndex = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
const parentalRating = parentalRatingsRef.current[parentalRatingIndex] as ParentalRating;
const score = parentalRating?.RatingScore?.score;
const subScore = parentalRating?.RatingScore?.subScore;
userPolicy.MaxParentalRating = Number.isNaN(score) ? null : score;
userPolicy.MaxParentalSubRating = Number.isNaN(subScore) ? null : subScore;
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
userPolicy.BlockUnratedItems = Array.prototype.filter
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
.map(i => i.getAttribute('data-itemtype'));
@@ -74,14 +69,33 @@ const UserParentalControl = () => {
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
const parentalRatingsRef = useRef<ParentalRating[]>([]);
const handleToastClose = useCallback(() => {
setIsSettingsSavedToastOpen(false);
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
let rating;
const ratings: ParentalRating[] = [];
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
rating = allParentalRatings[i];
if (ratings.length) {
const lastRating = ratings[ratings.length - 1];
if (lastRating.Value === rating.Value) {
lastRating.Name += '/' + rating.Name;
continue;
}
}
ratings.push({
Name: rating.Name,
Value: rating.Value
});
}
setParentalRatings(ratings);
}, []);
const loadUnratedItems = useCallback((user: UserDto) => {
@@ -147,52 +161,16 @@ const UserParentalControl = () => {
setAllowedTags(user.Policy?.AllowedTags || []);
setBlockedTags(user.Policy?.BlockedTags || []);
populateRatings(allParentalRatings);
// Build the grouped ratings array
const ratings: ParentalRating[] = [];
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
const rating = allParentalRatings[i];
if (ratings.length) {
const lastRating = ratings[ratings.length - 1];
if (lastRating.RatingScore?.score === rating.RatingScore?.score && lastRating.RatingScore?.subScore == rating.RatingScore?.subScore) {
lastRating.Name += '/' + rating.Name;
continue;
}
let ratingValue = '';
allParentalRatings.forEach(rating => {
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
ratingValue = `${rating.Value}`;
}
});
ratings.push(rating);
}
setParentalRatings(ratings);
parentalRatingsRef.current = ratings;
// Find matching rating - first try exact match with score and subscore
let ratingIndex = '';
const userMaxRating = user.Policy?.MaxParentalRating;
const userMaxSubRating = user.Policy?.MaxParentalSubRating;
if (userMaxRating != null) {
// First try to find exact match with both score and subscore
ratings.forEach((rating, index) => {
if (rating.RatingScore?.score === userMaxRating
&& rating.RatingScore?.subScore === userMaxSubRating) {
ratingIndex = `${index}`;
}
});
// If no exact match found, fallback to score-only match
if (!ratingIndex) {
ratings.forEach((rating, index) => {
if (rating.RatingScore?.score != null
&& rating.RatingScore.score <= userMaxRating) {
ratingIndex = `${index}`;
}
});
}
}
setMaxParentalRating(ratingIndex);
setMaxParentalRating(ratingValue);
if (user.Policy?.IsAdministrator) {
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
@@ -201,7 +179,7 @@ const UserParentalControl = () => {
}
setAccessSchedules(user.Policy?.AccessSchedules || []);
loading.hide();
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems]);
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
const loadData = useCallback(() => {
if (!userId) {
@@ -305,10 +283,10 @@ const UserParentalControl = () => {
const onSaveComplete = () => {
loading.hide();
setIsSettingsSavedToastOpen(true);
toast(globalize.translate('SettingsSaved'));
};
const saveUser = handleSaveUser(page, parentalRatingsRef, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
const onSubmit = (e: Event) => {
if (!userId) {
@@ -364,11 +342,11 @@ const UserParentalControl = () => {
const optionMaxParentalRating = () => {
let content = '';
content += '<option value=\'\'></option>';
parentalRatings.forEach((rating, index) => {
if (rating.RatingScore != null) {
content += `<option value='${index}'>${escapeHTML(rating.Name)}</option>`;
for (const rating of parentalRatings) {
if (rating.Value != null) {
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
}
});
}
return content;
};
@@ -392,11 +370,6 @@ const UserParentalControl = () => {
id='userParentalControlPage'
className='mainAnimatedPage type-interior'
>
<Toast
open={isSettingsSavedToastOpen}
onClose={handleToastClose}
message={globalize.translate('SettingsSaved')}
/>
<div ref={element} className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer

View File

@@ -1,21 +1,37 @@
import React from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import Page from '../../../../components/Page';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import Loading from 'components/loading/LoadingComponent';
import loading from '../../../../components/loading/loading';
const UserPassword = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const { data: user, isPending } = useUser(userId ? { userId: userId } : undefined);
const [ userName, setUserName ] = useState('');
if (isPending || !user) {
return <Loading />;
}
const loadUser = useCallback(() => {
if (!userId) {
console.error('[userpassword] missing user id');
return;
}
loading.show();
window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name) {
throw new Error('Unexpected null user.Name');
}
setUserName(user.Name);
loading.hide();
}).catch(err => {
console.error('[userpassword] failed to fetch user', err);
});
}, [userId]);
useEffect(() => {
loadUser();
}, [loadUser]);
return (
<Page
@@ -25,13 +41,13 @@ const UserPassword = () => {
<div className='content-primary'>
<div className='verticalSection'>
<SectionTitleContainer
title={user?.Name || undefined}
title={userName}
/>
</div>
<SectionTabs activeTab='userpassword'/>
<div className='readOnlyContent'>
<UserPasswordForm
user={user}
userId={userId}
/>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
import escapeHTML from 'escape-html';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import Button from '../../../../elements/emby-button/Button';
import CheckBoxElement from '../../../../elements/CheckBoxElement';
@@ -11,16 +12,9 @@ import Input from '../../../../elements/emby-input/Input';
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import SelectElement from '../../../../elements/SelectElement';
import Page from '../../../../components/Page';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import { useAuthProviders } from 'apps/dashboard/features/users/api/useAuthProviders';
import { usePasswordResetProviders } from 'apps/dashboard/features/users/api/usePasswordResetProviders';
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
import { useUpdateUser } from 'apps/dashboard/features/users/api/useUpdateUser';
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
import { useNetworkConfig } from 'apps/dashboard/features/users/api/useNetworkConfig';
type ResetProvider = BaseItemDto & {
checkedAttribute: string
@@ -31,26 +25,27 @@ const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
.map(e => e.getAttribute('data-id'))
);
function onSaveComplete() {
Dashboard.navigate('/dashboard/users')
.catch(err => {
console.error('[useredit] failed to navigate to user profile', err);
});
loading.hide();
toast(globalize.translate('SettingsSaved'));
}
const UserEdit = () => {
const navigate = useNavigate();
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const [ userDto, setUserDto ] = useState<UserDto>();
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 [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
const { data: userDto, isSuccess: isUserSuccess } = useUser(userId ? { userId: userId } : undefined);
const { data: authProviders, isSuccess: isAuthProvidersSuccess } = useAuthProviders();
const { data: passwordResetProviders, isSuccess: isPasswordResetProvidersSuccess } = usePasswordResetProviders();
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders({ isHidden: false });
const { data: channels, isSuccess: isChannelsSuccess } = useChannels({ supportsMediaDeletion: true });
const { data: netConfig, isSuccess: isNetConfigSuccess } = useNetworkConfig();
const updateUser = useUpdateUser();
const updateUserPolicy = useUpdateUserPolicy();
const element = useRef<HTMLDivElement>(null);
const triggerChange = (select: HTMLInputElement) => {
@@ -58,10 +53,17 @@ const UserEdit = () => {
select.dispatchEvent(evt);
};
const getUser = () => {
if (!userId) throw new Error('missing user id');
return window.ApiClient.getUser(userId);
};
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
setAuthProviders(providers);
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
setAuthenticationProviderId(currentProviderId);
}, []);
@@ -70,26 +72,30 @@ const UserEdit = () => {
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
setPasswordResetProviders(providers);
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
setPasswordResetProviderId(currentProviderId);
}, []);
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, folders: BaseItemDto[]) => {
let isChecked;
let checkedAttribute;
const itemsArr: ResetProvider[] = [];
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
SupportsMediaDeletion: true
})).then(function (channelsResult) {
let isChecked;
let checkedAttribute;
const itemsArr: ResetProvider[] = [];
for (const mediaFolder of folders) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
...mediaFolder,
checkedAttribute: checkedAttribute
});
}
for (const mediaFolder of mediaFolders) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
...mediaFolder,
checkedAttribute: checkedAttribute
});
}
if (channels?.Items) {
for (const channel of channels.Items) {
for (const channel of channelsResult.Items) {
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
checkedAttribute = isChecked ? ' checked="checked"' : '';
itemsArr.push({
@@ -97,66 +103,16 @@ const UserEdit = () => {
checkedAttribute: checkedAttribute
});
}
}
setDeleteFoldersAccess(itemsArr);
setDeleteFoldersAccess(itemsArr);
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
triggerChange(chkEnableDeleteAllFolders);
}, [channels]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isAuthProvidersSuccess && authProviders != null) {
loadAuthProviders(page, userDto, authProviders);
}
}, [authProviders, isAuthProvidersSuccess, userDto, loadAuthProviders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isPasswordResetProvidersSuccess && passwordResetProviders != null) {
loadPasswordResetProviders(page, userDto, passwordResetProviders);
}
}, [passwordResetProviders, isPasswordResetProvidersSuccess, userDto, loadPasswordResetProviders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (userDto && isMediaFoldersSuccess && isChannelsSuccess && mediaFolders?.Items != null) {
loadDeleteFolders(page, userDto, mediaFolders.Items);
}
}, [userDto, mediaFolders, isMediaFoldersSuccess, isChannelsSuccess, channels, loadDeleteFolders]);
useEffect(() => {
const page = element.current;
if (!page) {
console.error('[useredit] Unexpected null page reference');
return;
}
if (netConfig && isNetConfigSuccess) {
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !netConfig.EnableRemoteAccess);
}
}, [netConfig, isNetConfigSuccess]);
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
triggerChange(chkEnableDeleteAllFolders);
}).catch(err => {
console.error('[useredit] failed to fetch channels', err);
});
}, []);
const loadUser = useCallback((user: UserDto) => {
const page = element.current;
@@ -166,6 +122,24 @@ const UserEdit = () => {
return;
}
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
loadAuthProviders(page, 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);
}).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);
}).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);
@@ -175,6 +149,7 @@ const UserEdit = () => {
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;
@@ -198,22 +173,16 @@ const UserEdit = () => {
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
loading.hide();
}, [ libraryMenu ]);
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
const loadData = useCallback(() => {
if (!userDto) {
console.error('[profile] No user available');
return;
}
loading.show();
loadUser(userDto);
}, [userDto, loadUser]);
useEffect(() => {
if (isUserSuccess) {
loadData();
}
}, [loadData, isUserSuccess]);
getUser().then(function (user) {
loadUser(user);
}).catch(err => {
console.error('[useredit] failed to load data', err);
});
}, [loadUser]);
useEffect(() => {
const page = element.current;
@@ -223,6 +192,8 @@ const UserEdit = () => {
return;
}
loadData();
const saveUser = (user: UserDto) => {
if (!user.Id || !user.Policy) {
throw new Error('Unexpected null user id or policy');
@@ -254,58 +225,50 @@ const UserEdit = () => {
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
updateUser.mutate({ userId: user.Id, userDto: user }, {
onSuccess: () => {
if (user.Id) {
updateUserPolicy.mutate({
userId: user.Id,
userPolicy: user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' }
}, {
onSuccess: () => {
loading.hide();
navigate('/dashboard/users', {
state: { openSavedToast: true }
});
}
});
}
}
window.ApiClient.updateUser(user).then(() => (
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
)).then(() => {
onSaveComplete();
}).catch(err => {
console.error('[useredit] failed to update user', err);
});
};
const onSubmit = (e: Event) => {
loading.show();
if (userDto) {
saveUser(userDto);
}
getUser().then(function (result) {
saveUser(result);
}).catch(err => {
console.error('[useredit] failed to fetch user', err);
});
e.preventDefault();
e.stopPropagation();
return false;
};
const onBtnCancelClick = () => {
window.history.back();
};
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
});
window.ApiClient.getNamedConfiguration('network').then(function (config) {
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
}).catch(err => {
console.error('[useredit] failed to load network config', err);
});
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onBtnCancelClick);
return () => {
(page.querySelector('.editUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onBtnCancelClick);
};
}, [loadData, updateUser, userDto, updateUserPolicy, navigate]);
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
window.history.back();
});
}, [loadData]);
const optionLoginProvider = authProviders?.map((provider) => {
const optionLoginProvider = authProviders.map((provider) => {
const selected = provider.Id === authenticationProviderId || authProviders.length < 2 ? ' selected' : '';
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
});
const optionPasswordResetProvider = passwordResetProviders?.map((provider) => {
const optionPasswordResetProvider = passwordResetProviders.map((provider) => {
const selected = provider.Id === passwordResetProviderId || passwordResetProviders.length < 2 ? ' selected' : '';
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
});

View File

@@ -27,18 +27,7 @@ $mui-bp-xl: 1536px;
padding-top: 3.25rem !important;
}
.layout-mobile {
.itemBackdrop {
// Fix backdrop position on mobile item details page
margin-top: 0 !important;
// Add a subtle gradient over the backdrop to ensure the app bar buttons are visible
&::before {
display: block;
content: "";
height: 100%;
width: 100%;
background: linear-gradient(180deg, rgba(32, 32, 32, 0.6) 0%, rgba(32, 32, 32, 0.2) 4rem, rgba(0, 0, 0, 0) 50%);
}
}
// Fix backdrop position on mobile item details page
.layout-mobile .itemBackdrop {
margin-top: 0 !important;
}

View File

@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Cast from '@mui/icons-material/Cast';
import IconButton from '@mui/material/IconButton';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { playbackManager } from 'components/playback/playbackmanager';
@@ -15,6 +15,7 @@ import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
const RemotePlayButton = () => {
const theme = useTheme();
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
const updatePlayerInfo = useCallback(() => {
@@ -69,10 +70,9 @@ const RemotePlayButton = () => {
aria-haspopup='true'
onClick={onRemotePlayActiveButtonClick}
color='inherit'
// eslint-disable-next-line react/jsx-no-bind
sx={(theme) => ({
color: theme.vars.palette.primary.main
})}
sx={{
color: theme.palette.primary.main
}}
>
{playerInfo.deviceName || playerInfo.name}
</Button>

View File

@@ -3,7 +3,6 @@ import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collec
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import Favorite from '@mui/icons-material/Favorite';
import Button from '@mui/material/Button/Button';
import Icon from '@mui/material/Icon';
import { Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import React, { useCallback, useMemo, useState } from 'react';
@@ -16,7 +15,6 @@ import { appRouter } from 'components/router/appRouter';
import { useApi } from 'hooks/useApi';
import useCurrentTab from 'hooks/useCurrentTab';
import { useUserViews } from 'hooks/useUserViews';
import { useWebConfig } from 'hooks/useWebConfig';
import globalize from 'lib/globalize';
import UserViewsMenu from './UserViewsMenu';
@@ -58,19 +56,14 @@ const UserViewNav = () => {
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
const collectionType = searchParams.get('collectionType');
const { activeTab } = useCurrentTab();
const webConfig = useWebConfig();
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
const maxViews = useMemo(() => {
let _maxViews = MAX_USER_VIEWS_MD;
if (isExtraLargeScreen) _maxViews = MAX_USER_VIEWS_XL;
else if (isLargeScreen) _maxViews = MAX_USER_VIEWS_LG;
const customLinks = (webConfig.menuLinks || []).length;
return _maxViews - customLinks;
}, [ isExtraLargeScreen, isLargeScreen, webConfig.menuLinks ]);
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
if (isLargeScreen) return MAX_USER_VIEWS_LG;
return MAX_USER_VIEWS_MD;
}, [ isExtraLargeScreen, isLargeScreen ]);
const { user } = useApi();
const {
@@ -115,21 +108,6 @@ const UserViewNav = () => {
{globalize.translate(MetaView.Favorites.Name)}
</Button>
{webConfig.menuLinks?.map(link => (
<Button
key={link.name}
variant='text'
color='inherit'
startIcon={<Icon>{link.icon || 'link'}</Icon>}
component='a'
href={link.url}
target='_blank'
rel='noopener noreferrer'
>
{link.name}
</Button>
))}
{primaryViews?.map(view => (
<Button
key={view.Id}

View File

@@ -9,7 +9,6 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import classNames from 'classnames';
import React, { type FC, useCallback } from 'react';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useLocalStorage } from 'hooks/useLocalStorage';
import { useGetItemsViewByType } from 'hooks/useFetchItems';
@@ -100,7 +99,7 @@ const ItemsView: FC<ItemsViewProps> = ({
if (viewType === LibraryTab.Songs) {
listOptions.showParentTitle = true;
listOptions.action = ItemAction.PlayAllFromHere;
listOptions.action = 'playallfromhere';
listOptions.smallIcon = true;
listOptions.showArtist = true;
listOptions.addToListButton = true;

View File

@@ -1,16 +1,14 @@
import React, { type FC } from 'react';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from 'components/common/SectionContainer';
import Loading from 'components/loading/LoadingComponent';
import { appRouter } from 'components/router/appRouter';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
import { appRouter } from 'components/router/appRouter';
import globalize from 'lib/globalize';
import Loading from 'components/loading/LoadingComponent';
import NoItemsMessage from 'components/common/NoItemsMessage';
import SectionContainer from 'components/common/SectionContainer';
import { CardShape } from 'utils/card';
import type { ParentId } from 'types/library';
import type { Section, SectionType } from 'types/sections';
import { CardShape } from 'utils/card';
interface ProgramsSectionViewProps {
parentId: ParentId;
@@ -94,7 +92,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
showChannelName: false,
cardLayout: true,
centerText: false,
action: ItemAction.Edit,
action: 'edit',
cardFooterAside: 'none',
preferThumb: true,
coverImage: true,

View File

@@ -2,6 +2,8 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
import React, { FC, useCallback } from 'react';
import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp';
import FilterAlt from '@mui/icons-material/FilterAlt';
import Clear from '@mui/icons-material/Clear';
import Button from '@mui/material/Button';
import Popover from '@mui/material/Popover';
import MuiAccordion, { AccordionProps } from '@mui/material/Accordion';
@@ -110,6 +112,16 @@ const FilterButton: FC<FilterButtonProps> = ({
setAnchorEl(event.currentTarget);
}, []);
const handleResetFiltersClick = useCallback(() => {
if (hasFilters) {
setLibraryViewSettings((prevState) => ({
...prevState,
StartIndex: 0,
Filters: {}
}));
}
}, [hasFilters, setLibraryViewSettings]);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
@@ -447,6 +459,19 @@ const FilterButton: FC<FilterButtonProps> = ({
</AccordionDetails>
</Accordion>
)}
<Button
disabled={!hasFilters}
title={globalize.translate('ResetFilters')}
aria-describedby={id}
onClick={handleResetFiltersClick}
fullWidth={true}
startIcon={<Clear />}
sx={{
justifyContent: 'right'
}}
>
{globalize.translate('ResetFilters')}
</Button>
</Popover>
</>
);

View File

@@ -4,7 +4,6 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ReplayIcon from '@mui/icons-material/Replay';
import { useQueryClient } from '@tanstack/react-query';
import { ItemAction } from 'constants/itemAction';
import { useApi } from 'hooks/useApi';
import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel';
import globalize from 'lib/globalize';
@@ -77,7 +76,7 @@ const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
return (
<IconButton
className='button-flat btnPlayOrResume'
data-action={isResumable ? ItemAction.Resume : ItemAction.Play}
data-action={isResumable ? 'resume' : 'play'}
title={
isResumable ?
globalize.translate('ButtonResume') :

View File

@@ -12,7 +12,6 @@ import React, { Fragment } from 'react';
import { appHost } from 'components/apphost';
import { AppFeature } from 'constants/appFeature';
import { LayoutMode } from 'constants/layoutMode';
import { useApi } from 'hooks/useApi';
import { useThemes } from 'hooks/useThemes';
import globalize from 'lib/globalize';
@@ -46,10 +45,11 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
onChange={onChange}
value={values.layout}
>
<MenuItem value={LayoutMode.Auto}>{globalize.translate('Auto')}</MenuItem>
<MenuItem value={LayoutMode.Desktop}>{globalize.translate('Desktop')}</MenuItem>
<MenuItem value={LayoutMode.Mobile}>{globalize.translate('Mobile')}</MenuItem>
<MenuItem value={LayoutMode.Tv}>{globalize.translate('TV')}</MenuItem>
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
</Select>
<FormHelperText component={Stack} id='display-settings-layout-description'>
<span>{globalize.translate('DisplayModeHelp')}</span>
@@ -169,30 +169,6 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
</Fragment>
) }
<FormControl fullWidth>
<TextField
aria-describedby='display-settings-slideshow-interval-description'
value={values.slideshowInterval}
label={globalize.translate('LabelSlideshowInterval')}
name='slideshowInterval'
onChange={onChange}
slotProps={{
htmlInput: {
inputMode: 'numeric',
max: '3600',
min: '1',
pattern: '[0-9]',
required: true,
step: '1',
type: 'number'
}
}}
/>
<FormHelperText id='display-settings-slideshow-interval-description'>
{globalize.translate('LabelSlideshowIntervalHelp')}
</FormHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
aria-describedby='display-settings-faster-animations-description'

View File

@@ -10,9 +10,6 @@ import themeManager from 'scripts/themeManager';
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
import { useThemes } from 'hooks/useThemes';
import { Theme } from 'types/webConfig';
import { FALLBACK_THEME_ID } from 'hooks/useUserTheme';
interface UseDisplaySettingsParams {
userId?: string | null;
@@ -23,7 +20,6 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
const [userSettings, setUserSettings] = useState<UserSettings>();
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
const { __legacyApiClient__, user: currentUser } = useApi();
const { defaultTheme } = useThemes();
useEffect(() => {
if (!userId || !currentUser || !__legacyApiClient__) {
@@ -33,7 +29,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
setLoading(true);
void (async () => {
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId, defaultTheme });
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
setDisplaySettings(loadedSettings.displaySettings);
setUserSettings(loadedSettings.userSettings);
@@ -66,17 +62,15 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
}
interface LoadDisplaySettingsParams {
currentUser: UserDto
userId?: string
api: ApiClient
defaultTheme?: Theme
currentUser: UserDto;
userId?: string;
api: ApiClient;
}
async function loadDisplaySettings({
currentUser,
userId,
api,
defaultTheme
api
}: LoadDisplaySettingsParams) {
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
@@ -84,8 +78,8 @@ async function loadDisplaySettings({
await settings.setUserInfo(userId, api);
const displaySettings = {
customCss: settings.customCss() || '',
dashboardTheme: settings.dashboardTheme() || defaultTheme?.id || FALLBACK_THEME_ID,
customCss: settings.customCss(),
dashboardTheme: settings.dashboardTheme() || 'auto',
dateTimeLocale: settings.dateTimeLocale() || 'auto',
disableCustomCss: Boolean(settings.disableCustomCss()),
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
@@ -103,8 +97,7 @@ async function loadDisplaySettings({
maxDaysForNextUp: settings.maxDaysForNextUp(),
screensaver: settings.screensaver() || 'none',
screensaverInterval: settings.backdropScreensaverInterval(),
slideshowInterval: settings.slideshowInterval(),
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
theme: settings.theme()
};
return {
@@ -132,7 +125,7 @@ async function saveDisplaySettings({
userSettings.language(normalizeValue(newDisplaySettings.language));
}
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);

View File

@@ -18,6 +18,5 @@ export interface DisplaySettingsValues {
maxDaysForNextUp: number;
screensaver: string;
screensaverInterval: number;
slideshowInterval: number;
theme: string;
}

View File

@@ -5,7 +5,7 @@ import globalize from '../../../lib/globalize';
import { clearBackdrop } from '../../../components/backdrop/backdrop';
import layoutManager from '../../../components/layoutManager';
import Page from '../../../components/Page';
import { EventType } from 'constants/eventType';
import { EventType } from 'types/eventType';
import Events from 'utils/events';
import '../../../elements/emby-tabs/emby-tabs';

View File

@@ -11,7 +11,6 @@ import { MovieSuggestionsSectionsView } from 'types/sections';
const moviesTabContent: LibraryTabContent = {
viewType: LibraryTab.Movies,
collectionType: CollectionType.Movies,
isBtnPlayAllEnabled: true,
isBtnShuffleEnabled: true,
itemType: [BaseItemKind.Movie]
};
@@ -21,6 +20,7 @@ const collectionsTabContent: LibraryTabContent = {
collectionType: CollectionType.Movies,
isBtnFilterEnabled: false,
isBtnNewCollectionEnabled: true,
isAlphabetPickerEnabled: false,
itemType: [BaseItemKind.BoxSet],
noItemsMessage: 'MessageNoCollectionsAvailable'
};

View File

@@ -6,7 +6,7 @@ import RemotePlayButton from 'apps/experimental/components/AppToolbar/RemotePlay
import SyncPlayButton from 'apps/experimental/components/AppToolbar/SyncPlayButton';
import AppToolbar from 'components/toolbar/AppToolbar';
import ViewManagerPage from 'components/viewManager/ViewManagerPage';
import { EventType } from 'constants/eventType';
import { EventType } from 'types/eventType';
import Events, { type Event } from 'utils/events';
/**

View File

@@ -1,13 +0,0 @@
/**
* Options specifying if the player's native subtitle (cue) element should be used, a custom element (div), or allow
* Jellyfin to choose automatically based on known browser support. Some browsers do not properly apply CSS styling to
* the native subtitle element.
*/
export const SubtitleStylingOption = {
Auto: 'Auto',
Custom: 'Custom',
Native: 'Native'
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type SubtitleStylingOption = typeof SubtitleStylingOption[keyof typeof SubtitleStylingOption];

View File

@@ -51,20 +51,19 @@ class MediaSessionSubscriber extends PlaybackSubscriber {
}
private bindNavigatorSession() {
const actions: MediaSessionAction[] = ['pause', 'play', 'previoustrack', 'nexttrack', 'stop', 'seekto'];
/* eslint-disable compat/compat */
navigator.mediaSession.setActionHandler('pause', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('play', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('stop', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('previoustrack', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('nexttrack', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('seekto', this.onMediaSessionAction.bind(this));
// iOS will only show next/prev track controls or seek controls
if (!browser.iOS) actions.push('seekbackward', 'seekforward');
for (const action of actions) {
try {
/* eslint-disable-next-line compat/compat */
navigator.mediaSession.setActionHandler(action, this.onMediaSessionAction.bind(this));
} catch (err) {
// NOTE: Some legacy (TV) browsers lack support for the stop and seekto actions
console.warn(`[MediaSessionSubscriber] Failed to add "${action}" action handler`, err);
}
if (!browser.iOS) {
navigator.mediaSession.setActionHandler('seekbackward', this.onMediaSessionAction.bind(this));
navigator.mediaSession.setActionHandler('seekforward', this.onMediaSessionAction.bind(this));
}
/* eslint-enable compat/compat */
}
private onMediaSessionAction(details: MediaSessionActionDetails) {

View File

@@ -78,14 +78,12 @@ export abstract class PlaybackSubscriber {
constructor(
protected readonly playbackManager: PlaybackManager
) {
// Bind player events before invoking any player change handlers
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
Object.entries(this.playbackManagerEvents).forEach(([event, handler]) => {
if (handler) Events.on(playbackManager, event, handler);
});
this.bindPlayerEvents();
Events.on(playbackManager, PlaybackManagerEvent.PlayerChange, this.bindPlayerEvents.bind(this));
}
private bindPlayerEvents() {

View File

@@ -1,43 +0,0 @@
import { SubtitleStylingOption } from 'apps/stable/features/playback/constants/subtitleStylingOption';
import browser from 'scripts/browser';
import type { UserSettings } from 'scripts/settings/userSettings';
// TODO: This type override should be removed when userSettings are properly typed
interface SubtitleAppearanceSettings {
subtitleStyling: SubtitleStylingOption
}
export function useCustomSubtitles(userSettings: UserSettings) {
const subtitleAppearance = userSettings.getSubtitleAppearanceSettings() as SubtitleAppearanceSettings;
switch (subtitleAppearance.subtitleStyling) {
case SubtitleStylingOption.Native:
return false;
case SubtitleStylingOption.Custom:
return true;
default:
// after a system update, ps4 isn't showing anything when creating a track element dynamically
// going to have to do it ourselves
if (browser.ps4) {
return true;
}
// Tizen 5 doesn't support displaying secondary subtitles
if ((browser.tizenVersion && browser.tizenVersion >= 5) || browser.web0s) {
return true;
}
if (browser.edge) {
return true;
}
// font-size styling does not seem to work natively in firefox. Switching to custom subtitles element for firefox.
if (browser.firefox) {
return true;
}
// iOS/macOS global caption settings are causing huge font-size and margins
if (browser.safari) return true;
return false;
}
}

View File

@@ -1,25 +1,23 @@
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import React, { FunctionComponent, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { FunctionComponent, useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import Dashboard from '../../../../utils/dashboard';
import globalize from '../../../../lib/globalize';
import { appHost } from '../../../../components/apphost';
import confirm from '../../../../components/confirm/confirm';
import Button from '../../../../elements/emby-button/Button';
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
import loading from '../../../../components/loading/loading';
import toast from '../../../../components/toast/toast';
import { useUser } from 'apps/dashboard/features/users/api/useUser';
import loading from 'components/loading/loading';
import { queryClient } from 'utils/query/queryClient';
import UserPasswordForm from 'components/dashboard/users/UserPasswordForm';
import Page from 'components/Page';
import Loading from 'components/loading/LoadingComponent';
import Button from 'elements/emby-button/Button';
import Page from '../../../../components/Page';
import { AppFeature } from 'constants/appFeature';
const UserProfile: FunctionComponent = () => {
const [ searchParams ] = useSearchParams();
const userId = searchParams.get('userId');
const { data: user, isPending: isUserPending } = useUser(userId ? { userId: userId } : undefined);
const [ userName, setUserName ] = useState('');
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
const element = useRef<HTMLDivElement>(null);
@@ -32,38 +30,50 @@ const UserProfile: FunctionComponent = () => {
return;
}
if (!user?.Name || !user?.Id) {
throw new Error('Unexpected null user name or id');
if (!userId) {
console.error('[userprofile] missing user id');
return;
}
void libraryMenu.then(menu => menu.setTitle(user.Name));
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
tag: user.PrimaryImageTag,
type: 'Primary'
});
}
const userImage = (page.querySelector('#image') as HTMLDivElement);
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
loading.show();
window.ApiClient.getUser(userId).then(function (user) {
if (!user.Name || !user.Id) {
throw new Error('Unexpected null user name or id');
}
setUserName(user.Name);
void libraryMenu.then(menu => menu.setTitle(user.Name));
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
} else if (appHost.supports('fileinput') && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
imageUrl = window.ApiClient.getUserImageUrl(user.Id, {
tag: user.PrimaryImageTag,
type: 'Primary'
});
}
const userImage = (page.querySelector('#image') as HTMLDivElement);
userImage.style.backgroundImage = 'url(' + imageUrl + ')';
Dashboard.getCurrentUser().then(function (loggedInUser: UserDto) {
if (!user.Policy) {
throw new Error('Unexpected null user.Policy');
}
if (user.PrimaryImageTag) {
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.remove('hide');
} else if (appHost.supports(AppFeature.FileInput) && (loggedInUser?.Policy?.IsAdministrator || user.Policy.EnableUserPreferenceAccess)) {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).classList.add('hide');
(page.querySelector('#btnAddImage') as HTMLButtonElement).classList.remove('hide');
}
}).catch(err => {
console.error('[userprofile] failed to get current user', err);
});
loading.hide();
}).catch(err => {
console.error('[userprofile] failed to get current user', err);
console.error('[userprofile] failed to load data', err);
});
}, [user, libraryMenu]);
}, [userId]);
useEffect(() => {
const page = element.current;
@@ -115,9 +125,7 @@ const UserProfile: FunctionComponent = () => {
userImage.style.backgroundImage = 'url(' + reader.result + ')';
window.ApiClient.uploadUserImage(userId, ImageType.Primary, file).then(function () {
loading.hide();
void queryClient.invalidateQueries({
queryKey: ['User']
});
reloadUser();
}).catch(err => {
console.error('[userprofile] failed to upload image', err);
});
@@ -126,7 +134,7 @@ const UserProfile: FunctionComponent = () => {
reader.readAsDataURL(file);
};
const onDeleteImageClick = function () {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', function () {
if (!userId) {
console.error('[userprofile] missing user id');
return;
@@ -139,41 +147,25 @@ const UserProfile: FunctionComponent = () => {
loading.show();
window.ApiClient.deleteUserImage(userId, ImageType.Primary).then(function () {
loading.hide();
void queryClient.invalidateQueries({
queryKey: ['User']
});
reloadUser();
}).catch(err => {
console.error('[userprofile] failed to delete image', err);
});
}).catch(() => {
// confirm dialog closed
});
};
});
const addImageClick = function () {
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', function () {
const uploadImage = page.querySelector('#uploadImage') as HTMLInputElement;
uploadImage.value = '';
uploadImage.click();
};
});
const onUploadImage = (e: Event) => {
setFiles(e);
};
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).addEventListener('click', onDeleteImageClick);
(page.querySelector('#btnAddImage') as HTMLButtonElement).addEventListener('click', addImageClick);
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', onUploadImage);
return () => {
(page.querySelector('#btnDeleteImage') as HTMLButtonElement).removeEventListener('click', onDeleteImageClick);
(page.querySelector('#btnAddImage') as HTMLButtonElement).removeEventListener('click', addImageClick);
(page.querySelector('#uploadImage') as HTMLInputElement).removeEventListener('change', onUploadImage);
};
}, [reloadUser, user, userId]);
if (isUserPending || !user) {
return <Loading />;
}
(page.querySelector('#uploadImage') as HTMLInputElement).addEventListener('change', function (evt: Event) {
setFiles(evt);
});
}, [reloadUser, userId]);
return (
<Page
@@ -203,7 +195,7 @@ const UserProfile: FunctionComponent = () => {
</div>
<div style={{ verticalAlign: 'top', margin: '1em 2em', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h2 className='username' style={{ margin: 0, fontSize: 'xx-large' }}>
{user?.Name}
{userName}
</h2>
<br />
<Button
@@ -221,7 +213,7 @@ const UserProfile: FunctionComponent = () => {
</div>
</div>
<UserPasswordForm
user={user}
userId={userId}
/>
</div>
</Page>

View File

@@ -354,7 +354,7 @@ function getVirtualFolderHtml(page, virtualFolder, index) {
html += '</div>';
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
html += escapeHtml(virtualFolder.Locations[0]);
html += virtualFolder.Locations[0];
html += '</div>';
} else {
html += "<div class='cardText cardText-secondary'>";

View File

@@ -1,12 +0,0 @@
<svg width="35" height="33" viewBox="0 0 35 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<script xmlns="" />
<path
d="M0.0146346 0.593029L0.764804 3.57722C0.809995 3.75303 0.945568 3.88826 1.12633 3.92432L14.765 6.91753C15.1897 7.01219 15.5106 7.37733 15.5377 7.81459L16.6585 23.9031C16.6675 24.0158 16.7127 24.1195 16.7895 24.2006L17.133 24.5567C17.2233 24.6514 17.3454 24.7055 17.4764 24.7055C17.6075 24.7055 17.7295 24.6514 17.8199 24.5567L18.1633 24.2006C18.2401 24.1195 18.2853 24.0158 18.2944 23.9031L19.4151 7.81459C19.4467 7.38184 19.7631 7.01219 20.1879 6.91753L33.8265 3.92432C34.0027 3.88375 34.1428 3.75303 34.188 3.57722L34.9382 0.593029C34.9789 0.430747 34.9382 0.268465 34.8162 0.146753C34.6987 0.0250412 34.536 -0.0245451 34.3733 0.0115177L17.6843 3.66738C17.5442 3.69893 17.3996 3.69893 17.2595 3.66738L0.579521 0.0160255C0.543368 0.00250199 0.511735 0.00250199 0.475582 0.00250199C0.349047 0.00250199 0.227032 0.0520882 0.13665 0.146753C0.0191537 0.268465 -0.0260373 0.430747 0.0146346 0.593029Z"
fill="#fff" />
<path
d="M23.0349 32.301L23.8257 32.0936C23.9658 32.0576 24.0743 31.9494 24.0969 31.8096L27.7528 14.0397C27.8116 13.7062 28.0692 13.4357 28.4036 13.3545L31.7567 12.5476C31.8878 12.5161 31.9872 12.4169 32.0188 12.2862L32.8142 9.11267C32.8458 8.99095 32.8142 8.86474 32.7193 8.77458C32.6515 8.70696 32.5611 8.6709 32.4708 8.6709C32.4436 8.6709 32.412 8.6709 32.3849 8.67991L24.3319 10.6228C24.1782 10.6589 24.0698 10.7896 24.0607 10.9429L22.5965 31.9268C22.5875 32.0395 22.6327 32.1477 22.7231 32.2244C22.8089 32.2965 22.9264 32.3235 23.0394 32.2965L23.0349 32.301Z"
fill="#fff" />
<path
d="M10.996 10.6274L2.94297 8.68455C2.91586 8.67554 2.88422 8.67554 2.85711 8.67554C2.76221 8.67554 2.67635 8.7116 2.60856 8.77922C2.51818 8.86937 2.48654 8.99109 2.51366 9.11731L3.30902 12.2908C3.34065 12.4216 3.44007 12.5207 3.57113 12.5523L6.92429 13.3592C7.25871 13.4403 7.51629 13.7108 7.57504 14.0489L11.231 31.8053C11.2581 31.9495 11.362 32.0622 11.4976 32.0938L12.2975 32.3011C12.406 32.3282 12.5189 32.3011 12.6048 32.2245C12.6907 32.1524 12.7404 32.0442 12.7313 31.927L11.2671 10.943C11.2581 10.7852 11.1451 10.659 10.996 10.6229V10.6274Z"
fill="#fff" />
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -32,7 +32,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
{title}
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>

View File

@@ -11,24 +11,13 @@ import Stack from '@mui/material/Stack';
interface InputDialogProps extends DialogProps {
title: string;
label: string;
helperText?: string;
initialText?: string;
confirmButtonText?: string;
onClose: () => void;
onConfirm: (text: string) => void;
};
const InputDialog = ({
open,
title,
label,
helperText,
initialText,
onClose,
confirmButtonText,
onConfirm
}: InputDialogProps) => {
const [ text, setText ] = useState(initialText || '');
const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: InputDialogProps) => {
const [ text, setText ] = useState('');
const onTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
@@ -48,7 +37,7 @@ const InputDialog = ({
>
{title && (
<DialogTitle>
{title || ''}
{title}
</DialogTitle>
)}
<DialogContent>
@@ -56,7 +45,6 @@ const InputDialog = ({
<TextField
label={label}
value={text}
helperText={helperText}
onChange={onTextChange}
variant='standard'
/>

View File

@@ -1,7 +1,7 @@
import React, { type FC } from 'react';
import React, { FC } from 'react';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import Avatar from '@mui/material/Avatar';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { useTheme } from '@mui/material/styles';
import { useApi } from 'hooks/useApi';
@@ -11,6 +11,7 @@ interface UserAvatarProps {
const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
const { api } = useApi();
const theme = useTheme();
return user ? (
<Avatar
@@ -20,13 +21,12 @@ const UserAvatar: FC<UserAvatarProps> = ({ user }) => {
`${api.basePath}/Users/${user.Id}/Images/Primary?tag=${user.PrimaryImageTag}` :
undefined
}
// eslint-disable-next-line react/jsx-no-bind
sx={(theme) => ({
sx={{
bgcolor: api && user.Id && user.PrimaryImageTag ?
theme.vars.palette.background.paper :
theme.vars.palette.primary.dark,
theme.palette.background.paper :
theme.palette.primary.dark,
color: 'inherit'
})}
}}
/>
) : null;
};

View File

@@ -1,12 +1,27 @@
import { appRouter } from './router/appRouter';
import browser from '../scripts/browser';
import dialog from './dialog/dialog';
import globalize from '../lib/globalize';
export default async function (text, title) {
// Modals seem to be blocked on Web OS and Tizen 2.x
const canUseNativeAlert = !!(
!browser.web0s
&& !(browser.tizenVersion && (browser.tizenVersion < 3 || browser.tizenVersion >= 8))
&& browser.tv
&& window.alert
);
const options = typeof text === 'string' ? { title, text } : text;
await appRouter.ready();
if (canUseNativeAlert) {
alert((options.text || '').replaceAll('<br/>', '\n'));
return Promise.resolve();
}
options.buttons = [
{
name: globalize.translate('ButtonGotIt'),

View File

@@ -6,14 +6,12 @@ import * as webSettings from '../scripts/settings/webSettings';
import globalize from '../lib/globalize';
import profileBuilder from '../scripts/browserDeviceProfile';
import { AppFeature } from 'constants/appFeature';
import { LayoutMode } from 'constants/layoutMode';
const appName = 'Jellyfin Web';
const BrowserName = {
tizen: 'Samsung Smart TV',
web0s: 'LG Smart TV',
titanos: 'Titan OS',
operaTv: 'Opera TV',
xboxOne: 'Xbox One',
ps4: 'Sony PS4',
@@ -182,7 +180,7 @@ function supportsFullscreen() {
}
function getDefaultLayout() {
return LayoutMode.Experimental;
return 'desktop';
}
function supportsHtmlMediaAutoplay() {
@@ -372,7 +370,7 @@ export const appHost = {
return getDefaultLayout();
},
getDeviceProfile,
getDeviceProfile: getDeviceProfile,
init: function () {
if (window.NativeShell) {
return window.NativeShell.AppHost.init();

View File

@@ -12,7 +12,9 @@ function enableAnimation() {
}
function enableRotation() {
return !browser.tv;
return !browser.tv
// Causes high cpu usage
&& !browser.firefox;
}
class Backdrop {
@@ -234,7 +236,7 @@ export function setBackdropImages(images) {
currentRotationIndex = -1;
if (images.length > 1 && enableRotation()) {
rotationInterval = setInterval(onRotationInterval, 10000);
rotationInterval = setInterval(onRotationInterval, 24000);
}
onRotationInterval();

View File

@@ -1,18 +1,17 @@
import React, { type FC } from 'react';
import layoutManager from 'components/layoutManager';
import { ItemAction } from 'constants/itemAction';
import { CardShape } from 'utils/card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import CardOverlayButtons from './CardOverlayButtons';
import CardHoverMenu from './CardHoverMenu';
import CardOuterFooter from './CardOuterFooter';
import CardContent from './CardContent';
import { CardShape } from 'utils/card';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardBoxProps {
action: ItemAction;
action: string;
item: ItemDto;
cardOptions: CardOptions;
className: string;

View File

@@ -2,14 +2,12 @@ import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
import { appRouter } from 'components/router/appRouter';
import itemHelper from 'components/itemHelper';
import { playbackManager } from 'components/playback/playbackmanager';
import { ItemAction } from 'constants/itemAction';
import PlayedButton from 'elements/emby-playstatebutton/PlayedButton';
import FavoriteButton from 'elements/emby-ratingbutton/FavoriteButton';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
@@ -17,7 +15,7 @@ import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface CardHoverMenuProps {
action: ItemAction,
action: string,
item: ItemDto;
cardOptions: CardOptions;
}
@@ -31,7 +29,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
parentId: cardOptions.parentId
});
const btnCssClass =
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
'paper-icon-button-light cardOverlayButton cardOverlayButton-hover itemAction';
const centerPlayButtonClass = classNames(
btnCssClass,
@@ -53,7 +51,7 @@ const CardHoverMenu: FC<CardHoverMenuProps> = ({
{playbackManager.canPlay(item) && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action={ItemAction.Play}
action='play'
title='Play'
/>
)}

View File

@@ -2,17 +2,15 @@ import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location
import React, { type FC } from 'react';
import ButtonGroup from '@mui/material/ButtonGroup';
import classNames from 'classnames';
import { appRouter } from 'components/router/appRouter';
import { ItemAction } from 'constants/itemAction';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import PlayArrowIconButton from '../../common/PlayArrowIconButton';
import MoreVertIconButton from '../../common/MoreVertIconButton';
const sholudShowOverlayPlayButton = (
overlayPlayButton: boolean | undefined,
item: ItemDto
@@ -80,7 +78,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
{cardOptions.centerPlayButton && (
<PlayArrowIconButton
className={centerPlayButtonClass}
action={ItemAction.Play}
action='play'
title='Play'
/>
)}
@@ -89,7 +87,7 @@ const CardOverlayButtons: FC<CardOverlayButtonsProps> = ({
{sholudShowOverlayPlayButton(overlayPlayButton, item) && (
<PlayArrowIconButton
className={btnCssClass}
action={ItemAction.Play}
action='play'
title='Play'
/>
)}

View File

@@ -1,8 +1,5 @@
import React, { type FC } from 'react';
import Box from '@mui/material/Box';
import { ensureArray } from 'utils/array';
import type { TextLine } from './cardHelper';
interface CardTextProps {
@@ -10,33 +7,27 @@ interface CardTextProps {
textLine: TextLine;
}
const SEPARATOR = ' / ';
const CardText: FC<CardTextProps> = ({ className, textLine }) => {
const { title, titleAction } = textLine;
// eslint-disable-next-line sonarjs/function-return-type
const renderCardText = () => {
if (titleAction) {
return (
<a
className='itemAction textActionButton'
href={titleAction.url}
title={titleAction.title}
{...titleAction.dataAttributes}
>
{titleAction.title}
</a>
);
} else {
return title;
}
};
return (
<Box className={className}>
{titleAction ? (
ensureArray(titleAction).map((action, i, arr) => (
<>
<a
className='itemAction textActionButton'
href={action.url}
title={action.title}
{...action.dataAttributes}
>
{action.title}
</a>
{/* If there are more items, add the separator */}
{(i < arr.length - 1) && SEPARATOR}
</>
))
) : (
ensureArray(title).join(SEPARATOR)
)}
</Box>
);
return <Box className={className}>{renderCardText()}</Box>;
};
export default CardText;

View File

@@ -1,5 +1,4 @@
import { Api } from '@jellyfin/sdk';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import type { BaseItemPerson } from '@jellyfin/sdk/lib/generated-client/models/base-item-person';
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
@@ -7,14 +6,12 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
import { appRouter } from 'components/router/appRouter';
import layoutManager from 'components/layoutManager';
import itemHelper from 'components/itemHelper';
import { ItemAction } from 'constants/itemAction';
import globalize from 'lib/globalize';
import datetime from 'scripts/datetime';
import { isUsingLiveTvNaming } from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import { ensureArray } from 'utils/array';
import type { NullableNumber, NullableString } from 'types/base/common/shared/types';
import type { ItemDto } from 'types/base/models/item-dto';
@@ -68,8 +65,8 @@ interface TextAction {
}
export interface TextLine {
title?: NullableString | string[];
titleAction?: TextAction | TextAction[];
title?: NullableString;
titleAction?: TextAction;
}
export function getTextActionButton(
@@ -89,7 +86,7 @@ export function getTextActionButton(
const dataAttributes = getDataAttributes(
{
action: ItemAction.Link,
action: 'link',
itemServerId: serverId ?? item.ServerId,
itemId: item.Id,
itemChannelId: item.ChannelId,
@@ -213,25 +210,9 @@ function getParentTitle(
item: ItemDto
) {
if (isOuterFooter && item.AlbumArtists?.length) {
return item.AlbumArtists
.map(artist => {
const artistItem: ItemDto = {
...artist,
Type: BaseItemKind.MusicArtist,
IsFolder: true
};
return getTextActionButton(artistItem, null, serverId);
})
.reduce((acc, line) => ({
title: [
...ensureArray(acc.title),
...ensureArray(line.title)
],
titleAction: [
...ensureArray(acc.titleAction),
...ensureArray(line.titleAction)
]
}), {});
(item.AlbumArtists[0] as ItemDto).Type = ItemKind.MusicArtist;
(item.AlbumArtists[0] as ItemDto).IsFolder = true;
return getTextActionButton(item.AlbumArtists[0], null, serverId);
} else {
return {
title: isUsingLiveTvNaming(item.Type) ?

View File

@@ -1,19 +1,17 @@
import classNames from 'classnames';
import layoutManager from 'components/layoutManager';
import { ItemAction } from 'constants/itemAction';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
import { CardShape } from 'utils/card';
import { getDataAttributes } from 'utils/items';
import useCardImageUrl from './useCardImageUrl';
import {
resolveAction,
resolveMixedShapeByAspectRatio
} from '../cardBuilderUtils';
import { getDataAttributes } from 'utils/items';
import { CardShape } from 'utils/card';
import layoutManager from 'components/layoutManager';
import { ItemKind } from 'types/base/models/item-kind';
import { ItemMediaKind } from 'types/base/models/item-media-kind';
import type { ItemDto } from 'types/base/models/item-dto';
import type { CardOptions } from 'types/cardOptions';
interface UseCardProps {
item: ItemDto;
@@ -22,7 +20,7 @@ interface UseCardProps {
function useCard({ item, cardOptions }: UseCardProps) {
const action = resolveAction({
defaultAction: cardOptions.action ?? ItemAction.Link,
defaultAction: cardOptions.action ?? 'link',
isFolder: item.IsFolder ?? false,
isPhoto: item.MediaType === ItemMediaKind.Photo
});

Some files were not shown because too many files have changed in this diff Show More