From ae2bb005b07ee3e2062501caa4d75eccebcd2a26 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:13:34 +0530 Subject: [PATCH] Upgrade to react 19, add titles, add duplicate files util (#1154) * Upgrade to react 19, add titles * Add duplicate files util --- package.json | 10 +- pnpm-lock.yaml | 612 +++++++++--------- src/components/Collection/constants.ts | 2 + src/components/Layout/TopNav.tsx | 4 +- .../Utilities/ReleaseManagement/Episode.tsx | 175 +++++ .../MultiplesUtilEpisode.tsx | 101 --- .../{MultiplesUtilList.tsx => SeriesList.tsx} | 142 ++-- .../Utilities/ReleaseManagement/Title.tsx | 4 +- src/components/Utilities/constants.tsx | 6 +- src/core/react-query/file/mutations.ts | 6 + src/core/react-query/file/types.ts | 6 + .../react-query/release-management/queries.ts | 26 +- .../react-query/release-management/types.ts | 4 +- src/core/router/index.tsx | 5 +- src/core/types/api/file.ts | 2 + src/core/types/api/series.ts | 2 +- src/pages/collection/Collection.tsx | 99 +-- src/pages/collection/Series.tsx | 2 +- src/pages/collection/series/SeriesCredits.tsx | 83 +-- .../collection/series/SeriesEpisodes.tsx | 215 +++--- .../collection/series/SeriesFileSummary.tsx | 54 +- src/pages/collection/series/SeriesImages.tsx | 159 ++--- .../collection/series/SeriesOverview.tsx | 27 +- src/pages/collection/series/SeriesTags.tsx | 73 +-- src/pages/dashboard/DashboardPage.tsx | 1 + src/pages/logs/LogsPage.tsx | 129 ++-- src/pages/settings/SettingsPage.tsx | 2 +- src/pages/settings/tabs/AniDBSettings.tsx | 1 + src/pages/settings/tabs/ApiKeys.tsx | 1 + .../settings/tabs/CollectionSettings.tsx | 1 + src/pages/settings/tabs/GeneralSettings.tsx | 1 + src/pages/settings/tabs/ImportSettings.tsx | 1 + .../settings/tabs/IntegrationsSettings.tsx | 1 + .../settings/tabs/MetadataSitesSettings.tsx | 1 + .../settings/tabs/UserManagementSettings.tsx | 1 + src/pages/utilities/FileSearch.tsx | 137 ++-- src/pages/utilities/ReleaseManagement.tsx | 210 ++++++ .../MultiplesUtil.tsx | 180 ------ src/pages/utilities/Renamer.tsx | 347 +++++----- .../utilities/SeriesWithoutFilesUtility.tsx | 93 +-- .../IgnoredFilesTab.tsx | 93 +-- .../ManuallyLinkedTab.tsx | 171 ++--- .../UnrecognizedTab.tsx | 1 + 43 files changed, 1690 insertions(+), 1501 deletions(-) create mode 100644 src/components/Utilities/ReleaseManagement/Episode.tsx delete mode 100644 src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx rename src/components/Utilities/ReleaseManagement/{MultiplesUtilList.tsx => SeriesList.tsx} (63%) create mode 100644 src/pages/utilities/ReleaseManagement.tsx delete mode 100644 src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx diff --git a/package.json b/package.json index 536127864..ed04d3174 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "lodash": "^4.17.21", "monaco-editor": "^0.52.0", "pretty-bytes": "^6.1.1", - "react": "^18.3.1", + "react": "^19.0.0", "react-animate-height": "^3.2.3", "react-avatar-editor": "^13.0.2", - "react-dom": "^18.3.1", + "react-dom": "^19.0.0", "react-grid-layout": "^1.5.0", "react-modal": "^3.16.1", "react-redux": "^9.1.2", @@ -60,9 +60,9 @@ "@types/format-thousands": "^2.0.3", "@types/lodash": "^4.17.13", "@types/node": "^22.10.1", - "@types/react": "^18.3.14", + "@types/react": "^19.0.1", "@types/react-avatar-editor": "^13.0.3", - "@types/react-dom": "^18.3.2", + "@types/react-dom": "^19.0.1", "@types/react-grid-layout": "^1.3.5", "@types/react-modal": "^3.16.3", "@types/react-redux": "^7.1.34", @@ -95,7 +95,7 @@ "tailwindcss": "^3.4.16", "tailwindcss-text-fill-stroke": "2.0.0-beta.1", "typescript": "5.6.3", - "vite": "^5.4.11", + "vite": "^6.0.3", "vite-plugin-webpackchunkname": "^1.0.3" }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e33d13b17..d1f58aebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,13 @@ importers: version: 5.1.0 '@headlessui/react': specifier: ^2.2.0 - version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@headlessui/tailwindcss': specifier: ^0.2.1 version: 0.2.1(tailwindcss@3.4.16) '@hello-pangea/dnd': specifier: ^17.0.0 - version: 17.0.0(@types/react@18.3.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 17.0.0(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -31,25 +31,25 @@ importers: version: 8.0.7 '@monaco-editor/react': specifier: ^4.6.0 - version: 4.6.0(monaco-editor@0.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@reduxjs/toolkit': specifier: ^2.4.0 - version: 2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + version: 2.4.0(react-redux@9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1))(react@19.0.0) '@sentry/browser': specifier: ^8.42.0 version: 8.42.0 '@sentry/react': specifier: ^8.42.0 - version: 8.42.0(react@18.3.1) + version: 8.42.0(react@19.0.0) '@tanstack/react-query': specifier: ^5.62.3 - version: 5.62.3(react@18.3.1) + version: 5.62.3(react@19.0.0) '@tanstack/react-query-devtools': specifier: ^5.62.3 - version: 5.62.3(@tanstack/react-query@5.62.3(react@18.3.1))(react@18.3.1) + version: 5.62.3(@tanstack/react-query@5.62.3(react@19.0.0))(react@19.0.0) '@tanstack/react-virtual': specifier: ^3.11.0 - version: 3.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.11.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) axios: specifier: ^1.7.9 version: 1.7.9 @@ -81,47 +81,47 @@ importers: specifier: ^6.1.1 version: 6.1.1 react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^19.0.0 + version: 19.0.0 react-animate-height: specifier: ^3.2.3 - version: 3.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-avatar-editor: specifier: ^13.0.2 - version: 13.0.2(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 13.0.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) react-grid-layout: specifier: ^1.5.0 - version: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-modal: specifier: ^3.16.1 - version: 3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.16.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-redux: specifier: ^9.1.2 - version: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) + version: 9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1) react-resizable: specifier: ^3.0.5 - version: 3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-responsive: specifier: ^10.0.0 - version: 10.0.0(react@18.3.1) + version: 10.0.0(react@19.0.0) react-router: specifier: ^6.28.0 - version: 6.28.0(react@18.3.1) + version: 6.28.0(react@19.0.0) react-router-dom: specifier: ^6.28.0 - version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.28.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-toastify: specifier: ^10.0.6 - version: 10.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 10.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-tooltip: specifier: ^5.28.0 - version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 5.28.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-use-measure: specifier: ^2.1.1 - version: 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) redux: specifier: ^5.0.1 version: 5.0.1 @@ -133,10 +133,10 @@ importers: version: 13.20.0 use-immer: specifier: ^0.10.0 - version: 0.10.0(immer@10.1.1)(react@18.3.1) + version: 0.10.0(immer@10.1.1)(react@19.0.0) usehooks-ts: specifier: ^3.1.0 - version: 3.1.0(react@18.3.1) + version: 3.1.0(react@19.0.0) devDependencies: '@sentry/vite-plugin': specifier: ^2.22.7 @@ -160,14 +160,14 @@ importers: specifier: ^22.10.1 version: 22.10.1 '@types/react': - specifier: ^18.3.14 - version: 18.3.14 + specifier: ^19.0.1 + version: 19.0.1 '@types/react-avatar-editor': specifier: ^13.0.3 version: 13.0.3 '@types/react-dom': - specifier: ^18.3.2 - version: 18.3.2 + specifier: ^19.0.1 + version: 19.0.1 '@types/react-grid-layout': specifier: ^1.3.5 version: 1.3.5 @@ -188,7 +188,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@5.4.11(@types/node@22.10.1)) + version: 4.3.4(vite@6.0.3(@types/node@22.10.1)(jiti@1.21.6)(yaml@2.6.1)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -265,8 +265,8 @@ importers: specifier: 5.6.3 version: 5.6.3 vite: - specifier: ^5.4.11 - version: 5.4.11(@types/node@22.10.1) + specifier: ^6.0.3 + version: 6.0.3(@types/node@22.10.1)(jiti@1.21.6)(yaml@2.6.1) vite-plugin-webpackchunkname: specifier: ^1.0.3 version: 1.0.3(rollup@4.28.1) @@ -682,141 +682,147 @@ packages: '@dual-bundle/import-meta-resolve@4.1.0': resolution: {integrity: sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.24.0': + resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.24.0': + resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.24.0': + resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.24.0': + resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.24.0': + resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.24.0': + resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.24.0': + resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.24.0': + resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.24.0': + resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.24.0': + resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.24.0': + resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.24.0': + resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.24.0': + resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.24.0': + resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.24.0': + resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.24.0': + resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-x64@0.24.0': + resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.24.0': + resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/sunos-x64@0.24.0': + resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.24.0': + resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.24.0': + resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1283,14 +1289,11 @@ packages: '@types/node@22.10.1': resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} - '@types/prop-types@15.7.14': - resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} - '@types/react-avatar-editor@13.0.3': resolution: {integrity: sha512-icRAOKLKjkIsExFAiFSquztByJwpyTKEgnBRYSuLG2V81bM3LtQZi7hRS+Hr+4AXreq0yNRjVZiMOVeEeh6DLg==} - '@types/react-dom@18.3.2': - resolution: {integrity: sha512-Fqp+rcvem9wEnGr3RY8dYNvSQ8PoLqjZ9HLgaPUOjJJD120uDyOxOjc/39M4Kddp9JQCxpGQbnhVQF0C0ncYVg==} + '@types/react-dom@19.0.1': + resolution: {integrity: sha512-hljHij7MpWPKF6u5vojuyfV0YA4YURsQG7KT6SzV0Zs2BXAtgdTxG6A229Ub/xiWV4w/7JL8fi6aAyjshH4meA==} '@types/react-grid-layout@1.3.5': resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==} @@ -1301,8 +1304,8 @@ packages: '@types/react-redux@7.1.34': resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} - '@types/react@18.3.14': - resolution: {integrity: sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg==} + '@types/react@19.0.1': + resolution: {integrity: sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ==} '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -1904,9 +1907,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -3160,10 +3163,10 @@ packages: react: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@19.0.0: + resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} peerDependencies: - react: ^18.3.1 + react: ^19.0.0 react-draggable@4.4.6: resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} @@ -3248,8 +3251,8 @@ packages: react: '>=16.13' react-dom: '>=16.13' - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -3345,8 +3348,8 @@ packages: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.25.0: + resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -3671,22 +3674,27 @@ packages: vite-plugin-webpackchunkname@1.0.3: resolution: {integrity: sha512-88lt6IrgCumnf4Up8eyaSJbmo4V0ZIaR4M94fbZvGGmK2aWMmPGVsiFBszYE7Kq04I9tGjLFnyremn+KEgEGyw==} - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.0.3: + resolution: {integrity: sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' less: '*' lightningcss: ^1.21.0 sass: '*' sass-embedded: '*' stylus: '*' sugarss: '*' - terser: ^5.4.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -3701,6 +3709,10 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} @@ -4235,73 +4247,76 @@ snapshots: '@dual-bundle/import-meta-resolve@4.1.0': {} - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.24.0': + optional: true + + '@esbuild/android-arm64@0.24.0': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm@0.24.0': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-x64@0.24.0': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/darwin-arm64@0.24.0': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-x64@0.24.0': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/freebsd-arm64@0.24.0': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-x64@0.24.0': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-arm64@0.24.0': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm@0.24.0': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-ia32@0.24.0': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-loong64@0.24.0': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-mips64el@0.24.0': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-ppc64@0.24.0': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-riscv64@0.24.0': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-s390x@0.24.0': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-x64@0.24.0': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/netbsd-x64@0.24.0': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openbsd-arm64@0.24.0': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.24.0': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/sunos-x64@0.24.0': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/win32-arm64@0.24.0': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-ia32@0.24.0': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-x64@0.24.0': optional: true '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': @@ -4336,48 +4351,48 @@ snapshots: '@floating-ui/core': 1.6.8 '@floating-ui/utils': 0.2.8 - '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@floating-ui/dom': 1.6.12 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - '@floating-ui/react@0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: - '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@floating-ui/utils': 0.2.8 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) tabbable: 6.2.0 '@floating-ui/utils@0.2.8': {} '@fontsource/sora@5.1.0': {} - '@headlessui/react@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@headlessui/react@2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-aria/focus': 3.19.0(react@18.3.1) - '@react-aria/interactions': 3.22.5(react@18.3.1) - '@tanstack/react-virtual': 3.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@react-aria/focus': 3.19.0(react@19.0.0) + '@react-aria/interactions': 3.22.5(react@19.0.0) + '@tanstack/react-virtual': 3.11.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) '@headlessui/tailwindcss@0.2.1(tailwindcss@3.4.16)': dependencies: tailwindcss: 3.4.16 - '@hello-pangea/dnd@17.0.0(@types/react@18.3.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@hello-pangea/dnd@17.0.0(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.26.0 css-box-model: 1.2.1 memoize-one: 6.0.0 raf-schd: 4.0.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-redux: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-redux: 9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1) redux: 5.0.1 - use-memo-one: 1.1.3(react@18.3.1) + use-memo-one: 1.1.3(react@19.0.0) transitivePeerDependencies: - '@types/react' @@ -4442,12 +4457,12 @@ snapshots: monaco-editor: 0.52.0 state-local: 1.0.7 - '@monaco-editor/react@4.6.0(monaco-editor@0.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@monaco-editor/react@4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@monaco-editor/loader': 1.4.0(monaco-editor@0.52.0) monaco-editor: 0.52.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) '@nodelib/fs.scandir@2.1.5': dependencies: @@ -4466,55 +4481,55 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@react-aria/focus@3.19.0(react@18.3.1)': + '@react-aria/focus@3.19.0(react@19.0.0)': dependencies: - '@react-aria/interactions': 3.22.5(react@18.3.1) - '@react-aria/utils': 3.26.0(react@18.3.1) - '@react-types/shared': 3.26.0(react@18.3.1) + '@react-aria/interactions': 3.22.5(react@19.0.0) + '@react-aria/utils': 3.26.0(react@19.0.0) + '@react-types/shared': 3.26.0(react@19.0.0) '@swc/helpers': 0.5.15 clsx: 2.1.1 - react: 18.3.1 + react: 19.0.0 - '@react-aria/interactions@3.22.5(react@18.3.1)': + '@react-aria/interactions@3.22.5(react@19.0.0)': dependencies: - '@react-aria/ssr': 3.9.7(react@18.3.1) - '@react-aria/utils': 3.26.0(react@18.3.1) - '@react-types/shared': 3.26.0(react@18.3.1) + '@react-aria/ssr': 3.9.7(react@19.0.0) + '@react-aria/utils': 3.26.0(react@19.0.0) + '@react-types/shared': 3.26.0(react@19.0.0) '@swc/helpers': 0.5.15 - react: 18.3.1 + react: 19.0.0 - '@react-aria/ssr@3.9.7(react@18.3.1)': + '@react-aria/ssr@3.9.7(react@19.0.0)': dependencies: '@swc/helpers': 0.5.15 - react: 18.3.1 + react: 19.0.0 - '@react-aria/utils@3.26.0(react@18.3.1)': + '@react-aria/utils@3.26.0(react@19.0.0)': dependencies: - '@react-aria/ssr': 3.9.7(react@18.3.1) - '@react-stately/utils': 3.10.5(react@18.3.1) - '@react-types/shared': 3.26.0(react@18.3.1) + '@react-aria/ssr': 3.9.7(react@19.0.0) + '@react-stately/utils': 3.10.5(react@19.0.0) + '@react-types/shared': 3.26.0(react@19.0.0) '@swc/helpers': 0.5.15 clsx: 2.1.1 - react: 18.3.1 + react: 19.0.0 - '@react-stately/utils@3.10.5(react@18.3.1)': + '@react-stately/utils@3.10.5(react@19.0.0)': dependencies: '@swc/helpers': 0.5.15 - react: 18.3.1 + react: 19.0.0 - '@react-types/shared@3.26.0(react@18.3.1)': + '@react-types/shared@3.26.0(react@19.0.0)': dependencies: - react: 18.3.1 + react: 19.0.0 - '@reduxjs/toolkit@2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + '@reduxjs/toolkit@2.4.0(react-redux@9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1))(react@19.0.0)': dependencies: immer: 10.1.1 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 18.3.1 - react-redux: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) + react: 19.0.0 + react-redux: 9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1) '@remix-run/router@1.21.0': {} @@ -4673,12 +4688,12 @@ snapshots: '@sentry/core@8.42.0': {} - '@sentry/react@8.42.0(react@18.3.1)': + '@sentry/react@8.42.0(react@19.0.0)': dependencies: '@sentry/browser': 8.42.0 '@sentry/core': 8.42.0 hoist-non-react-statics: 3.3.2 - react: 18.3.1 + react: 19.0.0 '@sentry/vite-plugin@2.22.7': dependencies: @@ -4720,22 +4735,22 @@ snapshots: '@tanstack/query-devtools@5.61.4': {} - '@tanstack/react-query-devtools@5.62.3(@tanstack/react-query@5.62.3(react@18.3.1))(react@18.3.1)': + '@tanstack/react-query-devtools@5.62.3(@tanstack/react-query@5.62.3(react@19.0.0))(react@19.0.0)': dependencies: '@tanstack/query-devtools': 5.61.4 - '@tanstack/react-query': 5.62.3(react@18.3.1) - react: 18.3.1 + '@tanstack/react-query': 5.62.3(react@19.0.0) + react: 19.0.0 - '@tanstack/react-query@5.62.3(react@18.3.1)': + '@tanstack/react-query@5.62.3(react@19.0.0)': dependencies: '@tanstack/query-core': 5.62.3 - react: 18.3.1 + react: 19.0.0 - '@tanstack/react-virtual@3.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.11.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@tanstack/virtual-core': 3.10.9 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) '@tanstack/virtual-core@3.10.9': {} @@ -4766,7 +4781,7 @@ snapshots: '@types/hoist-non-react-statics@3.3.6': dependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 hoist-non-react-statics: 3.3.2 '@types/json5@0.0.29': {} @@ -4777,34 +4792,31 @@ snapshots: dependencies: undici-types: 6.20.0 - '@types/prop-types@15.7.14': {} - '@types/react-avatar-editor@13.0.3': dependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 - '@types/react-dom@18.3.2': + '@types/react-dom@19.0.1': dependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 '@types/react-grid-layout@1.3.5': dependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 '@types/react-modal@3.16.3': dependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 '@types/react-redux@7.1.34': dependencies: '@types/hoist-non-react-statics': 3.3.6 - '@types/react': 18.3.14 + '@types/react': 19.0.1 hoist-non-react-statics: 3.3.2 redux: 4.2.1 - '@types/react@18.3.14': + '@types/react@19.0.1': dependencies: - '@types/prop-types': 15.7.14 csstype: 3.1.3 '@types/semver@7.5.8': {} @@ -4933,14 +4945,14 @@ snapshots: '@ungap/structured-clone@1.2.1': {} - '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@22.10.1))': + '@vitejs/plugin-react@4.3.4(vite@6.0.3(@types/node@22.10.1)(jiti@1.21.6)(yaml@2.6.1))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.4.11(@types/node@22.10.1) + vite: 6.0.3(@types/node@22.10.1)(jiti@1.21.6)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -5510,31 +5522,32 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.1.0 - esbuild@0.21.5: + esbuild@0.24.0: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.24.0 + '@esbuild/android-arm': 0.24.0 + '@esbuild/android-arm64': 0.24.0 + '@esbuild/android-x64': 0.24.0 + '@esbuild/darwin-arm64': 0.24.0 + '@esbuild/darwin-x64': 0.24.0 + '@esbuild/freebsd-arm64': 0.24.0 + '@esbuild/freebsd-x64': 0.24.0 + '@esbuild/linux-arm': 0.24.0 + '@esbuild/linux-arm64': 0.24.0 + '@esbuild/linux-ia32': 0.24.0 + '@esbuild/linux-loong64': 0.24.0 + '@esbuild/linux-mips64el': 0.24.0 + '@esbuild/linux-ppc64': 0.24.0 + '@esbuild/linux-riscv64': 0.24.0 + '@esbuild/linux-s390x': 0.24.0 + '@esbuild/linux-x64': 0.24.0 + '@esbuild/netbsd-x64': 0.24.0 + '@esbuild/openbsd-arm64': 0.24.0 + '@esbuild/openbsd-x64': 0.24.0 + '@esbuild/sunos-x64': 0.24.0 + '@esbuild/win32-arm64': 0.24.0 + '@esbuild/win32-ia32': 0.24.0 + '@esbuild/win32-x64': 0.24.0 escalade@3.2.0: {} @@ -6824,120 +6837,117 @@ snapshots: raf-schd@4.0.3: {} - react-animate-height@3.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-animate-height@3.2.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - react-avatar-editor@13.0.2(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-avatar-editor@13.0.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@babel/plugin-transform-runtime': 7.25.9(@babel/core@7.26.0) '@babel/runtime': 7.26.0 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) transitivePeerDependencies: - '@babel/core' - supports-color - react-dom@18.3.1(react@18.3.1): + react-dom@19.0.0(react@19.0.0): dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 19.0.0 + scheduler: 0.25.0 - react-draggable@4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draggable@4.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: clsx: 1.2.1 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - react-grid-layout@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-grid-layout@1.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: clsx: 2.1.1 fast-equals: 4.0.3 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-draggable: 4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-resizable: 3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-draggable: 4.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-resizable: 3.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) resize-observer-polyfill: 1.5.1 react-is@16.13.1: {} react-lifecycles-compat@3.0.4: {} - react-modal@3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-modal@3.16.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: exenv: 1.2.2 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) react-lifecycles-compat: 3.0.4 warning: 4.0.3 - react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1): + react-redux@9.1.2(@types/react@19.0.1)(react@19.0.0)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.3 - react: 18.3.1 - use-sync-external-store: 1.4.0(react@18.3.1) + react: 19.0.0 + use-sync-external-store: 1.4.0(react@19.0.0) optionalDependencies: - '@types/react': 18.3.14 + '@types/react': 19.0.1 redux: 5.0.1 react-refresh@0.14.2: {} - react-resizable@3.0.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-resizable@3.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: prop-types: 15.8.1 - react: 18.3.1 - react-draggable: 4.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 19.0.0 + react-draggable: 4.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) transitivePeerDependencies: - react-dom - react-responsive@10.0.0(react@18.3.1): + react-responsive@10.0.0(react@19.0.0): dependencies: hyphenate-style-name: 1.1.0 matchmediaquery: 0.4.2 prop-types: 15.8.1 - react: 18.3.1 + react: 19.0.0 shallow-equal: 3.1.0 - react-router-dom@6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.28.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@remix-run/router': 1.21.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.28.0(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-router: 6.28.0(react@19.0.0) - react-router@6.28.0(react@18.3.1): + react-router@6.28.0(react@19.0.0): dependencies: '@remix-run/router': 1.21.0 - react: 18.3.1 + react: 19.0.0 - react-toastify@10.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-toastify@10.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: clsx: 2.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - react-tooltip@5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-tooltip@5.28.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@floating-ui/dom': 1.6.12 classnames: 2.5.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - react-use-measure@2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-use-measure@2.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: debounce: 1.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) - react@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.0.0: {} read-cache@1.0.0: dependencies: @@ -7058,9 +7068,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.0 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 + scheduler@0.25.0: {} semver@6.3.1: {} @@ -7479,23 +7487,23 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - use-immer@0.10.0(immer@10.1.1)(react@18.3.1): + use-immer@0.10.0(immer@10.1.1)(react@19.0.0): dependencies: immer: 10.1.1 - react: 18.3.1 + react: 19.0.0 - use-memo-one@1.1.3(react@18.3.1): + use-memo-one@1.1.3(react@19.0.0): dependencies: - react: 18.3.1 + react: 19.0.0 - use-sync-external-store@1.4.0(react@18.3.1): + use-sync-external-store@1.4.0(react@19.0.0): dependencies: - react: 18.3.1 + react: 19.0.0 - usehooks-ts@3.1.0(react@18.3.1): + usehooks-ts@3.1.0(react@19.0.0): dependencies: lodash.debounce: 4.0.8 - react: 18.3.1 + react: 19.0.0 util-deprecate@1.0.2: {} @@ -7508,14 +7516,16 @@ snapshots: transitivePeerDependencies: - rollup - vite@5.4.11(@types/node@22.10.1): + vite@6.0.3(@types/node@22.10.1)(jiti@1.21.6)(yaml@2.6.1): dependencies: - esbuild: 0.21.5 + esbuild: 0.24.0 postcss: 8.4.49 rollup: 4.28.1 optionalDependencies: '@types/node': 22.10.1 fsevents: 2.3.3 + jiti: 1.21.6 + yaml: 2.6.1 warning@4.0.3: dependencies: diff --git a/src/components/Collection/constants.ts b/src/components/Collection/constants.ts index 1d5492e06..47ccbbbc9 100644 --- a/src/components/Collection/constants.ts +++ b/src/components/Collection/constants.ts @@ -1,6 +1,7 @@ import type React from 'react'; import type { ImageType } from '@/core/types/api/common'; +import type { SeriesType } from '@/core/types/api/series'; export const posterItemSize = { width: 209, @@ -18,4 +19,5 @@ export const listItemSize = { export type SeriesContextType = { backdrop?: ImageType; scrollRef: React.RefObject<HTMLDivElement>; + series: SeriesType; }; diff --git a/src/components/Layout/TopNav.tsx b/src/components/Layout/TopNav.tsx index 853677704..42ced171d 100644 --- a/src/components/Layout/TopNav.tsx +++ b/src/components/Layout/TopNav.tsx @@ -185,7 +185,7 @@ function TopNav() { <NavLink to="settings" className={({ isActive }) => - cx({ 'text-header-icon-primary': isActive, 'opacity-65 pointer-events-none': layoutEditMode })} + cx({ 'text-topnav-text-primary': isActive, 'opacity-65 pointer-events-none': layoutEditMode })} onClick={closeModalsAndSubmenus} data-tooltip-id="tooltip" data-tooltip-content="Settings" @@ -348,7 +348,7 @@ function TopNav() { icon={mdiFileSearchOutline} onClick={closeModalsAndSubmenus} path="utilities/file-search" - text="Files Search" + text="File Search" /> <LinkMenuItem icon={mdiFileDocumentEditOutline} diff --git a/src/components/Utilities/ReleaseManagement/Episode.tsx b/src/components/Utilities/ReleaseManagement/Episode.tsx new file mode 100644 index 000000000..a87d96fdd --- /dev/null +++ b/src/components/Utilities/ReleaseManagement/Episode.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { mdiOpenInNew } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { countBy, flatMap, forEach, map, toNumber } from 'lodash'; + +import FileInfo from '@/components/FileInfo'; +import Select from '@/components/Input/Select'; +import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; + +import type { ReleaseManagementOptionsType } from '@/components/Utilities/constants'; +import type { EpisodeType } from '@/core/types/api/episode'; + +type Props = { + type: 'multiples' | 'duplicates'; + episode: EpisodeType | undefined; + setFileOptions: (options: ReleaseManagementOptionsType) => void; +}; + +const Episode = ({ episode, setFileOptions, type }: Props) => { + const [options, setOptions] = useState<ReleaseManagementOptionsType>( + () => { + const tempOptions: ReleaseManagementOptionsType = {}; + if (!episode) return tempOptions; + + if (type === 'multiples') { + forEach(episode.Files, (file) => { + if (file.IsVariation) tempOptions[file.ID] = 'variation'; + else tempOptions[file.ID] = 'keep'; + }); + return tempOptions; + } + + forEach( + flatMap(episode.Files, file => file.Locations), + (location) => { + tempOptions[location.ID] = 'keep'; + }, + ); + return tempOptions; + }, + ); + + const optionCounts = useMemo(() => countBy(options), [options]); + + useEffect(() => { + setFileOptions(options); + }, [options, setFileOptions]); + + if (!episode) return null; + + const handleOptionChange = (fileId: number, value: 'keep' | 'variation' | 'delete') => { + setOptions(tempOptions => ( + { ...tempOptions, [fileId]: value } + )); + }; + + return ( + <> + <div className="flex justify-between rounded-lg border border-panel-border bg-panel-table-header p-4 font-semibold"> + {`${getEpisodePrefix(episode.AniDB?.Type)}${episode.AniDB?.EpisodeNumber} - ${episode.Name}`} + <div> + <span className="text-panel-text-important">{optionCounts.keep ?? 0}</span> + Kept | + {type === 'multiples' && ( + <> + <span className="text-panel-text-warning">{optionCounts.variation ?? 0}</span> + Variation | + </> + )} + <span className="text-panel-text-danger">{optionCounts.delete ?? 0}</span> + Delete + </div> + </div> + + {type === 'duplicates' && flatMap(episode.Files, file => + map(file.Locations, (location, index) => { + const absolutePath = location.AbsolutePath ?? '??'; + const fileName = absolutePath.split(/[/\\]+/).pop(); + const folderPath = absolutePath.slice(0, absolutePath.replaceAll('\\', '/').lastIndexOf('/') + 1); + + return ( + <div + key={location.ID} + className="flex justify-between gap-x-4 rounded-lg border border-panel-border bg-panel-background-alt p-4" + > + <div className="flex flex-col gap-y-4"> + <div className="flex flex-col gap-y-1"> + <div className="flex"> + <div className="min-w-[9.375rem] font-semibold">File Name</div> + {fileName} + </div> + <div className="flex"> + <div className="min-w-[9.375rem] font-semibold">Location</div> + {folderPath} + </div> + </div> + </div> + + <div className="flex flex-col gap-y-4"> + <Select + id="mark-variation" + value={options[toNumber(`${file.ID}${index}`)]} + onChange={event => + handleOptionChange( + location.ID, + event.target.value as 'keep' | 'delete', + )} + > + <option value="keep">Will be kept</option> + <option value="delete">Will be deleted</option> + </Select> + + {file.AniDB?.ID && ( + <div className="flex gap-x-2"> + <div className="metadata-link-icon AniDB" /> + <a + href={`https://anidb.net/file/${file.AniDB.ID}`} + target="_blank" + rel="noreferrer noopener" + className="flex cursor-pointer gap-x-1 font-semibold text-panel-text-primary" + aria-label="Open AniDB file page" + > + {file.AniDB.ID} + <span>(AniDB)</span> + <Icon path={mdiOpenInNew} size={1} /> + </a> + </div> + )} + </div> + </div> + ); + }))} + + {type === 'multiples' && episode.Files?.map(file => ( + <div + key={file.ID} + className="flex justify-between gap-x-4 rounded-lg border border-panel-border bg-panel-background-alt p-4" + > + <FileInfo file={file} compact /> + + <div className="flex flex-col gap-y-4"> + <Select + id="mark-variation" + value={options[file.ID]} + onChange={event => handleOptionChange(file.ID, event.target.value as 'keep' | 'variation' | 'delete')} + > + <option value="keep">Will be kept</option> + <option value="delete">Will be deleted</option> + <option value="variation">Marked as Variation</option> + </Select> + + {file.AniDB?.ID && ( + <div className="flex gap-x-2"> + <div className="metadata-link-icon AniDB" /> + <a + href={`https://anidb.net/file/${file.AniDB.ID}`} + target="_blank" + rel="noreferrer noopener" + className="flex cursor-pointer gap-x-1 font-semibold text-panel-text-primary" + aria-label="Open AniDB file page" + > + {file.AniDB.ID} + <span>(AniDB)</span> + <Icon path={mdiOpenInNew} size={1} /> + </a> + </div> + )} + </div> + </div> + ))} + </> + ); +}; + +export default Episode; diff --git a/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx b/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx deleted file mode 100644 index 6039efd19..000000000 --- a/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { mdiOpenInNew } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import { countBy, forEach } from 'lodash'; - -import FileInfo from '@/components/FileInfo'; -import Select from '@/components/Input/Select'; -import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; - -import type { MultipleFileOptionsType } from '@/components/Utilities/constants'; -import type { EpisodeType } from '@/core/types/api/episode'; - -type Props = { - episode: EpisodeType | undefined; - setFileOptions: (options: MultipleFileOptionsType) => void; -}; - -const MultiplesUtilEpisode = ({ episode, setFileOptions }: Props) => { - const [options, setOptions] = useState<MultipleFileOptionsType>( - () => { - const tempOptions: MultipleFileOptionsType = {}; - if (!episode) return tempOptions; - - forEach(episode.Files, (file) => { - if (file.IsVariation) tempOptions[file.ID] = 'variation'; - else tempOptions[file.ID] = 'keep'; - }); - return tempOptions; - }, - ); - - const optionCounts = useMemo(() => countBy(options), [options]); - - useEffect(() => { - setFileOptions(options); - }, [options, setFileOptions]); - - if (!episode) return null; - - const handleOptionChange = (fileId: number, value: 'keep' | 'variation' | 'delete') => { - setOptions(tempOptions => ( - { ...tempOptions, [fileId]: value } - )); - }; - - return ( - <> - <div className="flex justify-between rounded-lg border border-panel-border bg-panel-table-header p-4 font-semibold"> - {`${getEpisodePrefix(episode.AniDB?.Type)}${episode.AniDB?.EpisodeNumber} - ${episode.Name}`} - <div> - <span className="text-panel-text-important">{optionCounts.keep ?? 0}</span> - Kept | - <span className="text-panel-text-warning">{optionCounts.variation ?? 0}</span> - Variation | - <span className="text-panel-text-danger">{optionCounts.delete ?? 0}</span> - Delete - </div> - </div> - - {episode.Files?.map(file => ( - <div - key={file.ID} - className="flex justify-between gap-x-4 rounded-lg border border-panel-border bg-panel-background-alt p-4" - > - <FileInfo file={file} compact /> - - <div className="flex flex-col gap-y-4"> - <Select - id="mark-variation" - value={options[file.ID]} - onChange={event => handleOptionChange(file.ID, event.target.value as 'keep' | 'variation' | 'delete')} - > - <option value="keep">Will be kept</option> - <option value="delete">Will be deleted</option> - <option value="variation">Marked as Variation</option> - </Select> - - {file.AniDB?.ID && ( - <div className="flex gap-x-2"> - <div className="metadata-link-icon AniDB" /> - <a - href={`https://anidb.net/file/${file.AniDB.ID}`} - target="_blank" - rel="noreferrer noopener" - className="flex cursor-pointer gap-x-1 font-semibold text-panel-text-primary" - aria-label="Open AniDB file page" - > - {file.AniDB.ID} - <span>(AniDB)</span> - <Icon path={mdiOpenInNew} size={1} /> - </a> - </div> - )} - </div> - </div> - ))} - </> - ); -}; - -export default MultiplesUtilEpisode; diff --git a/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx b/src/components/Utilities/ReleaseManagement/SeriesList.tsx similarity index 63% rename from src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx rename to src/components/Utilities/ReleaseManagement/SeriesList.tsx index 0361e2eec..d0de8c38f 100644 --- a/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx +++ b/src/components/Utilities/ReleaseManagement/SeriesList.tsx @@ -1,20 +1,21 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { mdiLoading, mdiOpenInNew } from '@mdi/js'; import { Icon } from '@mdi/react'; +import { forEach } from 'lodash'; import UtilitiesTable from '@/components/Utilities/UtilitiesTable'; import { - useSeriesEpisodesWithMultipleReleases, - useSeriesWithMultipleReleases, + useReleaseManagementSeries, + useReleaseManagementSeriesEpisodes, } from '@/core/react-query/release-management/queries'; import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; import useFlattenListResult from '@/hooks/useFlattenListResult'; import type { UtilityHeaderType } from '@/components/Utilities/constants'; import type { EpisodeType } from '@/core/types/api/episode'; -import type { SeriesWithMultipleReleasesType } from '@/core/types/api/series'; +import type { ReleaseManagementSeriesType } from '@/core/types/api/series'; -const seriesColumns: UtilityHeaderType<SeriesWithMultipleReleasesType>[] = [ +const seriesColumns: UtilityHeaderType<ReleaseManagementSeriesType>[] = [ { id: 'series', name: 'Series (AniDB ID)', @@ -57,58 +58,76 @@ const seriesColumns: UtilityHeaderType<SeriesWithMultipleReleasesType>[] = [ }, ]; -const episodeColumns: UtilityHeaderType<EpisodeType>[] = [ - { - id: 'episode', - name: 'Episode Name', - className: 'line-clamp-1 grow basis-0 overflow-hidden', - item: episode => ( - <div - className="flex items-center gap-x-1" - data-tooltip-id="tooltip" - data-tooltip-content={episode.Name} - > - <span className="line-clamp-1"> - {getEpisodePrefix(episode.AniDB?.Type)} - {episode.AniDB?.EpisodeNumber} - - - {episode.Name} - </span> - <div> - ( - <span className="text-panel-text-primary">{episode.IDs.AniDB}</span> - ) - </div> - <a - href={`https://anidb.net/episode/${episode.IDs.AniDB}`} - target="_blank" - rel="noreferrer noopener" - className="cursor-pointer text-panel-text-primary" - aria-label="Open AniDB episode page" - onClick={event => event.stopPropagation()} - > - <Icon path={mdiOpenInNew} size={1} /> - </a> +const episodeNameColumn: UtilityHeaderType<EpisodeType> = { + id: 'episode', + name: 'Episode Name', + className: 'line-clamp-1 grow basis-0 overflow-hidden', + item: episode => ( + <div + className="flex items-center gap-x-1" + data-tooltip-id="tooltip" + data-tooltip-content={episode.Name} + > + <span className="line-clamp-1"> + {getEpisodePrefix(episode.AniDB?.Type)} + {episode.AniDB?.EpisodeNumber} + - + {episode.Name} + </span> + <div> + ( + <span className="text-panel-text-primary">{episode.IDs.AniDB}</span> + ) </div> - ), + <a + href={`https://anidb.net/episode/${episode.IDs.AniDB}`} + target="_blank" + rel="noreferrer noopener" + className="cursor-pointer text-panel-text-primary" + aria-label="Open AniDB episode page" + onClick={event => event.stopPropagation()} + > + <Icon path={mdiOpenInNew} size={1} /> + </a> + </div> + ), +}; + +const multiplesEpisodeFileCountColumn: UtilityHeaderType<EpisodeType> = { + id: 'file-count', + name: 'File Count', + className: 'w-28', + item: (episode) => { + const count = episode.Files?.length ?? 0; + return ( + <> + <span className="text-panel-text-important">{count}</span> + {count === 1 ? ' File' : ' Files'} + </> + ); }, - { - id: 'file-count', - name: 'File Count', - className: 'w-28', - item: (episode) => { - const count = episode.Files?.length ?? 0; - return ( - <> - <span className="text-panel-text-important">{count}</span> - {count === 1 ? ' File' : ' Files'} - </> - ); - }, +}; + +const duplicatesEpisodeFileCountColumn: UtilityHeaderType<EpisodeType> = { + id: 'duplicate-count', + name: 'Duplicate Count', + className: 'w-40', + item: (episode) => { + let count = 0; + forEach(episode.Files, (file) => { + if (file.Locations.length > 1) count += 1; + }); + return ( + <> + <span className="text-panel-text-important">{count}</span> + {count === 1 ? ' Duplicate' : ' Duplicates'} + </> + ); }, -]; +}; type Props = { + type: 'multiples' | 'duplicates'; ignoreVariations: boolean; onlyFinishedSeries: boolean; setSelectedEpisode: (episode: EpisodeType) => void; @@ -116,15 +135,16 @@ type Props = { setSeriesCount: (count: number) => void; }; -const MultiplesUtilList = ( - { ignoreVariations, onlyFinishedSeries, setSelectedEpisode, setSelectedSeriesId, setSeriesCount }: Props, +const SeriesList = ( + { ignoreVariations, onlyFinishedSeries, setSelectedEpisode, setSelectedSeriesId, setSeriesCount, type }: Props, ) => { const [selectedSeries, setSelectedSeries] = useState(0); - const seriesQuery = useSeriesWithMultipleReleases({ ignoreVariations, onlyFinishedSeries, pageSize: 25 }); + const seriesQuery = useReleaseManagementSeries(type, { ignoreVariations, onlyFinishedSeries, pageSize: 25 }); const [series, seriesCount] = useFlattenListResult(seriesQuery.data); - const episodesQuery = useSeriesEpisodesWithMultipleReleases( + const episodesQuery = useReleaseManagementSeriesEpisodes( + type, selectedSeries, { ignoreVariations, includeDataFrom: ['AniDB'], includeAbsolutePaths: true, pageSize: 25 }, selectedSeries > 0, @@ -141,6 +161,11 @@ const MultiplesUtilList = ( setSelectedSeries(0); }, [seriesQuery.data]); + const episodeColumns = useMemo(() => [ + episodeNameColumn, + type === 'multiples' ? multiplesEpisodeFileCountColumn : duplicatesEpisodeFileCountColumn, + ], [type]); + return ( <> <div className="flex w-1/2 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6"> @@ -152,7 +177,8 @@ const MultiplesUtilList = ( {!seriesQuery.isPending && seriesCount === 0 && ( <div className="flex grow items-center justify-center text-lg font-semibold"> - No series with multiple files! + No series with + {type === 'multiples' ? ' multiple releases!' : ' duplicate files!'} </div> )} @@ -196,4 +222,4 @@ const MultiplesUtilList = ( ); }; -export default MultiplesUtilList; +export default SeriesList; diff --git a/src/components/Utilities/ReleaseManagement/Title.tsx b/src/components/Utilities/ReleaseManagement/Title.tsx index 4300cd9fa..1fdcb7089 100644 --- a/src/components/Utilities/ReleaseManagement/Title.tsx +++ b/src/components/Utilities/ReleaseManagement/Title.tsx @@ -19,8 +19,8 @@ const Title = () => ( Release Management <Icon path={mdiChevronRight} size={1} /> <TabButton id="multiples" name="Multiples" /> - {/* <div>|</div> */} - {/* <TabButton id="duplicates" name="Duplicates" /> */} + <div>|</div> + <TabButton id="duplicates" name="Duplicates" /> </div> ); diff --git a/src/components/Utilities/constants.tsx b/src/components/Utilities/constants.tsx index 476a8c2cd..4bad515c9 100644 --- a/src/components/Utilities/constants.tsx +++ b/src/components/Utilities/constants.tsx @@ -6,16 +6,16 @@ import { dayjs } from '@/core/util'; import type { EpisodeType } from '@/core/types/api/episode'; import type { FileType } from '@/core/types/api/file'; -import type { SeriesType, SeriesWithMultipleReleasesType } from '@/core/types/api/series'; +import type { ReleaseManagementSeriesType, SeriesType } from '@/core/types/api/series'; -export type UtilityHeaderType<T extends EpisodeType | FileType | SeriesType | SeriesWithMultipleReleasesType> = { +export type UtilityHeaderType<T extends EpisodeType | FileType | SeriesType | ReleaseManagementSeriesType> = { id: string; name: string; className: string; item: (_: T) => React.ReactNode; }; -export type MultipleFileOptionsType = Record<number, 'keep' | 'variation' | 'delete'>; +export type ReleaseManagementOptionsType = Record<number, 'keep' | 'variation' | 'delete'>; export const criteriaMap = { importFolder: FileSortCriteriaEnum.ImportFolderName, diff --git a/src/core/react-query/file/mutations.ts b/src/core/react-query/file/mutations.ts index 90c4894ef..802187c9b 100644 --- a/src/core/react-query/file/mutations.ts +++ b/src/core/react-query/file/mutations.ts @@ -6,6 +6,7 @@ import { axios } from '@/core/axios'; import queryClient, { invalidateQueries } from '@/core/react-query/queryClient'; import type { + DeleteFileLocationRequestType, DeleteFileRequestType, DeleteFilesRequestType, IgnoreFileRequestType, @@ -68,6 +69,11 @@ export const useDeleteFileLinkMutation = () => ), }); +export const useDeleteFileLocationMutation = () => + useMutation({ + mutationFn: ({ locationId }: DeleteFileLocationRequestType) => axios.delete(`File/Location/${locationId}`), + }); + export const useIgnoreFileMutation = () => useMutation({ mutationFn: ({ fileId, ignore }: IgnoreFileRequestType) => diff --git a/src/core/react-query/file/types.ts b/src/core/react-query/file/types.ts index 2e039bfd7..c61d67b3e 100644 --- a/src/core/react-query/file/types.ts +++ b/src/core/react-query/file/types.ts @@ -8,6 +8,12 @@ export type DeleteFileRequestType = { removeFolder: boolean; }; +export type DeleteFileLocationRequestType = { + locationId: number; + deleteFile?: boolean; + deleteFolder?: boolean; +}; + export type IgnoreFileRequestType = { fileId: number; ignore: boolean; diff --git a/src/core/react-query/release-management/queries.ts b/src/core/react-query/release-management/queries.ts index 9d4b6e35e..1ad7de7db 100644 --- a/src/core/react-query/release-management/queries.ts +++ b/src/core/react-query/release-management/queries.ts @@ -3,19 +3,22 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { axios } from '@/core/axios'; import type { - SeriesEpisodesWithMultipleReleasesType, - SeriesWithMultipleReleasesRequestType, + ReleaseManagementSeriesEpisodesType, + ReleaseManagementSeriesRequestType, } from '@/core/react-query/release-management/types'; import type { ListResultType } from '@/core/types/api'; import type { EpisodeType } from '@/core/types/api/episode'; -import type { SeriesWithMultipleReleasesType } from '@/core/types/api/series'; +import type { ReleaseManagementSeriesType } from '@/core/types/api/series'; -export const useSeriesWithMultipleReleases = (params: SeriesWithMultipleReleasesRequestType) => - useInfiniteQuery<ListResultType<SeriesWithMultipleReleasesType>>({ - queryKey: ['release-management', 'series', params], +export const useReleaseManagementSeries = ( + type: 'multiples' | 'duplicates', + params: ReleaseManagementSeriesRequestType, +) => + useInfiniteQuery<ListResultType<ReleaseManagementSeriesType>>({ + queryKey: ['release-management', 'series', type, params], queryFn: ({ pageParam }) => axios.get( - 'ReleaseManagement/Series', + `ReleaseManagement/${type === 'multiples' ? 'MultipleReleases' : 'DuplicateFiles'}/Series`, { params: { ...params, @@ -31,16 +34,17 @@ export const useSeriesWithMultipleReleases = (params: SeriesWithMultipleReleases }, }); -export const useSeriesEpisodesWithMultipleReleases = ( +export const useReleaseManagementSeriesEpisodes = ( + type: 'multiples' | 'duplicates', seriesId: number, - params: SeriesEpisodesWithMultipleReleasesType, + params: ReleaseManagementSeriesEpisodesType, enabled = true, ) => useInfiniteQuery<ListResultType<EpisodeType>>({ - queryKey: ['release-management', 'series', 'episodes', seriesId, params], + queryKey: ['release-management', 'series', 'episodes', type, seriesId, params], queryFn: ({ pageParam }) => axios.get( - `ReleaseManagement/Series/${seriesId}`, + `ReleaseManagement/${type === 'multiples' ? 'MultipleReleases' : 'DuplicateFiles'}/Series/${seriesId}/Episodes`, { params: { ...params, diff --git a/src/core/react-query/release-management/types.ts b/src/core/react-query/release-management/types.ts index 262b71e92..17b4575e6 100644 --- a/src/core/react-query/release-management/types.ts +++ b/src/core/react-query/release-management/types.ts @@ -1,13 +1,13 @@ import type { PaginationType } from '@/core/types/api'; import type { DataSourceType } from '@/core/types/api/common'; -export type SeriesWithMultipleReleasesRequestType = { +export type ReleaseManagementSeriesRequestType = { ignoreVariations?: boolean; includeDataFrom?: DataSourceType[]; onlyFinishedSeries?: boolean; } & PaginationType; -export type SeriesEpisodesWithMultipleReleasesType = { +export type ReleaseManagementSeriesEpisodesType = { includeDataFrom?: DataSourceType[]; includeFiles?: boolean; includeMediaInfo?: boolean; diff --git a/src/core/router/index.tsx b/src/core/router/index.tsx index 0cc0a0c53..d9db4e36e 100644 --- a/src/core/router/index.tsx +++ b/src/core/router/index.tsx @@ -41,7 +41,7 @@ import MetadataSitesSettings from '@/pages/settings/tabs/MetadataSitesSettings'; import UserManagementSettings from '@/pages/settings/tabs/UserManagementSettings'; import UnsupportedPage from '@/pages/unsupported/UnsupportedPage'; import FileSearch from '@/pages/utilities/FileSearch'; -import MultiplesUtil from '@/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil'; +import ReleaseManagement from '@/pages/utilities/ReleaseManagement'; import Renamer from '@/pages/utilities/Renamer'; import SeriesWithoutFilesUtility from '@/pages/utilities/SeriesWithoutFilesUtility'; import IgnoredFilesTab from '@/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab'; @@ -91,7 +91,8 @@ const router = sentryCreateBrowserRouter( <Route path="unrecognized/manually-linked-files" element={<ManuallyLinkedTab />} /> <Route path="unrecognized/ignored-files" element={<IgnoredFilesTab />} /> <Route path="release-management" element={<Navigate to="multiples" replace />} /> - <Route path="release-management/multiples" element={<MultiplesUtil />} /> + <Route path="release-management/multiples" element={<ReleaseManagement type="multiples" />} /> + <Route path="release-management/duplicates" element={<ReleaseManagement type="duplicates" />} /> <Route path="series-without-files" element={<SeriesWithoutFilesUtility />} /> <Route path="file-search" element={<FileSearch />} /> <Route path="renamer" element={<Renamer />} /> diff --git a/src/core/types/api/file.ts b/src/core/types/api/file.ts index 6676d853f..0acf23146 100644 --- a/src/core/types/api/file.ts +++ b/src/core/types/api/file.ts @@ -9,6 +9,8 @@ type XRefsType = { }; type FileTypeLocation = { + ID: number; + FileID: number; ImportFolderID: number; RelativePath: string; AbsolutePath?: string; diff --git a/src/core/types/api/series.ts b/src/core/types/api/series.ts index acde6fff3..243590d7d 100644 --- a/src/core/types/api/series.ts +++ b/src/core/types/api/series.ts @@ -19,7 +19,7 @@ export type SeriesType = { }; }; -export type SeriesWithMultipleReleasesType = { +export type ReleaseManagementSeriesType = { EpisodeCount: number; } & SeriesType; diff --git a/src/pages/collection/Collection.tsx b/src/pages/collection/Collection.tsx index 2b1b29ca3..28b8172d8 100644 --- a/src/pages/collection/Collection.tsx +++ b/src/pages/collection/Collection.tsx @@ -219,57 +219,60 @@ function Collection() { }); return ( - <div className="flex grow flex-col gap-y-6"> - <div className="sticky -top-6 z-10 flex items-center justify-between rounded-lg border border-panel-border bg-panel-background p-6"> - <CollectionTitle - // eslint-disable-next-line no-nested-ternary - count={(total === 0 && isFetching) ? -1 : (isSeries ? total : groupsTotal)} - filterName={filterQuery?.data?.Name} - groupName={groupQuery?.data?.Name} - filterActive={!!activeFilter} - searchQuery={isSeries ? seriesSearch : groupSearch} - /> - <TitleOptions - groupSearch={groupSearch} - isSeries={isSeries} - item={item} - mode={mode} - seriesSearch={seriesSearch} - setSearch={setSearch} - toggleFilterSidebar={handleFilterSidebarToggle} - toggleMode={toggleMode} - /> - </div> - <div className="flex grow"> - <CollectionView - groupExtras={groupExtras ?? []} - fetchNextPage={groupsQuery.fetchNextPage} - isFetchingNextPage={groupsQuery.isFetchingNextPage} - isFetching={isFetching} - isSeries={isSeries} - isSidebarOpen={showFilterSidebar} - items={items} - mode={mode} - total={total} - /> - <div - className={cx( - 'flex items-start', - !isSeries && 'transition-all', - showFilterSidebar - ? 'w-[28rem] opacity-100' - : 'w-0 opacity-0 overflow-hidden ', + <> + <title>{`${isSeries ? groupQuery?.data?.Name : 'Collection'} | Shoko`}</title> + <div className="flex grow flex-col gap-y-6"> + <div className="sticky -top-6 z-10 flex items-center justify-between rounded-lg border border-panel-border bg-panel-background p-6"> + <CollectionTitle + // eslint-disable-next-line no-nested-ternary + count={(total === 0 && isFetching) ? -1 : (isSeries ? total : groupsTotal)} + filterName={filterQuery?.data?.Name} + groupName={groupQuery?.data?.Name} + filterActive={!!activeFilter} + searchQuery={isSeries ? seriesSearch : groupSearch} + /> + <TitleOptions + groupSearch={groupSearch} + isSeries={isSeries} + item={item} + mode={mode} + seriesSearch={seriesSearch} + setSearch={setSearch} + toggleFilterSidebar={handleFilterSidebarToggle} + toggleMode={toggleMode} + /> + </div> + <div className="flex grow"> + <CollectionView + groupExtras={groupExtras ?? []} + fetchNextPage={groupsQuery.fetchNextPage} + isFetchingNextPage={groupsQuery.isFetchingNextPage} + isFetching={isFetching} + isSeries={isSeries} + isSidebarOpen={showFilterSidebar} + items={items} + mode={mode} + total={total} + /> + <div + className={cx( + 'flex items-start', + !isSeries && 'transition-all', + showFilterSidebar + ? 'w-[28rem] opacity-100' + : 'w-0 opacity-0 overflow-hidden ', + )} + > + <FilterSidebar /> + </div> + {isSeries && !showFilterSidebar && ( + <TimelineSidebar series={timelineSeries} isFetching={seriesQuery.isPending} /> )} - > - <FilterSidebar /> </div> - {isSeries && !showFilterSidebar && ( - <TimelineSidebar series={timelineSeries} isFetching={seriesQuery.isPending} /> - )} + <EditSeriesModal /> + <EditGroupModal /> </div> - <EditSeriesModal /> - <EditGroupModal /> - </div> + </> ); } diff --git a/src/pages/collection/Series.tsx b/src/pages/collection/Series.tsx index b1322bff0..b6228cbb7 100644 --- a/src/pages/collection/Series.tsx +++ b/src/pages/collection/Series.tsx @@ -155,7 +155,7 @@ const Series = () => { <EditSeriesModal /> - <Outlet context={{ backdrop, scrollRef } satisfies SeriesContextType} /> + <Outlet context={{ backdrop, scrollRef, series } satisfies SeriesContextType} /> <div className="fixed left-0 top-0 -z-10 w-full bg-cover bg-fixed opacity-5" diff --git a/src/pages/collection/series/SeriesCredits.tsx b/src/pages/collection/series/SeriesCredits.tsx index 562b7649a..d6d2b155f 100644 --- a/src/pages/collection/series/SeriesCredits.tsx +++ b/src/pages/collection/series/SeriesCredits.tsx @@ -1,6 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { useParams } from 'react-router'; -import { toNumber } from 'lodash'; +import { useOutletContext } from 'react-router-dom'; import CreditsSearchAndFilterPanel from '@/components/Collection/Credits/CreditsSearchAndFilterPanel'; import StaffPanelVirtualizer from '@/components/Collection/Credits/CreditsStaffVirtualizer'; @@ -10,6 +9,7 @@ import { useRefreshSeriesAniDBInfoMutation } from '@/core/react-query/series/mut import { useSeriesCastQuery } from '@/core/react-query/series/queries'; import useEventCallback from '@/hooks/useEventCallback'; +import type { SeriesContextType } from '@/components/Collection/constants'; import type { SeriesCast } from '@/core/types/api/series'; export type CreditsModeType = 'Character' | 'Staff'; @@ -24,12 +24,12 @@ const modeStates: { label?: string, value: CreditsModeType }[] = [ ]; const SeriesCredits = () => { - const { seriesId } = useParams(); + const { series } = useOutletContext<SeriesContextType>(); const { isPending: pendingRefreshAniDb, mutate: refreshAniDbMutation } = useRefreshSeriesAniDBInfoMutation(); const refreshAniDb = useEventCallback(() => { - refreshAniDbMutation({ seriesId: toNumber(seriesId), force: true }, { + refreshAniDbMutation({ seriesId: series.IDs.ID, force: true }, { onSuccess: () => toast.success('AniDB refresh queued!'), }); }); @@ -61,7 +61,7 @@ const SeriesCredits = () => { setSearch(event.target.value); }); - const cast = useSeriesCastQuery(toNumber(seriesId!), !!seriesId).data; + const cast = useSeriesCastQuery(series.IDs.ID).data; const castByType = useMemo(() => ({ Character: cast?.filter(credit => credit.RoleName === 'Seiyuu') ?? [], Staff: cast?.filter(credit => credit.RoleName !== 'Seiyuu') ?? [], @@ -84,47 +84,48 @@ const SeriesCredits = () => { return 0; })), [castByType, mode, search, roleFilter]); - if (!seriesId) return null; - return ( - <div className="flex w-full gap-x-6"> - <div className="flex flex-col gap-y-6"> - <CreditsSearchAndFilterPanel - inputPlaceholder={mode === 'Character' ? 'Character or Seiyuu\'s Name...' : 'Staff Name...'} - search={search} - roleFilter={roleFilter} - uniqueRoles={uniqueRoles[mode]} - handleSearchChange={handleSearchChange} - handleFilterChange={handleFilterChange} - refreshAniDbAction={refreshAniDb} - aniDbRefreshing={pendingRefreshAniDb} - /> - </div> + <> + <title>{`${series.Name} > Credits | Shoko`}</title> + <div className="flex w-full gap-x-6"> + <div className="flex flex-col gap-y-6"> + <CreditsSearchAndFilterPanel + inputPlaceholder={mode === 'Character' ? 'Character or Seiyuu\'s Name...' : 'Staff Name...'} + search={search} + roleFilter={roleFilter} + uniqueRoles={uniqueRoles[mode]} + handleSearchChange={handleSearchChange} + handleFilterChange={handleFilterChange} + refreshAniDbAction={refreshAniDb} + aniDbRefreshing={pendingRefreshAniDb} + /> + </div> - <div className="flex w-full grow flex-col gap-x-6 gap-y-4"> - <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent px-6 py-4"> - <div className="text-xl font-semibold"> - Credits | - {(search !== '' || roleFilter.size > 0) && ( - <> - <span className="text-panel-text-important"> - {filteredCast.length} - </span> - of - </> - )} - <span className="text-panel-text-important"> - {castByType[mode].length ?? 0} - </span> - - {mode === 'Character' ? 'Characters' : mode} - Listed + <div className="flex w-full grow flex-col gap-x-6 gap-y-4"> + <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent px-6 py-4"> + <div className="text-xl font-semibold"> + Credits | + {(search !== '' || roleFilter.size > 0) && ( + <> + <span className="text-panel-text-important"> + {filteredCast.length} + </span> + of + </> + )} + <span className="text-panel-text-important"> + {castByType[mode].length ?? 0} + </span> + + {mode === 'Character' ? 'Characters' : mode} + Listed + </div> + <MultiStateButton activeState={mode} states={modeStates} onStateChange={handleModeChange} /> </div> - <MultiStateButton activeState={mode} states={modeStates} onStateChange={handleModeChange} /> + <StaffPanelVirtualizer castArray={filteredCast} mode={mode} /> </div> - <StaffPanelVirtualizer castArray={filteredCast} mode={mode} /> </div> - </div> + </> ); }; diff --git a/src/pages/collection/series/SeriesEpisodes.tsx b/src/pages/collection/series/SeriesEpisodes.tsx index 134475715..ae76ac647 100644 --- a/src/pages/collection/series/SeriesEpisodes.tsx +++ b/src/pages/collection/series/SeriesEpisodes.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { useParams } from 'react-router'; import { useOutletContext, useSearchParams } from 'react-router-dom'; import { mdiCloseCircleOutline, mdiEyeOutline, mdiLoading } from '@mdi/js'; import { Icon } from '@mdi/react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { debounce, toNumber } from 'lodash'; +import { debounce } from 'lodash'; import { useDebounceValue } from 'usehooks-ts'; import EpisodeSearchAndFilterPanel from '@/components/Collection/Episode/EpisodeSearchAndFilterPanel'; @@ -13,7 +12,7 @@ import EpisodeWatchModal from '@/components/Collection/Episode/EpisodeWatchModal import Button from '@/components/Input/Button'; import toast from '@/components/Toast'; import { useWatchSeriesEpisodesMutation } from '@/core/react-query/series/mutations'; -import { useSeriesEpisodesInfiniteQuery, useSeriesQuery } from '@/core/react-query/series/queries'; +import { useSeriesEpisodesInfiniteQuery } from '@/core/react-query/series/queries'; import { IncludeOnlyFilterEnum } from '@/core/react-query/series/types'; import { EpisodeTypeEnum } from '@/core/types/api/episode'; import { dayjs } from '@/core/util'; @@ -34,7 +33,8 @@ type FilterOptionsType = { }; const SeriesEpisodes = () => { - const { seriesId } = useParams(); + const { series } = useOutletContext<SeriesContextType>(); + const [searchParams, setSearchParams] = useSearchParams(); const [showOptionsModal, setShowOptionsModal] = useState(false); @@ -71,15 +71,13 @@ const SeriesEpisodes = () => { setSelectedEpisodes(new Set()); }, [filterOptions]); - const seriesQueryData = useSeriesQuery(toNumber(seriesId!), { includeDataFrom: ['AniDB', 'TMDB'] }, !!seriesId).data; const seriesEpisodesQuery = useSeriesEpisodesInfiniteQuery( - toNumber(seriesId!), + series.IDs.ID, { pageSize, includeDataFrom: ['AniDB'], ...filterOptions, }, - !!seriesId, ); const { data, @@ -92,20 +90,18 @@ const SeriesEpisodes = () => { const { mutate: watchEpisode } = useWatchSeriesEpisodesMutation(); - const anidbSeriesId = useMemo(() => seriesQueryData?.IDs.AniDB ?? 0, [seriesQueryData]); - const hasMissingEpisodes = useMemo( - () => ((seriesQueryData?.Sizes.Missing.Episodes ?? 0) > 0), - [seriesQueryData?.Sizes], + () => ((series.Sizes.Missing.Episodes ?? 0) > 0), + [series.Sizes], ); const startDate = useMemo( - () => (seriesQueryData?.AniDB?.AirDate != null ? dayjs(seriesQueryData?.AniDB?.AirDate) : null), - [seriesQueryData], + () => (series.AniDB?.AirDate != null ? dayjs(series.AniDB?.AirDate) : null), + [series], ); const endDate = useMemo( - () => (seriesQueryData?.AniDB?.EndDate != null ? dayjs(seriesQueryData?.AniDB?.EndDate) : null), - [seriesQueryData], + () => (series.AniDB?.EndDate != null ? dayjs(series.AniDB?.EndDate) : null), + [series], ); const hasUnairedEpisodes = useMemo( () => (!!startDate && (endDate === null || endDate.isAfter(dayjs()))), @@ -133,7 +129,7 @@ const SeriesEpisodes = () => { const handleMarkWatched = useEventCallback((watched: boolean) => { watchEpisode({ - seriesId: toNumber(seriesId), + seriesId: series.IDs.ID, value: watched, ...filterOptions, }, { @@ -150,105 +146,108 @@ const SeriesEpisodes = () => { const openOptionsModal = useEventCallback(() => setShowOptionsModal(true)); return ( - <div className="flex w-full gap-x-6"> - <EpisodeSearchAndFilterPanel - onFilterChange={onFilterChange} - search={filterOptions.search} - type={filterOptions.type[0]} - availability={filterOptions.includeMissing} - watched={filterOptions.includeWatched} - hidden={filterOptions.includeHidden} - unaired={filterOptions.includeUnaired} - hasUnaired={hasUnairedEpisodes} - hasMissing={hasMissingEpisodes} - /> - <div className="flex grow flex-col gap-y-4"> - <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent px-6 py-4"> - <div className="flex flex-wrap text-xl font-semibold 2xl:flex-nowrap"> - <span>Episodes</span> - <span className="hidden px-2 2xl:inline">|</span> - <span> - <span className="pr-2 text-panel-text-important"> - {isSuccess ? episodeCount : '-'} + <> + <title>{`${series.Name} > Episodes | Shoko`}</title> + <div className="flex w-full gap-x-6"> + <EpisodeSearchAndFilterPanel + onFilterChange={onFilterChange} + search={filterOptions.search} + type={filterOptions.type[0]} + availability={filterOptions.includeMissing} + watched={filterOptions.includeWatched} + hidden={filterOptions.includeHidden} + unaired={filterOptions.includeUnaired} + hasUnaired={hasUnairedEpisodes} + hasMissing={hasMissingEpisodes} + /> + <div className="flex grow flex-col gap-y-4"> + <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent px-6 py-4"> + <div className="flex flex-wrap text-xl font-semibold 2xl:flex-nowrap"> + <span>Episodes</span> + <span className="hidden px-2 2xl:inline">|</span> + <span> + <span className="pr-2 text-panel-text-important"> + {isSuccess ? episodeCount : '-'} + </span> + Entries Listed + {selectedEpisodes.size > 0 && ( + <> + | + <span className="text-panel-text-important"> + {selectedEpisodes.size} + </span> + Entries Selected + </> + )} </span> - Entries Listed + </div> + <div className="flex flex-row gap-x-2"> {selectedEpisodes.size > 0 && ( - <> - | - <span className="text-panel-text-important"> - {selectedEpisodes.size} - </span> - Entries Selected - </> + <Button buttonType="secondary" buttonSize="normal" className="flex gap-x-2" onClick={resetSelection}> + <Icon path={mdiCloseCircleOutline} size={1} /> + Cancel Selection + </Button> )} - </span> - </div> - <div className="flex flex-row gap-x-2"> - {selectedEpisodes.size > 0 && ( - <Button buttonType="secondary" buttonSize="normal" className="flex gap-x-2" onClick={resetSelection}> - <Icon path={mdiCloseCircleOutline} size={1} /> - Cancel Selection + <Button buttonType="secondary" buttonSize="normal" className="flex gap-x-2" onClick={openOptionsModal}> + <Icon path={mdiEyeOutline} size={1} /> + Options </Button> - )} - <Button buttonType="secondary" buttonSize="normal" className="flex gap-x-2" onClick={openOptionsModal}> - <Icon path={mdiEyeOutline} size={1} /> - Options - </Button> + </div> + </div> + <div className="grow"> + {isPending + ? ( + <div className="flex h-full items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} spin size={4} /> + </div> + ) + : ( + <div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}> + {virtualItems.map((virtualItem) => { + const page = Math.ceil((virtualItem.index + 1) / pageSize); + const episode = episodes[virtualItem.index]; + + if (!episode && !isFetchingNextPage) fetchNextPageDebounced(); + + return ( + <div + key={episode ? episode.IDs.ID : `loading-${virtualItem.key}`} + className="absolute left-0 top-0 flex w-full flex-col rounded-lg border border-panel-border bg-panel-background-transparent" + data-index={virtualItem.index} + style={{ transform: `translateY(${virtualItem.start ?? 0}px)` }} + ref={rowVirtualizer.measureElement} + > + {episode + ? ( + <EpisodeSummary + selected={selectedEpisodes.has(episode.IDs.ID)} + onSelectionChange={() => onSelectionChange(episode.IDs.ID)} + seriesId={series.IDs.ID} + anidbSeriesId={series.IDs.AniDB} + episode={episode} + page={page} + /> + ) + : ( + <div className="flex h-[20.75rem] items-center justify-center p-6 text-panel-text-primary"> + <Icon path={mdiLoading} spin size={3} /> + </div> + )} + </div> + ); + })} + </div> + )} </div> </div> - <div className="grow"> - {isPending - ? ( - <div className="flex h-full items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} spin size={4} /> - </div> - ) - : ( - <div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}> - {virtualItems.map((virtualItem) => { - const page = Math.ceil((virtualItem.index + 1) / pageSize); - const episode = episodes[virtualItem.index]; - - if (!episode && !isFetchingNextPage) fetchNextPageDebounced(); - - return ( - <div - key={episode ? episode.IDs.ID : `loading-${virtualItem.key}`} - className="absolute left-0 top-0 flex w-full flex-col rounded-lg border border-panel-border bg-panel-background-transparent" - data-index={virtualItem.index} - style={{ transform: `translateY(${virtualItem.start ?? 0}px)` }} - ref={rowVirtualizer.measureElement} - > - {episode - ? ( - <EpisodeSummary - selected={selectedEpisodes.has(episode.IDs.ID)} - onSelectionChange={() => onSelectionChange(episode.IDs.ID)} - seriesId={toNumber(seriesId)} - anidbSeriesId={anidbSeriesId} - episode={episode} - page={page} - /> - ) - : ( - <div className="flex h-[20.75rem] items-center justify-center p-6 text-panel-text-primary"> - <Icon path={mdiLoading} spin size={3} /> - </div> - )} - </div> - ); - })} - </div> - )} - </div> + <EpisodeWatchModal + show={showOptionsModal} + onRequestClose={() => setShowOptionsModal(false)} + markFilteredWatched={markFilteredWatched} + markFilteredUnwatched={markFilteredUnwatched} + /> </div> - <EpisodeWatchModal - show={showOptionsModal} - onRequestClose={() => setShowOptionsModal(false)} - markFilteredWatched={markFilteredWatched} - markFilteredUnwatched={markFilteredUnwatched} - /> - </div> + </> ); }; diff --git a/src/pages/collection/series/SeriesFileSummary.tsx b/src/pages/collection/series/SeriesFileSummary.tsx index 7cbf090fa..e3a7f0bdf 100644 --- a/src/pages/collection/series/SeriesFileSummary.tsx +++ b/src/pages/collection/series/SeriesFileSummary.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; -import { useParams } from 'react-router'; +import { useOutletContext } from 'react-router-dom'; import { mdiLoading } from '@mdi/js'; import { Icon } from '@mdi/react'; -import { toNumber } from 'lodash'; import FileMissingEpisodes from '@/components/Collection/Files/FilesMissingEpisodes'; import FileOverview from '@/components/Collection/Files/FilesOverview'; @@ -10,6 +9,7 @@ import FilesSummaryGroups from '@/components/Collection/Files/FilesSummaryGroup' import MultiStateButton from '@/components/Input/MultiStateButton'; import { useSeriesFileSummaryQuery } from '@/core/react-query/webui/queries'; +import type { SeriesContextType } from '@/components/Collection/constants'; import type { WebuiSeriesFileSummaryType } from '@/core/types/api/webui'; type ModeType = 'Series' | 'Missing'; @@ -47,43 +47,43 @@ const FilesSelectionHeader = React.memo(({ fileSummary, mode, setMode }: FileSel )); const SeriesFileSummary = () => { - const { seriesId } = useParams(); + const { series } = useOutletContext<SeriesContextType>(); const [mode, setMode] = useState<ModeType>('Series'); const { data: fileSummary, isLoading } = useSeriesFileSummaryQuery( - toNumber(seriesId!), + series.IDs.ID, { groupBy: 'GroupName,FileVersion,FileLocation,AudioLanguages,SubtitleLanguages,VideoResolution' }, - !!seriesId, ); - if (!seriesId) return null; - return ( - <div className="flex w-full gap-x-6"> - <div className="flex flex-col gap-y-6"> - <FileOverview overview={fileSummary?.Overview} /> - </div> + <> + <title>{`${series.Name} > Files | Shoko`}</title> + <div className="flex w-full gap-x-6"> + <div className="flex flex-col gap-y-6"> + <FileOverview overview={fileSummary?.Overview} /> + </div> - <div className="flex w-full flex-col gap-y-6"> - <FilesSelectionHeader - mode={mode} - setMode={setMode} - fileSummary={fileSummary} - /> + <div className="flex w-full flex-col gap-y-6"> + <FilesSelectionHeader + mode={mode} + setMode={setMode} + fileSummary={fileSummary} + /> - <div className="flex grow flex-col gap-y-6"> - {isLoading && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} spin size={3} /> - </div> - )} - {mode === 'Series' - ? <FilesSummaryGroups groups={fileSummary?.Groups} /> - : <FileMissingEpisodes missingEps={fileSummary?.MissingEpisodes} />} + <div className="flex grow flex-col gap-y-6"> + {isLoading && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} spin size={3} /> + </div> + )} + {mode === 'Series' + ? <FilesSummaryGroups groups={fileSummary?.Groups} /> + : <FileMissingEpisodes missingEps={fileSummary?.MissingEpisodes} />} + </div> </div> </div> - </div> + </> ); }; diff --git a/src/pages/collection/series/SeriesImages.tsx b/src/pages/collection/series/SeriesImages.tsx index aa2be1330..d94b38c07 100644 --- a/src/pages/collection/series/SeriesImages.tsx +++ b/src/pages/collection/series/SeriesImages.tsx @@ -1,9 +1,10 @@ import React, { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; +import { useOutletContext } from 'react-router-dom'; import { mdiStarCircleOutline } from '@mdi/js'; import { Icon } from '@mdi/react'; import cx from 'classnames'; -import { capitalize, split, toNumber } from 'lodash'; +import { capitalize, split } from 'lodash'; import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; import Button from '@/components/Input/Button'; @@ -14,6 +15,7 @@ import { useChangeSeriesImageMutation } from '@/core/react-query/series/mutation import { useSeriesImagesQuery } from '@/core/react-query/series/queries'; import useEventCallback from '@/hooks/useEventCallback'; +import type { SeriesContextType } from '@/components/Collection/constants'; import type { ImageType } from '@/core/types/api/common'; type ImageTabType = 'Posters' | 'Backdrops' | 'Logos'; @@ -37,7 +39,9 @@ const sizeMap = { }; const SeriesImages = () => { - const { imageType, seriesId } = useParams(); + const { imageType } = useParams(); + + const { series } = useOutletContext<SeriesContextType>(); const navigate = useNavigate(); @@ -46,7 +50,7 @@ const SeriesImages = () => { return capitalize(imageType) as ImageTabType; }, [imageType]); const [selectedImage, setSelectedImage] = useState<ImageType | null>(null); - const images = useSeriesImagesQuery(toNumber(seriesId!), !!seriesId).data; + const images = useSeriesImagesQuery(series.IDs.ID).data; const { mutate: changeImage } = useChangeSeriesImageMutation(); const splitPath = split(selectedImage?.RelativeFilepath ?? '-', '/'); @@ -59,7 +63,7 @@ const SeriesImages = () => { const handleSetPreferredImage = useEventCallback(() => { if (!selectedImage) return; - changeImage({ seriesId: toNumber(seriesId), image: selectedImage }, { + changeImage({ seriesId: series.IDs.ID, image: selectedImage }, { onSuccess: () => { setSelectedImage(null); toast.success(`Series ${selectedImage.Type} image has been changed.`); @@ -72,85 +76,86 @@ const SeriesImages = () => { navigate(`../images/${newType.toLowerCase()}`); }); - if (!seriesId) return null; - return ( - <div className="flex w-full gap-x-6"> - <div className="flex w-100 min-w-64 flex-col"> - <ShokoPanel - title="Selected Image Info" - contentClassName="gap-y-6" - fullHeight={false} - transparent - sticky - > - <InfoLine title="Filename" value={filename} /> - <InfoLine title="Location" value={filepath} /> - <InfoLine title="Source" value={selectedImage?.Source ?? '-'} /> - <InfoLine - title="Size" - value={selectedImage?.Width && selectedImage?.Height - ? `${selectedImage.Width} x ${selectedImage.Height}` - : '-'} - /> - <Button - buttonType="primary" - buttonSize="normal" - disabled={!selectedImage || selectedImage.Preferred} - onClick={handleSetPreferredImage} + <> + <title>{`${series.Name} > Images | Shoko`}</title> + <div className="flex w-full gap-x-6"> + <div className="flex w-100 min-w-64 flex-col"> + <ShokoPanel + title="Selected Image Info" + contentClassName="gap-y-6" + fullHeight={false} + transparent + sticky > - {`Set As Preferred ${tabType.slice(0, -1)}`} - </Button> - </ShokoPanel> - </div> - <div className="flex grow flex-col gap-y-6"> - <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent p-6"> - <div className="text-xl font-semibold"> - Images | - <span className="text-panel-text-important">{images?.[tabType]?.length ?? '-'}</span> - - {tabType} - Listed - </div> - <MultiStateButton activeState={tabType} onStateChange={handleTabChange} states={tabStates} /> - </div> - <div - className={cx( - sizeMap[tabType].grid, - 'grid gap-6 rounded-lg border border-panel-border bg-panel-background-transparent p-6', - )} - > - {images?.[tabType].map(item => ( - <div - onClick={() => handleSelectionChange(item)} - key={`${item.Source}-${item.Type}-${item.ID}`} - className="group flex cursor-pointer items-center justify-between" + <InfoLine title="Filename" value={filename} /> + <InfoLine title="Location" value={filepath} /> + <InfoLine title="Source" value={selectedImage?.Source ?? '-'} /> + <InfoLine + title="Size" + value={selectedImage?.Width && selectedImage?.Height + ? `${selectedImage.Width} x ${selectedImage.Height}` + : '-'} + /> + <Button + buttonType="primary" + buttonSize="normal" + disabled={!selectedImage || selectedImage.Preferred} + onClick={handleSetPreferredImage} > - <BackgroundImagePlaceholderDiv - image={item} - contain={tabType === 'Logos'} - className={cx( - 'rounded-lg drop-shadow-md transition-transform outline grow', - item === selectedImage - ? 'outline-panel-text-important outline-4' - : 'outline-2 outline-panel-border', - sizeMap[tabType].image, - )} - linkToImage - zoomOnHover - > - {item.Preferred && ( - <div className="absolute bottom-2 mx-[5%] flex w-[90%] justify-center gap-2.5 rounded-lg bg-panel-background-overlay py-2 text-sm font-semibold text-panel-text opacity-100 transition-opacity group-hover:opacity-0"> - <Icon path={mdiStarCircleOutline} size={1} /> - Preferred - </div> - )} - </BackgroundImagePlaceholderDiv> + {`Set As Preferred ${tabType.slice(0, -1)}`} + </Button> + </ShokoPanel> + </div> + <div className="flex grow flex-col gap-y-6"> + <div className="flex h-[6.125rem] items-center justify-between rounded-lg border border-panel-border bg-panel-background-transparent p-6"> + <div className="text-xl font-semibold"> + Images | + <span className="text-panel-text-important">{images?.[tabType]?.length ?? '-'}</span> + + {tabType} + Listed </div> - ))} + <MultiStateButton activeState={tabType} onStateChange={handleTabChange} states={tabStates} /> + </div> + <div + className={cx( + sizeMap[tabType].grid, + 'grid gap-6 rounded-lg border border-panel-border bg-panel-background-transparent p-6', + )} + > + {images?.[tabType].map(item => ( + <div + onClick={() => handleSelectionChange(item)} + key={`${item.Source}-${item.Type}-${item.ID}`} + className="group flex cursor-pointer items-center justify-between" + > + <BackgroundImagePlaceholderDiv + image={item} + contain={tabType === 'Logos'} + className={cx( + 'rounded-lg drop-shadow-md transition-transform outline grow', + item === selectedImage + ? 'outline-panel-text-important outline-4' + : 'outline-2 outline-panel-border', + sizeMap[tabType].image, + )} + linkToImage + zoomOnHover + > + {item.Preferred && ( + <div className="absolute bottom-2 mx-[5%] flex w-[90%] justify-center gap-2.5 rounded-lg bg-panel-background-overlay py-2 text-sm font-semibold text-panel-text opacity-100 transition-opacity group-hover:opacity-0"> + <Icon path={mdiStarCircleOutline} size={1} /> + Preferred + </div> + )} + </BackgroundImagePlaceholderDiv> + </div> + ))} + </div> </div> </div> - </div> + </> ); }; diff --git a/src/pages/collection/series/SeriesOverview.tsx b/src/pages/collection/series/SeriesOverview.tsx index faedebbba..065372d27 100644 --- a/src/pages/collection/series/SeriesOverview.tsx +++ b/src/pages/collection/series/SeriesOverview.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState } from 'react'; -import { useParams } from 'react-router'; +import { useOutletContext } from 'react-router-dom'; import { mdiEarth, mdiOpenInNew } from '@mdi/js'; import { Icon } from '@mdi/react'; import cx from 'classnames'; -import { flatMap, get, map, round, toNumber } from 'lodash'; +import { flatMap, get, map, round } from 'lodash'; import CharacterImage from '@/components/CharacterImage'; import EpisodeSummary from '@/components/Collection/Episode/EpisodeSummary'; @@ -15,11 +15,11 @@ import { useRelatedAnimeQuery, useSeriesCastQuery, useSeriesNextUpQuery, - useSeriesQuery, useSimilarAnimeQuery, } from '@/core/react-query/series/queries'; import useEventCallback from '@/hooks/useEventCallback'; +import type { SeriesContextType } from '@/components/Collection/constants'; import type { ImageType } from '@/core/types/api/common'; import type { SeriesCast } from '@/core/types/api/series'; @@ -27,20 +27,15 @@ import type { SeriesCast } from '@/core/types/api/series'; const MetadataLinks = ['AniDB', 'TMDB', 'TraktTv'] as const; const SeriesOverview = () => { - const { seriesId } = useParams(); + const { series } = useOutletContext<SeriesContextType>(); - const { data: series, ...seriesQuery } = useSeriesQuery( - toNumber(seriesId!), - { includeDataFrom: ['AniDB', 'TMDB'] }, - !!seriesId, - ); - const nextUpEpisodeQuery = useSeriesNextUpQuery(toNumber(seriesId!), { + const nextUpEpisodeQuery = useSeriesNextUpQuery(series.IDs.ID, { includeDataFrom: ['AniDB'], includeMissing: false, onlyUnwatched: false, - }, !!seriesId); - const relatedAnimeQuery = useRelatedAnimeQuery(toNumber(seriesId!), !!seriesId); - const similarAnimeQuery = useSimilarAnimeQuery(toNumber(seriesId!), !!seriesId); + }); + const relatedAnimeQuery = useRelatedAnimeQuery(series.IDs.ID); + const similarAnimeQuery = useSimilarAnimeQuery(series.IDs.ID); const tabStates = [ { label: 'Metadata Sites', value: 'metadata' }, @@ -54,7 +49,7 @@ const SeriesOverview = () => { const relatedAnime = useMemo(() => relatedAnimeQuery?.data ?? [], [relatedAnimeQuery.data]); const similarAnime = useMemo(() => similarAnimeQuery?.data ?? [], [similarAnimeQuery.data]); - const cast = useSeriesCastQuery(toNumber(seriesId!), !!seriesId).data; + const cast = useSeriesCastQuery(series.IDs.ID).data; const getThumbnailUrl = (item: SeriesCast, mode: string) => { const thumbnail = get<SeriesCast, string, ImageType | null>(item, `${mode}.Image`, null); @@ -64,6 +59,7 @@ const SeriesOverview = () => { return ( <> + <title>{`${series.Name} > Overview | Shoko`}</title> <div className="flex gap-x-6"> <div className="flex w-full gap-x-6"> <ShokoPanel @@ -74,7 +70,6 @@ const SeriesOverview = () => { options={ <MultiStateButton states={tabStates} activeState={currentTab} onStateChange={handleTabStateChange} /> } - isFetching={seriesQuery.isFetching} > {series && currentTab === 'metadata' && ( <div @@ -157,7 +152,7 @@ const SeriesOverview = () => { isFetching={nextUpEpisodeQuery.isFetching} > {nextUpEpisodeQuery.isSuccess && nextUpEpisodeQuery.data - ? <EpisodeSummary seriesId={toNumber(seriesId)} episode={nextUpEpisodeQuery.data} nextUp /> + ? <EpisodeSummary seriesId={series.IDs.ID} episode={nextUpEpisodeQuery.data} nextUp /> : ( <div className="flex grow items-center justify-center font-semibold"> All available episodes have already been watched diff --git a/src/pages/collection/series/SeriesTags.tsx b/src/pages/collection/series/SeriesTags.tsx index 0cc0fb6d8..3d67fecb9 100644 --- a/src/pages/collection/series/SeriesTags.tsx +++ b/src/pages/collection/series/SeriesTags.tsx @@ -1,8 +1,7 @@ import React, { useMemo, useState } from 'react'; -import { useParams } from 'react-router'; +import { useOutletContext } from 'react-router-dom'; import { mdiLoading, mdiTagTextOutline } from '@mdi/js'; import { Icon } from '@mdi/react'; -import { toNumber } from 'lodash'; import { useDebounceValue, useToggle } from 'usehooks-ts'; import CleanDescription from '@/components/Collection/CleanDescription'; @@ -11,6 +10,7 @@ import TagsSearchAndFilterPanel from '@/components/Collection/Tags/TagsSearchAnd import { useSeriesTagsQuery } from '@/core/react-query/series/queries'; import useEventCallback from '@/hooks/useEventCallback'; +import type { SeriesContextType } from '@/components/Collection/constants'; import type { TagType } from '@/core/types/api/tags'; const cleanString = (input = '') => input.replaceAll(' ', '').toLowerCase(); @@ -47,7 +47,7 @@ const SingleTag = React.memo(({ onTagExpand, tag }: { tag: TagType, onTagExpand: }); const SeriesTags = () => { - const { seriesId } = useParams(); + const { series } = useOutletContext<SeriesContextType>(); const [selectedTag, setSelectedTag] = useState<TagType>(); const [showTagModal, toggleShowTagModal] = useToggle(false); @@ -83,11 +83,7 @@ const SeriesTags = () => { } }); - const { data: tagsQueryData, isLoading, isSuccess } = useSeriesTagsQuery( - toNumber(seriesId!), - { filter: 1 }, - !!seriesId, - ); + const { data: tagsQueryData, isLoading, isSuccess } = useSeriesTagsQuery(series.IDs.ID, { filter: 1 }); const filteredTags = useMemo( () => @@ -133,39 +129,40 @@ const SeriesTags = () => { }); const clearTagSelection = useEventCallback(toggleShowTagModal); - if (!seriesId) return null; - return ( - <div className="flex w-full gap-x-6"> - <TagsSearchAndFilterPanel - seriesId={toNumber(seriesId)} - search={search} - tagSourceFilter={tagSourceFilter} - showSpoilers={showSpoilers} - sort={sort} - handleInputChange={handleInputChange} - toggleSort={toggleSort} - /> - <div className="flex w-full flex-col gap-y-6"> - {header} - <div className="flex grow flex-col gap-y-6"> - {isLoading - ? ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} spin size={1} /> - </div> - ) - : ( - <div className="grid grid-cols-3 gap-4 2xl:gap-6"> - {filteredTags?.map(tag => ( - <SingleTag key={`${tag.Source}-${tag.ID}`} tag={tag} onTagExpand={onTagSelection} /> - ))} - </div> - )} + <> + <title>{`${series.Name} > Tags | Shoko`}</title> + <div className="flex w-full gap-x-6"> + <TagsSearchAndFilterPanel + seriesId={series.IDs.ID} + search={search} + tagSourceFilter={tagSourceFilter} + showSpoilers={showSpoilers} + sort={sort} + handleInputChange={handleInputChange} + toggleSort={toggleSort} + /> + <div className="flex w-full flex-col gap-y-6"> + {header} + <div className="flex grow flex-col gap-y-6"> + {isLoading + ? ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} spin size={1} /> + </div> + ) + : ( + <div className="grid grid-cols-3 gap-4 2xl:gap-6"> + {filteredTags?.map(tag => ( + <SingleTag key={`${tag.Source}-${tag.ID}`} tag={tag} onTagExpand={onTagSelection} /> + ))} + </div> + )} + </div> </div> + <TagDetailsModal show={showTagModal} tag={selectedTag} onClose={clearTagSelection} /> </div> - <TagDetailsModal show={showTagModal} tag={selectedTag} onClose={clearTagSelection} /> - </div> + </> ); }; diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index 8fa0267a9..e1c6ba608 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -150,6 +150,7 @@ function DashboardPage() { return ( <> + <title>Dashboard | Shoko</title> <ResponsiveGridLayout layouts={currentLayout} breakpoints={{ lg: 1024, md: 768, sm: 640 }} // These match tailwind breakpoints (for consistency) diff --git a/src/pages/logs/LogsPage.tsx b/src/pages/logs/LogsPage.tsx index 7a97dacfb..c78eaf286 100644 --- a/src/pages/logs/LogsPage.tsx +++ b/src/pages/logs/LogsPage.tsx @@ -50,76 +50,79 @@ const LogsPage = () => { }; return ( - <div className="flex grow flex-col gap-y-6"> - <div className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-background p-6"> - <div className="text-xl font-semibold">Logs</div> - <div className="flex gap-x-2"> - {/* TODO: Disabled until functionality is implemented */} - {/* <Input */} - {/* id="search" */} - {/* onChange={event => setSearch(event.target.value)} */} - {/* type="text" */} - {/* value={search} */} - {/* placeholder="Search Logs..." */} - {/* startIcon={mdiMagnify} */} - {/* className="w-80" */} - {/* disabled */} - {/* /> */} - {/* <IconButton icon={mdiFilterOutline} buttonType="secondary" buttonSize="normal" tooltip="Filter"/> */} - {/* <IconButton icon={mdiCogOutline} buttonType="secondary" buttonSize="normal" tooltip="Settings"/> */} - <IconButton - icon={mdiArrowVerticalLock} - buttonType="secondary" - buttonSize="normal" - className={cx(scrollToBottom ? 'text-panel-text-primary' : '!text-panel-text')} - onClick={() => setScrollToBottom(prev => !prev)} - tooltip={`${scrollToBottom ? 'Disable' : 'Enable'} scroll to bottom`} - /> + <> + <title>Logs | Shoko</title> + <div className="flex grow flex-col gap-y-6"> + <div className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-background p-6"> + <div className="text-xl font-semibold">Logs</div> + <div className="flex gap-x-2"> + {/* TODO: Disabled until functionality is implemented */} + {/* <Input */} + {/* id="search" */} + {/* onChange={event => setSearch(event.target.value)} */} + {/* type="text" */} + {/* value={search} */} + {/* placeholder="Search Logs..." */} + {/* startIcon={mdiMagnify} */} + {/* className="w-80" */} + {/* disabled */} + {/* /> */} + {/* <IconButton icon={mdiFilterOutline} buttonType="secondary" buttonSize="normal" tooltip="Filter"/> */} + {/* <IconButton icon={mdiCogOutline} buttonType="secondary" buttonSize="normal" tooltip="Settings"/> */} + <IconButton + icon={mdiArrowVerticalLock} + buttonType="secondary" + buttonSize="normal" + className={cx(scrollToBottom ? 'text-panel-text-primary' : '!text-panel-text')} + onClick={() => setScrollToBottom(prev => !prev)} + tooltip={`${scrollToBottom ? 'Disable' : 'Enable'} scroll to bottom`} + /> + </div> </div> - </div> - <div className="flex grow rounded-lg border border-panel-border bg-panel-background p-6"> - <div - className="w-full overflow-y-auto rounded-lg border-16 border-panel-input bg-panel-input contain-strict" - ref={parentRef} - onScroll={handleScroll} - > - {logLines.length === 0 - ? ( - <div className="flex h-full items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - ) - : ( - <div - className="relative w-full" - style={{ height: rowVirtualizer.getTotalSize() }} - > + <div className="flex grow rounded-lg border border-panel-border bg-panel-background p-6"> + <div + className="w-full overflow-y-auto rounded-lg border-16 border-panel-input bg-panel-input contain-strict" + ref={parentRef} + onScroll={handleScroll} + > + {logLines.length === 0 + ? ( + <div className="flex h-full items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + ) + : ( <div - className="absolute left-4 top-0 w-[95%]" - style={{ transform: `translateY(${virtualItems[0]?.start ?? 0}px)` }} + className="relative w-full" + style={{ height: rowVirtualizer.getTotalSize() }} > - {virtualItems.map((virtualRow) => { - const row = logLines[virtualRow.index]; - return ( - <div - className="flex gap-x-6 pt-2" - key={virtualRow.key} - data-index={virtualRow.index} - ref={rowVirtualizer.measureElement} - > - <div className="w-44 shrink-0 opacity-65">{row.TimeStamp}</div> - <div className="w-[2.8rem] shrink-0">{row.Level}</div> - <div className="break-all">{row.Message}</div> - </div> - ); - })} + <div + className="absolute left-4 top-0 w-[95%]" + style={{ transform: `translateY(${virtualItems[0]?.start ?? 0}px)` }} + > + {virtualItems.map((virtualRow) => { + const row = logLines[virtualRow.index]; + return ( + <div + className="flex gap-x-6 pt-2" + key={virtualRow.key} + data-index={virtualRow.index} + ref={rowVirtualizer.measureElement} + > + <div className="w-44 shrink-0 opacity-65">{row.TimeStamp}</div> + <div className="w-[2.8rem] shrink-0">{row.Level}</div> + <div className="break-all">{row.Message}</div> + </div> + ); + })} + </div> </div> - </div> - )} + )} + </div> </div> </div> - </div> + </> ); }; diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index eeee929f4..a23a58ca5 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -36,7 +36,7 @@ function SettingsPage() { const { pathname } = useLocation(); - const toastId = useRef<number | string>(); + const toastId = useRef<number | string>(undefined); const settingsQuery = useSettingsQuery(); const settings = settingsQuery.data; diff --git a/src/pages/settings/tabs/AniDBSettings.tsx b/src/pages/settings/tabs/AniDBSettings.tsx index 3ae1292e7..5da966364 100644 --- a/src/pages/settings/tabs/AniDBSettings.tsx +++ b/src/pages/settings/tabs/AniDBSettings.tsx @@ -53,6 +53,7 @@ function AniDBSettings() { return ( <> + <title>Settings > AniDB | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">AniDB</div> <div> diff --git a/src/pages/settings/tabs/ApiKeys.tsx b/src/pages/settings/tabs/ApiKeys.tsx index 713931924..5934ac460 100644 --- a/src/pages/settings/tabs/ApiKeys.tsx +++ b/src/pages/settings/tabs/ApiKeys.tsx @@ -97,6 +97,7 @@ const ApiKeys = () => { return ( <> + <title>Settings > API Keys | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">API Keys</div> <div> diff --git a/src/pages/settings/tabs/CollectionSettings.tsx b/src/pages/settings/tabs/CollectionSettings.tsx index a04908886..968387724 100644 --- a/src/pages/settings/tabs/CollectionSettings.tsx +++ b/src/pages/settings/tabs/CollectionSettings.tsx @@ -150,6 +150,7 @@ const CollectionSettings = () => { return ( <> + <title>Settings > Collection | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">Collection</div> <div> diff --git a/src/pages/settings/tabs/GeneralSettings.tsx b/src/pages/settings/tabs/GeneralSettings.tsx index 7dc0a4703..34cde95ea 100644 --- a/src/pages/settings/tabs/GeneralSettings.tsx +++ b/src/pages/settings/tabs/GeneralSettings.tsx @@ -82,6 +82,7 @@ function GeneralSettings() { return ( <> + <title>Settings > General | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">General</div> <div> diff --git a/src/pages/settings/tabs/ImportSettings.tsx b/src/pages/settings/tabs/ImportSettings.tsx index 71528744f..7b1a62210 100644 --- a/src/pages/settings/tabs/ImportSettings.tsx +++ b/src/pages/settings/tabs/ImportSettings.tsx @@ -34,6 +34,7 @@ function ImportSettings() { return ( <> + <title>Settings > Import | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">Import</div> <div> diff --git a/src/pages/settings/tabs/IntegrationsSettings.tsx b/src/pages/settings/tabs/IntegrationsSettings.tsx index 2e792bf21..45b56212c 100644 --- a/src/pages/settings/tabs/IntegrationsSettings.tsx +++ b/src/pages/settings/tabs/IntegrationsSettings.tsx @@ -5,6 +5,7 @@ import TraktSettings from '@/components/Settings/MetadataSitesSettings/TraktSett const IntegrationsSettings = () => ( <> + <title>Settings > Integrations | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">Integrations</div> <div> diff --git a/src/pages/settings/tabs/MetadataSitesSettings.tsx b/src/pages/settings/tabs/MetadataSitesSettings.tsx index fc61c768a..1a990fa42 100644 --- a/src/pages/settings/tabs/MetadataSitesSettings.tsx +++ b/src/pages/settings/tabs/MetadataSitesSettings.tsx @@ -9,6 +9,7 @@ function MetadataSitesSettings() { return ( <> + <title>Settings > Metadata Sites | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">Metadata Sites</div> <div> diff --git a/src/pages/settings/tabs/UserManagementSettings.tsx b/src/pages/settings/tabs/UserManagementSettings.tsx index 24a4515bc..b6a7d47d0 100644 --- a/src/pages/settings/tabs/UserManagementSettings.tsx +++ b/src/pages/settings/tabs/UserManagementSettings.tsx @@ -174,6 +174,7 @@ function UserManagementSettings() { return ( <> + <title>Settings > User Management | Shoko</title> <div className="flex flex-col gap-y-1"> <div className="text-xl font-semibold">User Management</div> <div> diff --git a/src/pages/utilities/FileSearch.tsx b/src/pages/utilities/FileSearch.tsx index d38bbe0dc..0d9c6fb42 100644 --- a/src/pages/utilities/FileSearch.tsx +++ b/src/pages/utilities/FileSearch.tsx @@ -374,81 +374,84 @@ const FileSearch = () => { const selectedId = useMemo(() => fileSearchSelectedRows[viewIndex]?.ID, [fileSearchSelectedRows, viewIndex]); return ( - <div className="flex grow flex-col gap-y-6"> - <ShokoPanel title="File Search" options={<ItemCount count={fileCount} selected={selectedRows?.length} />}> - <div className="flex items-center gap-x-3"> - <Input - type="text" - placeholder="Search..." - startIcon={mdiMagnify} - id="search" - onChange={setSearch} - value={search} - inputClassName="px-4 py-3" - /> - <Menu - selectedRows={selectedRows} - setSelectedRows={setRowSelection} - /> - </div> - </ShokoPanel> - <div className="flex grow justify-between gap-x-6 overflow-y-auto contain-strict"> - <div className="flex w-full rounded-lg border border-panel-border bg-panel-background p-6 lg:max-w-[75%]"> - {filesQuery.isPending && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - )} - - {!filesQuery.isPending && fileCount === 0 && ( - <div className="flex grow items-center justify-center font-semibold">No search results!</div> - )} - - {filesQuery.isSuccess && fileCount > 0 && ( - <UtilitiesTable - count={fileCount} - fetchNextPage={filesQuery.fetchNextPage} - handleRowSelect={handleRowSelect} - columns={staticColumns} - isFetchingNextPage={filesQuery.isFetchingNextPage} - rows={files} - rowSelection={rowSelection} + <> + <title>Utilities > File Search | Shoko</title> + <div className="flex grow flex-col gap-y-6"> + <ShokoPanel title="File Search" options={<ItemCount count={fileCount} selected={selectedRows?.length} />}> + <div className="flex items-center gap-x-3"> + <Input + type="text" + placeholder="Search..." + startIcon={mdiMagnify} + id="search" + onChange={setSearch} + value={search} + inputClassName="px-4 py-3" + /> + <Menu + selectedRows={selectedRows} setSelectedRows={setRowSelection} - setSortCriteria={setSortCriteria} - sortCriteria={sortCriteria} /> - )} - </div> - <div className="flex w-full flex-col lg:max-w-[25%]"> - {selectedRows?.length > 0 && ( - <div className="flex size-full flex-col overflow-y-auto overflow-x-hidden rounded-lg border border-panel-border bg-panel-background p-6"> - <div className="flex w-full grow flex-col gap-y-6 overflow-y-auto pr-4"> - <FilesSummary title="Selected Summary" items={selectedRows} /> - <div className="flex w-full text-xl font-semibold"> - <div className="flex w-full justify-between"> - <span className="grow">Selected File</span> - <div className={cx('flex', selectedRows.length <= 1 ? 'hidden' : '')}> - <Button buttonType="secondary" onClick={onPrevView}> - <Icon className="text-panel-icon-action" path={mdiChevronLeft} size={1} /> - </Button> - <Button buttonType="secondary" onClick={onNextView}> - <Icon className="text-panel-icon-action" path={mdiChevronRight} size={1} /> - </Button> + </div> + </ShokoPanel> + <div className="flex grow justify-between gap-x-6 overflow-y-auto contain-strict"> + <div className="flex w-full rounded-lg border border-panel-border bg-panel-background p-6 lg:max-w-[75%]"> + {filesQuery.isPending && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + )} + + {!filesQuery.isPending && fileCount === 0 && ( + <div className="flex grow items-center justify-center font-semibold">No search results!</div> + )} + + {filesQuery.isSuccess && fileCount > 0 && ( + <UtilitiesTable + count={fileCount} + fetchNextPage={filesQuery.fetchNextPage} + handleRowSelect={handleRowSelect} + columns={staticColumns} + isFetchingNextPage={filesQuery.isFetchingNextPage} + rows={files} + rowSelection={rowSelection} + setSelectedRows={setRowSelection} + setSortCriteria={setSortCriteria} + sortCriteria={sortCriteria} + /> + )} + </div> + <div className="flex w-full flex-col lg:max-w-[25%]"> + {selectedRows?.length > 0 && ( + <div className="flex size-full flex-col overflow-y-auto overflow-x-hidden rounded-lg border border-panel-border bg-panel-background p-6"> + <div className="flex w-full grow flex-col gap-y-6 overflow-y-auto pr-4"> + <FilesSummary title="Selected Summary" items={selectedRows} /> + <div className="flex w-full text-xl font-semibold"> + <div className="flex w-full justify-between"> + <span className="grow">Selected File</span> + <div className={cx('flex', selectedRows.length <= 1 ? 'hidden' : '')}> + <Button buttonType="secondary" onClick={onPrevView}> + <Icon className="text-panel-icon-action" path={mdiChevronLeft} size={1} /> + </Button> + <Button buttonType="secondary" onClick={onNextView}> + <Icon className="text-panel-icon-action" path={mdiChevronRight} size={1} /> + </Button> + </div> </div> </div> + <FileDetails fileId={selectedId} /> </div> - <FileDetails fileId={selectedId} /> </div> - </div> - )} - {!selectedRows?.length && ( - <div className="flex size-full flex-col rounded-lg border border-panel-border bg-panel-background p-6"> - <div className="flex grow items-center justify-center font-semibold">Select File To Populate</div> - </div> - )} + )} + {!selectedRows?.length && ( + <div className="flex size-full flex-col rounded-lg border border-panel-border bg-panel-background p-6"> + <div className="flex grow items-center justify-center font-semibold">Select File To Populate</div> + </div> + )} + </div> </div> </div> - </div> + </> ); }; export default FileSearch; diff --git a/src/pages/utilities/ReleaseManagement.tsx b/src/pages/utilities/ReleaseManagement.tsx new file mode 100644 index 000000000..9e5b9c3f6 --- /dev/null +++ b/src/pages/utilities/ReleaseManagement.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useState } from 'react'; +import { mdiCloseCircleOutline, mdiFileDocumentMultipleOutline, mdiRefresh, mdiSelectMultiple } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; +import { map, toNumber } from 'lodash'; +import { useToggle } from 'usehooks-ts'; + +import Button from '@/components/Input/Button'; +import Checkbox from '@/components/Input/Checkbox'; +import ShokoPanel from '@/components/Panels/ShokoPanel'; +import toast from '@/components/Toast'; +import TransitionDiv from '@/components/TransitionDiv'; +import ItemCount from '@/components/Utilities/ItemCount'; +import Episode from '@/components/Utilities/ReleaseManagement/Episode'; +import QuickSelectModal from '@/components/Utilities/ReleaseManagement/QuickSelectModal'; +import SeriesList from '@/components/Utilities/ReleaseManagement/SeriesList'; +import Title from '@/components/Utilities/ReleaseManagement/Title'; +import MenuButton from '@/components/Utilities/Unrecognized/MenuButton'; +import { + useDeleteFileLocationMutation, + useDeleteFileMutation, + useMarkVariationMutation, +} from '@/core/react-query/file/mutations'; +import { invalidateQueries, resetQueries } from '@/core/react-query/queryClient'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { ReleaseManagementOptionsType } from '@/components/Utilities/constants'; +import type { EpisodeType } from '@/core/types/api/episode'; +import type { AxiosResponse } from 'axios'; + +type Props = { + type: 'multiples' | 'duplicates'; +}; + +const ReleaseManagement = ({ type }: Props) => { + const [ignoreVariations, toggleIgnoreVariations] = useToggle(true); + const [onlyFinishedSeries, toggleOnlyFinishedSeries] = useToggle(false); + const [seriesCount, setSeriesCount] = useState(0); + const [selectedSeries, setSelectedSeries] = useState(0); + const [selectedEpisode, setSelectedEpisode] = useState<EpisodeType>(); + const [operationsPending, setOperationsPending] = useState(false); + const [fileOptions, setFileOptions] = useState<ReleaseManagementOptionsType>({}); + const [showQuickSelectModal, toggleShowQuickSelectModal] = useToggle(false); + + useEffect(() => () => { + setSelectedSeries(0); + }, []); + + const { mutateAsync: deleteFile } = useDeleteFileMutation(); + const { mutateAsync: markVariation } = useMarkVariationMutation(); + const { mutateAsync: deleteFileLocation } = useDeleteFileLocationMutation(); + + const handleCheckboxChange = (checkboxType: 'variations' | 'series') => { + if (checkboxType === 'variations') toggleIgnoreVariations(); + if (checkboxType === 'series') toggleOnlyFinishedSeries(); + }; + + const confirmChanges = useEventCallback(() => { + setOperationsPending(true); + + let operations: (Promise<AxiosResponse<unknown, unknown>> | null)[]; + + if (type === 'multiples') { + operations = map(fileOptions, (option, id) => { + if (!selectedEpisode) return null; + + const file = selectedEpisode.Files!.find(item => item.ID === toNumber(id))!; + if (!file) return null; + if (option === 'delete') return deleteFile({ fileId: file.ID, removeFolder: false }); + if (option === 'variation' && !file.IsVariation) return markVariation({ fileId: file.ID, variation: true }); + if (option === 'keep' && file.IsVariation) return markVariation({ fileId: file.ID, variation: false }); + return null; + }); + } else { + operations = map(fileOptions, (option, id) => { + if (!selectedEpisode || option !== 'delete') return null; + return deleteFileLocation({ locationId: toNumber(id) }); + }); + } + + Promise.all(operations) + .then(() => toast.success('Successful!')) + .catch(() => toast.error('One or more operations failed!')) + .finally(() => { + setOperationsPending(false); + resetQueries(['release-management']); + setSelectedEpisode(undefined); + }); + }); + + return ( + <> + <title>{`Utilities > ${type === 'multiples' ? 'Multiple Releases' : 'Duplicate Files'} | Shoko`}</title> + <div className="flex grow flex-col gap-y-6 overflow-y-auto"> + <ShokoPanel title={<Title />} options={<ItemCount count={seriesCount} suffix="Series" />}> + <div className="flex items-center gap-x-3"> + <div + className={cx( + 'relative box-border flex grow items-center gap-x-4 rounded-md border border-panel-border bg-panel-background-alt px-4 py-2 transition-opacity', + selectedEpisode && 'pointer-events-none opacity-65', + )} + > + <MenuButton + onClick={() => invalidateQueries(['release-management', 'series'])} + icon={mdiRefresh} + name="Refresh" + /> + + {type === 'multiples' && ( + <Checkbox + id="ignore-variations" + isChecked={ignoreVariations} + onChange={() => handleCheckboxChange('variations')} + label="Ignore Variations" + labelRight + /> + )} + + <Checkbox + id="only-finished-series" + isChecked={onlyFinishedSeries} + onChange={() => handleCheckboxChange('series')} + label="Only Finished Series" + labelRight + /> + </div> + + {/* TODO: Add support for auto-delete */} + {/* {!selectedEpisode && ( */} + {/* <Button */} + {/* buttonType="primary" */} + {/* className="flex gap-x-2.5 px-4 py-3 font-semibold" */} + {/* disabled={seriesCount === 0} */} + {/* > */} + {/* <Icon path={mdiFileDocumentMultipleOutline} size={0.8333} /> */} + {/* Auto-Delete Multiples */} + {/* </Button> */} + {/* )} */} + + {(type === 'multiples' && !selectedEpisode) && ( + <Button + buttonType="secondary" + className="flex gap-x-2.5 px-4 py-3 font-semibold" + disabled={!selectedSeries} + onClick={toggleShowQuickSelectModal} + > + <Icon path={mdiSelectMultiple} size={0.8333} /> + Quick Select + </Button> + )} + + {selectedEpisode && ( + <div className="flex items-center justify-end gap-x-3"> + <Button + buttonType="secondary" + className="flex gap-x-2.5 px-4 py-3 font-semibold" + onClick={() => setSelectedEpisode(undefined)} + > + <Icon path={mdiCloseCircleOutline} size={0.8333} /> + Cancel + </Button> + <Button + buttonType="primary" + className="flex gap-x-2.5 px-4 py-3 font-semibold" + onClick={confirmChanges} + loading={operationsPending} + > + <Icon path={mdiFileDocumentMultipleOutline} size={0.8333} /> + Confirm + </Button> + </div> + )} + </div> + </ShokoPanel> + + <div className="relative flex grow"> + <TransitionDiv show={!selectedEpisode} className="absolute flex size-full gap-x-3"> + <SeriesList + type={type} + ignoreVariations={ignoreVariations} + onlyFinishedSeries={onlyFinishedSeries} + setSelectedEpisode={setSelectedEpisode} + setSelectedSeriesId={setSelectedSeries} + setSeriesCount={setSeriesCount} + /> + </TransitionDiv> + + <TransitionDiv + show={!!selectedEpisode} + className="absolute flex size-full flex-col gap-y-6 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6" + > + <Episode + type={type} + episode={selectedEpisode} + setFileOptions={setFileOptions} + /> + </TransitionDiv> + </div> + + <QuickSelectModal + show={showQuickSelectModal} + onClose={toggleShowQuickSelectModal} + seriesId={selectedSeries} + /> + </div> + </> + ); +}; + +export default ReleaseManagement; diff --git a/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx b/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx deleted file mode 100644 index 1ce9855e7..000000000 --- a/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, { useState } from 'react'; -import { mdiCloseCircleOutline, mdiFileDocumentMultipleOutline, mdiRefresh, mdiSelectMultiple } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import cx from 'classnames'; -import { map, toNumber } from 'lodash'; -import { useToggle } from 'usehooks-ts'; - -import Button from '@/components/Input/Button'; -import Checkbox from '@/components/Input/Checkbox'; -import ShokoPanel from '@/components/Panels/ShokoPanel'; -import toast from '@/components/Toast'; -import TransitionDiv from '@/components/TransitionDiv'; -import ItemCount from '@/components/Utilities/ItemCount'; -import MultiplesUtilEpisode from '@/components/Utilities/ReleaseManagement/MultiplesUtilEpisode'; -import MultiplesUtilList from '@/components/Utilities/ReleaseManagement/MultiplesUtilList'; -import QuickSelectModal from '@/components/Utilities/ReleaseManagement/QuickSelectModal'; -import Title from '@/components/Utilities/ReleaseManagement/Title'; -import MenuButton from '@/components/Utilities/Unrecognized/MenuButton'; -import { useDeleteFileMutation, useMarkVariationMutation } from '@/core/react-query/file/mutations'; -import { invalidateQueries, resetQueries } from '@/core/react-query/queryClient'; -import useEventCallback from '@/hooks/useEventCallback'; - -import type { MultipleFileOptionsType } from '@/components/Utilities/constants'; -import type { EpisodeType } from '@/core/types/api/episode'; - -const MultiplesUtil = () => { - const [ignoreVariations, toggleIgnoreVariations] = useToggle(true); - const [onlyFinishedSeries, toggleOnlyFinishedSeries] = useToggle(false); - const [seriesCount, setSeriesCount] = useState(0); - const [selectedSeries, setSelectedSeries] = useState(0); - const [selectedEpisode, setSelectedEpisode] = useState<EpisodeType>(); - const [operationsPending, setOperationsPending] = useState(false); - const [fileOptions, setFileOptions] = useState<MultipleFileOptionsType>({}); - const [showQuickSelectModal, toggleShowQuickSelectModal] = useToggle(false); - - const { mutateAsync: deleteFile } = useDeleteFileMutation(); - const { mutateAsync: markVariation } = useMarkVariationMutation(); - - const handleCheckboxChange = (type: 'variations' | 'series') => { - if (type === 'variations') toggleIgnoreVariations(); - if (type === 'series') toggleOnlyFinishedSeries(); - }; - - const confirmChanges = useEventCallback(() => { - setOperationsPending(true); - - const operations = map(fileOptions, (option, id) => { - if (!selectedEpisode) return null; - - const file = selectedEpisode.Files!.find(item => item.ID === toNumber(id))!; - if (!file) return null; - if (option === 'delete') return deleteFile({ fileId: file.ID, removeFolder: false }); - if (option === 'variation' && !file.IsVariation) return markVariation({ fileId: file.ID, variation: true }); - if (option === 'keep' && file.IsVariation) return markVariation({ fileId: file.ID, variation: false }); - return null; - }); - - Promise.all(operations) - .then(() => toast.success('Successful!')) - .catch(() => toast.error('One or more operations failed!')) - .finally(() => { - setOperationsPending(false); - resetQueries(['release-management']); - setSelectedEpisode(undefined); - }); - }); - - return ( - <div className="flex grow flex-col gap-y-6 overflow-y-auto"> - <ShokoPanel title={<Title />} options={<ItemCount count={seriesCount} suffix="Series" />}> - <div className="flex items-center gap-x-3"> - <div - className={cx( - 'relative box-border flex grow items-center gap-x-4 rounded-md border border-panel-border bg-panel-background-alt px-4 py-2 transition-opacity', - selectedEpisode && 'pointer-events-none opacity-65', - )} - > - <MenuButton - onClick={() => invalidateQueries(['release-management', 'series'])} - icon={mdiRefresh} - name="Refresh" - /> - - <Checkbox - id="ignore-variations" - isChecked={ignoreVariations} - onChange={() => handleCheckboxChange('variations')} - label="Ignore Variations" - labelRight - /> - - <Checkbox - id="only-finished-series" - isChecked={onlyFinishedSeries} - onChange={() => handleCheckboxChange('series')} - label="Only Finished Series" - labelRight - /> - </div> - - {/* TODO: Add support for auto-delete */} - {/* {!selectedEpisode && ( */} - {/* <Button */} - {/* buttonType="primary" */} - {/* className="flex gap-x-2.5 px-4 py-3 font-semibold" */} - {/* disabled={seriesCount === 0} */} - {/* > */} - {/* <Icon path={mdiFileDocumentMultipleOutline} size={0.8333} /> */} - {/* Auto-Delete Multiples */} - {/* </Button> */} - {/* )} */} - - {!selectedEpisode && ( - <Button - buttonType="secondary" - className="flex gap-x-2.5 px-4 py-3 font-semibold" - disabled={!selectedSeries} - onClick={toggleShowQuickSelectModal} - > - <Icon path={mdiSelectMultiple} size={0.8333} /> - Quick Select - </Button> - )} - - {selectedEpisode && ( - <div className="flex items-center justify-end gap-x-3"> - <Button - buttonType="secondary" - className="flex gap-x-2.5 px-4 py-3 font-semibold" - onClick={() => setSelectedEpisode(undefined)} - > - <Icon path={mdiCloseCircleOutline} size={0.8333} /> - Cancel - </Button> - <Button - buttonType="primary" - className="flex gap-x-2.5 px-4 py-3 font-semibold" - onClick={confirmChanges} - loading={operationsPending} - > - <Icon path={mdiFileDocumentMultipleOutline} size={0.8333} /> - Confirm - </Button> - </div> - )} - </div> - </ShokoPanel> - - <div className="relative flex grow"> - <TransitionDiv show={!selectedEpisode} className="absolute flex size-full gap-x-3"> - <MultiplesUtilList - ignoreVariations={ignoreVariations} - onlyFinishedSeries={onlyFinishedSeries} - setSelectedEpisode={setSelectedEpisode} - setSelectedSeriesId={setSelectedSeries} - setSeriesCount={setSeriesCount} - /> - </TransitionDiv> - - <TransitionDiv - show={!!selectedEpisode} - className="absolute flex size-full flex-col gap-y-6 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6" - > - <MultiplesUtilEpisode - episode={selectedEpisode} - setFileOptions={setFileOptions} - /> - </TransitionDiv> - </div> - - <QuickSelectModal - show={showQuickSelectModal} - onClose={toggleShowQuickSelectModal} - seriesId={selectedSeries} - /> - </div> - ); -}; - -export default MultiplesUtil; diff --git a/src/pages/utilities/Renamer.tsx b/src/pages/utilities/Renamer.tsx index 6cd6fa6d3..7b6f69269 100644 --- a/src/pages/utilities/Renamer.tsx +++ b/src/pages/utilities/Renamer.tsx @@ -497,184 +497,187 @@ const Renamer = () => { }, [addedFiles.length, configEdited, moveFiles, relocatePending, renameFiles]); return ( - <div className="flex grow flex-col gap-y-3"> - <ShokoPanel title="File Rename"> - <div className="flex items-center gap-x-3"> - <Menu - selectedRows={selectedRows} - moveFiles={moveFiles} - renameFiles={renameFiles} - toggleMoveFiles={toggleMoveFiles} - toggleRenameFiles={toggleRenameFiles} - disable={relocatePending} - /> - <div className="flex gap-x-3"> - <Button - buttonType="secondary" - buttonSize="normal" - className="flex h-13 items-center" - onClick={toggleSettings} - disabled={!renamerConfigsQuery.isSuccess} - > - <Icon path={mdiCogOutline} size={1} /> - </Button> - <Button - buttonType="secondary" - buttonSize="normal" - className="flex h-13 items-center" - onClick={toggleAddFilesModal} - disabled={relocatePending} - > - Add Files - </Button> - <Button - buttonType="primary" - buttonSize="normal" - className="flex h-13 flex-wrap items-center gap-x-2" - onClick={handleRename} - loading={relocatePending} - disabled={renameDisabled} - tooltip={renameDisabledReason} - > - <Icon path={mdiFileDocumentEditOutline} size={1} /> - Rename Files - </Button> + <> + <title>Utilities > File Rename | Shoko</title> + <div className="flex grow flex-col gap-y-3"> + <ShokoPanel title="File Rename"> + <div className="flex items-center gap-x-3"> + <Menu + selectedRows={selectedRows} + moveFiles={moveFiles} + renameFiles={renameFiles} + toggleMoveFiles={toggleMoveFiles} + toggleRenameFiles={toggleRenameFiles} + disable={relocatePending} + /> + <div className="flex gap-x-3"> + <Button + buttonType="secondary" + buttonSize="normal" + className="flex h-13 items-center" + onClick={toggleSettings} + disabled={!renamerConfigsQuery.isSuccess} + > + <Icon path={mdiCogOutline} size={1} /> + </Button> + <Button + buttonType="secondary" + buttonSize="normal" + className="flex h-13 items-center" + onClick={toggleAddFilesModal} + disabled={relocatePending} + > + Add Files + </Button> + <Button + buttonType="primary" + buttonSize="normal" + className="flex h-13 flex-wrap items-center gap-x-2" + onClick={handleRename} + loading={relocatePending} + disabled={renameDisabled} + tooltip={renameDisabledReason} + > + <Icon path={mdiFileDocumentEditOutline} size={1} /> + Rename Files + </Button> + </div> + <AddFilesModal show={showAddFilesModal} onClose={toggleAddFilesModal} /> </div> - <AddFilesModal show={showAddFilesModal} onClose={toggleAddFilesModal} /> - </div> - </ShokoPanel> - - <AnimateHeight height={showSettings ? 'auto' : 0}> - <div className={cx('my-3 flex !h-[32rem] gap-x-6', relocatePending && 'opacity-65 pointer-events-none')}> - {renamerConfigsQuery.isSuccess && ( - <> - <div className="flex w-1/3 flex-col gap-y-6"> - <ShokoPanel title="Renamer Selection" contentClassName="gap-y-5" fullHeight={!renamerSettingsExist}> - <Select - label="Config" - id="renamer-config" - value={selectedConfig.Name} - onChange={event => changeSelectedConfig(event.target.value)} - > - {renamerConfigsQuery.data.map(renamerConfig => ( - <ConfigOption config={renamerConfig} key={renamerConfig.Name} /> - ))} - - {renamerConfigsQuery.data.length === 0 && ( - <option key="na" value="na"> - No renamer found! - </option> - )} - </Select> - <div className="flex justify-end gap-x-3 font-semibold"> - <Button - onClick={handleSetAsDefault} - buttonType="secondary" - buttonSize="normal" - loading={settingsPatchPending} - disabled={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) - || settingsPatchPending} - tooltip={selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer - ? 'Already set as default!' - : ''} - > - Set as default - </Button> - <Button - onClick={handleDeleteConfig} - buttonType="danger" - buttonSize="normal" - loading={deletePending} - disabled={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) || deletePending} - tooltip={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) - ? 'Cannot delete default config!' - : ''} - > - Delete - </Button> - <Button - onClick={() => openConfigModal(true)} - buttonType="secondary" - buttonSize="normal" - > - Rename - </Button> - <Button - onClick={() => openConfigModal(false)} - buttonType="secondary" - buttonSize="normal" + </ShokoPanel> + + <AnimateHeight height={showSettings ? 'auto' : 0}> + <div className={cx('my-3 flex !h-[32rem] gap-x-6', relocatePending && 'opacity-65 pointer-events-none')}> + {renamerConfigsQuery.isSuccess && ( + <> + <div className="flex w-1/3 flex-col gap-y-6"> + <ShokoPanel title="Renamer Selection" contentClassName="gap-y-5" fullHeight={!renamerSettingsExist}> + <Select + label="Config" + id="renamer-config" + value={selectedConfig.Name} + onChange={event => changeSelectedConfig(event.target.value)} > - New - </Button> - <Button - onClick={handleSaveConfig} - buttonType="primary" - buttonSize="normal" - loading={savePending} - disabled={savePending || !renamer?.DefaultSettings} - tooltip={!renamer?.DefaultSettings ? 'Renamer does not have any settings to save.' : ''} - > - Save - </Button> - <ConfigModal - show={showConfigModal} - onClose={toggleConfigModal} - rename={configModelRename} - config={selectedConfig} - changeSelectedConfig={changeSelectedConfig} + {renamerConfigsQuery.data.map(renamerConfig => ( + <ConfigOption config={renamerConfig} key={renamerConfig.Name} /> + ))} + + {renamerConfigsQuery.data.length === 0 && ( + <option key="na" value="na"> + No renamer found! + </option> + )} + </Select> + <div className="flex justify-end gap-x-3 font-semibold"> + <Button + onClick={handleSetAsDefault} + buttonType="secondary" + buttonSize="normal" + loading={settingsPatchPending} + disabled={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) + || settingsPatchPending} + tooltip={selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer + ? 'Already set as default!' + : ''} + > + Set as default + </Button> + <Button + onClick={handleDeleteConfig} + buttonType="danger" + buttonSize="normal" + loading={deletePending} + disabled={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) || deletePending} + tooltip={(selectedConfig.Name === settings.Plugins.Renamer.DefaultRenamer) + ? 'Cannot delete default config!' + : ''} + > + Delete + </Button> + <Button + onClick={() => openConfigModal(true)} + buttonType="secondary" + buttonSize="normal" + > + Rename + </Button> + <Button + onClick={() => openConfigModal(false)} + buttonType="secondary" + buttonSize="normal" + > + New + </Button> + <Button + onClick={handleSaveConfig} + buttonType="primary" + buttonSize="normal" + loading={savePending} + disabled={savePending || !renamer?.DefaultSettings} + tooltip={!renamer?.DefaultSettings ? 'Renamer does not have any settings to save.' : ''} + > + Save + </Button> + <ConfigModal + show={showConfigModal} + onClose={toggleConfigModal} + rename={configModelRename} + config={selectedConfig} + changeSelectedConfig={changeSelectedConfig} + /> + </div> + </ShokoPanel> + + {renamerSettingsExist && ( + <ShokoPanel title="Selected Renamer Config"> + {/* TODO: Maybe a todo... The transition div for checkbox is buggy when AnimateHeight is used. */} + {/* It doesn't appear before a click event when height is changed. Adding showSetting force re-renders it. */} + {newConfig && renamer?.Settings && showSettings && ( + <RenamerSettings + newConfig={newConfig} + setNewConfig={setNewConfig} + settingsModel={renamer.Settings} + /> + )} + </ShokoPanel> + )} + </div> + + <ShokoPanel title="Selected Renamer Script" className="w-2/3" disableOverflow> + {newConfig && renamer?.Settings && ( + <RenamerScript + newConfig={newConfig} + setNewConfig={setNewConfig} + settingsModel={renamer.Settings} /> - </div> + )} </ShokoPanel> + </> + )} + </div> + </AnimateHeight> - {renamerSettingsExist && ( - <ShokoPanel title="Selected Renamer Config"> - {/* TODO: Maybe a todo... The transition div for checkbox is buggy when AnimateHeight is used. */} - {/* It doesn't appear before a click event when height is changed. Adding showSetting force re-renders it. */} - {newConfig && renamer?.Settings && showSettings && ( - <RenamerSettings - newConfig={newConfig} - setNewConfig={setNewConfig} - settingsModel={renamer.Settings} - /> - )} - </ShokoPanel> - )} - </div> - - <ShokoPanel title="Selected Renamer Script" className="w-2/3" disableOverflow> - {newConfig && renamer?.Settings && ( - <RenamerScript - newConfig={newConfig} - setNewConfig={setNewConfig} - settingsModel={renamer.Settings} - /> - )} - </ShokoPanel> - </> + <ShokoPanel title="Renamer Preview" className="min-h-[40rem] grow"> + {addedFiles.length === 0 && ( + <div className="flex grow items-center justify-center font-semibold">No files selected!</div> )} - </div> - </AnimateHeight> - - <ShokoPanel title="Renamer Preview" className="min-h-[40rem] grow"> - {addedFiles.length === 0 && ( - <div className="flex grow items-center justify-center font-semibold">No files selected!</div> - )} - - {addedFiles.length > 0 && ( - <UtilitiesTable - columns={columns} - count={addedFiles.length} - handleRowSelect={handleRowSelect} - rows={addedFiles} - rowSelection={rowSelection} - setSelectedRows={setRowSelection} - fetchNextPreviewPage={fetchPreviewPage} - isFetchingNextPage={previewPending} - isRenamer - /> - )} - </ShokoPanel> - </div> + + {addedFiles.length > 0 && ( + <UtilitiesTable + columns={columns} + count={addedFiles.length} + handleRowSelect={handleRowSelect} + rows={addedFiles} + rowSelection={rowSelection} + setSelectedRows={setRowSelection} + fetchNextPreviewPage={fetchPreviewPage} + isFetchingNextPage={previewPending} + isRenamer + /> + )} + </ShokoPanel> + </div> + </> ); }; diff --git a/src/pages/utilities/SeriesWithoutFilesUtility.tsx b/src/pages/utilities/SeriesWithoutFilesUtility.tsx index 259c718de..69c262bf7 100644 --- a/src/pages/utilities/SeriesWithoutFilesUtility.tsx +++ b/src/pages/utilities/SeriesWithoutFilesUtility.tsx @@ -158,53 +158,56 @@ function SeriesWithoutFilesUtility() { } = useRowSelection<SeriesType>(series); return ( - <div className="flex grow flex-col gap-y-6"> - <div> - <ShokoPanel - title="Series Without Files" - options={<ItemCount count={seriesCount} selected={selectedRows?.length} suffix="Series" />} - > - <div className="flex items-center gap-x-3"> - <Input - type="text" - placeholder="Search..." - startIcon={mdiMagnify} - id="search" - value={search} - onChange={event => setSearch(event.target.value)} - inputClassName="px-4 py-3" + <> + <title>Utilities > Series Without Files | Shoko</title> + <div className="flex grow flex-col gap-y-6"> + <div> + <ShokoPanel + title="Series Without Files" + options={<ItemCount count={seriesCount} selected={selectedRows?.length} suffix="Series" />} + > + <div className="flex items-center gap-x-3"> + <Input + type="text" + placeholder="Search..." + startIcon={mdiMagnify} + id="search" + value={search} + onChange={event => setSearch(event.target.value)} + inputClassName="px-4 py-3" + /> + <Menu selectedRows={selectedRows} setSelectedRows={setRowSelection} /> + </div> + </ShokoPanel> + </div> + + <div className="flex grow overflow-y-auto rounded-lg border border-panel-border bg-panel-background px-4 py-6"> + {seriesQuery.isPending && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + )} + + {!seriesQuery.isPending && seriesCount === 0 && ( + <div className="flex grow items-center justify-center font-semibold">No series without files!</div> + )} + + {seriesQuery.isSuccess && seriesCount > 0 && ( + <UtilitiesTable + columns={columns} + count={seriesCount} + fetchNextPage={seriesQuery.fetchNextPage} + handleRowSelect={handleRowSelect} + isFetchingNextPage={seriesQuery.isFetchingNextPage} + rows={series} + rowSelection={rowSelection} + setSelectedRows={setRowSelection} + skipSort /> - <Menu selectedRows={selectedRows} setSelectedRows={setRowSelection} /> - </div> - </ShokoPanel> - </div> - - <div className="flex grow overflow-y-auto rounded-lg border border-panel-border bg-panel-background px-4 py-6"> - {seriesQuery.isPending && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - )} - - {!seriesQuery.isPending && seriesCount === 0 && ( - <div className="flex grow items-center justify-center font-semibold">No series without files!</div> - )} - - {seriesQuery.isSuccess && seriesCount > 0 && ( - <UtilitiesTable - columns={columns} - count={seriesCount} - fetchNextPage={seriesQuery.fetchNextPage} - handleRowSelect={handleRowSelect} - isFetchingNextPage={seriesQuery.isFetchingNextPage} - rows={series} - rowSelection={rowSelection} - setSelectedRows={setRowSelection} - skipSort - /> - )} + )} + </div> </div> - </div> + </> ); } diff --git a/src/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab.tsx b/src/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab.tsx index 19c7e850d..c76f97fab 100644 --- a/src/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab.tsx +++ b/src/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab.tsx @@ -132,54 +132,57 @@ function IgnoredFilesTab() { } = useRowSelection<FileType>(files); return ( - <div className="flex grow flex-col gap-y-6"> - <div> - <ShokoPanel title={<Title />} options={<ItemCount count={fileCount} selected={selectedRows?.length} />}> - <div className="flex items-center gap-x-3"> - <Input - type="text" - placeholder="Search..." - startIcon={mdiMagnify} - id="search" - value={search} - onChange={setSearch} - inputClassName="px-4 py-3" - /> - <Menu - selectedRows={selectedRows} + <> + <title>Utilities > Ignored Files | Shoko</title> + <div className="flex grow flex-col gap-y-6"> + <div> + <ShokoPanel title={<Title />} options={<ItemCount count={fileCount} selected={selectedRows?.length} />}> + <div className="flex items-center gap-x-3"> + <Input + type="text" + placeholder="Search..." + startIcon={mdiMagnify} + id="search" + value={search} + onChange={setSearch} + inputClassName="px-4 py-3" + /> + <Menu + selectedRows={selectedRows} + setSelectedRows={setRowSelection} + /> + </div> + </ShokoPanel> + </div> + + <TransitionDiv className="flex grow overflow-y-auto rounded-lg border border-panel-border bg-panel-background p-6"> + {filesQuery.isPending && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + )} + + {!filesQuery.isPending && fileCount === 0 && ( + <div className="flex grow items-center justify-center font-semibold">No ignored file(s)!</div> + )} + + {filesQuery.isSuccess && fileCount > 0 && ( + <UtilitiesTable + count={fileCount} + fetchNextPage={filesQuery.fetchNextPage} + handleRowSelect={handleRowSelect} + columns={columns} + isFetchingNextPage={filesQuery.isFetchingNextPage} + rows={files} + rowSelection={rowSelection} setSelectedRows={setRowSelection} + setSortCriteria={setSortCriteria} + sortCriteria={sortCriteria} /> - </div> - </ShokoPanel> + )} + </TransitionDiv> </div> - - <TransitionDiv className="flex grow overflow-y-auto rounded-lg border border-panel-border bg-panel-background p-6"> - {filesQuery.isPending && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - )} - - {!filesQuery.isPending && fileCount === 0 && ( - <div className="flex grow items-center justify-center font-semibold">No ignored file(s)!</div> - )} - - {filesQuery.isSuccess && fileCount > 0 && ( - <UtilitiesTable - count={fileCount} - fetchNextPage={filesQuery.fetchNextPage} - handleRowSelect={handleRowSelect} - columns={columns} - isFetchingNextPage={filesQuery.isFetchingNextPage} - rows={files} - rowSelection={rowSelection} - setSelectedRows={setRowSelection} - setSortCriteria={setSortCriteria} - sortCriteria={sortCriteria} - /> - )} - </TransitionDiv> - </div> + </> ); } diff --git a/src/pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab.tsx b/src/pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab.tsx index 11fe3f36b..4981b7f05 100644 --- a/src/pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab.tsx +++ b/src/pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab.tsx @@ -220,97 +220,100 @@ const ManuallyLinkedTab = () => { }, [seriesQuery.data]); return ( - <TransitionDiv className="flex grow flex-col gap-y-6 overflow-y-auto"> - <ShokoPanel - title={<Title />} - options={ - <ItemCount - count={seriesCount} - selected={selectedFileIds.length} - suffix="Series" - selectedSuffix={selectedFileIds.length === 1 ? 'File' : 'Files'} - /> - } - > - <div className="flex items-center gap-x-3"> - <Input - type="text" - placeholder="Search..." - startIcon={mdiMagnify} - id="search" - value={search} - onChange={event => setSearch(event.target.value)} - inputClassName="px-4 py-3" - /> - <Menu selectedFileIds={selectedFileIds} setSelectedRows={setRowSelection} /> - <TransitionDiv show={selectedFileIds.length !== 0} className="flex gap-x-3"> - <Button - buttonType="primary" - buttonSize="normal" - className="flex gap-x-2.5 px-4 py-3 font-semibold" - onClick={unlinkFiles} - loading={unlinkingInProgress} - > - <Icon path={mdiLinkOff} size={1} /> - Unlink - </Button> - </TransitionDiv> - </div> - </ShokoPanel> + <> + <title>Utilities > Manually Linked Files | Shoko</title> + <TransitionDiv className="flex grow flex-col gap-y-6 overflow-y-auto"> + <ShokoPanel + title={<Title />} + options={ + <ItemCount + count={seriesCount} + selected={selectedFileIds.length} + suffix="Series" + selectedSuffix={selectedFileIds.length === 1 ? 'File' : 'Files'} + /> + } + > + <div className="flex items-center gap-x-3"> + <Input + type="text" + placeholder="Search..." + startIcon={mdiMagnify} + id="search" + value={search} + onChange={event => setSearch(event.target.value)} + inputClassName="px-4 py-3" + /> + <Menu selectedFileIds={selectedFileIds} setSelectedRows={setRowSelection} /> + <TransitionDiv show={selectedFileIds.length !== 0} className="flex gap-x-3"> + <Button + buttonType="primary" + buttonSize="normal" + className="flex gap-x-2.5 px-4 py-3 font-semibold" + onClick={unlinkFiles} + loading={unlinkingInProgress} + > + <Icon path={mdiLinkOff} size={1} /> + Unlink + </Button> + </TransitionDiv> + </div> + </ShokoPanel> - <div className="flex grow gap-x-3"> - <div className="flex w-1/2 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6"> - {seriesQuery.isPending && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - )} + <div className="flex grow gap-x-3"> + <div className="flex w-1/2 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6"> + {seriesQuery.isPending && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + )} - {!seriesQuery.isPending && seriesCount === 0 && ( - <div className="flex grow items-center justify-center text-lg font-semibold"> - No series with manually linked files! - </div> - )} + {!seriesQuery.isPending && seriesCount === 0 && ( + <div className="flex grow items-center justify-center text-lg font-semibold"> + No series with manually linked files! + </div> + )} - {seriesQuery.isSuccess && seriesCount > 0 && ( - <UtilitiesTable - columns={seriesColumns} - count={seriesCount} - fetchNextPage={seriesQuery.fetchNextPage} - isFetchingNextPage={seriesQuery.isFetchingNextPage} - rows={series} - skipSort - handleRowSelect={(id, _) => setSelectedSeries(id)} - rowSelection={{ [selectedSeries]: true }} - /> - )} - </div> + {seriesQuery.isSuccess && seriesCount > 0 && ( + <UtilitiesTable + columns={seriesColumns} + count={seriesCount} + fetchNextPage={seriesQuery.fetchNextPage} + isFetchingNextPage={seriesQuery.isFetchingNextPage} + rows={series} + skipSort + handleRowSelect={(id, _) => setSelectedSeries(id)} + rowSelection={{ [selectedSeries]: true }} + /> + )} + </div> - <div className="flex w-1/2 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6"> - {selectedSeries === 0 && <div className="m-auto text-lg font-semibold">Select Series to Populate</div>} + <div className="flex w-1/2 overflow-y-auto rounded-md border border-panel-border bg-panel-background p-6"> + {selectedSeries === 0 && <div className="m-auto text-lg font-semibold">Select Series to Populate</div>} - {selectedSeries > 0 && episodesQuery.isPending && ( - <div className="flex grow items-center justify-center text-panel-text-primary"> - <Icon path={mdiLoading} size={4} spin /> - </div> - )} + {selectedSeries > 0 && episodesQuery.isPending && ( + <div className="flex grow items-center justify-center text-panel-text-primary"> + <Icon path={mdiLoading} size={4} spin /> + </div> + )} - {selectedSeries > 0 && episodesQuery.isSuccess && episodeCount > 0 && ( - <UtilitiesTable - columns={episodeColumns} - count={episodeCount} - fetchNextPage={episodesQuery.fetchNextPage} - isFetchingNextPage={episodesQuery.isFetchingNextPage} - rows={episodes} - skipSort - handleRowSelect={handleRowSelect} - rowSelection={rowSelection} - setSelectedRows={setRowSelection} - /> - )} + {selectedSeries > 0 && episodesQuery.isSuccess && episodeCount > 0 && ( + <UtilitiesTable + columns={episodeColumns} + count={episodeCount} + fetchNextPage={episodesQuery.fetchNextPage} + isFetchingNextPage={episodesQuery.isFetchingNextPage} + rows={episodes} + skipSort + handleRowSelect={handleRowSelect} + rowSelection={rowSelection} + setSelectedRows={setRowSelection} + /> + )} + </div> </div> - </div> - </TransitionDiv> + </TransitionDiv> + </> ); }; diff --git a/src/pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab.tsx b/src/pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab.tsx index 982c43543..5ce05dce5 100644 --- a/src/pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab.tsx +++ b/src/pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab.tsx @@ -373,6 +373,7 @@ function UnrecognizedTab() { return ( <> + <title>Utilities > Unrecognized Files | Shoko</title> <div className="flex grow flex-col gap-y-6" ref={tabContainerRef}> <div> <ShokoPanel title={<Title />} options={<ItemCount count={fileCount} selected={selectedRows?.length} />}>