node build fixed
2
seanime-2.9.10/seanime-web/.env.denshi
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_PLATFORM="desktop"
|
||||
NEXT_PUBLIC_DESKTOP="electron"
|
||||
2
seanime-2.9.10/seanime-web/.env.desktop
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_PLATFORM="desktop"
|
||||
NEXT_PUBLIC_DESKTOP="tauri"
|
||||
2
seanime-2.9.10/seanime-web/.env.development.desktop
Normal file
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_PLATFORM="desktop"
|
||||
NEXT_PUBLIC_DEVBUILD="true"
|
||||
1
seanime-2.9.10/seanime-web/.env.mobile
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_PLATFORM="mobile"
|
||||
1
seanime-2.9.10/seanime-web/.env.web
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_PLATFORM="web"
|
||||
43
seanime-2.9.10/seanime-web/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea
|
||||
snapshot
|
||||
logs
|
||||
analyze
|
||||
TODO-priv.md
|
||||
CHANGELOG-priv.md
|
||||
21
seanime-2.9.10/seanime-web/Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
build-web-only:
|
||||
rm -rf ../web
|
||||
npm run build
|
||||
cp -r out ../web
|
||||
|
||||
build-web:
|
||||
rm -rf ../web
|
||||
rm -rf ../web-desktop
|
||||
npm run build
|
||||
cp -r out ../web
|
||||
npm run build:desktop
|
||||
cp -r out-desktop ../web-desktop
|
||||
|
||||
build-denshi:
|
||||
rm -rf ../web-denshi
|
||||
npm run build:denshi
|
||||
cp -r out-denshi ../web-denshi
|
||||
rm -rf ../seanime-denshi/web-denshi
|
||||
cp -r ../web-denshi ../seanime-denshi/web-denshi
|
||||
|
||||
.PHONY: build-web build-denshi
|
||||
45
seanime-2.9.10/seanime-web/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
<p align="center">
|
||||
<img src="public/logo_2.png" alt="preview" width="150px"/>
|
||||
</p>
|
||||
|
||||
<h2 align="center"><b>Seanime Web</b></h2>
|
||||
|
||||
<h4 align="center">Web interface</h4>
|
||||
|
||||
```txt
|
||||
📁 api
|
||||
├── 📁 client
|
||||
├── 📁 generated
|
||||
└── 📁 hooks
|
||||
📁 app/(main)
|
||||
├── 📁 _atoms
|
||||
├── 📁 _features
|
||||
├── 📁 _hooks
|
||||
├── 📁 _listeners
|
||||
└── 📁 {route}
|
||||
├── 📁 _containers
|
||||
├── 📁 _components
|
||||
├── 📁 _lib
|
||||
├── 📄 layout.tsx
|
||||
└── 📄 page.tsx
|
||||
📁 components
|
||||
```
|
||||
|
||||
- `api`: API related code.
|
||||
- `client`: React-Query and Axios related code.
|
||||
- `generated`: Generated types and endpoints.
|
||||
- `hooks`: Data-fetching hooks.
|
||||
|
||||
|
||||
- `app`
|
||||
- `_atoms`: Global Jotai atoms
|
||||
- `_hooks`: Top-level queries (loaders) and global state hooks.
|
||||
- `_features`: Specialized components that are used across multiple pages.
|
||||
- `_listeners`: Websocket listeners.
|
||||
- `{route}`: Route directory.
|
||||
- `_components`: Route-specific components that only depend on props.
|
||||
- `_containers`: Route-specific components that interact with global state and API.
|
||||
- `_lib`: Route-specific utility functions, hooks, constants, and data-related functions.
|
||||
|
||||
|
||||
- `components`: Primitive components, not tied to any feature or route.
|
||||
BIN
seanime-2.9.10/seanime-web/internal/logo.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
25
seanime-2.9.10/seanime-web/next.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const isDesktop = process.env.NEXT_PUBLIC_PLATFORM === 'desktop';
|
||||
const isTauriDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'tauri';
|
||||
const isElectronDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'electron';
|
||||
const internalHost = process.env.TAURI_DEV_HOST || '127.0.0.1';
|
||||
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
...(isProd && {output: "export"}),
|
||||
distDir: isDesktop ? (isElectronDesktop ? "out-denshi" : "out-desktop") : undefined,
|
||||
cleanDistDir: true,
|
||||
reactStrictMode: false,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
transpilePackages: ["@uiw/react-textarea-code-editor", "@replit/codemirror-vscode-keymap", "media-chrome", "anime4k-webgpu"],
|
||||
assetPrefix: isProd ? undefined : (isDesktop ? `http://${internalHost}:43210` : undefined),
|
||||
experimental: {
|
||||
reactCompiler: true,
|
||||
},
|
||||
devIndicators: false,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
9056
seanime-2.9.10/seanime-web/package-lock.json
generated
Normal file
167
seanime-2.9.10/seanime-web/package.json
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"name": "seanime-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd -f .env.web next dev --hostname=0.0.0.0 --port=43210 --turbo",
|
||||
"dev:mobile": "env-cmd -f .env.mobile next dev --hostname=0.0.0.0 --port=43210 --turbo",
|
||||
"dev:desktop": "env-cmd -f .env.desktop next dev --hostname=0.0.0.0 --port=43210 --turbo",
|
||||
"dev:denshi": "env-cmd -f .env.denshi next dev --hostname=0.0.0.0 --port=43210 --turbo",
|
||||
"build": "next build",
|
||||
"build:desktop": "env-cmd -f .env.desktop next build",
|
||||
"build:denshi": "env-cmd -f .env.denshi next build",
|
||||
"build:development:desktop": "env-cmd -f .env.development.desktop next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.1",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@headlessui/react": "1.7.18",
|
||||
"@headlessui/tailwindcss": "0.2.1",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@next/bundle-analyzer": "^15.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.81.5",
|
||||
"@tanstack/react-query-devtools": "^5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.4.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.14",
|
||||
"@uiw/react-codemirror": "^4.23.14",
|
||||
"@vidstack/react": "^1.10.9",
|
||||
"@zag-js/number-input": "^1.15.7",
|
||||
"@zag-js/react": "^1.15.7",
|
||||
"anime4k-webgpu": "^1.0.0",
|
||||
"artplayer": "^5.2.3",
|
||||
"autoprefixer": "^10",
|
||||
"axios": "^1.11.0",
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405",
|
||||
"bottleneck": "^2.19.5",
|
||||
"bytes-iec": "^3.1.1",
|
||||
"chalk": "^5.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"collect.js": "^4.36.1",
|
||||
"colord": "^2.9.3",
|
||||
"concurrently": "^8.2.2",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"embla-carousel-auto-scroll": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"execa": "^8.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"hls.js": "1.5.20",
|
||||
"inflection": "^3.0.2",
|
||||
"install": "^0.13.0",
|
||||
"jassub": "^1.8.6",
|
||||
"jotai": "^2.13.0",
|
||||
"jotai-derive": "^0.1.3",
|
||||
"jotai-immer": "^0.4.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"jotai-scope": "^0.9.3",
|
||||
"js-cookies": "^1.0.4",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"js-sha256": "^0.11.1",
|
||||
"json2toml": "^6.1.1",
|
||||
"libphonenumber-js": "^1.12.9",
|
||||
"media-icons": "^0.10.0",
|
||||
"memory-cache": "^0.2.0",
|
||||
"motion": "^12.23.12",
|
||||
"mousetrap": "^1.6.5",
|
||||
"needle": "^3.3.1",
|
||||
"next": "15.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"normalize-path": "^3.0.0",
|
||||
"ora": "^8.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-currency-input-field": "^3.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-use": "^17.6.0",
|
||||
"react-virtuoso": "^4.13.0",
|
||||
"recharts": "^2.15.3",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"sonner": "2.0.5",
|
||||
"tailwind-merge": "~2.6.0",
|
||||
"tailwind-scrollbar-hide": "~1.1.7",
|
||||
"tailwindcss-animate": "~1.0.7",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5",
|
||||
"upath": "^2.0.1",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/client-preset": "4.8.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/lodash": "^4.17.18",
|
||||
"@types/memory-cache": "^0.2.6",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/needle": "^3.3.0",
|
||||
"@types/node": "^22.15.32",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"autoprefixer": "^10",
|
||||
"encoding": "^0.1.13",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
6
seanime-2.9.10/seanime-web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 89 KiB |
BIN
seanime-2.9.10/seanime-web/public/icons/apple-icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
seanime-2.9.10/seanime-web/public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 736 B |
BIN
seanime-2.9.10/seanime-web/public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
seanime-2.9.10/seanime-web/public/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
seanime-2.9.10/seanime-web/public/jassub/Roboto-Medium.ttf
Normal file
BIN
seanime-2.9.10/seanime-web/public/jassub/default.woff2
Normal file
15
seanime-2.9.10/seanime-web/public/jassub/jassub-worker.js
Normal file
BIN
seanime-2.9.10/seanime-web/public/jassub/jassub-worker.wasm
Normal file
BIN
seanime-2.9.10/seanime-web/public/logo.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
seanime-2.9.10/seanime-web/public/logo_2.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
seanime-2.9.10/seanime-web/public/luffy-01.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
seanime-2.9.10/seanime-web/public/no-cover.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
28
seanime-2.9.10/seanime-web/public/pattern-1.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg width="273" height="568" viewBox="0 0 273 568" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#a)" stroke="#fff" stroke-width=".5" stroke-miterlimit="10" style="mix-blend-mode:overlay">
|
||||
<path d="M269.428.185c11.813 41.392 45.921 68.306 61.736 106.356 12.766 30.44 4.382 65.336-10.671 99.488-9.908 22.459-20.007 43.062-24.199 62.18-4.192 18.562-5.526 36.937-3.239 56.612 4.573 37.308-2.668 73.502-11.242 109.511-11.242 47.517-31.44 93.92-12.957 142.922"/>
|
||||
<path d="M254.565.185c11.814 41.206 47.636 67.563 64.594 105.242 13.719 30.441 5.145 65.336-9.908 99.674-9.908 22.459-20.007 43.248-24.58 62.18-4.382 18.561-5.907 37.123-4.001 56.798 3.81 37.308-3.43 73.688-12.005 109.882-11.242 47.702-31.82 94.105-14.862 143.293"/>
|
||||
<path d="M239.512.186c11.814 41.205 49.16 66.634 67.262 104.128 14.481 30.255 6.097 65.335-9.146 99.674-9.908 22.459-20.198 43.433-24.771 62.365-4.573 18.562-6.288 37.308-4.763 56.798 3.049 37.308-4.192 73.688-12.767 110.068-11.242 47.702-32.392 94.476-16.577 143.664"/>
|
||||
<path d="M224.65 0c12.004 41.206 50.875 65.707 69.929 103.015 15.434 30.254 6.86 65.521-8.193 99.859-9.908 22.459-20.388 43.433-24.961 62.366-4.573 18.747-6.669 37.308-5.526 56.983 2.287 37.493-4.954 73.873-13.528 110.439-11.242 47.888-32.774 94.848-18.292 144.221"/>
|
||||
<path d="M209.788 0c12.004 41.02 52.589 64.778 72.787 101.901 16.387 30.069 7.812 65.521-7.431 100.045-9.908 22.645-20.388 43.619-25.342 62.551-4.764 18.747-7.05 37.494-6.288 57.169 1.524 37.493-5.526 74.059-14.291 110.625-11.433 48.073-33.345 95.033-20.198 144.592"/>
|
||||
<path d="M194.735 0c12.004 41.02 54.305 63.85 75.455 100.787 17.149 30.069 8.574 65.707-6.669 100.231-9.908 22.645-20.579 43.804-25.533 62.551-4.954 18.747-7.431 37.679-7.05 57.354.762 37.494-6.288 74.245-15.053 110.996-11.432 48.26-33.726 95.405-21.912 144.964"/>
|
||||
<path d="M179.872 0c12.195 41.02 56.02 63.108 78.123 99.674 18.102 29.884 9.337 65.707-5.907 100.416-9.908 22.645-20.769 43.805-25.723 62.737-5.145 18.933-8.003 37.865-7.812 57.354.19 37.494-7.051 74.431-15.815 111.182-11.433 48.445-34.298 95.776-23.628 145.334"/>
|
||||
<path d="M164.82 0c12.194 40.835 57.734 62.18 80.98 98.746 19.055 29.883 10.29 65.706-4.954 100.601-9.908 22.645-20.769 43.99-25.914 62.737-5.144 18.933-8.193 37.865-8.574 57.54-.762 37.679-7.812 74.616-16.577 111.553-11.433 48.63-34.679 96.147-25.343 145.706"/>
|
||||
<path d="M149.957 0c12.195 40.835 59.259 61.252 83.649 97.632 19.816 29.698 11.051 65.892-4.192 100.787-9.909 22.645-20.96 44.176-26.295 62.923-5.335 19.118-8.575 38.05-9.337 57.725-1.524 37.68-8.574 74.616-17.339 111.739-11.433 48.816-35.06 96.333-27.248 146.262"/>
|
||||
<path d="M134.904 0c12.195 40.835 60.974 60.324 86.316 96.518 20.769 29.698 12.004 65.893-3.43 100.973-9.908 22.645-21.15 44.176-26.485 62.923-5.526 19.118-8.955 38.236-10.099 57.725-2.286 37.679-9.336 74.802-18.101 112.11-11.433 49.002-35.632 96.704-28.963 146.634"/>
|
||||
<path d="M120.042 0c12.385 40.649 62.689 59.396 89.174 95.404 21.722 29.513 12.767 65.893-2.477 101.159-9.908 22.83-21.15 44.361-26.676 63.108-5.716 19.118-9.336 38.236-10.861 57.911-3.048 37.68-10.099 74.988-18.864 112.296-11.432 49.187-36.012 97.075-30.677 147.005"/>
|
||||
<path d="M105.179 0c12.386 40.649 64.404 58.468 91.842 94.29 22.484 29.328 13.529 66.079-1.715 101.345-9.908 22.83-21.341 44.547-26.866 63.108-5.717 19.304-9.718 38.422-11.623 58.097-3.811 37.865-10.861 75.173-19.626 112.667-11.623 49.372-36.584 97.26-32.583 147.376"/>
|
||||
<path d="M90.127 0c12.385 40.649 66.118 57.725 94.509 93.177 23.437 29.327 14.481 66.078-.953 101.53-9.908 22.83-21.531 44.547-27.247 63.294-5.907 19.304-10.099 38.607-12.386 58.282-4.573 37.865-11.623 75.359-20.388 112.852-11.623 49.559-36.965 97.632-34.297 147.933"/>
|
||||
<path d="M75.265 0c12.575 40.464 67.833 56.798 97.367 92.064 24.389 29.141 15.243 66.078-.191 101.53-9.908 22.83-21.531 44.732-27.438 63.294-6.097 19.303-10.48 38.792-13.147 58.282-5.526 37.865-12.195 75.358-21.15 113.223-11.624 49.559-37.537 98.003-36.013 148.304"/>
|
||||
<path d="M60.402 0c12.576 40.464 69.548 55.87 100.035 90.95 25.152 29.141 16.006 66.264.762 101.716-9.908 22.83-21.722 44.732-27.628 63.479-6.288 19.489-10.861 38.793-13.91 58.468-6.288 37.865-12.957 75.544-21.912 113.409-11.624 49.744-37.919 98.189-37.919 148.675"/>
|
||||
<path d="M45.35 0c12.575 40.463 71.262 54.941 102.702 90.022 26.104 28.955 16.958 66.263 1.524 101.901-9.908 23.016-21.912 44.918-28.01 63.479-6.478 19.49-11.242 38.979-14.671 58.654-7.05 38.05-13.72 75.729-22.675 113.78-11.623 49.93-38.49 98.56-39.633 149.047"/>
|
||||
<path d="M30.487 0c12.576 40.278 72.978 54.013 105.561 88.908 27.057 28.77 17.72 66.449 2.286 102.087-9.908 23.016-21.912 45.104-28.2 63.665-6.479 19.489-11.433 39.164-15.434 58.653-7.812 38.051-14.481 75.916-23.437 113.966-11.623 50.115-38.87 98.931-41.348 149.604"/>
|
||||
<path d="M15.434 0C28.2 40.278 90.127 53.085 123.662 87.795c28.01 28.77 18.674 66.449 3.24 102.272-9.909 23.016-22.103 45.104-28.391 63.851-6.67 19.674-11.814 39.164-16.197 58.839-8.574 38.05-15.243 76.101-24.198 114.337-11.623 50.301-39.443 99.117-43.254 149.974"/>
|
||||
<path d="M.572 0c12.766 40.278 76.217 52.157 110.896 86.68 47.254 46.961-8.194 118.607-24.58 166.309C66.88 310.9 58.497 368.44 44.968 426.537 33.345 477.023 5.145 526.025 0 576.883"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" transform="matrix(0 -1 -1 0 552.032 577.439)" d="M0 0h577.44v552.032H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
4
seanime-2.9.10/seanime-web/public/pattern-2.svg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
4
seanime-2.9.10/seanime-web/public/pattern-3.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='100' height='18' viewBox='0 0 100 18'>
|
||||
<path fill='#a9aed6' fill-opacity='0.3'
|
||||
d='M61.82 18c3.47-1.45 6.86-3.78 11.3-7.34C78 6.76 80.34 5.1 83.87 3.42 88.56 1.16 93.75 0 100 0v6.16C98.76 6.05 97.43 6 96 6c-9.59 0-14.23 2.23-23.13 9.34-1.28 1.03-2.39 1.9-3.4 2.66h-7.65zm-23.64 0H22.52c-1-.76-2.1-1.63-3.4-2.66C11.57 9.3 7.08 6.78 0 6.16V0c6.25 0 11.44 1.16 16.14 3.42 3.53 1.7 5.87 3.35 10.73 7.24 4.45 3.56 7.84 5.9 11.31 7.34zM61.82 0h7.66a39.57 39.57 0 0 1-7.34 4.58C57.44 6.84 52.25 8 46 8S34.56 6.84 29.86 4.58A39.57 39.57 0 0 1 22.52 0h15.66C41.65 1.44 45.21 2 50 2c4.8 0 8.35-.56 11.82-2z'></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
181
seanime-2.9.10/seanime-web/src/api/client/requests.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
import { getServerBaseUrl } from "@/api/client/server-url"
|
||||
import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms"
|
||||
import { useMutation, UseMutationOptions, useQuery, UseQueryOptions } from "@tanstack/react-query"
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type SeaError = AxiosError<{ error: string }>
|
||||
|
||||
type SeaQuery<D> = {
|
||||
endpoint: string
|
||||
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
|
||||
data?: D
|
||||
params?: D
|
||||
password?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create axios query to the server
|
||||
* - First generic: Return type
|
||||
* - Second generic: Params/Data type
|
||||
*/
|
||||
export async function buildSeaQuery<T, D extends any = any>(
|
||||
{
|
||||
endpoint,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
password,
|
||||
}: SeaQuery<D>): Promise<T | undefined> {
|
||||
|
||||
axios.interceptors.request.use((request: InternalAxiosRequestConfig) => {
|
||||
if (password) {
|
||||
request.headers.set("X-Seanime-Token", password)
|
||||
}
|
||||
return request
|
||||
},
|
||||
)
|
||||
|
||||
const res = await axios<T>({
|
||||
url: getServerBaseUrl() + endpoint,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
})
|
||||
const response = _handleSeaResponse<T>(res.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
type ServerMutationProps<R, V = void> = UseMutationOptions<R | undefined, SeaError, V, unknown> & {
|
||||
endpoint: string
|
||||
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mutation hook to the server
|
||||
* - First generic: Return type
|
||||
* - Second generic: Params/Data type
|
||||
*/
|
||||
export function useServerMutation<R = void, V = void>(
|
||||
{
|
||||
endpoint,
|
||||
method,
|
||||
...options
|
||||
}: ServerMutationProps<R, V>) {
|
||||
|
||||
const password = useAtomValue(serverAuthTokenAtom)
|
||||
|
||||
return useMutation<R | undefined, SeaError, V>({
|
||||
onError: error => {
|
||||
console.log("Mutation error", error)
|
||||
toast.error(_handleSeaError(error.response?.data))
|
||||
},
|
||||
mutationFn: async (variables) => {
|
||||
return buildSeaQuery<R, V>({
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
data: variables,
|
||||
password: password,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
type ServerQueryProps<R, V> = UseQueryOptions<R | undefined, SeaError, R | undefined> & {
|
||||
endpoint: string
|
||||
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
|
||||
params?: V
|
||||
data?: V
|
||||
muteError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create query hook to the server
|
||||
* - First generic: Return type
|
||||
* - Second generic: Params/Data type
|
||||
*/
|
||||
export function useServerQuery<R, V = any>(
|
||||
{
|
||||
endpoint,
|
||||
method,
|
||||
params,
|
||||
data,
|
||||
muteError,
|
||||
...options
|
||||
}: ServerQueryProps<R | undefined, V>) {
|
||||
|
||||
const pathname = usePathname()
|
||||
const [password, setPassword] = useAtom(serverAuthTokenAtom)
|
||||
|
||||
const props = useQuery<R | undefined, SeaError>({
|
||||
queryFn: async () => {
|
||||
return buildSeaQuery<R, V>({
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
params: params,
|
||||
data: data,
|
||||
password: password,
|
||||
})
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!muteError && props.isError) {
|
||||
if (props.error?.response?.data?.error === "UNAUTHENTICATED" && pathname !== "/public/auth") {
|
||||
setPassword(undefined)
|
||||
window.location.href = "/public/auth"
|
||||
return
|
||||
}
|
||||
console.log("Server error", props.error)
|
||||
toast.error(_handleSeaError(props.error?.response?.data))
|
||||
}
|
||||
}, [props.error, props.isError, muteError])
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function _handleSeaError(data: any): string {
|
||||
if (typeof data === "string") return "Server Error: " + data
|
||||
|
||||
const err = data?.error as string
|
||||
|
||||
if (!err) return "Unknown error"
|
||||
|
||||
if (err.includes("Too many requests"))
|
||||
return "AniList: Too many requests, please wait a moment and try again."
|
||||
|
||||
try {
|
||||
const graphqlErr = JSON.parse(err) as any
|
||||
console.log("AniList error", graphqlErr)
|
||||
if (graphqlErr.graphqlErrors && graphqlErr.graphqlErrors.length > 0 && !!graphqlErr.graphqlErrors[0]?.message) {
|
||||
return "AniList error: " + graphqlErr.graphqlErrors[0]?.message
|
||||
}
|
||||
return "AniList error"
|
||||
}
|
||||
catch (e) {
|
||||
return "Error: " + err
|
||||
}
|
||||
}
|
||||
|
||||
function _handleSeaResponse<T>(res: unknown): { data: T | undefined, error: string | undefined } {
|
||||
|
||||
if (typeof res === "object" && !!res && "error" in res && typeof res.error === "string") {
|
||||
return { data: undefined, error: res.error }
|
||||
}
|
||||
if (typeof res === "object" && !!res && "data" in res) {
|
||||
return { data: res.data as T, error: undefined }
|
||||
}
|
||||
|
||||
return { data: undefined, error: "No response from the server" }
|
||||
|
||||
}
|
||||
42
seanime-2.9.10/seanime-web/src/api/client/server-url.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { __DEV_SERVER_PORT } from "@/lib/server/config"
|
||||
import { __isDesktop__ } from "@/types/constants"
|
||||
|
||||
function devOrProd(dev: string, prod: string): string {
|
||||
return process.env.NODE_ENV === "development" ? dev : prod
|
||||
}
|
||||
|
||||
export function getServerBaseUrl(removeProtocol: boolean = false): string {
|
||||
if (__isDesktop__) {
|
||||
let ret = devOrProd(`http://127.0.0.1:${__DEV_SERVER_PORT}`, "http://127.0.0.1:43211")
|
||||
if (removeProtocol) {
|
||||
ret = ret.replace("http://", "").replace("https://", "")
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// // DEV ONLY: Hack to allow multiple development servers for the same web server
|
||||
// // localhost:43210 -> 127.0.0.1:43001
|
||||
// // 192.168.1.100:43210 -> 127.0.0.1:43002
|
||||
// if (process.env.NODE_ENV === "development" && window.location.host.includes("localhost")) {
|
||||
// let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT2}`
|
||||
// if (removeProtocol) {
|
||||
// ret = ret.replace("http://", "").replace("https://", "")
|
||||
// }
|
||||
// return ret
|
||||
// }
|
||||
// if (process.env.NODE_ENV === "development" && window.location.host.startsWith("192.168")) {
|
||||
// let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT3}`
|
||||
// if (removeProtocol) {
|
||||
// ret = ret.replace("http://", "").replace("https://", "")
|
||||
// }
|
||||
// return ret
|
||||
// }
|
||||
|
||||
let ret = typeof window !== "undefined"
|
||||
? (`${window?.location?.protocol}//` + devOrProd(`${window?.location?.hostname}:${__DEV_SERVER_PORT}`, window?.location?.host))
|
||||
: ""
|
||||
if (removeProtocol) {
|
||||
ret = ret.replace("http://", "").replace("https://", "")
|
||||
}
|
||||
return ret
|
||||
}
|
||||
1815
seanime-2.9.10/seanime-web/src/api/generated/endpoint.types.ts
Normal file
1996
seanime-2.9.10/seanime-web/src/api/generated/endpoints.ts
Normal file
2401
seanime-2.9.10/seanime-web/src/api/generated/hooks_template.ts
Normal file
31
seanime-2.9.10/seanime-web/src/api/generated/queries_tmpl.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
type QueryHookProps = {
|
||||
key: any[]
|
||||
}
|
||||
|
||||
export function useTemplateQuery(props: QueryHookProps) {
|
||||
return useServerQuery({
|
||||
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
|
||||
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DOCS.GetDocs.key, ...props.key],
|
||||
})
|
||||
}
|
||||
|
||||
// type MutationHookProps = {
|
||||
// onSuccess
|
||||
// }
|
||||
|
||||
export function useTemplateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useServerMutation({
|
||||
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
|
||||
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DOCS.GetDocs.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
4630
seanime-2.9.10/seanime-web/src/api/generated/types.ts
Normal file
162
seanime-2.9.10/seanime-web/src/api/hooks/anilist.hooks.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
AnilistListAnime_Variables,
|
||||
AnilistListRecentAiringAnime_Variables,
|
||||
DeleteAnilistListEntry_Variables,
|
||||
EditAnilistListEntry_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import {
|
||||
AL_AnimeCollection,
|
||||
AL_AnimeDetailsById_Media,
|
||||
AL_BaseAnime,
|
||||
AL_ListAnime,
|
||||
AL_ListRecentAnime,
|
||||
AL_Stats,
|
||||
AL_StudioDetails,
|
||||
Nullish,
|
||||
} from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetAnimeCollection() {
|
||||
return useServerQuery<AL_AnimeCollection>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetRawAnimeCollection() {
|
||||
return useServerQuery<AL_AnimeCollection>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRefreshAnimeCollection() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<AL_AnimeCollection>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[1],
|
||||
mutationKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("AniList is up-to-date")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useEditAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga") {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, EditAnilistListEntry_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.EditAnilistListEntry.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.EditAnilistListEntry.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANILIST.EditAnilistListEntry.key, String(id)],
|
||||
onSuccess: async () => {
|
||||
toast.success("Entry updated")
|
||||
if (type === "anime") {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
} else if (type === "manga") {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAnilistAnimeDetails(id: Nullish<number | string>) {
|
||||
return useServerQuery<AL_AnimeDetailsById_Media>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.key, String(id)],
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga", onSuccess: () => void) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, DeleteAnilistListEntry_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Entry deleted")
|
||||
if (type === "anime") {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
} else if (type === "manga") {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
}
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnilistListAnime(variables: AnilistListAnime_Variables, enabled: boolean) {
|
||||
return useServerQuery<AL_ListAnime, AnilistListAnime_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.AnilistListAnime.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.AnilistListAnime.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.AnilistListAnime.key, variables],
|
||||
data: variables,
|
||||
enabled: enabled ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnilistListRecentAiringAnime(variables: AnilistListRecentAiringAnime_Variables, enabled: boolean = true) {
|
||||
return useServerQuery<AL_ListRecentAnime, AnilistListRecentAiringAnime_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.key, JSON.stringify(variables)],
|
||||
data: variables,
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAnilistStudioDetails(id: number) {
|
||||
return useServerQuery<AL_StudioDetails>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.key, String(id)],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAniListStats(enabled: boolean = true) {
|
||||
return useServerQuery<AL_Stats>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.GetAniListStats.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.GetAniListStats.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.GetAniListStats.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnilistListMissedSequels(enabled: boolean) {
|
||||
return useServerQuery<Array<AL_BaseAnime>>({
|
||||
endpoint: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.endpoint,
|
||||
method: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANILIST.AnilistListMissedSequels.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
12
seanime-2.9.10/seanime-web/src/api/hooks/anime.hooks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useServerQuery } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_EpisodeCollection } from "@/api/generated/types"
|
||||
|
||||
export function useGetAnimeEpisodeCollection(id: number) {
|
||||
return useServerQuery<Anime_EpisodeCollection>({
|
||||
endpoint: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key, String(id)],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { AddUnknownMedia_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { AL_AnimeCollection, Anime_LibraryCollection, Anime_ScheduleItem } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetLibraryCollection() {
|
||||
return useServerQuery<Anime_LibraryCollection>({
|
||||
endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddUnknownMedia() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<AL_AnimeCollection, AddUnknownMedia_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Media added successfully")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAnimeCollectionSchedule() {
|
||||
return useServerQuery<Array<Anime_ScheduleItem>>({
|
||||
endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
143
seanime-2.9.10/seanime-web/src/api/hooks/anime_entries.hooks.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
AnimeEntryBulkAction_Variables,
|
||||
AnimeEntryManualMatch_Variables,
|
||||
FetchAnimeEntrySuggestions_Variables,
|
||||
OpenAnimeEntryInExplorer_Variables,
|
||||
ToggleAnimeEntrySilenceStatus_Variables,
|
||||
UpdateAnimeEntryProgress_Variables,
|
||||
UpdateAnimeEntryRepeat_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { AL_BaseAnime, Anime_Entry, Anime_LocalFile, Anime_MissingEpisodes, Nullish } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetAnimeEntry(id: Nullish<string | number>) {
|
||||
return useServerQuery<Anime_Entry>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)],
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnimeEntryBulkAction(id?: Nullish<number>, onSuccess?: () => void) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Array<Anime_LocalFile>, AnimeEntryBulkAction_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.key, String(id)],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenAnimeEntryInExplorer() {
|
||||
return useServerMutation<boolean, OpenAnimeEntryInExplorer_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useFetchAnimeEntrySuggestions() {
|
||||
return useServerMutation<Array<AL_BaseAnime>, FetchAnimeEntrySuggestions_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnimeEntryManualMatch() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Array<Anime_LocalFile>, AnimeEntryManualMatch_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
toast.success("Files matched")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMissingEpisodes(enabled?: boolean) {
|
||||
return useServerQuery<Anime_MissingEpisodes>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key],
|
||||
enabled: enabled ?? true, // Default to true if not provided
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAnimeEntrySilenceStatus(id: Nullish<string | number>) {
|
||||
const { data, ...rest } = useServerQuery({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key],
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
return { isSilenced: !!data, ...rest }
|
||||
}
|
||||
|
||||
export function useToggleAnimeEntrySilenceStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, ToggleAnimeEntrySilenceStatus_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAnimeEntryProgress(id: Nullish<string | number>, episodeNumber: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, UpdateAnimeEntryProgress_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.key, id, episodeNumber],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
if (id) {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
}
|
||||
toast.success("Progress updated successfully")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAnimeEntryRepeat(id: Nullish<string | number>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, UpdateAnimeEntryRepeat_Variables>({
|
||||
endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.endpoint,
|
||||
method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.key, id],
|
||||
onSuccess: async () => {
|
||||
// if (id) {
|
||||
// await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
// }
|
||||
// toast.success("Updated successfully")
|
||||
},
|
||||
})
|
||||
}
|
||||
61
seanime-2.9.10/seanime-web/src/api/hooks/auth.hooks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { Login_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Status } from "@/api/generated/types"
|
||||
import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useLogin() {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const setServerStatus = useSetServerStatus()
|
||||
|
||||
return useServerMutation<Status, Login_Variables>({
|
||||
endpoint: API_ENDPOINTS.AUTH.Login.endpoint,
|
||||
method: API_ENDPOINTS.AUTH.Login.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTH.Login.key],
|
||||
onSuccess: async data => {
|
||||
if (data) {
|
||||
toast.success("Successfully authenticated")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
setServerStatus(data)
|
||||
router.push("/")
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
|
||||
}
|
||||
},
|
||||
onError: async error => {
|
||||
toast.error(error.message)
|
||||
router.push("/")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const setServerStatus = useSetServerStatus()
|
||||
|
||||
return useServerMutation<Status>({
|
||||
endpoint: API_ENDPOINTS.AUTH.Logout.endpoint,
|
||||
method: API_ENDPOINTS.AUTH.Logout.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTH.Logout.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Successfully logged out")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
router.push("/")
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
CreateAutoDownloaderRule_Variables,
|
||||
DeleteAutoDownloaderItem_Variables,
|
||||
UpdateAutoDownloaderRule_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_AutoDownloaderRule, Models_AutoDownloaderItem, Nullish } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useRunAutoDownloader() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Auto downloader started")
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
|
||||
}, 1000)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAutoDownloaderRule(id: number) {
|
||||
return useServerQuery<Anime_AutoDownloaderRule>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.methods[0],
|
||||
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAutoDownloaderRules() {
|
||||
return useServerQuery<Array<Anime_AutoDownloaderRule>>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.methods[0],
|
||||
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateAutoDownloaderRule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Anime_AutoDownloaderRule, CreateAutoDownloaderRule_Variables>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
|
||||
toast.success("Rule created")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAutoDownloaderRule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Anime_AutoDownloaderRule, UpdateAutoDownloaderRule_Variables>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
|
||||
toast.success("Rule updated")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAutoDownloaderRule(id: Nullish<number>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.key, String(id)],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
|
||||
toast.success("Rule deleted")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAutoDownloaderItems(enabled: boolean = true) {
|
||||
return useServerQuery<Array<Models_AutoDownloaderItem>>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.methods[0],
|
||||
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAutoDownloaderItem() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, DeleteAutoDownloaderItem_Variables>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.endpoint,
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Item deleted")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAutoDownloaderRulesByAnime(id: number, enabled: boolean) {
|
||||
return useServerQuery<Array<Anime_AutoDownloaderRule>>({
|
||||
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.methods[0],
|
||||
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key, String(id)],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
125
seanime-2.9.10/seanime-web/src/api/hooks/continuity.hooks.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { GetContinuityWatchHistoryItem_Variables, UpdateContinuityWatchHistoryItem_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Continuity_WatchHistory, Continuity_WatchHistoryItemResponse, Nullish } from "@/api/generated/types"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { logger } from "@/lib/helpers/debug"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { MediaPlayerInstance } from "@vidstack/react"
|
||||
import React from "react"
|
||||
|
||||
export function useUpdateContinuityWatchHistoryItem() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, UpdateContinuityWatchHistoryItem_Variables>({
|
||||
endpoint: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.endpoint,
|
||||
method: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetContinuityWatchHistoryItem(mediaId: Nullish<number | string>) {
|
||||
const serverStatus = useServerStatus()
|
||||
return useServerQuery<Continuity_WatchHistoryItemResponse, GetContinuityWatchHistoryItem_Variables>({
|
||||
endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.endpoint.replace("{id}", String(mediaId)),
|
||||
method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.methods[0],
|
||||
queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key, String(mediaId)],
|
||||
enabled: serverStatus?.settings?.library?.enableWatchContinuity && !!mediaId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetContinuityWatchHistory() {
|
||||
return useServerQuery<Continuity_WatchHistory>({
|
||||
endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.endpoint,
|
||||
method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.methods[0],
|
||||
queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function getEpisodePercentageComplete(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) {
|
||||
if (!history) return 0
|
||||
const item = history[mediaId]
|
||||
if (!item || !item.currentTime || !item.duration) return 0
|
||||
if (item.episodeNumber !== progressNumber) return 0
|
||||
const percent = Math.round((item.currentTime / item.duration) * 100)
|
||||
if (percent > 90 || percent < 5) return 0
|
||||
return percent
|
||||
}
|
||||
|
||||
export function getEpisodeMinutesRemaining(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) {
|
||||
if (!history) return 0
|
||||
const item = history[mediaId]
|
||||
if (!item || !item.currentTime || !item.duration) return 0
|
||||
if (item.episodeNumber !== progressNumber) return 0
|
||||
return Math.round((item.duration - item.currentTime) / 60)
|
||||
}
|
||||
|
||||
export function useHandleContinuityWithMediaPlayer(playerRef: React.RefObject<MediaPlayerInstance | HTMLVideoElement>,
|
||||
episodeNumber: Nullish<number>,
|
||||
mediaId: Nullish<number | string>,
|
||||
) {
|
||||
const serverStatus = useServerStatus()
|
||||
const qc = useQueryClient()
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] })
|
||||
})()
|
||||
}, [episodeNumber ?? 0])
|
||||
|
||||
const { mutate: updateWatchHistory } = useUpdateContinuityWatchHistoryItem()
|
||||
|
||||
function handleUpdateWatchHistory() {
|
||||
if (!serverStatus?.settings?.library?.enableWatchContinuity) return
|
||||
|
||||
if (playerRef.current?.duration && playerRef.current?.currentTime) {
|
||||
logger("CONTINUITY").info("Watch history updated", {
|
||||
currentTime: playerRef.current?.currentTime,
|
||||
duration: playerRef.current?.duration,
|
||||
})
|
||||
|
||||
updateWatchHistory({
|
||||
options: {
|
||||
currentTime: playerRef.current?.currentTime ?? 0,
|
||||
duration: playerRef.current?.duration ?? 0,
|
||||
mediaId: Number(mediaId),
|
||||
episodeNumber: episodeNumber ?? 0,
|
||||
kind: "onlinestream",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { handleUpdateWatchHistory }
|
||||
}
|
||||
|
||||
export function useHandleCurrentMediaContinuity(mediaId: Nullish<number | string>) {
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const { data: watchHistory, isLoading: watchHistoryLoading } = useGetContinuityWatchHistoryItem(mediaId)
|
||||
|
||||
const waitForWatchHistory = watchHistoryLoading && serverStatus?.settings?.library?.enableWatchContinuity
|
||||
|
||||
function getEpisodeContinuitySeekTo(episodeNumber: Nullish<number>, playerCurrentTime: Nullish<number>, playerDuration: Nullish<number>) {
|
||||
if (!serverStatus?.settings?.library?.enableWatchContinuity || !mediaId || !watchHistory || !playerDuration || !episodeNumber) return 0
|
||||
const item = watchHistory?.item
|
||||
if (!item || !item.currentTime || !item.duration || item.episodeNumber !== episodeNumber) return 0
|
||||
if (!(item.currentTime > 0 && item.currentTime < playerDuration) || (item.currentTime / item.duration) > 90) return 0
|
||||
logger("CONTINUITY").info("Found last watched time", {
|
||||
currentTime: item.currentTime,
|
||||
duration: item.duration,
|
||||
episodeNumber: item.episodeNumber,
|
||||
})
|
||||
return item.currentTime
|
||||
}
|
||||
|
||||
return {
|
||||
watchHistory,
|
||||
waitForWatchHistory,
|
||||
getEpisodeContinuitySeekTo,
|
||||
}
|
||||
}
|
||||
142
seanime-2.9.10/seanime-web/src/api/hooks/debrid.hooks.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
DebridAddTorrents_Variables,
|
||||
DebridCancelDownload_Variables,
|
||||
DebridCancelStream_Variables,
|
||||
DebridDeleteTorrent_Variables,
|
||||
DebridDownloadTorrent_Variables,
|
||||
DebridGetTorrentFilePreviews_Variables,
|
||||
DebridGetTorrentInfo_Variables,
|
||||
DebridStartStream_Variables,
|
||||
SaveDebridSettings_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Debrid_TorrentInfo, Debrid_TorrentItem, DebridClient_FilePreview, Models_DebridSettings } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetDebridSettings() {
|
||||
return useServerQuery<Models_DebridSettings>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.GetDebridSettings.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.GetDebridSettings.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveDebridSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<Models_DebridSettings, SaveDebridSettings_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.SaveDebridSettings.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.SaveDebridSettings.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.SaveDebridSettings.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridAddTorrents(onSuccess: () => void) {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, DebridAddTorrents_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridAddTorrents.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridAddTorrents.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridAddTorrents.key],
|
||||
onSuccess: async () => {
|
||||
onSuccess()
|
||||
toast.success("Torrent added")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridDownloadTorrent() {
|
||||
return useServerMutation<boolean, DebridDownloadTorrent_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridDownloadTorrent.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Download started")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridCancelDownload() {
|
||||
return useServerMutation<boolean, DebridCancelDownload_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridCancelDownload.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridCancelDownload.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelDownload.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Download cancelled")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridDeleteTorrent() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, DebridDeleteTorrent_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridDeleteTorrent.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Torrent deleted")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridGetTorrents(enabled: boolean, refetchInterval: number) {
|
||||
return useServerQuery<Array<Debrid_TorrentItem>>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrents.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridGetTorrents.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key],
|
||||
enabled: enabled,
|
||||
retry: 3,
|
||||
refetchInterval: refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridGetTorrentInfo(variables: Partial<DebridGetTorrentInfo_Variables>, enabled: boolean) {
|
||||
return useServerQuery<Debrid_TorrentInfo, DebridGetTorrentInfo_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.key, variables?.torrent?.infoHash],
|
||||
data: variables as DebridGetTorrentInfo_Variables,
|
||||
enabled: enabled,
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridGetTorrentFilePreviews(variables: Partial<DebridGetTorrentFilePreviews_Variables>, enabled: boolean) {
|
||||
return useServerQuery<Array<DebridClient_FilePreview>, DebridGetTorrentFilePreviews_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.key, variables?.torrent?.infoHash],
|
||||
data: variables as DebridGetTorrentFilePreviews_Variables,
|
||||
enabled: enabled,
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridStartStream() {
|
||||
return useServerMutation<boolean, DebridStartStream_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridStartStream.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridStartStream.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridStartStream.key],
|
||||
onSuccess: async () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDebridCancelStream() {
|
||||
return useServerMutation<boolean, DebridCancelStream_Variables>({
|
||||
endpoint: API_ENDPOINTS.DEBRID.DebridCancelStream.endpoint,
|
||||
method: API_ENDPOINTS.DEBRID.DebridCancelStream.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelStream.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Stream cancelled")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useServerQuery } from "@/api/client/requests"
|
||||
import { DirectorySelector_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { DirectorySelectorResponse } from "@/api/generated/types"
|
||||
|
||||
export function useDirectorySelector(debouncedInput: string) {
|
||||
return useServerQuery<DirectorySelectorResponse, DirectorySelector_Variables>({
|
||||
endpoint: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.endpoint,
|
||||
method: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.key, debouncedInput],
|
||||
data: { input: debouncedInput },
|
||||
enabled: debouncedInput.length > 0,
|
||||
muteError: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useServerMutation } from "../client/requests"
|
||||
import { DirectstreamPlayLocalFile_Variables } from "../generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "../generated/endpoints"
|
||||
import { Mediastream_MediaContainer } from "../generated/types"
|
||||
|
||||
export function useDirectstreamPlayLocalFile() {
|
||||
return useServerMutation<Mediastream_MediaContainer, DirectstreamPlayLocalFile_Variables>({
|
||||
endpoint: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.endpoint,
|
||||
method: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
64
seanime-2.9.10/seanime-web/src/api/hooks/discord.hooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import {
|
||||
SetDiscordAnimeActivityWithProgress_Variables,
|
||||
SetDiscordLegacyAnimeActivity_Variables,
|
||||
SetDiscordMangaActivity_Variables,
|
||||
UpdateDiscordAnimeActivityWithProgress_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
|
||||
export function useSetDiscordMangaActivity() {
|
||||
return useServerMutation<boolean, SetDiscordMangaActivity_Variables>({
|
||||
endpoint: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.endpoint,
|
||||
method: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetDiscordLegacyAnimeActivity() {
|
||||
return useServerMutation<boolean, SetDiscordLegacyAnimeActivity_Variables>({
|
||||
endpoint: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.endpoint,
|
||||
method: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetDiscordAnimeActivityWithProgress() {
|
||||
return useServerMutation<boolean, SetDiscordAnimeActivityWithProgress_Variables>({
|
||||
endpoint: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.endpoint,
|
||||
method: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateDiscordAnimeActivityWithProgress() {
|
||||
return useServerMutation<boolean, UpdateDiscordAnimeActivityWithProgress_Variables>({
|
||||
endpoint: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.endpoint,
|
||||
method: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelDiscordActivity() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.DISCORD.CancelDiscordActivity.endpoint,
|
||||
method: API_ENDPOINTS.DISCORD.CancelDiscordActivity.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DISCORD.CancelDiscordActivity.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
13
seanime-2.9.10/seanime-web/src/api/hooks/docs.hooks.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useServerQuery } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { ApiDocsGroup } from "@/api/generated/types"
|
||||
|
||||
export function useGetDocs() {
|
||||
return useServerQuery<ApiDocsGroup[]>({
|
||||
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
|
||||
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
|
||||
queryKey: [API_ENDPOINTS.DOCS.GetDocs.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
40
seanime-2.9.10/seanime-web/src/api/hooks/download.hooks.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { DownloadRelease_Variables, DownloadTorrentFile_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { DownloadReleaseResponse } from "@/api/generated/types"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useDownloadTorrentFile(onSuccess?: () => void) {
|
||||
return useServerMutation<boolean, DownloadTorrentFile_Variables>({
|
||||
endpoint: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.endpoint,
|
||||
method: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Files downloaded")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadRelease() {
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
return useServerMutation<DownloadReleaseResponse, DownloadRelease_Variables>({
|
||||
endpoint: API_ENDPOINTS.DOWNLOAD.DownloadRelease.endpoint,
|
||||
method: API_ENDPOINTS.DOWNLOAD.DownloadRelease.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadRelease.key],
|
||||
onSuccess: async data => {
|
||||
toast.success("Update downloaded successfully!")
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
}
|
||||
if (data?.destination) {
|
||||
openInExplorer({
|
||||
path: data.destination,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
15
seanime-2.9.10/seanime-web/src/api/hooks/explorer.hooks.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { OpenInExplorer_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
|
||||
export function useOpenInExplorer() {
|
||||
return useServerMutation<boolean, OpenInExplorer_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXPLORER.OpenInExplorer.endpoint,
|
||||
method: API_ENDPOINTS.EXPLORER.OpenInExplorer.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXPLORER.OpenInExplorer.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
271
seanime-2.9.10/seanime-web/src/api/hooks/extensions.hooks.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
FetchExternalExtensionData_Variables,
|
||||
GetAllExtensions_Variables,
|
||||
GrantPluginPermissions_Variables,
|
||||
InstallExternalExtension_Variables,
|
||||
ReloadExternalExtension_Variables,
|
||||
RunExtensionPlaygroundCode_Variables,
|
||||
SaveExtensionUserConfig_Variables,
|
||||
SetPluginSettingsPinnedTrays_Variables,
|
||||
UninstallExternalExtension_Variables,
|
||||
UpdateExtensionCode_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import {
|
||||
Extension_Extension,
|
||||
ExtensionRepo_AllExtensions,
|
||||
ExtensionRepo_AnimeTorrentProviderExtensionItem,
|
||||
ExtensionRepo_ExtensionInstallResponse,
|
||||
ExtensionRepo_ExtensionUserConfig,
|
||||
ExtensionRepo_MangaProviderExtensionItem,
|
||||
ExtensionRepo_OnlinestreamProviderExtensionItem,
|
||||
ExtensionRepo_StoredPluginSettingsData,
|
||||
ExtensionRepo_UpdateData,
|
||||
Nullish,
|
||||
RunPlaygroundCodeResponse,
|
||||
} from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const unauthorizedPluginCountAtom = atom(0)
|
||||
|
||||
export function useUnauthorizedPluginCount() {
|
||||
const [count] = useAtom(unauthorizedPluginCountAtom)
|
||||
return count
|
||||
}
|
||||
|
||||
export function useGetAllExtensions(withUpdates: boolean) {
|
||||
const { data, ...rest } = useServerQuery<ExtensionRepo_AllExtensions, GetAllExtensions_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key, withUpdates],
|
||||
data: {
|
||||
withUpdates: withUpdates,
|
||||
},
|
||||
gcTime: 0,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const [, setCount] = useAtom(unauthorizedPluginCountAtom)
|
||||
React.useEffect(() => {
|
||||
setCount((data?.invalidExtensions ?? []).filter(n => n.code === "plugin_permissions_not_granted")?.length ?? 0)
|
||||
}, [data])
|
||||
|
||||
return { data, ...rest }
|
||||
}
|
||||
|
||||
export function useFetchExternalExtensionData(id: Nullish<string>) {
|
||||
return useServerMutation<Extension_Extension, FetchExternalExtensionData_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.key, id],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstallExternalExtension() {
|
||||
return useServerMutation<ExtensionRepo_ExtensionInstallResponse, InstallExternalExtension_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.key],
|
||||
onSuccess: async () => {
|
||||
// DEVNOTE: No need to refetch, the websocket listener will do it
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUninstallExternalExtension() {
|
||||
return useServerMutation<boolean, UninstallExternalExtension_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.key],
|
||||
onSuccess: async () => {
|
||||
// DEVNOTE: No need to refetch, the websocket listener will do it
|
||||
toast.success("Extension uninstalled successfully.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetExtensionPayload(id: string) {
|
||||
return useServerQuery<string>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.endpoint.replace("{id}", id),
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.key, id],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateExtensionCode() {
|
||||
return useServerMutation<boolean, UpdateExtensionCode_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.key],
|
||||
onSuccess: async () => {
|
||||
// DEVNOTE: No need to refetch, the websocket listener will do it
|
||||
toast.success("Extension updated successfully.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReloadExternalExtensions() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useListExtensionData() {
|
||||
return useServerQuery<Array<Extension_Extension>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ListExtensionData.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ListExtensionData.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.ListExtensionData.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useListMangaProviderExtensions() {
|
||||
return useServerQuery<Array<ExtensionRepo_MangaProviderExtensionItem>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useListOnlinestreamProviderExtensions() {
|
||||
return useServerQuery<Array<ExtensionRepo_OnlinestreamProviderExtensionItem>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnimeListTorrentProviderExtensions() {
|
||||
return useServerQuery<Array<ExtensionRepo_AnimeTorrentProviderExtensionItem>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRunExtensionPlaygroundCode() {
|
||||
return useServerMutation<RunPlaygroundCodeResponse, RunExtensionPlaygroundCode_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetExtensionUserConfig(id: string) {
|
||||
return useServerQuery<ExtensionRepo_ExtensionUserConfig>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.endpoint.replace("{id}", id),
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.key, id],
|
||||
enabled: true,
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveExtensionUserConfig() {
|
||||
return useServerMutation<boolean, SaveExtensionUserConfig_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.key],
|
||||
onSuccess: async () => {
|
||||
// DEVNOTE: No need to refetch, the websocket listener will do it
|
||||
toast.success("Config saved successfully.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useListDevelopmentModeExtensions() {
|
||||
return useServerQuery<Array<Extension_Extension>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useReloadExternalExtension() {
|
||||
const queryClient = useQueryClient()
|
||||
return useServerMutation<boolean, ReloadExternalExtension_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Extension reloaded successfully.")
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key] })
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
|
||||
// DEVNOTE: No need to refetch, the websocket listener will do it
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetPluginSettings() {
|
||||
return useServerQuery<ExtensionRepo_StoredPluginSettingsData>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetPluginSettingsPinnedTrays() {
|
||||
const queryClient = useQueryClient()
|
||||
return useServerMutation<boolean, SetPluginSettingsPinnedTrays_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.key],
|
||||
onSuccess: async () => {
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGrantPluginPermissions() {
|
||||
const queryClient = useQueryClient()
|
||||
return useServerMutation<boolean, GrantPluginPermissions_Variables>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Plugin permissions granted successfully.")
|
||||
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMarketplaceExtensions(marketplaceUrl?: string) {
|
||||
const url = marketplaceUrl ? `?marketplace=${encodeURIComponent(marketplaceUrl)}` : ""
|
||||
return useServerQuery<Array<Extension_Extension>>({
|
||||
endpoint: `${API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.endpoint}${url}`,
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.key, marketplaceUrl],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetExtensionUpdateData() {
|
||||
return useServerQuery<Array<ExtensionRepo_UpdateData>>({
|
||||
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.endpoint,
|
||||
method: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.methods[0],
|
||||
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
44
seanime-2.9.10/seanime-web/src/api/hooks/filecache.hooks.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { RemoveFileCacheBucket_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetFileCacheTotalSize() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.endpoint,
|
||||
method: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.key],
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveFileCacheBucket(onSuccess?: () => void) {
|
||||
return useServerMutation<boolean, RemoveFileCacheBucket_Variables>({
|
||||
endpoint: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.endpoint,
|
||||
method: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Cache cleared")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetFileCacheMediastreamVideoFilesTotalSize() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.endpoint,
|
||||
method: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.key],
|
||||
})
|
||||
}
|
||||
|
||||
export function useClearFileCacheMediastreamVideoFiles(onSuccess?: () => void) {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.endpoint,
|
||||
method: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Cache cleared")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
165
seanime-2.9.10/seanime-web/src/api/hooks/local.hooks.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Local_QueueState, Local_TrackedMediaItem } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
LocalAddTrackedMedia_Variables,
|
||||
LocalRemoveTrackedMedia_Variables,
|
||||
LocalSetHasLocalChanges_Variables,
|
||||
SetOfflineMode_Variables,
|
||||
} from "../generated/endpoint.types"
|
||||
|
||||
export function useLocalGetTrackedMediaItems() {
|
||||
return useServerQuery<Array<Local_TrackedMediaItem>>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key],
|
||||
enabled: true,
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalAddTrackedMedia() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, LocalAddTrackedMedia_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
|
||||
toast.success("Added media for offline syncing")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalRemoveTrackedMedia() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, LocalRemoveTrackedMedia_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
|
||||
toast.success("Removed offline data")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalSyncData() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalSyncData.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalSyncData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncData.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Syncing local data...")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalGetSyncQueueData() {
|
||||
return useServerQuery<Local_QueueState>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalGetIsMediaTracked(id: number, type: string) {
|
||||
return useServerQuery<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.endpoint.replace("{id}", String(id)).replace("{type}", String(type)),
|
||||
method: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key, id, type],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalSyncAnilistData() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncAnilistData.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
|
||||
toast.success("Updated Anilist data")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalSetHasLocalChanges() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, LocalSetHasLocalChanges_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalGetHasLocalChanges() {
|
||||
return useServerQuery<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalGetLocalStorageSize() {
|
||||
return useServerQuery<string>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalSyncSimulatedDataToAnilist() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.key],
|
||||
onSuccess: async () => {
|
||||
({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
|
||||
toast.success("Updated Anilist data")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetOfflineMode() {
|
||||
return useServerMutation<boolean, SetOfflineMode_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCAL.SetOfflineMode.endpoint,
|
||||
method: API_ENDPOINTS.LOCAL.SetOfflineMode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCAL.SetOfflineMode.key],
|
||||
onSuccess: async (data) => {
|
||||
if (data) {
|
||||
toast.success("Offline mode enabled")
|
||||
window.location.href = "/offline"
|
||||
} else {
|
||||
toast.success("Offline mode disabled")
|
||||
window.location.href = "/"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
125
seanime-2.9.10/seanime-web/src/api/hooks/localfiles.hooks.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
DeleteLocalFiles_Variables,
|
||||
ImportLocalFiles_Variables,
|
||||
LocalFileBulkAction_Variables,
|
||||
UpdateLocalFileData_Variables,
|
||||
UpdateLocalFiles_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_LocalFile, Nullish } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetLocalFiles() {
|
||||
return useServerQuery<Array<Anime_LocalFile>>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.GetLocalFiles.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.GetLocalFiles.methods[0],
|
||||
queryKey: [API_ENDPOINTS.LOCALFILES.GetLocalFiles.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLocalFileBulkAction() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useServerMutation<Array<Anime_LocalFile>, LocalFileBulkAction_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateLocalFiles() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, UpdateLocalFiles_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateLocalFileData(id: Nullish<number>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const opts = useServerMutation<Array<Anime_LocalFile>, UpdateLocalFileData_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("File metadata updated")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
if (id) {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
updateLocalFile: (lf: Anime_LocalFile, variables: Partial<UpdateLocalFileData_Variables>, onSuccess?: () => void) => {
|
||||
opts.mutate({
|
||||
path: lf.path,
|
||||
metadata: lf.metadata,
|
||||
locked: lf.locked,
|
||||
ignored: lf.ignored,
|
||||
mediaId: lf.mediaId,
|
||||
...variables,
|
||||
}, {
|
||||
onSuccess: () => onSuccess?.(),
|
||||
})
|
||||
},
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeleteLocalFiles(id: Nullish<number>) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useServerMutation<Array<Anime_LocalFile>, DeleteLocalFiles_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Files deleted")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
if (id) {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveEmptyDirectories() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Empty directories removed")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportLocalFiles() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, ImportLocalFiles_Variables>({
|
||||
endpoint: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.endpoint,
|
||||
method: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.LOCALFILES.ImportLocalFiles.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
toast.success("Local files imported")
|
||||
},
|
||||
})
|
||||
}
|
||||
42
seanime-2.9.10/seanime-web/src/api/hooks/mal.hooks.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { EditMALListEntryProgress_Variables, MALAuth_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { MalAuthResponse } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useMALAuth(variables: Partial<MALAuth_Variables>, enabled: boolean) {
|
||||
return useServerQuery<MalAuthResponse, MALAuth_Variables>({
|
||||
endpoint: API_ENDPOINTS.MAL.MALAuth.endpoint,
|
||||
method: API_ENDPOINTS.MAL.MALAuth.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MAL.MALAuth.key],
|
||||
data: variables as MALAuth_Variables,
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEditMALListEntryProgress() {
|
||||
return useServerMutation<boolean, EditMALListEntryProgress_Variables>({
|
||||
endpoint: API_ENDPOINTS.MAL.EditMALListEntryProgress.endpoint,
|
||||
method: API_ENDPOINTS.MAL.EditMALListEntryProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MAL.EditMALListEntryProgress.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMALLogout() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MAL.MALLogout.endpoint,
|
||||
method: API_ENDPOINTS.MAL.MALLogout.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MAL.MALLogout.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Successfully logged out of MyAnimeList")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
222
seanime-2.9.10/seanime-web/src/api/hooks/manga.hooks.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
AnilistListManga_Variables,
|
||||
EmptyMangaEntryCache_Variables,
|
||||
GetAnilistMangaCollection_Variables,
|
||||
GetMangaEntryChapters_Variables,
|
||||
GetMangaEntryPages_Variables,
|
||||
GetMangaMapping_Variables,
|
||||
MangaManualMapping_Variables,
|
||||
MangaManualSearch_Variables,
|
||||
RefetchMangaChapterContainers_Variables,
|
||||
RemoveMangaMapping_Variables,
|
||||
UpdateMangaProgress_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import {
|
||||
AL_ListManga,
|
||||
AL_MangaCollection,
|
||||
AL_MangaDetailsById_Media,
|
||||
HibikeManga_SearchResult,
|
||||
Manga_ChapterContainer,
|
||||
Manga_Collection,
|
||||
Manga_Entry,
|
||||
Manga_MangaLatestChapterNumberItem,
|
||||
Manga_MappingResponse,
|
||||
Manga_PageContainer,
|
||||
Nullish,
|
||||
} from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetAnilistMangaCollection() {
|
||||
return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function useGetRawAnilistMangaCollection() {
|
||||
return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaCollection() {
|
||||
return useServerQuery<Manga_Collection>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaCollection.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetMangaCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaEntry(id: Nullish<string | number>) {
|
||||
return useServerQuery<Manga_Entry>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaEntry.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.MANGA.GetMangaEntry.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)],
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaEntryDetails(id: Nullish<string | number>) {
|
||||
return useServerQuery<AL_MangaDetailsById_Media>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDetails.endpoint.replace("{id}", String(id)),
|
||||
method: API_ENDPOINTS.MANGA.GetMangaEntryDetails.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDetails.key, String(id)],
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEmptyMangaEntryCache() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, EmptyMangaEntryCache_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.EmptyMangaEntryCache.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaEntryChapters(variables: Partial<GetMangaEntryChapters_Variables>) {
|
||||
return useServerQuery<Manga_ChapterContainer, GetMangaEntryChapters_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryChapters.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetMangaEntryChapters.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key, String(variables.mediaId), variables.provider],
|
||||
data: variables as GetMangaEntryChapters_Variables,
|
||||
enabled: !!variables.mediaId && !!variables.provider,
|
||||
muteError: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaEntryPages(variables: Partial<GetMangaEntryPages_Variables>) {
|
||||
return useServerQuery<Manga_PageContainer, GetMangaEntryPages_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryPages.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetMangaEntryPages.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key, String(variables.mediaId), variables.provider, variables.chapterId,
|
||||
variables.doublePage],
|
||||
data: variables as GetMangaEntryPages_Variables,
|
||||
enabled: !!variables.mediaId && !!variables.provider && !!variables.chapterId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAnilistListManga(variables: AnilistListManga_Variables, enabled?: boolean) {
|
||||
return useServerQuery<AL_ListManga, AnilistListManga_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.AnilistListManga.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.AnilistListManga.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.AnilistListManga.key, variables],
|
||||
data: variables,
|
||||
enabled: enabled ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateMangaProgress(id: Nullish<string | number>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, UpdateMangaProgress_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.UpdateMangaProgress.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.UpdateMangaProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.UpdateMangaProgress.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function useMangaManualSearch(mediaId: Nullish<number>, provider: Nullish<string>) {
|
||||
return useServerMutation<Array<HibikeManga_SearchResult>, MangaManualSearch_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.MangaManualSearch.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.MangaManualSearch.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.MangaManualSearch.key, String(mediaId), provider],
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMangaManualMapping() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, MangaManualMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.MangaManualMapping.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.MangaManualMapping.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.MangaManualMapping.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Mapping added")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaMapping(variables: Partial<GetMangaMapping_Variables>) {
|
||||
return useServerQuery<Manga_MappingResponse, GetMangaMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaMapping.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetMangaMapping.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key, String(variables.mediaId), variables.provider],
|
||||
data: variables as GetMangaMapping_Variables,
|
||||
enabled: !!variables.provider && !!variables.mediaId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveMangaMapping() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, RemoveMangaMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.RemoveMangaMapping.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.RemoveMangaMapping.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.RemoveMangaMapping.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Mapping removed")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaEntryDownloadedChapters(mId: Nullish<string | number>) {
|
||||
return useServerQuery<Array<Manga_ChapterContainer>>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.endpoint.replace("{id}", String(mId)),
|
||||
method: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.key, String(mId)],
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaLatestChapterNumbersMap() {
|
||||
return useServerQuery<Record<number, Array<Manga_MangaLatestChapterNumberItem>>>({
|
||||
endpoint: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRefetchMangaChapterContainers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, RefetchMangaChapterContainers_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.endpoint,
|
||||
method: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
|
||||
toast.success("Sources refreshed")
|
||||
},
|
||||
})
|
||||
}
|
||||
122
seanime-2.9.10/seanime-web/src/api/hooks/manga_download.hooks.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
DeleteMangaDownloadedChapters_Variables,
|
||||
DownloadMangaChapters_Variables,
|
||||
GetMangaDownloadData_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Manga_DownloadListItem, Manga_MediaDownloadData, Models_ChapterDownloadQueueItem, Nullish } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useDownloadMangaChapters(id: Nullish<string | number>, provider: Nullish<string>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, DownloadMangaChapters_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.key, String(id), provider],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaDownloadData(variables: Partial<GetMangaDownloadData_Variables>) {
|
||||
return useServerQuery<Manga_MediaDownloadData, GetMangaDownloadData_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key, String(variables.mediaId), String(variables.cached)],
|
||||
data: variables as GetMangaDownloadData_Variables,
|
||||
enabled: !!variables.mediaId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaDownloadQueue() {
|
||||
return useServerQuery<Array<Models_ChapterDownloadQueueItem>>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartMangaDownloadQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
|
||||
toast.info("Downloading chapters")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopMangaDownloadQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
|
||||
toast.success("Download queue stopped")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useClearAllChapterDownloadQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
|
||||
toast.success("Download queue cleared")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useResetErroredChapterDownloadQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
|
||||
toast.success("Reset errored chapters")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteMangaDownloadedChapters(id: Nullish<string | number>, provider: string | null) {
|
||||
const queryClient = useQueryClient()
|
||||
return useServerMutation<boolean, DeleteMangaDownloadedChapters_Variables>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.key, String(id), provider],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] })
|
||||
toast.success("Chapters deleted")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetMangaDownloadsList() {
|
||||
|
||||
return useServerQuery<Array<Manga_DownloadListItem>>({
|
||||
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.endpoint,
|
||||
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
|
||||
export function useStartDefaultMediaPlayer() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.endpoint,
|
||||
method: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
PreloadMediastreamMediaContainer_Variables,
|
||||
RequestMediastreamMediaContainer_Variables,
|
||||
SaveMediastreamSettings_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Mediastream_MediaContainer, Models_MediastreamSettings } from "@/api/generated/types"
|
||||
import { logger } from "@/lib/helpers/debug"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetMediastreamSettings(enabled?: boolean) {
|
||||
return useServerQuery<Models_MediastreamSettings>({
|
||||
endpoint: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.endpoint,
|
||||
method: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveMediastreamSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<Models_MediastreamSettings, SaveMediastreamSettings_Variables>({
|
||||
endpoint: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.endpoint,
|
||||
method: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRequestMediastreamMediaContainer(variables: Partial<RequestMediastreamMediaContainer_Variables>, enabled: boolean) {
|
||||
return useServerQuery<Mediastream_MediaContainer, RequestMediastreamMediaContainer_Variables>({
|
||||
endpoint: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.endpoint,
|
||||
method: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.methods[0],
|
||||
queryKey: [API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.key, variables?.path, variables?.streamType],
|
||||
data: variables as RequestMediastreamMediaContainer_Variables,
|
||||
enabled: !!variables.path && !!variables.streamType && enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreloadMediastreamMediaContainer() {
|
||||
return useServerMutation<boolean, PreloadMediastreamMediaContainer_Variables>({
|
||||
endpoint: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.endpoint,
|
||||
method: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.key],
|
||||
onSuccess: async () => {
|
||||
logger("MEDIASTREAM").success("Preloaded mediastream media container")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMediastreamShutdownTranscodeStream() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.endpoint,
|
||||
method: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
36
seanime-2.9.10/seanime-web/src/api/hooks/metadata.hooks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { PopulateFillerData_Variables, RemoveFillerData_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function usePopulateFillerData() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<true, PopulateFillerData_Variables>({
|
||||
endpoint: API_ENDPOINTS.METADATA.PopulateFillerData.endpoint,
|
||||
method: API_ENDPOINTS.METADATA.PopulateFillerData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.METADATA.PopulateFillerData.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
toast.success("Filler data fetched")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveFillerData() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, RemoveFillerData_Variables>({
|
||||
endpoint: API_ENDPOINTS.METADATA.RemoveFillerData.endpoint,
|
||||
method: API_ENDPOINTS.METADATA.RemoveFillerData.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.METADATA.RemoveFillerData.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
toast.success("Filler data removed")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
100
seanime-2.9.10/seanime-web/src/api/hooks/nakama.hooks.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useServerMutation, useServerQuery } from "../client/requests"
|
||||
import { NakamaCreateWatchParty_Variables, NakamaPlayVideo_Variables, SendNakamaMessage_Variables } from "../generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "../generated/endpoints"
|
||||
import { Anime_LibraryCollection, Nakama_MessageResponse } from "../generated/types"
|
||||
|
||||
export function useNakamaWebSocket() {
|
||||
return useServerQuery<boolean>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaWebSocket.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaWebSocket.methods[0],
|
||||
queryKey: [API_ENDPOINTS.NAKAMA.NakamaWebSocket.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function useSendNakamaMessage() {
|
||||
return useServerMutation<Nakama_MessageResponse, SendNakamaMessage_Variables>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.SendNakamaMessage.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.SendNakamaMessage.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.SendNakamaMessage.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaReconnectToHost() {
|
||||
return useServerMutation<Nakama_MessageResponse, {}>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaRemoveStaleConnections() {
|
||||
return useServerMutation<Nakama_MessageResponse, {}>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetNakamaAnimeLibraryCollection() {
|
||||
return useServerQuery<Anime_LibraryCollection>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.methods[0],
|
||||
queryKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaPlayVideo() {
|
||||
return useServerMutation<boolean, NakamaPlayVideo_Variables>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaPlayVideo.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaCreateWatchParty() {
|
||||
return useServerMutation<any, NakamaCreateWatchParty_Variables>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaJoinWatchParty() {
|
||||
return useServerMutation<Nakama_MessageResponse>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNakamaLeaveWatchParty() {
|
||||
return useServerMutation<Nakama_MessageResponse>({
|
||||
endpoint: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.endpoint,
|
||||
method: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
125
seanime-2.9.10/seanime-web/src/api/hooks/onlinestream.hooks.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
GetOnlineStreamEpisodeList_Variables,
|
||||
GetOnlineStreamEpisodeSource_Variables,
|
||||
GetOnlinestreamMapping_Variables,
|
||||
OnlineStreamEmptyCache_Variables,
|
||||
OnlinestreamManualMapping_Variables,
|
||||
OnlinestreamManualSearch_Variables,
|
||||
RemoveOnlinestreamMapping_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import {
|
||||
HibikeOnlinestream_SearchResult,
|
||||
Nullish,
|
||||
Onlinestream_EpisodeListResponse,
|
||||
Onlinestream_EpisodeSource,
|
||||
Onlinestream_MappingResponse,
|
||||
} from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetOnlineStreamEpisodeList(id: Nullish<string | number>, provider: Nullish<string>, dubbed: boolean) {
|
||||
return useServerQuery<Onlinestream_EpisodeListResponse, GetOnlineStreamEpisodeList_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key, String(id), provider, dubbed],
|
||||
data: {
|
||||
mediaId: Number(id),
|
||||
provider: provider!,
|
||||
dubbed,
|
||||
},
|
||||
enabled: !!id,
|
||||
muteError: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetOnlineStreamEpisodeSource(id: Nullish<string | number>,
|
||||
provider: Nullish<string>,
|
||||
episodeNumber: Nullish<number>,
|
||||
dubbed: boolean,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useServerQuery<Onlinestream_EpisodeSource, GetOnlineStreamEpisodeSource_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key, String(id), provider, episodeNumber, dubbed],
|
||||
data: {
|
||||
mediaId: Number(id),
|
||||
episodeNumber: episodeNumber!,
|
||||
dubbed: dubbed,
|
||||
provider: provider!,
|
||||
},
|
||||
enabled: enabled && !!provider,
|
||||
muteError: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOnlineStreamEmptyCache() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, OnlineStreamEmptyCache_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
|
||||
toast.info("Stream cache emptied")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function useOnlinestreamManualSearch(mId: number, provider: Nullish<string>) {
|
||||
return useServerMutation<Array<HibikeOnlinestream_SearchResult>, OnlinestreamManualSearch_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useOnlinestreamManualMapping() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, OnlinestreamManualMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Mapping added")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetOnlinestreamMapping(variables: Partial<GetOnlinestreamMapping_Variables>) {
|
||||
return useServerQuery<Onlinestream_MappingResponse, GetOnlinestreamMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.methods[0],
|
||||
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key, String(variables.mediaId), variables.provider],
|
||||
data: variables as GetOnlinestreamMapping_Variables,
|
||||
enabled: !!variables.provider && !!variables.mediaId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveOnlinestreamMapping() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, RemoveOnlinestreamMapping_Variables>({
|
||||
endpoint: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.endpoint,
|
||||
method: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Mapping removed")
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { PlaybackPlayVideo_Variables, PlaybackStartManualTracking_Variables, PlaybackStartPlaylist_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function usePlaybackSyncCurrentProgress() {
|
||||
const serverStatus = useServerStatus()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<number>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.key],
|
||||
onSuccess: async mediaId => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(mediaId)] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackPlayNextEpisode(...keys: any) {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.key, ...keys],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackStartPlaylist({
|
||||
onSuccess,
|
||||
}: {
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, PlaybackStartPlaylist_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackCancelCurrentPlaylist(...keys: any) {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.key, ...keys],
|
||||
onSuccess: async () => {
|
||||
toast.info("Cancelling playlist")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackPlaylistNext(...keys: any) {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.key, ...keys],
|
||||
onSuccess: async () => {
|
||||
toast.info("Loading next file")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackPlayVideo() {
|
||||
return useServerMutation<boolean, PlaybackPlayVideo_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackPlayRandomVideo() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Playing random episode")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackStartManualTracking() {
|
||||
return useServerMutation<boolean, PlaybackStartManualTracking_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackCancelManualTracking({ onSuccess }: { onSuccess?: () => void }) {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.key],
|
||||
onSuccess: async () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackGetNextEpisode() {
|
||||
return useServerMutation<Anime_LocalFile>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.key],
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlaybackAutoPlayNextEpisode() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.endpoint,
|
||||
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.key],
|
||||
onSuccess: async () => {
|
||||
toast.info("Loading next episode")
|
||||
},
|
||||
})
|
||||
}
|
||||
68
seanime-2.9.10/seanime-web/src/api/hooks/playlist.hooks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { CreatePlaylist_Variables, DeletePlaylist_Variables, UpdatePlaylist_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_LocalFile, Anime_Playlist } from "@/api/generated/types"
|
||||
import { Nullish } from "@/types/common"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useCreatePlaylist() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Anime_Playlist, CreatePlaylist_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYLIST.CreatePlaylist.endpoint,
|
||||
method: API_ENDPOINTS.PLAYLIST.CreatePlaylist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYLIST.CreatePlaylist.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
|
||||
toast.success("Playlist created")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetPlaylists() {
|
||||
return useServerQuery<Array<Anime_Playlist>>({
|
||||
endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylists.endpoint,
|
||||
method: API_ENDPOINTS.PLAYLIST.GetPlaylists.methods[0],
|
||||
queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePlaylist() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Anime_Playlist, UpdatePlaylist_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.endpoint,
|
||||
method: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYLIST.UpdatePlaylist.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
|
||||
toast.success("Playlist updated")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeletePlaylist() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, DeletePlaylist_Variables>({
|
||||
endpoint: API_ENDPOINTS.PLAYLIST.DeletePlaylist.endpoint,
|
||||
method: API_ENDPOINTS.PLAYLIST.DeletePlaylist.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.PLAYLIST.DeletePlaylist.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
|
||||
toast.success("Playlist deleted")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetPlaylistEpisodes(id: Nullish<number>, progress: Nullish<number>) {
|
||||
return useServerQuery<Array<Anime_LocalFile>>({
|
||||
endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.endpoint.replace("{id}", String(id)).replace("{progress}", String(progress || 0)),
|
||||
method: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.methods[0],
|
||||
queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.key, String(id), String(progress || 0)],
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
37
seanime-2.9.10/seanime-web/src/api/hooks/releases.hooks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { InstallLatestUpdate_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Status, Updater_Update } from "@/api/generated/types"
|
||||
import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetLatestUpdate(enabled: boolean) {
|
||||
return useServerQuery<Updater_Update>({
|
||||
endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint,
|
||||
method: API_ENDPOINTS.RELEASES.GetLatestUpdate.methods[0],
|
||||
queryKey: [API_ENDPOINTS.RELEASES.GetLatestUpdate.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstallLatestUpdate() {
|
||||
const setServerStatus = useSetServerStatus()
|
||||
return useServerMutation<Status, InstallLatestUpdate_Variables>({
|
||||
endpoint: API_ENDPOINTS.RELEASES.InstallLatestUpdate.endpoint,
|
||||
method: API_ENDPOINTS.RELEASES.InstallLatestUpdate.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.RELEASES.InstallLatestUpdate.key],
|
||||
onSuccess: async (data) => {
|
||||
setServerStatus(data) // Update server status
|
||||
toast.info("Installing update...")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetChangelog(before: string, after: string, enabled: boolean) {
|
||||
return useServerQuery<{ version: string, lines: string[] }[]>({
|
||||
endpoint: API_ENDPOINTS.RELEASES.GetChangelog.endpoint + `?before=${before}&after=${after}`,
|
||||
method: API_ENDPOINTS.RELEASES.GetChangelog.methods[0],
|
||||
queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key],
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
23
seanime-2.9.10/seanime-web/src/api/hooks/report.hooks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { SaveIssueReport_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
|
||||
export function useSaveIssueReport() {
|
||||
return useServerMutation<boolean, SaveIssueReport_Variables>({
|
||||
endpoint: API_ENDPOINTS.REPORT.SaveIssueReport.endpoint,
|
||||
method: API_ENDPOINTS.REPORT.SaveIssueReport.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.REPORT.SaveIssueReport.key],
|
||||
onSuccess: async () => {
|
||||
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadIssueReport() {
|
||||
return useServerQuery<string>({
|
||||
endpoint: API_ENDPOINTS.REPORT.DownloadIssueReport.endpoint,
|
||||
method: API_ENDPOINTS.REPORT.DownloadIssueReport.methods[0],
|
||||
queryKey: [API_ENDPOINTS.REPORT.DownloadIssueReport.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
26
seanime-2.9.10/seanime-web/src/api/hooks/scan.hooks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useServerMutation } from "@/api/client/requests"
|
||||
import { ScanLocalFiles_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useScanLocalFiles(onSuccess?: () => void) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Array<Anime_LocalFile>, ScanLocalFiles_Variables>({
|
||||
endpoint: API_ENDPOINTS.SCAN.ScanLocalFiles.endpoint,
|
||||
method: API_ENDPOINTS.SCAN.ScanLocalFiles.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.SCAN.ScanLocalFiles.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
toast.success("Library scanned")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useServerQuery } from "@/api/client/requests"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Summary_ScanSummaryItem } from "@/api/generated/types"
|
||||
|
||||
export function useGetScanSummaries() {
|
||||
return useServerQuery<Array<Summary_ScanSummaryItem>>({
|
||||
endpoint: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.endpoint,
|
||||
method: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.methods[0],
|
||||
queryKey: [API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
64
seanime-2.9.10/seanime-web/src/api/hooks/settings.hooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { GettingStarted_Variables, SaveAutoDownloaderSettings_Variables, SaveSettings_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Models_Settings, Status } from "@/api/generated/types"
|
||||
import { isLoginModalOpenAtom } from "@/app/(main)/_atoms/server-status.atoms"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetSettings() {
|
||||
return useServerQuery<Models_Settings>({
|
||||
endpoint: API_ENDPOINTS.SETTINGS.GetSettings.endpoint,
|
||||
method: API_ENDPOINTS.SETTINGS.GetSettings.methods[0],
|
||||
queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGettingStarted() {
|
||||
const queryClient = useQueryClient()
|
||||
const setLoginModalOpen = useSetAtom(isLoginModalOpenAtom)
|
||||
|
||||
return useServerMutation<Status, GettingStarted_Variables>({
|
||||
endpoint: API_ENDPOINTS.SETTINGS.GettingStarted.endpoint,
|
||||
method: API_ENDPOINTS.SETTINGS.GettingStarted.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.SETTINGS.GettingStarted.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
setLoginModalOpen(true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Status, SaveSettings_Variables>({
|
||||
endpoint: API_ENDPOINTS.SETTINGS.SaveSettings.endpoint,
|
||||
method: API_ENDPOINTS.SETTINGS.SaveSettings.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.SETTINGS.SaveSettings.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveAutoDownloaderSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, SaveAutoDownloaderSettings_Variables>({
|
||||
endpoint: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.endpoint,
|
||||
method: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
278
seanime-2.9.10/seanime-web/src/api/hooks/status.hooks.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { getServerBaseUrl } from "@/api/client/server-url"
|
||||
import { DeleteLogs_Variables, GetAnnouncements_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { MemoryStatsResponse, Status, Updater_Announcement } from "@/api/generated/types"
|
||||
import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms"
|
||||
import { copyToClipboard, openTab } from "@/lib/helpers/browser"
|
||||
import { __isDesktop__ } from "@/types/constants"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useAtomValue } from "jotai"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetStatus() {
|
||||
return useServerQuery<Status>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetStatus.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetStatus.methods[0],
|
||||
queryKey: [API_ENDPOINTS.STATUS.GetStatus.key],
|
||||
enabled: true,
|
||||
retryDelay: 1000,
|
||||
// Fixes macOS desktop app startup issue
|
||||
retry: 6,
|
||||
// Mute error if the platform is desktop
|
||||
muteError: __isDesktop__,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetLogFilenames() {
|
||||
return useServerQuery<Array<string>>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetLogFilenames.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetLogFilenames.methods[0],
|
||||
queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteLogs() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<boolean, DeleteLogs_Variables>({
|
||||
endpoint: API_ENDPOINTS.STATUS.DeleteLogs.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.DeleteLogs.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.DeleteLogs.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key] })
|
||||
toast.success("Logs deleted")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetLatestLogContent() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<string>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetLatestLogContent.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetLatestLogContent.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.GetLatestLogContent.key],
|
||||
onSuccess: async data => {
|
||||
if (!data) return toast.error("Couldn't fetch logs")
|
||||
try {
|
||||
await copyToClipboard(data)
|
||||
toast.success("Copied to clipboard")
|
||||
}
|
||||
catch (err: any) {
|
||||
console.error("Clipboard write error:", err)
|
||||
toast.error("Failed to copy logs: " + err.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetAnnouncements() {
|
||||
return useServerMutation<Array<Updater_Announcement>, GetAnnouncements_Variables>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetAnnouncements.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetAnnouncements.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.GetAnnouncements.key],
|
||||
})
|
||||
}
|
||||
|
||||
// Memory profiling hooks
|
||||
|
||||
export function useGetMemoryStats() {
|
||||
return useServerQuery<MemoryStatsResponse>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetMemoryStats.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetMemoryStats.methods[0],
|
||||
queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key],
|
||||
enabled: false, // Manual trigger only
|
||||
refetchInterval: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForceGC() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<MemoryStatsResponse>({
|
||||
endpoint: API_ENDPOINTS.STATUS.ForceGC.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.ForceGC.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.ForceGC.key],
|
||||
onSuccess: async () => {
|
||||
// Invalidate and refetch memory stats after GC
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key] })
|
||||
toast.success("Garbage collection completed")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadMemoryProfile() {
|
||||
const password = useAtomValue(serverAuthTokenAtom)
|
||||
|
||||
return useServerMutation<string, { profileType: "heap" | "allocs" }>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetMemoryProfile.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.GetMemoryProfile.key],
|
||||
onMutate: async (variables) => {
|
||||
const profileType = variables.profileType || "heap"
|
||||
toast.info(`Generating ${profileType} profile...`)
|
||||
|
||||
let downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint
|
||||
if (profileType === "heap") {
|
||||
downloadUrl += "?heap=true"
|
||||
} else if (profileType === "allocs") {
|
||||
downloadUrl += "?allocs=true"
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (password) {
|
||||
headers["X-Seanime-Token"] = password
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: status: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
|
||||
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
|
||||
const filename = `seanime-${profileType}-profile-${timestamp}.pprof`
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.setAttribute("download", filename)
|
||||
link.style.display = "none"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
toast.success(`Profile "${profileType}" downloaded`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Download error:", error)
|
||||
toast.error(`Failed to download ${profileType} profile`)
|
||||
}
|
||||
|
||||
throw new Error("Download handled in onMutate")
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message !== "Download handled in onMutate") {
|
||||
toast.error("Failed to download memory profile")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadGoRoutineProfile() {
|
||||
const password = useAtomValue(serverAuthTokenAtom)
|
||||
|
||||
return useServerMutation<string>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetGoRoutineProfile.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.GetGoRoutineProfile.key],
|
||||
onMutate: async () => {
|
||||
toast.info("Generating goroutine profile...")
|
||||
|
||||
const downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (password) {
|
||||
headers["X-Seanime-Token"] = password
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
|
||||
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
|
||||
const filename = `seanime-goroutine-profile-${timestamp}.pprof`
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
openTab(url)
|
||||
|
||||
toast.success("Goroutine profile downloaded")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Download error:", error)
|
||||
toast.error("Failed to download goroutine profile")
|
||||
}
|
||||
|
||||
throw new Error("Download handled in onMutate")
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message !== "Download handled in onMutate") {
|
||||
toast.error("Failed to download goroutine profile")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDownloadCPUProfile() {
|
||||
const password = useAtomValue(serverAuthTokenAtom)
|
||||
|
||||
return useServerMutation<string, { duration?: number }>({
|
||||
endpoint: API_ENDPOINTS.STATUS.GetCPUProfile.endpoint,
|
||||
method: API_ENDPOINTS.STATUS.GetCPUProfile.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.STATUS.GetCPUProfile.key],
|
||||
onMutate: async (variables) => {
|
||||
const duration = variables?.duration || 30
|
||||
toast.info(`Generating CPU profile for ${duration} seconds...`)
|
||||
|
||||
const downloadUrl = `${getServerBaseUrl()}${API_ENDPOINTS.STATUS.GetCPUProfile.endpoint}?duration=${duration}`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (password) {
|
||||
headers["X-Seanime-Token"] = password
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
|
||||
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
|
||||
const filename = `seanime-cpu-profile-${timestamp}.pprof`
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.setAttribute("download", filename)
|
||||
link.style.display = "none"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
toast.success(`CPU profile (${duration}s) downloaded`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Download error:", error)
|
||||
toast.error(`Failed to download CPU profile`)
|
||||
}
|
||||
|
||||
throw new Error("Download handled in onMutate")
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message !== "Download handled in onMutate") {
|
||||
toast.error("Failed to download CPU profile")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
30
seanime-2.9.10/seanime-web/src/api/hooks/theme.hooks.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import { UpdateTheme_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Models_Theme } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetTheme() {
|
||||
return useServerQuery<Models_Theme>({
|
||||
endpoint: API_ENDPOINTS.THEME.GetTheme.endpoint,
|
||||
method: API_ENDPOINTS.THEME.GetTheme.methods[0],
|
||||
queryKey: [API_ENDPOINTS.THEME.GetTheme.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTheme() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<Models_Theme, UpdateTheme_Variables>({
|
||||
endpoint: API_ENDPOINTS.THEME.UpdateTheme.endpoint,
|
||||
method: API_ENDPOINTS.THEME.UpdateTheme.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.THEME.UpdateTheme.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("UI settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
TorrentClientAction_Variables,
|
||||
TorrentClientAddMagnetFromRule_Variables,
|
||||
TorrentClientDownload_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { TorrentClient_Torrent } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetActiveTorrentList(enabled: boolean) {
|
||||
return useServerQuery<Array<TorrentClient_Torrent>>({
|
||||
endpoint: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.endpoint,
|
||||
method: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.methods[0],
|
||||
queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key],
|
||||
refetchInterval: 1500,
|
||||
gcTime: 0,
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentClientAction(onSuccess?: () => void) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, TorrentClientAction_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.endpoint,
|
||||
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.key],
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key] })
|
||||
toast.success("Action performed")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentClientDownload(onSuccess?: () => void) {
|
||||
return useServerMutation<boolean, TorrentClientDownload_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.endpoint,
|
||||
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Download started")
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentClientAddMagnetFromRule() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useServerMutation<boolean, TorrentClientAddMagnetFromRule_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.endpoint,
|
||||
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Download started")
|
||||
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useServerQuery } from "@/api/client/requests"
|
||||
import { SearchTorrent_Variables } from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Torrent_SearchData } from "@/api/generated/types"
|
||||
|
||||
export function useSearchTorrent(variables: SearchTorrent_Variables, enabled: boolean) {
|
||||
return useServerQuery<Torrent_SearchData, SearchTorrent_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.endpoint,
|
||||
method: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.methods[0],
|
||||
data: variables,
|
||||
queryKey: [API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.key, JSON.stringify(variables)],
|
||||
enabled: enabled,
|
||||
gcTime: variables.episodeNumber === 0 ? 0 : undefined,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useServerMutation, useServerQuery } from "@/api/client/requests"
|
||||
import {
|
||||
GetTorrentstreamBatchHistory_Variables,
|
||||
GetTorrentstreamTorrentFilePreviews_Variables,
|
||||
SaveTorrentstreamSettings_Variables,
|
||||
TorrentstreamStartStream_Variables,
|
||||
} from "@/api/generated/endpoint.types"
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { Models_TorrentstreamSettings, Nullish, Torrentstream_BatchHistoryResponse, Torrentstream_FilePreview } from "@/api/generated/types"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useGetTorrentstreamSettings() {
|
||||
return useServerQuery<Models_TorrentstreamSettings>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.methods[0],
|
||||
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveTorrentstreamSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useServerMutation<Models_TorrentstreamSettings, SaveTorrentstreamSettings_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.key],
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key] })
|
||||
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
|
||||
toast.success("Settings saved")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentstreamStartStream() {
|
||||
return useServerMutation<boolean, TorrentstreamStartStream_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.key],
|
||||
onSuccess: async () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentstreamStopStream() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Stream stopped")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTorrentstreamDropTorrent() {
|
||||
return useServerMutation<boolean>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.methods[0],
|
||||
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.key],
|
||||
onSuccess: async () => {
|
||||
toast.success("Torrent dropped")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetTorrentstreamTorrentFilePreviews(variables: Partial<GetTorrentstreamTorrentFilePreviews_Variables>, enabled: boolean) {
|
||||
return useServerQuery<Array<Torrentstream_FilePreview>, GetTorrentstreamTorrentFilePreviews_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.methods[0],
|
||||
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.key, variables],
|
||||
data: variables as GetTorrentstreamTorrentFilePreviews_Variables,
|
||||
enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetTorrentstreamBatchHistory(mediaId: Nullish<string | number>, enabled: boolean) {
|
||||
return useServerQuery<Torrentstream_BatchHistoryResponse, GetTorrentstreamBatchHistory_Variables>({
|
||||
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.endpoint,
|
||||
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.methods[0],
|
||||
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.key, String(mediaId), enabled],
|
||||
data: {
|
||||
mediaId: Number(mediaId)!,
|
||||
},
|
||||
enabled: !!mediaId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
import { Anime_Episode } from "@/api/generated/types"
|
||||
import { __libraryHeaderEpisodeAtom } from "@/app/(main)/(library)/_containers/continue-watching"
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { ThemeMediaPageBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import Image from "next/image"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useWindowScroll } from "react-use"
|
||||
|
||||
export const __libraryHeaderImageAtom = atom<{ bannerImage?: string | null, episodeImage?: string | null } | null>({
|
||||
bannerImage: null,
|
||||
episodeImage: null,
|
||||
})
|
||||
|
||||
const MotionImage = motion.create(Image)
|
||||
|
||||
export function LibraryHeader({ list }: { list: Anime_Episode[] }) {
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const image = useAtomValue(__libraryHeaderImageAtom)
|
||||
const [actualImage, setActualImage] = useState<string | null>(null)
|
||||
const [prevImage, setPrevImage] = useState<string | null>(null)
|
||||
const [dimmed, setDimmed] = useState(false)
|
||||
|
||||
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
const bannerImage = image?.bannerImage || image?.episodeImage || ""
|
||||
const shouldHideBanner = (
|
||||
(ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !image?.bannerImage)
|
||||
|| ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide
|
||||
)
|
||||
const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !image?.bannerImage) ||
|
||||
ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur
|
||||
|
||||
useEffect(() => {
|
||||
if (image != actualImage) {
|
||||
if (actualImage === null) {
|
||||
setActualImage(bannerImage)
|
||||
} else {
|
||||
setActualImage(null)
|
||||
}
|
||||
}
|
||||
}, [image])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (image != actualImage) {
|
||||
setActualImage(bannerImage)
|
||||
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === image?.episodeImage || ep.baseAnime?.coverImage?.extraLarge === image?.episodeImage || ep.episodeMetadata?.image === image?.episodeImage) || null)
|
||||
}
|
||||
}, 600)
|
||||
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [image])
|
||||
|
||||
useEffect(() => {
|
||||
if (actualImage) {
|
||||
setPrevImage(actualImage)
|
||||
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === actualImage || ep.baseAnime?.coverImage?.extraLarge === actualImage || ep.episodeMetadata?.image === actualImage) || null)
|
||||
}
|
||||
}, [actualImage])
|
||||
|
||||
const { y } = useWindowScroll()
|
||||
|
||||
useEffect(() => {
|
||||
if (y > 100)
|
||||
setDimmed(true)
|
||||
else
|
||||
setDimmed(false)
|
||||
}, [(y > 100)])
|
||||
|
||||
if (!image) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-library-header-container
|
||||
className={cn(
|
||||
"LIB_HEADER_CONTAINER __header h-[25rem] z-[1] top-0 w-full absolute group/library-header pointer-events-none",
|
||||
// Make it not fixed when the user scrolls down if a background image is set
|
||||
!ts.libraryScreenCustomBackgroundImage && "fixed",
|
||||
)}
|
||||
>
|
||||
|
||||
<div
|
||||
data-library-header-banner-top-gradient
|
||||
className={cn(
|
||||
"w-full z-[3] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
data-library-header-inner-container
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className={cn(
|
||||
"LIB_HEADER_INNER_CONTAINER h-full z-[0] w-full flex-none object-cover object-center absolute top-0 overflow-hidden bg-[--background]",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
>
|
||||
|
||||
{!ts.disableSidebarTransparency && <div
|
||||
data-library-header-banner-inner-container-top-gradient
|
||||
className="hidden lg:block h-full absolute z-[2] w-[20%] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
|
||||
/>}
|
||||
|
||||
<div
|
||||
data-library-header-banner-inner-container-bottom-gradient
|
||||
className="w-full z-[3] opacity-50 absolute top-0 h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
|
||||
/>
|
||||
|
||||
{/*<div*/}
|
||||
{/* className="LIB_HEADER_TOP_FADE w-full absolute z-[2] top-0 h-[10rem] opacity-20 bg-gradient-to-b from-[var(--background)] to-transparent via"*/}
|
||||
{/*/>*/}
|
||||
<AnimatePresence>
|
||||
{!!actualImage && (
|
||||
<motion.div
|
||||
key="library-header-banner-image-container"
|
||||
data-library-header-image-container
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0.4 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<MotionImage
|
||||
data-library-header-banner-image
|
||||
src={getImageUrl(actualImage || prevImage!)}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center z-[1] opacity-100 transition-opacity duration-700 scroll-locked-offset",
|
||||
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
|
||||
{ "opacity-5": dimmed },
|
||||
)}
|
||||
initial={{ scale: 1.01, y: 0 }}
|
||||
animate={{
|
||||
scale: Math.min(1 + y * 0.0002, 1.03),
|
||||
// y: Math.max(y * -0.9, -10)
|
||||
}}
|
||||
exit={{ scale: 1.01, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* {prevImage && <MotionImage
|
||||
data-library-header-banner-previous-image
|
||||
src={getImageUrl(actualImage || prevImage!)}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center z-[1] opacity-50 transition-opacity scroll-locked-offset",
|
||||
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
|
||||
{ "opacity-5": dimmed },
|
||||
)}
|
||||
initial={{ scale: 1, y: 0 }}
|
||||
animate={{ scale: Math.min(1 + y * 0.0002, 1.03), y: Math.max(y * -0.9, -10) }}
|
||||
exit={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>} */}
|
||||
<div
|
||||
data-library-header-banner-bottom-gradient
|
||||
className="LIB_HEADER_IMG_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] lg:h-[15rem] bg-gradient-to-t from-[--background] lg:via-opacity-50 lg:via-10% to-transparent"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useLocalFileBulkAction, useRemoveEmptyDirectories } from "@/api/hooks/localfiles.hooks"
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import React from "react"
|
||||
import { BiLockAlt, BiLockOpenAlt } from "react-icons/bi"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __bulkAction_modalAtomIsOpen = atom<boolean>(false)
|
||||
|
||||
export function BulkActionModal() {
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__bulkAction_modalAtomIsOpen)
|
||||
|
||||
const { mutate: performBulkAction, isPending } = useLocalFileBulkAction()
|
||||
|
||||
function handleLockFiles() {
|
||||
performBulkAction({
|
||||
action: "lock",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
toast.success("Files locked")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUnlockFiles() {
|
||||
performBulkAction({
|
||||
action: "unlock",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
toast.success("Files unlocked")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { mutate: removeEmptyDirectories, isPending: isRemoving } = useRemoveEmptyDirectories()
|
||||
|
||||
function handleRemoveEmptyDirectories() {
|
||||
removeEmptyDirectories(undefined, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const confirmRemoveEmptyDirs = useConfirmationDialog({
|
||||
title: "Remove empty directories",
|
||||
description: "This action will remove all empty directories in the library. Are you sure you want to continue?",
|
||||
onConfirm: () => {
|
||||
handleRemoveEmptyDirectories()
|
||||
},
|
||||
})
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
React.useEffect(() => {
|
||||
inject("anime-library-bulk-actions", {
|
||||
priority: 1,
|
||||
items: [
|
||||
{
|
||||
id: "lock-files", value: "lock", heading: "Library",
|
||||
render: () => (
|
||||
<p>Lock all files</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
handleLockFiles()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unlock-files", value: "unlock", heading: "Library",
|
||||
render: () => (
|
||||
<p>Unlock all files</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
handleUnlockFiles()
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase())
|
||||
},
|
||||
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
|
||||
showBasedOnInput: "startsWith",
|
||||
})
|
||||
|
||||
return () => remove("anime-library-bulk-actions")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen} onOpenChange={() => setIsOpen(false)} title="Bulk actions"
|
||||
contentClass="space-y-4"
|
||||
>
|
||||
<AppLayoutStack spacing="sm">
|
||||
{/*<p>These actions do not affect ignored files.</p>*/}
|
||||
<div className="flex gap-2 flex-col md:flex-row">
|
||||
<Button
|
||||
leftIcon={<BiLockAlt className="text-2xl" />}
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending || isRemoving}
|
||||
onClick={handleLockFiles}
|
||||
>
|
||||
Lock all files
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<BiLockOpenAlt className="text-2xl" />}
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending || isRemoving}
|
||||
onClick={handleUnlockFiles}
|
||||
>
|
||||
Unlock all files
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
loading={isRemoving}
|
||||
onClick={() => confirmRemoveEmptyDirs.open()}
|
||||
>
|
||||
Remove empty directories
|
||||
</Button>
|
||||
</AppLayoutStack>
|
||||
<ConfirmationDialog {...confirmRemoveEmptyDirs} />
|
||||
</Modal>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client"
|
||||
import { Anime_Episode, Continuity_WatchHistory } from "@/api/generated/types"
|
||||
import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { __libraryHeaderImageAtom } from "@/app/(main)/(library)/_components/library-header"
|
||||
import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms"
|
||||
import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card"
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { episodeCardCarouselItemClass } from "@/components/shared/classnames"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { TextGenerateEffect } from "@/components/shared/text-generate-effect"
|
||||
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { anilist_animeIsMovie } from "@/lib/helpers/media"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useWindowSize } from "@uidotdev/usehooks"
|
||||
import { atom } from "jotai/index"
|
||||
import { useAtom, useSetAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { seaCommand_compareMediaTitles } from "../../_features/sea-command/utils"
|
||||
|
||||
export const __libraryHeaderEpisodeAtom = atom<Anime_Episode | null>(null)
|
||||
|
||||
export function ContinueWatching({ episodes, isLoading, linkTemplate }: {
|
||||
episodes: Anime_Episode[],
|
||||
isLoading: boolean
|
||||
linkTemplate?: string
|
||||
}) {
|
||||
|
||||
const router = useRouter()
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
|
||||
const [headerEpisode, setHeaderEpisode] = useAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
const [episodeRefs, setEpisodeRefs] = React.useState<React.RefObject<any>[]>([])
|
||||
const [inViewEpisodes, setInViewEpisodes] = React.useState<number[]>([])
|
||||
const debouncedInViewEpisodes = useDebounce(inViewEpisodes, 500)
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// Create refs for each episode
|
||||
React.useEffect(() => {
|
||||
setEpisodeRefs(episodes.map(() => React.createRef()))
|
||||
}, [episodes])
|
||||
|
||||
// Observe each episode
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const index = episodeRefs.findIndex(ref => ref.current === entry.target)
|
||||
if (index !== -1) {
|
||||
if (entry.isIntersecting) {
|
||||
setInViewEpisodes(prev => prev.includes(index) ? prev : [...prev, index])
|
||||
} else {
|
||||
setInViewEpisodes(prev => prev.filter((idx) => idx !== index))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { threshold: 0.5 }) // Trigger callback when 50% of the element is visible
|
||||
|
||||
episodeRefs.forEach((ref) => {
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
episodeRefs.forEach((ref) => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [episodeRefs, width])
|
||||
|
||||
const prevSelectedEpisodeRef = React.useRef<Anime_Episode | null>(null)
|
||||
|
||||
// Set header image when new episode is in view
|
||||
React.useEffect(() => {
|
||||
if (debouncedInViewEpisodes.length === 0) return
|
||||
|
||||
let candidateIndices = debouncedInViewEpisodes
|
||||
// Exclude previously selected episode if possible
|
||||
if (prevSelectedEpisodeRef.current && debouncedInViewEpisodes.length > 1) {
|
||||
candidateIndices = debouncedInViewEpisodes.filter(idx => episodes[idx]?.baseAnime?.id !== prevSelectedEpisodeRef.current?.baseAnime?.id)
|
||||
}
|
||||
if (candidateIndices.length === 0) candidateIndices = debouncedInViewEpisodes
|
||||
|
||||
const randomCandidateIdx = candidateIndices[Math.floor(Math.random() * candidateIndices.length)]
|
||||
const selectedEpisode = episodes[randomCandidateIdx]
|
||||
|
||||
if (selectedEpisode) {
|
||||
setHeaderImage({
|
||||
bannerImage: selectedEpisode.baseAnime?.bannerImage || null,
|
||||
episodeImage: selectedEpisode.baseAnime?.bannerImage || selectedEpisode.baseAnime?.coverImage?.extraLarge || null,
|
||||
})
|
||||
prevSelectedEpisodeRef.current = selectedEpisode
|
||||
}
|
||||
}, [debouncedInViewEpisodes, episodes, setHeaderImage])
|
||||
|
||||
const { setPlayNext } = usePlayNext()
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
|
||||
React.useEffect(() => {
|
||||
inject("continue-watching", {
|
||||
items: episodes.map(episode => ({
|
||||
data: episode,
|
||||
id: `${episode.localFile?.path || episode.baseAnime?.title?.userPreferred || ""}-${episode.episodeNumber || 1}`,
|
||||
value: `${episode.episodeNumber || 1}`,
|
||||
heading: "Continue Watching",
|
||||
priority: 100,
|
||||
render: () => (
|
||||
<>
|
||||
<div className="w-12 aspect-[6/5] flex-none rounded-[--radius-md] relative overflow-hidden">
|
||||
<Image
|
||||
src={episode.episodeMetadata?.image || ""}
|
||||
alt="episode image"
|
||||
fill
|
||||
className="object-center object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center w-full">
|
||||
<p className="max-w-[70%] truncate">{episode.baseAnime?.title?.userPreferred || ""}</p> -
|
||||
{!anilist_animeIsMovie(episode.baseAnime) ? <>
|
||||
<p className="text-[--muted]">Ep</p><span>{episode.episodeNumber}</span>
|
||||
</> : <>
|
||||
<p className="text-[--muted]">Movie</p>
|
||||
</>}
|
||||
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
onSelect: () => setPlayNext(episode.baseAnime?.id, () => {
|
||||
router.push(`/entry?id=${episode.baseAnime?.id}`)
|
||||
}),
|
||||
})),
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase()) ||
|
||||
seaCommand_compareMediaTitles(item.data.baseAnime?.title, input)
|
||||
},
|
||||
priority: 100,
|
||||
})
|
||||
|
||||
return () => remove("continue-watching")
|
||||
}, [episodes, inject, remove, router, setPlayNext])
|
||||
|
||||
if (episodes.length > 0) return (
|
||||
<PageWrapper className="space-y-3 lg:space-y-6 p-4 relative z-[4]" data-continue-watching-container>
|
||||
<h2 data-continue-watching-title>Continue watching</h2>
|
||||
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerEpisode?.baseAnime) && <TextGenerateEffect
|
||||
data-continue-watching-media-title
|
||||
words={headerEpisode?.baseAnime?.title?.userPreferred || ""}
|
||||
className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1"
|
||||
/>}
|
||||
<Carousel
|
||||
className="w-full max-w-full"
|
||||
gap="md"
|
||||
opts={{
|
||||
align: "start",
|
||||
}}
|
||||
autoScroll
|
||||
autoScrollDelay={8000}
|
||||
>
|
||||
<CarouselDotButtons />
|
||||
<CarouselContent>
|
||||
{episodes.map((episode, idx) => (
|
||||
<CarouselItem
|
||||
key={episode?.localFile?.path || idx}
|
||||
className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)}
|
||||
>
|
||||
<_EpisodeCard
|
||||
key={episode.localFile?.path || ""}
|
||||
episode={episode}
|
||||
mRef={episodeRefs[idx]}
|
||||
overrideLink={linkTemplate}
|
||||
watchHistory={watchHistory}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</PageWrapper>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const _EpisodeCard = React.memo(({ episode, mRef, overrideLink, watchHistory }: {
|
||||
episode: Anime_Episode,
|
||||
mRef: React.RefObject<any>,
|
||||
overrideLink?: string
|
||||
watchHistory: Continuity_WatchHistory | undefined
|
||||
}) => {
|
||||
const serverStatus = useServerStatus()
|
||||
const router = useRouter()
|
||||
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
|
||||
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
React.useEffect(() => {
|
||||
setHeaderImage(prev => {
|
||||
if (prev?.episodeImage === null) {
|
||||
return {
|
||||
bannerImage: episode.baseAnime?.bannerImage || null,
|
||||
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
|
||||
}
|
||||
}
|
||||
return prev
|
||||
})
|
||||
setHeaderEpisode(prev => {
|
||||
if (prev === null) {
|
||||
return episode
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { setPlayNext } = usePlayNext()
|
||||
|
||||
return (
|
||||
<EpisodeCard
|
||||
key={episode.localFile?.path || ""}
|
||||
episode={episode}
|
||||
image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge}
|
||||
topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred}
|
||||
title={episode.displayTitle}
|
||||
isInvalid={episode.isInvalid}
|
||||
progressTotal={episode.baseAnime?.episodes}
|
||||
progressNumber={episode.progressNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
length={episode.episodeMetadata?.length}
|
||||
hasDiscrepancy={episode.episodeNumber !== episode.progressNumber}
|
||||
percentageComplete={getEpisodePercentageComplete(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
|
||||
minutesRemaining={getEpisodeMinutesRemaining(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
|
||||
anime={{
|
||||
id: episode?.baseAnime?.id || 0,
|
||||
image: episode?.baseAnime?.coverImage?.medium,
|
||||
title: episode?.baseAnime?.title?.userPreferred,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
React.startTransition(() => {
|
||||
setHeaderImage({
|
||||
bannerImage: episode.baseAnime?.bannerImage || null,
|
||||
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
|
||||
})
|
||||
})
|
||||
}}
|
||||
mRef={mRef}
|
||||
onClick={() => {
|
||||
if (!overrideLink) {
|
||||
setPlayNext(episode.baseAnime?.id, () => {
|
||||
if (!serverStatus?.isOffline) {
|
||||
router.push(`/entry?id=${episode.baseAnime?.id}`)
|
||||
} else {
|
||||
router.push(`/offline/entry/anime?id=${episode.baseAnime?.id}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setPlayNext(episode.baseAnime?.id, () => {
|
||||
router.push(overrideLink.replace("{id}", episode.baseAnime?.id ? String(episode.baseAnime.id) : ""))
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getAssetUrl } from "@/lib/server/assets"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { motion } from "motion/react"
|
||||
import React, { useEffect } from "react"
|
||||
import { useWindowScroll } from "react-use"
|
||||
|
||||
type CustomLibraryBannerProps = {
|
||||
discrete?: boolean
|
||||
isLibraryScreen?: boolean // Anime library or manga library
|
||||
}
|
||||
|
||||
export function CustomLibraryBanner(props: CustomLibraryBannerProps) {
|
||||
/**
|
||||
* Library screens: Shows the custom banner IF theme settings are set to use a custom banner
|
||||
* Other pages: Shows the custom banner
|
||||
*/
|
||||
const { discrete, isLibraryScreen } = props
|
||||
const ts = useThemeSettings()
|
||||
const image = React.useMemo(() => ts.libraryScreenCustomBannerImage ? getAssetUrl(ts.libraryScreenCustomBannerImage) : "",
|
||||
[ts.libraryScreenCustomBannerImage])
|
||||
const [dimmed, setDimmed] = React.useState(false)
|
||||
|
||||
const { y } = useWindowScroll()
|
||||
|
||||
useEffect(() => {
|
||||
if (y > 100)
|
||||
setDimmed(true)
|
||||
else
|
||||
setDimmed(false)
|
||||
}, [(y > 100)])
|
||||
|
||||
if (isLibraryScreen && ts.libraryScreenBannerType !== ThemeLibraryScreenBannerType.Custom) return null
|
||||
if (discrete && !!ts.libraryScreenCustomBackgroundImage) return null
|
||||
if (!image) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!discrete && <div
|
||||
data-custom-library-banner-top-spacer
|
||||
className={cn(
|
||||
"py-20",
|
||||
ts.hideTopNavbar && "py-28",
|
||||
)}
|
||||
></div>}
|
||||
<div
|
||||
data-custom-library-banner-container
|
||||
className={cn(
|
||||
"__header h-[30rem] z-[1] top-0 w-full fixed group/library-header transition-opacity duration-1000",
|
||||
discrete && "opacity-20",
|
||||
!!ts.libraryScreenCustomBackgroundImage && "absolute", // If there's a background image, make the banner absolute
|
||||
(!ts.libraryScreenCustomBackgroundImage && dimmed) && "opacity-5", // If the user has scrolled down, dim the banner
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
>
|
||||
{(!ts.disableSidebarTransparency && !discrete) && <div
|
||||
data-custom-library-banner-top-gradient
|
||||
className="hidden lg:block h-full absolute z-[2] w-[20rem] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
|
||||
/>}
|
||||
|
||||
<div
|
||||
data-custom-library-banner-bottom-gradient
|
||||
className="w-full z-[3] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className={cn(
|
||||
"h-[30rem] z-[0] w-full flex-none absolute top-0 overflow-hidden",
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
data-custom-library-banner-inner-container
|
||||
>
|
||||
<div
|
||||
data-custom-library-banner-top-gradient
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_TOP_FADE w-full absolute z-[2] top-0 h-[5rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via",
|
||||
discrete && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-custom-library-banner-image
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_IMG z-[1] absolute inset-0 w-full h-full bg-cover bg-no-repeat transition-opacity duration-1000",
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${image})`,
|
||||
backgroundPosition: ts.libraryScreenCustomBannerPosition || "50% 50%",
|
||||
opacity: (ts.libraryScreenCustomBannerOpacity || 100) / 100,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-custom-library-banner-bottom-gradient
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-opacity-50 via-10% to-transparent via",
|
||||
discrete && "via-50% via-opacity-100 h-[40rem]",
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { upath } from "@/lib/helpers/upath"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { TbFileSad } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __ignoredFileManagerIsOpen = atom(false)
|
||||
|
||||
type IgnoredFileManagerProps = {
|
||||
files: Anime_LocalFile[]
|
||||
}
|
||||
|
||||
export function IgnoredFileManager(props: IgnoredFileManagerProps) {
|
||||
|
||||
const { files } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__ignoredFileManagerIsOpen)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const { mutate: updateLocalFiles, isPending: isUpdating } = useUpdateLocalFiles()
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setSelectedPaths(files?.map(lf => lf.path) ?? [])
|
||||
}, [files])
|
||||
|
||||
function handleUnIgnoreSelected() {
|
||||
if (selectedPaths.length > 0) {
|
||||
updateLocalFiles({
|
||||
paths: selectedPaths,
|
||||
action: "unignore",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Files un-ignored")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
// contentClass="max-w-5xl"
|
||||
size="xl"
|
||||
title="Ignored files"
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
{files.length > 0 && <div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-1"></div>
|
||||
<Button
|
||||
leftIcon={<TbFileSad className="text-lg" />}
|
||||
intent="white"
|
||||
size="sm"
|
||||
rounded
|
||||
loading={isUpdating}
|
||||
onClick={handleUnIgnoreSelected}
|
||||
>
|
||||
Un-ignore selection
|
||||
</Button>
|
||||
</div>}
|
||||
|
||||
{files.length === 0 && <LuffyError title={null}>
|
||||
No ignored files
|
||||
</LuffyError>}
|
||||
|
||||
{files.length > 0 &&
|
||||
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[85vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
|
||||
<div className="p-2">
|
||||
<Checkbox
|
||||
label={`Select all files`}
|
||||
value={(selectedPaths.length === files?.length) ? true : (selectedPaths.length === 0
|
||||
? false
|
||||
: "indeterminate")}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (draft.length === files?.length) {
|
||||
return []
|
||||
} else {
|
||||
return files?.map(lf => lf.path) ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.map((lf, index) => (
|
||||
<div
|
||||
key={`${lf.path}-${index}`}
|
||||
className="p-2 "
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
label={`${upath.basename(lf.path)}`}
|
||||
value={selectedPaths.includes(lf.path)}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (checked) {
|
||||
return [...draft, lf.path]
|
||||
} else {
|
||||
return draft.filter(p => p !== lf.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { __mainLibrary_paramsAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { getLibraryCollectionTitle } from "@/lib/server/utils"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { LuListFilter } from "react-icons/lu"
|
||||
|
||||
export function LibraryCollectionLists({ collectionList, isLoading, streamingMediaIds }: {
|
||||
collectionList: Anime_LibraryCollectionList[],
|
||||
isLoading: boolean,
|
||||
streamingMediaIds: number[]
|
||||
}) {
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
key="library-collection-lists"
|
||||
className="space-y-8"
|
||||
data-library-collection-lists
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}>
|
||||
{collectionList.map(collection => {
|
||||
if (!collection.entries?.length) return null
|
||||
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</PageWrapper>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export function LibraryCollectionFilteredLists({ collectionList, isLoading, streamingMediaIds }: {
|
||||
collectionList: Anime_LibraryCollectionList[],
|
||||
isLoading: boolean,
|
||||
streamingMediaIds: number[]
|
||||
}) {
|
||||
|
||||
// const params = useAtomValue(__mainLibrary_paramsAtom)
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
key="library-filtered-lists"
|
||||
className="space-y-8"
|
||||
data-library-filtered-lists
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}>
|
||||
{/*<h3 className="text-center truncate">*/}
|
||||
{/* {params.genre?.join(", ")}*/}
|
||||
{/*</h3>*/}
|
||||
<MediaCardLazyGrid itemCount={collectionList?.flatMap(n => n.entries)?.length ?? 0}>
|
||||
{collectionList?.flatMap(n => n.entries)?.filter(Boolean)?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</PageWrapper>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: {
|
||||
list: Anime_LibraryCollectionList,
|
||||
streamingMediaIds: number[]
|
||||
}) => {
|
||||
|
||||
const isCurrentlyWatching = list.type === "CURRENT"
|
||||
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
|
||||
|
||||
return (
|
||||
<React.Fragment key={list.type}>
|
||||
<div className="flex gap-3 items-center" data-library-collection-list-item-header data-list-type={list.type}>
|
||||
<h2 className="p-0 m-0">{getLibraryCollectionTitle(list.type)}</h2>
|
||||
<div className="flex flex-1"></div>
|
||||
{isCurrentlyWatching && <DropdownMenu
|
||||
trigger={<IconButton
|
||||
intent="white-basic"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
icon={<LuListFilter />}
|
||||
/>}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setParams(draft => {
|
||||
draft.continueWatchingOnly = !draft.continueWatchingOnly
|
||||
return
|
||||
})
|
||||
}}
|
||||
>
|
||||
{params.continueWatchingOnly ? "Show all" : "Show unwatched only"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>}
|
||||
</div>
|
||||
<MediaCardLazyGrid
|
||||
itemCount={list?.entries?.length || 0}
|
||||
data-library-collection-list-item-media-card-lazy-grid
|
||||
data-list-type={list.type}
|
||||
>
|
||||
{list.entries?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
export const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
|
||||
entry: Anime_LibraryCollectionEntry,
|
||||
streamingMediaIds: number[]
|
||||
}) => {
|
||||
return (
|
||||
<MediaEntryCard
|
||||
media={entry.media!}
|
||||
listData={entry.listData}
|
||||
libraryData={entry.libraryData}
|
||||
nakamaLibraryData={entry.nakamaLibraryData}
|
||||
showListDataButton
|
||||
withAudienceScore={false}
|
||||
type="anime"
|
||||
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId) && entry.listData?.status === "CURRENT"}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
import { Anime_LibraryCollectionList, Anime_LocalFile, Anime_UnknownGroup } from "@/api/generated/types"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { __bulkAction_modalAtomIsOpen } from "@/app/(main)/(library)/_containers/bulk-action-modal"
|
||||
import { __ignoredFileManagerIsOpen } from "@/app/(main)/(library)/_containers/ignored-file-manager"
|
||||
import { PlayRandomEpisodeButton } from "@/app/(main)/(library)/_containers/play-random-episode-button"
|
||||
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
|
||||
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
import { __unknownMedia_drawerIsOpen } from "@/app/(main)/(library)/_containers/unknown-media-manager"
|
||||
import { __unmatchedFileManagerIsOpen } from "@/app/(main)/(library)/_containers/unmatched-file-manager"
|
||||
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useAtom, useSetAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { BiCollection, BiDotsVerticalRounded, BiFolder } from "react-icons/bi"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { IoLibrary, IoLibrarySharp } from "react-icons/io5"
|
||||
import { MdOutlineVideoLibrary } from "react-icons/md"
|
||||
import { PiClockCounterClockwiseFill } from "react-icons/pi"
|
||||
import { TbFileSad, TbReload } from "react-icons/tb"
|
||||
import { PluginAnimeLibraryDropdownItems } from "../../_features/plugin/actions/plugin-actions"
|
||||
|
||||
export type LibraryToolbarProps = {
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
ignoredLocalFiles: Anime_LocalFile[]
|
||||
unmatchedLocalFiles: Anime_LocalFile[]
|
||||
unknownGroups: Anime_UnknownGroup[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
isStreamingOnly: boolean
|
||||
isNakamaLibrary: boolean
|
||||
}
|
||||
|
||||
export function LibraryToolbar(props: LibraryToolbarProps) {
|
||||
|
||||
const {
|
||||
collectionList,
|
||||
ignoredLocalFiles,
|
||||
unmatchedLocalFiles,
|
||||
unknownGroups,
|
||||
hasEntries,
|
||||
isStreamingOnly,
|
||||
isNakamaLibrary,
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const setBulkActionIsOpen = useSetAtom(__bulkAction_modalAtomIsOpen)
|
||||
|
||||
const status = useServerStatus()
|
||||
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
|
||||
const setUnmatchedFileManagerOpen = useSetAtom(__unmatchedFileManagerIsOpen)
|
||||
const setIgnoredFileManagerOpen = useSetAtom(__ignoredFileManagerIsOpen)
|
||||
const setUnknownMediaManagerOpen = useSetAtom(__unknownMedia_drawerIsOpen)
|
||||
const setPlaylistsModalOpen = useSetAtom(__playlists_modalOpenAtom)
|
||||
|
||||
const [libraryView, setLibraryView] = useAtom(__library_viewAtom)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const hasLibraryPath = !!status?.settings?.library?.libraryPath
|
||||
|
||||
return (
|
||||
<>
|
||||
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && hasEntries) && <div
|
||||
className={cn(
|
||||
"h-28",
|
||||
ts.hideTopNavbar && "h-40",
|
||||
)}
|
||||
data-library-toolbar-top-padding
|
||||
></div>}
|
||||
<div className="flex flex-wrap w-full justify-end gap-2 p-4 relative z-[10]" data-library-toolbar-container>
|
||||
<div className="flex flex-1" data-library-toolbar-spacer></div>
|
||||
{(hasEntries) && (
|
||||
<>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-switch-view-button
|
||||
intent={libraryView === "base" ? "white-subtle" : "white"}
|
||||
icon={<IoLibrary className="text-2xl" />}
|
||||
onClick={() => setLibraryView(p => p === "detailed" ? "base" : "detailed")}
|
||||
/>}
|
||||
>
|
||||
Switch view
|
||||
</Tooltip>
|
||||
|
||||
{!(isStreamingOnly || isNakamaLibrary) && <Tooltip
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-playlists-button
|
||||
intent={"white-subtle"}
|
||||
icon={<MdOutlineVideoLibrary className="text-2xl" />}
|
||||
onClick={() => setPlaylistsModalOpen(true)}
|
||||
/>}
|
||||
>Playlists</Tooltip>}
|
||||
|
||||
{!(isStreamingOnly || isNakamaLibrary) && <PlayRandomEpisodeButton />}
|
||||
|
||||
{/*Show up even when there's no local entries*/}
|
||||
{!isNakamaLibrary && hasLibraryPath && <Button
|
||||
data-library-toolbar-scan-button
|
||||
intent={hasEntries ? "primary-subtle" : "primary"}
|
||||
leftIcon={hasEntries ? <TbReload className="text-xl" /> : <FiSearch className="text-xl" />}
|
||||
onClick={() => setScannerModalOpen(true)}
|
||||
hideTextOnSmallScreen
|
||||
>
|
||||
{hasEntries ? "Refresh library" : "Scan your library"}
|
||||
</Button>}
|
||||
</>
|
||||
)}
|
||||
{(unmatchedLocalFiles.length > 0) && <Button
|
||||
data-library-toolbar-unmatched-button
|
||||
intent="alert"
|
||||
leftIcon={<IoLibrarySharp />}
|
||||
className="animate-bounce"
|
||||
onClick={() => setUnmatchedFileManagerOpen(true)}
|
||||
>
|
||||
Resolve unmatched ({unmatchedLocalFiles.length})
|
||||
</Button>}
|
||||
{(unknownGroups.length > 0) && <Button
|
||||
data-library-toolbar-unknown-button
|
||||
intent="warning"
|
||||
leftIcon={<IoLibrarySharp />}
|
||||
className="animate-bounce"
|
||||
onClick={() => setUnknownMediaManagerOpen(true)}
|
||||
>
|
||||
Resolve hidden media ({unknownGroups.length})
|
||||
</Button>}
|
||||
|
||||
{(!isStreamingOnly && !isNakamaLibrary && hasLibraryPath) &&
|
||||
<DropdownMenu
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-dropdown-menu-trigger
|
||||
icon={<BiDotsVerticalRounded />} intent="gray-basic"
|
||||
/>}
|
||||
>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-open-directory-button
|
||||
disabled={!hasLibraryPath}
|
||||
className={cn("cursor-pointer", { "!text-[--muted]": !hasLibraryPath })}
|
||||
onClick={() => {
|
||||
openInExplorer({ path: status?.settings?.library?.libraryPath ?? "" })
|
||||
}}
|
||||
>
|
||||
<BiFolder />
|
||||
<span>Open directory</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-bulk-actions-button
|
||||
onClick={() => setBulkActionIsOpen(true)}
|
||||
disabled={!hasEntries}
|
||||
className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<BiCollection />
|
||||
<span>Bulk actions</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-ignored-files-button
|
||||
onClick={() => setIgnoredFileManagerOpen(true)}
|
||||
// disabled={!hasEntries}
|
||||
className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<TbFileSad />
|
||||
<span>Ignored files</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<SeaLink href="/scan-summaries">
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-scan-summaries-button
|
||||
// className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<PiClockCounterClockwiseFill />
|
||||
<span>Scan summaries</span>
|
||||
</DropdownMenuItem>
|
||||
</SeaLink>
|
||||
|
||||
<PluginAnimeLibraryDropdownItems />
|
||||
</DropdownMenu>}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { usePlaybackPlayRandomVideo } from "@/api/hooks/playback_manager.hooks"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import React from "react"
|
||||
import { LiaRandomSolid } from "react-icons/lia"
|
||||
|
||||
type PlayRandomEpisodeButtonProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PlayRandomEpisodeButton(props: PlayRandomEpisodeButtonProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { mutate: playRandom, isPending } = usePlaybackPlayRandomVideo()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
data-play-random-episode-button
|
||||
intent={"white-subtle"}
|
||||
icon={<LiaRandomSolid className="text-2xl" />}
|
||||
loading={isPending}
|
||||
onClick={() => playRandom()}
|
||||
/>}
|
||||
>Play random anime</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { AL_BaseAnime, Anime_LibraryCollectionEntry, Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useGetLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { useGetPlaylistEpisodes } from "@/api/hooks/playlist.hooks"
|
||||
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { DndContext, DragEndEvent } from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { useAtomValue } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiPlus, BiTrash } from "react-icons/bi"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
type PlaylistManagerProps = {
|
||||
paths: string[]
|
||||
setPaths: (paths: string[]) => void
|
||||
}
|
||||
|
||||
export function PlaylistManager(props: PlaylistManagerProps) {
|
||||
|
||||
const {
|
||||
paths: controlledPaths,
|
||||
setPaths: onChange,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const libraryCollection = useAtomValue(animeLibraryCollectionAtom)
|
||||
|
||||
const { data: localFiles } = useGetLocalFiles()
|
||||
|
||||
const firstRender = React.useRef(true)
|
||||
|
||||
const [paths, setPaths] = React.useState(controlledPaths)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false
|
||||
return
|
||||
}
|
||||
setPaths(controlledPaths)
|
||||
}, [controlledPaths])
|
||||
|
||||
React.useEffect(() => {
|
||||
onChange(paths)
|
||||
}, [paths])
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
setPaths((items) => {
|
||||
const oldIndex = items.indexOf(active.id as any)
|
||||
const newIndex = items.indexOf(over?.id as any)
|
||||
|
||||
return arrayMove(items, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = React.useState("CURRENT")
|
||||
const [searchInput, setSearchInput] = React.useState("")
|
||||
const debouncedSearchInput = useDebounce(searchInput, 500)
|
||||
|
||||
const entries = React.useMemo(() => {
|
||||
if (debouncedSearchInput.length !== 0) return (libraryCollection?.lists
|
||||
?.filter(n => n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT")
|
||||
?.flatMap(n => n.entries)
|
||||
?.filter(Boolean) ?? []).filter(n => n?.media?.title?.english?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()) ||
|
||||
n?.media?.title?.romaji?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()))
|
||||
|
||||
return libraryCollection?.lists?.filter(n => {
|
||||
if (selectedCategory === "-") return n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT"
|
||||
return n.type === selectedCategory
|
||||
})
|
||||
?.flatMap(n => n.entries)
|
||||
?.filter(n => !!n?.libraryData)
|
||||
?.filter(Boolean) ?? []
|
||||
}, [libraryCollection, debouncedSearchInput, selectedCategory])
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Modal
|
||||
title="Select an anime"
|
||||
contentClass="max-w-4xl"
|
||||
trigger={<Button
|
||||
leftIcon={<BiPlus className="text-2xl" />}
|
||||
intent="white"
|
||||
className="rounded-full"
|
||||
disabled={paths.length >= 10}
|
||||
>Add an episode</Button>}
|
||||
>
|
||||
|
||||
<div className="grid grid-cols-[150px,1fr] gap-2">
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={v => setSelectedCategory(v)}
|
||||
options={[
|
||||
{ label: "Current", value: "CURRENT" },
|
||||
{ label: "Paused", value: "PAUSED" },
|
||||
{ label: "Planning", value: "PLANNING" },
|
||||
{ label: "All", value: "-" },
|
||||
]}
|
||||
disabled={searchInput.length !== 0}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{entries?.map(entry => {
|
||||
return (
|
||||
<PlaylistMediaEntry key={entry.mediaId} entry={entry} paths={paths} setPaths={setPaths} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
strategy={verticalListSortingStrategy}
|
||||
items={paths}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<ul className="space-y-2">
|
||||
{paths.map(path => localFiles?.find(n => n.path === path))?.filter(Boolean).map((lf, index) => (
|
||||
<SortableItem
|
||||
key={lf.path}
|
||||
id={lf.path}
|
||||
localFile={lf}
|
||||
media={libraryCollection?.lists?.flatMap(n => n.entries)
|
||||
?.filter(Boolean)
|
||||
?.find(n => lf?.mediaId === n.mediaId)?.media}
|
||||
setPaths={setPaths}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PlaylistMediaEntryProps = {
|
||||
entry: Anime_LibraryCollectionEntry
|
||||
paths: string[]
|
||||
setPaths: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
function PlaylistMediaEntry(props: PlaylistMediaEntryProps) {
|
||||
const { entry, paths, setPaths } = props
|
||||
return <Modal
|
||||
title={entry.media?.title?.userPreferred || entry.media?.title?.romaji || ""}
|
||||
trigger={(
|
||||
<div
|
||||
key={entry.mediaId}
|
||||
className="col-span-1 aspect-[6/7] rounded-[--radius-md] border overflow-hidden relative transition cursor-pointer bg-[var(--background)] md:opacity-60 md:hover:opacity-100"
|
||||
>
|
||||
<Image
|
||||
src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover"
|
||||
/>
|
||||
<p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]">
|
||||
{entry.media?.title?.userPreferred || entry.media?.title?.romaji}
|
||||
</p>
|
||||
<div
|
||||
className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<EntryEpisodeList
|
||||
selectedPaths={paths}
|
||||
setSelectedPaths={setPaths}
|
||||
entry={entry}
|
||||
/>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
function SortableItem({ localFile, id, media, setPaths }: {
|
||||
id: string,
|
||||
localFile: Anime_LocalFile | undefined,
|
||||
media: AL_BaseAnime | undefined,
|
||||
setPaths: any
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
if (!localFile) return null
|
||||
|
||||
if (!media) return (
|
||||
<li ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className="px-2.5 py-2 bg-[var(--background)] border-[--red] rounded-[--radius-md] border flex gap-3 relative"
|
||||
|
||||
>
|
||||
<IconButton
|
||||
className="absolute top-2 right-2 rounded-full"
|
||||
icon={<BiTrash />}
|
||||
intent="alert-subtle"
|
||||
size="sm"
|
||||
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
|
||||
|
||||
/>
|
||||
<div
|
||||
|
||||
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
|
||||
{...attributes} {...listeners}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg text-white font-semibold">
|
||||
<span>
|
||||
???
|
||||
</span>
|
||||
<span className="text-gray-400 font-medium max-w-lg truncate">
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
return (
|
||||
<li ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"
|
||||
|
||||
>
|
||||
<IconButton
|
||||
className="absolute top-2 right-2 rounded-full"
|
||||
icon={<BiTrash />}
|
||||
intent="alert-subtle"
|
||||
size="sm"
|
||||
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
|
||||
|
||||
/>
|
||||
<div
|
||||
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
|
||||
{...attributes} {...listeners}
|
||||
/>
|
||||
<div
|
||||
|
||||
className="w-16 aspect-square rounded-[--radius-md] border overflow-hidden relative transition bg-[var(--background)]"
|
||||
>
|
||||
<Image
|
||||
src={media?.coverImage?.large || media?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover"
|
||||
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg text-white font-semibold flex gap-1">
|
||||
{localFile.metadata && <p>
|
||||
{media?.format !== "MOVIE" ? `Episode ${localFile.metadata?.episode}` : "Movie"}
|
||||
</p>}
|
||||
<p className="max-w-full truncate text-gray-400 font-medium max-w-lg truncate">
|
||||
{" - "}{media?.title?.userPreferred || media?.title?.romaji}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type EntryEpisodeListProps = {
|
||||
entry: Anime_LibraryCollectionEntry
|
||||
selectedPaths: string[]
|
||||
setSelectedPaths: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
function EntryEpisodeList(props: EntryEpisodeListProps) {
|
||||
|
||||
const {
|
||||
entry,
|
||||
selectedPaths,
|
||||
setSelectedPaths,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { data } = useGetPlaylistEpisodes(entry.mediaId, entry.listData?.progress || 0)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (selectedPaths.length <= 10) {
|
||||
setSelectedPaths(prev => {
|
||||
if (prev.includes(value)) {
|
||||
return prev.filter(n => n !== value)
|
||||
}
|
||||
return [...prev, value]
|
||||
})
|
||||
} else {
|
||||
toast.error("You can't add more than 10 episodes to a playlist")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 overflow-auto p-1">
|
||||
{data?.filter(n => !!n.metadata)?.sort((a, b) => a.metadata!.episode - b.metadata!.episode)?.map(lf => {
|
||||
return (
|
||||
<div
|
||||
key={lf.path}
|
||||
className={cn(
|
||||
"px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border cursor-pointer opacity-80 max-w-full",
|
||||
selectedPaths.includes(lf.path) ? "bg-gray-800 opacity-100 text-white ring-1 ring-[--zinc]" : "hover:bg-[--subtle]",
|
||||
"transition",
|
||||
)}
|
||||
onClick={() => handleSelect(lf.path)}
|
||||
>
|
||||
<p className="">{entry.media?.format !== "MOVIE" ? `Episode ${lf.metadata!.episode}` : "Movie"}</p>
|
||||
<p className="text-sm text-[--muted] font-normal italic max-w-lg line-clamp-1">{lf.name}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Anime_Playlist } from "@/api/generated/types"
|
||||
import { useCreatePlaylist, useDeletePlaylist, useUpdatePlaylist } from "@/api/hooks/playlist.hooks"
|
||||
import { PlaylistManager } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-manager"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DangerZone } from "@/components/ui/form"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type PlaylistModalProps = {
|
||||
playlist?: Anime_Playlist
|
||||
trigger: React.ReactElement
|
||||
}
|
||||
|
||||
export function PlaylistModal(props: PlaylistModalProps) {
|
||||
|
||||
const {
|
||||
playlist,
|
||||
trigger,
|
||||
} = props
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [name, setName] = React.useState(playlist?.name ?? "")
|
||||
const [paths, setPaths] = React.useState<string[]>(playlist?.localFiles?.map(l => l.path) ?? [])
|
||||
|
||||
const isUpdate = !!playlist
|
||||
|
||||
const { mutate: createPlaylist, isPending: isCreating } = useCreatePlaylist()
|
||||
|
||||
const { mutate: deletePlaylist, isPending: isDeleting } = useDeletePlaylist()
|
||||
|
||||
const { mutate: updatePlaylist, isPending: isUpdating } = useUpdatePlaylist()
|
||||
|
||||
function reset() {
|
||||
setName("")
|
||||
setPaths([])
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUpdate && !!playlist && !!playlist.localFiles) {
|
||||
setName(playlist.name)
|
||||
setPaths(playlist.localFiles.map(l => l.path))
|
||||
}
|
||||
}, [playlist, isOpen])
|
||||
|
||||
function handleSubmit() {
|
||||
if (name.length === 0) {
|
||||
toast.error("Please enter a name for the playlist")
|
||||
return
|
||||
}
|
||||
if (isUpdate && !!playlist) {
|
||||
updatePlaylist({ dbId: playlist.dbId, name, paths })
|
||||
} else {
|
||||
setIsOpen(false)
|
||||
createPlaylist({ name, paths }, {
|
||||
onSuccess: () => {
|
||||
reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isUpdate ? "Edit playlist" : "Create a playlist"}
|
||||
trigger={trigger}
|
||||
open={isOpen}
|
||||
onOpenChange={v => setIsOpen(v)}
|
||||
contentClass="max-w-4xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PlaylistManager
|
||||
paths={paths}
|
||||
setPaths={setPaths}
|
||||
/>
|
||||
<div className="">
|
||||
<Button disabled={paths.length === 0} onClick={handleSubmit} loading={isCreating || isDeleting || isUpdating}>
|
||||
{isUpdate ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUpdate && <DangerZone
|
||||
actionText="Delete playlist" onDelete={() => {
|
||||
if (isUpdate && !!playlist) {
|
||||
deletePlaylist({ dbId: playlist.dbId }, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useGetPlaylists } from "@/api/hooks/playlist.hooks"
|
||||
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
|
||||
import { StartPlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal"
|
||||
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
|
||||
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
|
||||
import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { useAtomValue, useSetAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiEditAlt } from "react-icons/bi"
|
||||
import { FaCirclePlay } from "react-icons/fa6"
|
||||
import { MdOutlineVideoLibrary } from "react-icons/md"
|
||||
|
||||
type PlaylistsListProps = {}
|
||||
|
||||
export function PlaylistsList(props: PlaylistsListProps) {
|
||||
|
||||
const {} = props
|
||||
|
||||
const { data: playlists, isLoading } = useGetPlaylists()
|
||||
const userMedia = useAtomValue(__anilist_userAnimeMediaAtom)
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const setOpen = useSetAtom(__playlists_modalOpenAtom)
|
||||
|
||||
const handlePlaylistLoaded = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
|
||||
if (!playlists?.length) {
|
||||
return (
|
||||
<div className="text-center text-[--muted] space-y-1">
|
||||
<MdOutlineVideoLibrary className="mx-auto text-5xl text-[--muted]" />
|
||||
<div>
|
||||
No playlists
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
// <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
<Carousel
|
||||
className="w-full max-w-full"
|
||||
gap="none"
|
||||
opts={{
|
||||
align: "start",
|
||||
}}
|
||||
>
|
||||
<CarouselDotButtons />
|
||||
<CarouselContent>
|
||||
{playlists.map(p => {
|
||||
|
||||
const mainMedia = userMedia?.find(m => m.id === p.localFiles?.[0]?.mediaId)
|
||||
|
||||
return (
|
||||
<CarouselItem
|
||||
key={p.dbId}
|
||||
className={cn(
|
||||
"md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 min-[2000px]:basis-1/6",
|
||||
"aspect-[7/6] p-2",
|
||||
)}
|
||||
// onClick={() => handleSelect(lf.path)}
|
||||
>
|
||||
<div className="group/playlist-item flex gap-3 h-full justify-between items-center bg-gray-950 rounded-[--radius-md] transition relative overflow-hidden">
|
||||
{(mainMedia?.coverImage?.large || mainMedia?.bannerImage) && <Image
|
||||
src={mainMedia?.coverImage?.extraLarge || mainMedia?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover z-[1]"
|
||||
/>}
|
||||
|
||||
<div className="absolute inset-0 z-[2] bg-gray-900 opacity-50 hover:opacity-70 transition-opacity flex items-center justify-center" />
|
||||
<div className="absolute inset-0 z-[6] flex items-center justify-center">
|
||||
<StartPlaylistModal
|
||||
canStart={serverStatus?.settings?.library?.autoUpdateProgress}
|
||||
playlist={p}
|
||||
onPlaylistLoaded={handlePlaylistLoaded}
|
||||
trigger={
|
||||
<FaCirclePlay className="block text-5xl cursor-pointer opacity-50 hover:opacity-100 transition-opacity" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 z-[6] flex items-center justify-center">
|
||||
<PlaylistModal
|
||||
trigger={<Button
|
||||
className="w-full flex-none rounded-full"
|
||||
leftIcon={<BiEditAlt />}
|
||||
intent="white-subtle"
|
||||
size="sm"
|
||||
|
||||
>Edit</Button>} playlist={p}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute w-full bottom-0 h-fit z-[6]">
|
||||
<div className="space-y-0 pb-3 items-center">
|
||||
<p className="text-md font-bold text-white max-w-lg truncate text-center">{p.name}</p>
|
||||
{p.localFiles &&
|
||||
<p className="text-sm text-[--muted] font-normal line-clamp-1 text-center">{p.localFiles.length} episode{p.localFiles.length > 1
|
||||
? `s`
|
||||
: ""}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaCardBodyBottomGradient />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
)
|
||||
})}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Anime_Playlist } from "@/api/generated/types"
|
||||
import { usePlaybackStartPlaylist } from "@/api/hooks/playback_manager.hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import React from "react"
|
||||
import { FaPlay } from "react-icons/fa"
|
||||
|
||||
type StartPlaylistModalProps = {
|
||||
trigger?: React.ReactElement
|
||||
playlist: Anime_Playlist
|
||||
canStart?: boolean
|
||||
onPlaylistLoaded: () => void
|
||||
}
|
||||
|
||||
export function StartPlaylistModal(props: StartPlaylistModalProps) {
|
||||
|
||||
const {
|
||||
trigger,
|
||||
playlist,
|
||||
canStart,
|
||||
onPlaylistLoaded,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { mutate: startPlaylist, isPending } = usePlaybackStartPlaylist({
|
||||
onSuccess: onPlaylistLoaded,
|
||||
})
|
||||
|
||||
if (!playlist?.localFiles?.length) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Start playlist"
|
||||
titleClass="text-center"
|
||||
trigger={trigger}
|
||||
>
|
||||
<p className="text-center">
|
||||
You are about to start the playlist <strong>"{playlist.name}"</strong>,
|
||||
which contains {playlist.localFiles.length} episode{playlist.localFiles.length > 1 ? "s" : ""}.
|
||||
</p>
|
||||
<p className="text-[--muted] text-center">
|
||||
Reminder: The playlist will be deleted once you start it, whether you finish it or not.
|
||||
</p>
|
||||
{!canStart && (
|
||||
<p className="text-orange-300 text-center">
|
||||
Please enable "Automatically update progress" to start
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full flex-none"
|
||||
leftIcon={<FaPlay />}
|
||||
intent="primary"
|
||||
size="lg"
|
||||
loading={isPending}
|
||||
disabled={!canStart}
|
||||
onClick={() => startPlaylist({ dbId: playlist.dbId })}
|
||||
>Start</Button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
|
||||
import { PlaylistsList } from "@/app/(main)/(library)/_containers/playlists/_components/playlists-list"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
|
||||
type PlaylistsModalProps = {}
|
||||
|
||||
export const __playlists_modalOpenAtom = atom(false)
|
||||
|
||||
export function PlaylistsModal(props: PlaylistsModalProps) {
|
||||
|
||||
const {} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const [isOpen, setIsOpen] = useAtom(__playlists_modalOpenAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={v => setIsOpen(v)}
|
||||
size="lg"
|
||||
side="bottom"
|
||||
contentClass=""
|
||||
>
|
||||
{/*<div*/}
|
||||
{/* className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover"*/}
|
||||
{/*>*/}
|
||||
{/* <div*/}
|
||||
{/* className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/}
|
||||
{/* />*/}
|
||||
{/*</div>*/}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h4>Playlists</h4>
|
||||
<p className="text-[--muted] text-sm">
|
||||
Playlists only work with system media players and local files.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center md:pr-8">
|
||||
<PlaylistModal
|
||||
trigger={
|
||||
<Button intent="white" className="rounded-full">
|
||||
Add a playlist
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!serverStatus?.settings?.library?.autoUpdateProgress && <Alert
|
||||
className="max-w-2xl mx-auto"
|
||||
intent="warning"
|
||||
description={<>
|
||||
<p>
|
||||
You need to enable "Automatically update progress" to use playlists.
|
||||
</p>
|
||||
</>}
|
||||
/>}
|
||||
|
||||
<div className="">
|
||||
<PlaylistsList />
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
import { __scanner_isScanningAtom } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { Card, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
import { Spinner } from "@/components/ui/loading-spinner"
|
||||
import { ProgressBar } from "@/components/ui/progress-bar"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React, { useState } from "react"
|
||||
|
||||
export function ScanProgressBar() {
|
||||
|
||||
const [isScanning] = useAtom(__scanner_isScanningAtom)
|
||||
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState("Scanning...")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isScanning) {
|
||||
setProgress(0)
|
||||
setStatus("Scanning...")
|
||||
}
|
||||
}, [isScanning])
|
||||
|
||||
useWebsocketMessageListener<number>({
|
||||
type: WSEvents.SCAN_PROGRESS,
|
||||
onMessage: data => {
|
||||
console.log("Scan progress", data)
|
||||
setProgress(data)
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.SCAN_STATUS,
|
||||
onMessage: data => {
|
||||
console.log("Scan status", data)
|
||||
setStatus(data)
|
||||
},
|
||||
})
|
||||
|
||||
if (!isScanning) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]" data-scan-progress-bar-container>
|
||||
<ProgressBar size="xs" value={progress} />
|
||||
</div>
|
||||
{/*<div className="fixed left-0 top-8 w-full flex justify-center z-[100]">*/}
|
||||
{/* <div className="bg-gray-900 rounded-full border h-14 px-6 flex gap-2 items-center">*/}
|
||||
{/* <Spinner className="w-4 h-4" />*/}
|
||||
{/* <p>{progress}% - {status}</p>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
<div className="z-50 fixed bottom-4 right-4" data-scan-progress-bar-card-container>
|
||||
<PageWrapper>
|
||||
<Card className="w-fit max-w-[400px] relative" data-scan-progress-bar-card>
|
||||
<CardHeader>
|
||||
<CardDescription className="flex items-center gap-2 text-base text-[--foregorund]">
|
||||
<Spinner className="size-6" /> {progress}% - {status}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useScanLocalFiles } from "@/api/hooks/scan.hooks"
|
||||
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
|
||||
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { GlowingEffect } from "@/components/shared/glowing-effect"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useBoolean } from "@/hooks/use-disclosure"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
|
||||
export const __scanner_modalIsOpen = atom(false)
|
||||
export const __scanner_isScanningAtom = atom(false)
|
||||
|
||||
|
||||
export function ScannerModal() {
|
||||
const serverStatus = useServerStatus()
|
||||
const [isOpen, setOpen] = useAtom(__scanner_modalIsOpen)
|
||||
const [, setScannerIsScanning] = useAtom(__scanner_isScanningAtom)
|
||||
const [userMedia] = useAtom(__anilist_userAnimeMediaAtom)
|
||||
const anilistDataOnly = useBoolean(true)
|
||||
const skipLockedFiles = useBoolean(true)
|
||||
const skipIgnoredFiles = useBoolean(true)
|
||||
|
||||
const { mutate: scanLibrary, isPending: isScanning } = useScanLocalFiles(() => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!userMedia?.length) anilistDataOnly.off()
|
||||
else anilistDataOnly.on()
|
||||
}, [userMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
setScannerIsScanning(isScanning)
|
||||
}, [isScanning])
|
||||
|
||||
function handleScan() {
|
||||
scanLibrary({
|
||||
enhanced: !anilistDataOnly.active,
|
||||
skipLockedFiles: skipLockedFiles.active,
|
||||
skipIgnoredFiles: skipIgnoredFiles.active,
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
React.useEffect(() => {
|
||||
inject("scanner-controls", {
|
||||
priority: 1,
|
||||
items: [{
|
||||
id: "refresh",
|
||||
value: "refresh",
|
||||
heading: "Library",
|
||||
render: () => (
|
||||
<p>Refresh library</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
ctx.close()
|
||||
setTimeout(() => {
|
||||
handleScan()
|
||||
}, 500)
|
||||
},
|
||||
showBasedOnInput: "startsWith",
|
||||
}],
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase())
|
||||
},
|
||||
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
|
||||
showBasedOnInput: "startsWith",
|
||||
})
|
||||
|
||||
return () => remove("scanner-controls")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
data-scanner-modal
|
||||
open={isOpen}
|
||||
onOpenChange={o => {
|
||||
// if (!isScanning) {
|
||||
// setOpen(o)
|
||||
// }
|
||||
setOpen(o)
|
||||
}}
|
||||
// title="Library scanner"
|
||||
titleClass="text-center"
|
||||
contentClass="space-y-4 max-w-2xl bg-gray-950 bg-opacity-70 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none rounded-xl"
|
||||
overlayClass="bg-gray-950/70 backdrop-blur-sm"
|
||||
>
|
||||
<GlowingEffect
|
||||
spread={50}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
// movementDuration={4}
|
||||
className="!mt-0 opacity-30"
|
||||
/>
|
||||
|
||||
{/* <div
|
||||
data-scanner-modal-top-pattern
|
||||
className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-40 top-0 left-0 bg-no-repeat bg-right bg-cover"
|
||||
>
|
||||
<div
|
||||
className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{serverStatus?.user?.isSimulated && <div className="border border-dashed rounded-md py-2 px-4 !mt-5">
|
||||
Using this feature without an AniList account is not recommended if you have a large library, as it may lead to rate limits and
|
||||
slower scanning. Please consider using an account for a better experience.
|
||||
</div>}
|
||||
|
||||
<div className="space-y-4" data-scanner-modal-content>
|
||||
|
||||
<AppLayoutStack className="space-y-2">
|
||||
<h5 className="text-[--muted]">Local files</h5>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Skip locked files"
|
||||
value={skipLockedFiles.active}
|
||||
onValueChange={v => skipLockedFiles.set(v as boolean)}
|
||||
// size="lg"
|
||||
/>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Skip ignored files"
|
||||
value={skipIgnoredFiles.active}
|
||||
onValueChange={v => skipIgnoredFiles.set(v as boolean)}
|
||||
// size="lg"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AppLayoutStack className="space-y-2">
|
||||
<h5 className="text-[--muted]">Matching data</h5>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Use my AniList lists only"
|
||||
moreHelp="Disabling this will cause Seanime to send more API requests which may lead to rate limits and slower scanning"
|
||||
// label="Enhanced scanning"
|
||||
value={anilistDataOnly.active}
|
||||
onValueChange={v => anilistDataOnly.set(v as boolean)}
|
||||
// className="data-[state=checked]:bg-amber-700 dark:data-[state=checked]:bg-amber-700"
|
||||
// size="lg"
|
||||
help={!anilistDataOnly.active
|
||||
? <span><span className="text-[--orange]">Slower for large libraries</span>. For faster scanning, add the anime
|
||||
entries present in your library to your
|
||||
lists and re-enable this before
|
||||
scanning.</span>
|
||||
: ""}
|
||||
disabled={!userMedia?.length}
|
||||
/>
|
||||
</AppLayoutStack>
|
||||
|
||||
</AppLayoutStack>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
intent="primary"
|
||||
leftIcon={<FiSearch />}
|
||||
loading={isScanning}
|
||||
className="w-full"
|
||||
disabled={!serverStatus?.settings?.library?.libraryPath}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Anime_UnknownGroup } from "@/api/generated/types"
|
||||
import { useAddUnknownMedia } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React, { useCallback } from "react"
|
||||
import { BiLinkExternal, BiPlus } from "react-icons/bi"
|
||||
import { TbDatabasePlus } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __unknownMedia_drawerIsOpen = atom(false)
|
||||
|
||||
type UnknownMediaManagerProps = {
|
||||
unknownGroups: Anime_UnknownGroup[]
|
||||
}
|
||||
|
||||
export function UnknownMediaManager(props: UnknownMediaManagerProps) {
|
||||
|
||||
const { unknownGroups } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__unknownMedia_drawerIsOpen)
|
||||
|
||||
const { mutate: addUnknownMedia, isPending: isAdding } = useAddUnknownMedia()
|
||||
const { mutate: performBulkAction, isPending: isUnmatching } = useAnimeEntryBulkAction()
|
||||
|
||||
/**
|
||||
* Add all unknown media to AniList
|
||||
*/
|
||||
const handleAddUnknownMedia = useCallback(() => {
|
||||
addUnknownMedia({ mediaIds: unknownGroups.map(n => n.mediaId) })
|
||||
}, [unknownGroups])
|
||||
|
||||
/**
|
||||
* Close the drawer if there are no unknown groups
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (unknownGroups.length === 0) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [unknownGroups])
|
||||
|
||||
/**
|
||||
* Unmatch all files for a media
|
||||
*/
|
||||
const handleUnmatchMedia = useCallback((mediaId: number) => {
|
||||
performBulkAction({
|
||||
mediaId,
|
||||
action: "unmatch",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Media unmatched")
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (unknownGroups.length === 0) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
data-unknown-media-manager-drawer
|
||||
open={isOpen}
|
||||
onOpenChange={o => {
|
||||
if (!isAdding) {
|
||||
setIsOpen(o)
|
||||
}
|
||||
}}
|
||||
size="xl"
|
||||
title="Resolve hidden media"
|
||||
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
<p className="">
|
||||
Seanime matched {unknownGroups.length} group{unknownGroups.length === 1 ? "" : "s"} to media that {unknownGroups.length === 1
|
||||
? "is"
|
||||
: "are"} absent from your
|
||||
AniList collection.<br />
|
||||
Add the media to be able to see the entry in your library or unmatch them if incorrect.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
leftIcon={<TbDatabasePlus />}
|
||||
onClick={handleAddUnknownMedia}
|
||||
loading={isAdding}
|
||||
disabled={isUnmatching}
|
||||
>
|
||||
Add all to AniList
|
||||
</Button>
|
||||
|
||||
<div className="divide divide-y divide-[--border] space-y-4">
|
||||
|
||||
{unknownGroups.map(group => {
|
||||
return (
|
||||
<div key={group.mediaId} className="pt-4 space-y-2">
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<h4 className="font-semibold flex gap-2 items-center">
|
||||
<span>Anilist ID:{" "}</span>
|
||||
<SeaLink
|
||||
href={`https://anilist.co/anime/${group.mediaId}`}
|
||||
target="_blank"
|
||||
className="underline text-brand-200 flex gap-1.5 items-center"
|
||||
>
|
||||
{group.mediaId} <BiLinkExternal />
|
||||
</SeaLink>
|
||||
</h4>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
intent="primary-subtle"
|
||||
disabled={isAdding}
|
||||
onClick={() => addUnknownMedia({ mediaIds: [group.mediaId] })}
|
||||
leftIcon={<BiPlus />}
|
||||
>
|
||||
Add to AniList
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
intent="warning-subtle"
|
||||
disabled={isUnmatching}
|
||||
onClick={() => handleUnmatchMedia(group.mediaId)}
|
||||
>
|
||||
Unmatch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border p-2 px-2 rounded-[--radius-md] space-y-1 max-h-40 max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
{group.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
|
||||
.map(lf => {
|
||||
return <p key={lf.path} className="text-[--muted] line-clamp-1 tracking-wide">
|
||||
{lf.parsedInfo?.original || lf.path}
|
||||
</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
})}
|
||||
</div>
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Anime_UnmatchedGroup } from "@/api/generated/types"
|
||||
import { useAnimeEntryManualMatch, useFetchAnimeEntrySuggestions } from "@/api/hooks/anime_entries.hooks"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { NumberInput } from "@/components/ui/number-input"
|
||||
import { RadioGroup } from "@/components/ui/radio-group"
|
||||
import { upath } from "@/lib/helpers/upath"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { FaArrowLeft, FaArrowRight } from "react-icons/fa"
|
||||
import { FcFolder } from "react-icons/fc"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { TbFileSad } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __unmatchedFileManagerIsOpen = atom(false)
|
||||
|
||||
type UnmatchedFileManagerProps = {
|
||||
unmatchedGroups: Anime_UnmatchedGroup[]
|
||||
}
|
||||
|
||||
export function UnmatchedFileManager(props: UnmatchedFileManagerProps) {
|
||||
|
||||
const { unmatchedGroups } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__unmatchedFileManagerIsOpen)
|
||||
const [page, setPage] = React.useState(0)
|
||||
const maxPage = unmatchedGroups.length - 1
|
||||
const [currentGroup, setCurrentGroup] = React.useState(unmatchedGroups?.[0])
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
|
||||
|
||||
const [anilistId, setAnilistId] = React.useState(0)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const {
|
||||
data: suggestions,
|
||||
mutate: fetchSuggestions,
|
||||
isPending: suggestionsLoading,
|
||||
reset: resetSuggestions,
|
||||
} = useFetchAnimeEntrySuggestions()
|
||||
|
||||
const { mutate: updateLocalFiles, isPending: isUpdatingFile } = useUpdateLocalFiles()
|
||||
|
||||
const { mutate: manualMatch, isPending: isMatching } = useAnimeEntryManualMatch()
|
||||
|
||||
const isUpdating = isUpdatingFile || isMatching
|
||||
|
||||
const [_r, setR] = React.useState(0)
|
||||
|
||||
const handleSelectAnime = React.useCallback((value: string | null) => {
|
||||
if (value && !isNaN(Number(value))) {
|
||||
setAnilistId(Number(value))
|
||||
setR(r => r + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset the selected paths when the current group changes
|
||||
React.useLayoutEffect(() => {
|
||||
setSelectedPaths(currentGroup?.localFiles?.map(lf => lf.path) ?? [])
|
||||
}, [currentGroup])
|
||||
|
||||
// Reset the current group and page when the drawer is opened
|
||||
React.useEffect(() => {
|
||||
setPage(0)
|
||||
setCurrentGroup(unmatchedGroups[0])
|
||||
}, [isOpen, unmatchedGroups])
|
||||
|
||||
// Set the current group when the page changes
|
||||
React.useEffect(() => {
|
||||
setCurrentGroup(unmatchedGroups[page])
|
||||
setAnilistId(0)
|
||||
resetSuggestions()
|
||||
}, [page, unmatchedGroups])
|
||||
|
||||
const AnilistIdInput = React.useCallback(() => {
|
||||
return <NumberInput
|
||||
value={anilistId}
|
||||
onValueChange={v => setAnilistId(v)}
|
||||
formatOptions={{
|
||||
useGrouping: false,
|
||||
}}
|
||||
/>
|
||||
}, [currentGroup?.dir, _r])
|
||||
|
||||
function onActionSuccess() {
|
||||
if (page === 0 && unmatchedGroups.length === 1) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
setAnilistId(0)
|
||||
resetSuggestions()
|
||||
setPage(0)
|
||||
setCurrentGroup(unmatchedGroups[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually match the current group with the specified Anilist ID.
|
||||
* If the current group is the last group and there are no more unmatched groups, close the drawer.
|
||||
*/
|
||||
function handleMatchSelected() {
|
||||
if (!!currentGroup && anilistId > 0 && selectedPaths.length > 0) {
|
||||
manualMatch({
|
||||
paths: selectedPaths,
|
||||
mediaId: anilistId,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onActionSuccess()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchSuggestions = React.useCallback(() => {
|
||||
fetchSuggestions({
|
||||
dir: currentGroup.dir,
|
||||
})
|
||||
}, [currentGroup?.dir, fetchSuggestions])
|
||||
|
||||
function handleIgnoreSelected() {
|
||||
if (selectedPaths.length > 0) {
|
||||
updateLocalFiles({
|
||||
paths: selectedPaths,
|
||||
action: "ignore",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onActionSuccess()
|
||||
toast.success("Files ignored")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentGroup) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [currentGroup])
|
||||
|
||||
if (!currentGroup) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
data-unmatched-file-manager-drawer
|
||||
open={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
// contentClass="max-w-5xl"
|
||||
size="xl"
|
||||
title="Unmatched files"
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
<div className={cn("flex w-full justify-between", { "hidden": unmatchedGroups.length <= 1 })}>
|
||||
<Button
|
||||
intent="gray-subtle"
|
||||
leftIcon={<FaArrowLeft />}
|
||||
disabled={page === 0}
|
||||
onClick={() => {
|
||||
setPage(p => p - 1)
|
||||
}}
|
||||
className={cn("transition-opacity", { "opacity-0": page === 0 })}
|
||||
>Previous</Button>
|
||||
|
||||
<p>
|
||||
{page + 1} / {maxPage + 1}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
intent="gray-subtle"
|
||||
rightIcon={<FaArrowRight />}
|
||||
disabled={page >= maxPage}
|
||||
onClick={() => {
|
||||
setPage(p => p + 1)
|
||||
}}
|
||||
className={cn("transition-opacity", { "opacity-0": page >= maxPage })}
|
||||
>Next</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-gray-900 border p-2 px-4 rounded-[--radius-md] line-clamp-1 flex gap-2 items-center cursor-pointer transition hover:bg-opacity-80"
|
||||
onClick={() => openInExplorer({
|
||||
path: currentGroup.dir,
|
||||
})}
|
||||
>
|
||||
<FcFolder className="text-2xl" />
|
||||
{currentGroup.dir}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<p className="flex-none text-lg mr-2 font-semibold">Anilist ID</p>
|
||||
<AnilistIdInput />
|
||||
<Button
|
||||
intent="white"
|
||||
onClick={handleMatchSelected}
|
||||
disabled={isUpdating}
|
||||
>Match selection</Button>
|
||||
</div>
|
||||
|
||||
{/*<div className="flex flex-1">*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[50vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
|
||||
<div className="p-2">
|
||||
<Checkbox
|
||||
label={`Select all files`}
|
||||
value={(selectedPaths.length === currentGroup?.localFiles?.length) ? true : (selectedPaths.length === 0
|
||||
? false
|
||||
: "indeterminate")}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (draft.length === currentGroup?.localFiles?.length) {
|
||||
return []
|
||||
} else {
|
||||
return currentGroup?.localFiles?.map(lf => lf.path) ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentGroup.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
|
||||
.map((lf, index) => (
|
||||
<div
|
||||
key={`${lf.path}-${index}`}
|
||||
className="p-2 "
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
label={`${upath.basename(lf.path)}`}
|
||||
value={selectedPaths.includes(lf.path)}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (checked) {
|
||||
return [...draft, lf.path]
|
||||
} else {
|
||||
return draft.filter(p => p !== lf.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
labelClass="text-sm tracking-wide data-[checked=false]:opacity-50"
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*<Separator />*/}
|
||||
|
||||
{/*<Separator />*/}
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
leftIcon={<FiSearch />}
|
||||
intent="primary-subtle"
|
||||
onClick={handleFetchSuggestions}
|
||||
>
|
||||
Fetch suggestions
|
||||
</Button>
|
||||
|
||||
<SeaLink
|
||||
target="_blank"
|
||||
href={`https://anilist.co/search/anime?search=${encodeURIComponent(currentGroup?.localFiles?.[0]?.parsedInfo?.title || currentGroup?.localFiles?.[0]?.parsedFolderInfo?.[0]?.title || "")}`}
|
||||
>
|
||||
<Button
|
||||
intent="white-link"
|
||||
>
|
||||
Search on AniList
|
||||
</Button>
|
||||
</SeaLink>
|
||||
|
||||
<div className="flex flex-1"></div>
|
||||
|
||||
<Button
|
||||
leftIcon={<TbFileSad className="text-lg" />}
|
||||
intent="warning-subtle"
|
||||
size="sm"
|
||||
rounded
|
||||
disabled={isUpdating}
|
||||
onClick={handleIgnoreSelected}
|
||||
>
|
||||
Ignore selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{suggestionsLoading && <LoadingSpinner />}
|
||||
|
||||
{(!suggestionsLoading && !!suggestions?.length) && <RadioGroup
|
||||
defaultValue="1"
|
||||
fieldClass="w-full"
|
||||
fieldLabelClass="text-md"
|
||||
label="Select Anime"
|
||||
value={String(anilistId)}
|
||||
onValueChange={handleSelectAnime}
|
||||
options={suggestions?.map((media) => (
|
||||
{
|
||||
label: <div>
|
||||
<p className="text-base md:text-md font-medium !-mt-1.5 line-clamp-1">{media.title?.userPreferred || media.title?.english || media.title?.romaji || "N/A"}</p>
|
||||
<div className="mt-2 flex w-full gap-4">
|
||||
{media.coverImage?.medium && <div
|
||||
className="h-28 w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={media.coverImage.medium}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="10rem"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>}
|
||||
<div className="text-[--muted]">
|
||||
<p>Type: <span
|
||||
className="text-gray-200 font-semibold"
|
||||
>{media.format}</span>
|
||||
</p>
|
||||
<p>Aired: {media.startDate?.year ? new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
}).format(new Date(media.startDate?.year || 0, media.startDate?.month || 0)) : "-"}</p>
|
||||
<p>Status: {media.status}</p>
|
||||
<SeaLink href={`https://anilist.co/anime/${media.id}`} target="_blank">
|
||||
<Button
|
||||
intent="primary-link"
|
||||
size="sm"
|
||||
className="px-0"
|
||||
>Open on AniList</Button>
|
||||
</SeaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
value: String(media.id) || "",
|
||||
}
|
||||
))}
|
||||
stackClass="grid grid-cols-1 md:grid-cols-2 gap-2 space-y-0"
|
||||
itemContainerClass={cn(
|
||||
"items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full",
|
||||
"bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900",
|
||||
"data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950",
|
||||
"focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition",
|
||||
"border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0",
|
||||
)}
|
||||
itemClass={cn(
|
||||
"border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent",
|
||||
"data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent",
|
||||
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent",
|
||||
)}
|
||||
itemIndicatorClass="hidden"
|
||||
itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer"
|
||||
/>}
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { CollectionParams, DEFAULT_ANIME_COLLECTION_PARAMS, filterAnimeCollectionEntries, filterEntriesByTitle } from "@/lib/helpers/filtering"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
import { useAtom, useAtomValue } from "jotai/index"
|
||||
import React from "react"
|
||||
|
||||
export const DETAILED_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
sorting: "TITLE",
|
||||
}
|
||||
|
||||
// export const __library_paramsAtom = atomWithStorage("sea-library-sorting-params", DETAILED_LIBRARY_DEFAULT_PARAMS, undefined, { getOnInit: true })
|
||||
export const __library_paramsAtom = atomWithImmer(DETAILED_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export const __library_selectedListAtom = atomWithImmer<string>("-")
|
||||
|
||||
export const __library_debouncedSearchInputAtom = atomWithImmer<string>("")
|
||||
|
||||
export function useHandleDetailedLibraryCollection() {
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const { animeLibraryCollectionDefaultSorting } = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
/**
|
||||
* Fetch the library collection data
|
||||
*/
|
||||
const { data, isLoading } = useGetLibraryCollection()
|
||||
|
||||
const [paramsToDebounce, setParamsToDebounce] = useAtom(__library_paramsAtom)
|
||||
const debouncedParams = useDebounce(paramsToDebounce, 500)
|
||||
|
||||
const debouncedSearchInput = useAtomValue(__library_debouncedSearchInputAtom)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
let _params = { ...paramsToDebounce }
|
||||
_params.sorting = animeLibraryCollectionDefaultSorting as any
|
||||
setParamsToDebounce(_params)
|
||||
}, [data, animeLibraryCollectionDefaultSorting])
|
||||
|
||||
|
||||
/**
|
||||
* Sort and filter the collection data
|
||||
*/
|
||||
const _filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
const arr = filterAnimeCollectionEntries(obj.entries,
|
||||
paramsToDebounce,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
data.continueWatchingList,
|
||||
watchHistory)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, debouncedParams, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
|
||||
|
||||
const filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
|
||||
return _filteredCollection.map(obj => {
|
||||
if (!obj) return obj
|
||||
const arr = filterEntriesByTitle(obj.entries, debouncedSearchInput)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}, [_filteredCollection, debouncedSearchInput])
|
||||
|
||||
const continueWatchingList = React.useMemo(() => {
|
||||
if (!data?.continueWatchingList) return []
|
||||
|
||||
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
|
||||
return data.continueWatchingList.filter(entry => entry.baseAnime?.isAdult === false)
|
||||
}
|
||||
|
||||
return data.continueWatchingList
|
||||
}, [
|
||||
data?.continueWatchingList,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
serverStatus?.settings?.anilist?.blurAdultContent,
|
||||
])
|
||||
|
||||
const libraryGenres = React.useMemo(() => {
|
||||
const allGenres = filteredCollection?.flatMap(l => {
|
||||
return l.entries?.flatMap(e => e.media?.genres) ?? []
|
||||
})
|
||||
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
|
||||
}, [filteredCollection])
|
||||
|
||||
return {
|
||||
isLoading: isLoading,
|
||||
stats: data?.stats,
|
||||
libraryCollectionList: filteredCollection,
|
||||
libraryGenres: libraryGenres,
|
||||
continueWatchingList: continueWatchingList,
|
||||
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
|
||||
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
|
||||
unmatchedGroups: data?.unmatchedGroups ?? [],
|
||||
unknownGroups: data?.unknownGroups ?? [],
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import {
|
||||
CollectionParams,
|
||||
DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
filterAnimeCollectionEntries,
|
||||
sortContinueWatchingEntries,
|
||||
} from "@/lib/helpers/filtering"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
import { useAtomValue, useSetAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
|
||||
export const MAIN_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
sorting: "TITLE", // Will be set to default sorting on mount
|
||||
continueWatchingOnly: false,
|
||||
}
|
||||
|
||||
export const __mainLibrary_paramsAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export const __mainLibrary_paramsInputAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export function useHandleLibraryCollection() {
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const atom_setLibraryCollection = useSetAtom(animeLibraryCollectionAtom)
|
||||
|
||||
const { animeLibraryCollectionDefaultSorting, continueWatchingDefaultSorting } = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
/**
|
||||
* Fetch the anime library collection
|
||||
*/
|
||||
const { data, isLoading } = useGetLibraryCollection()
|
||||
|
||||
/**
|
||||
* Store the received data in `libraryCollectionAtom`
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!!data) {
|
||||
atom_setLibraryCollection(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
/**
|
||||
* Get the current params
|
||||
*/
|
||||
const params = useAtomValue(__mainLibrary_paramsAtom)
|
||||
|
||||
/**
|
||||
* Sort the collection
|
||||
* - This is displayed when there's no filters applied
|
||||
*/
|
||||
const sortedCollection = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
// Stream
|
||||
if (data.stream) {
|
||||
// Add to current list
|
||||
let currentList = data.lists.find(n => n.type === "CURRENT")
|
||||
if (currentList) {
|
||||
let entries = [...(currentList.entries ?? [])]
|
||||
for (let anime of (data.stream.anime ?? [])) {
|
||||
if (!entries.some(e => e.mediaId === anime.id)) {
|
||||
entries.push({
|
||||
media: anime,
|
||||
mediaId: anime.id,
|
||||
listData: data.stream.listData?.[anime.id],
|
||||
libraryData: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
data.lists.find(n => n.type === "CURRENT")!.entries = entries
|
||||
}
|
||||
}
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
|
||||
//
|
||||
let sortingParams = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
continueWatchingOnly: params.continueWatchingOnly,
|
||||
sorting: animeLibraryCollectionDefaultSorting as any,
|
||||
} as CollectionParams<"anime">
|
||||
|
||||
let continueWatchingList = [...(data.continueWatchingList ?? [])]
|
||||
|
||||
if (data.stream) {
|
||||
for (let entry of (data.stream?.continueWatchingList ?? [])) {
|
||||
continueWatchingList = [...continueWatchingList, entry]
|
||||
}
|
||||
}
|
||||
let arr = filterAnimeCollectionEntries(
|
||||
obj.entries,
|
||||
sortingParams,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
continueWatchingList,
|
||||
watchHistory,
|
||||
)
|
||||
|
||||
// Reset `continueWatchingOnly` to false if it's about to make the list disappear
|
||||
if (arr.length === 0 && sortingParams.continueWatchingOnly) {
|
||||
|
||||
// TODO: Add a toast to notify the user that the list is empty
|
||||
sortingParams = {
|
||||
...sortingParams,
|
||||
continueWatchingOnly: false, // Override
|
||||
}
|
||||
|
||||
arr = filterAnimeCollectionEntries(
|
||||
obj.entries,
|
||||
sortingParams,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
continueWatchingList,
|
||||
watchHistory,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, params, animeLibraryCollectionDefaultSorting, serverStatus?.settings?.anilist?.enableAdultContent])
|
||||
|
||||
/**
|
||||
* Filter the collection
|
||||
* - This is displayed when there's filters applied
|
||||
*/
|
||||
const filteredCollection = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
const paramsToApply = {
|
||||
...params,
|
||||
sorting: animeLibraryCollectionDefaultSorting,
|
||||
} as CollectionParams<"anime">
|
||||
const arr = filterAnimeCollectionEntries(obj.entries,
|
||||
paramsToApply,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
data.continueWatchingList,
|
||||
watchHistory)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, params, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
|
||||
|
||||
/**
|
||||
* Sort the continue watching list
|
||||
*/
|
||||
const continueWatchingList = React.useMemo(() => {
|
||||
if (!data?.continueWatchingList) return []
|
||||
|
||||
let list = [...data.continueWatchingList]
|
||||
|
||||
|
||||
if (data.stream) {
|
||||
for (let entry of (data.stream.continueWatchingList ?? [])) {
|
||||
list = [...list, entry]
|
||||
}
|
||||
}
|
||||
|
||||
const entries = sortedCollection.flatMap(n => n.entries)
|
||||
|
||||
list = sortContinueWatchingEntries(list, continueWatchingDefaultSorting as any, entries, watchHistory)
|
||||
|
||||
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
|
||||
return list.filter(entry => entry.baseAnime?.isAdult === false)
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
data?.stream,
|
||||
sortedCollection,
|
||||
data?.continueWatchingList,
|
||||
continueWatchingDefaultSorting,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
serverStatus?.settings?.anilist?.blurAdultContent,
|
||||
watchHistory,
|
||||
])
|
||||
|
||||
/**
|
||||
* Get the genres from all media in the library
|
||||
*/
|
||||
const libraryGenres = React.useMemo(() => {
|
||||
const allGenres = filteredCollection?.flatMap(l => {
|
||||
return l.entries?.flatMap(e => e.media?.genres) ?? []
|
||||
})
|
||||
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
|
||||
}, [filteredCollection])
|
||||
|
||||
return {
|
||||
libraryGenres,
|
||||
isLoading: isLoading,
|
||||
libraryCollectionList: sortedCollection,
|
||||
filteredLibraryCollectionList: filteredCollection,
|
||||
continueWatchingList: continueWatchingList,
|
||||
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
|
||||
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
|
||||
unmatchedGroups: data?.unmatchedGroups ?? [],
|
||||
unknownGroups: data?.unknownGroups ?? [],
|
||||
streamingMediaIds: data?.stream?.anime?.map(n => n.id) ?? [],
|
||||
hasEntries: sortedCollection.some(n => n.entries?.length > 0),
|
||||
isStreamingOnly: sortedCollection.every(n => n.entries?.every(e => !e.libraryData)),
|
||||
isNakamaLibrary: React.useMemo(() => data?.lists?.some(l => l.entries?.some(e => !!e.nakamaLibraryData)) ?? false, [data?.lists]),
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai"
|
||||
|
||||
export const __library_viewAtom = atom<"base" | "detailed">("base")
|
||||
@@ -0,0 +1,366 @@
|
||||
import { Anime_Episode, Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import {
|
||||
__library_debouncedSearchInputAtom,
|
||||
__library_paramsAtom,
|
||||
__library_selectedListAtom,
|
||||
DETAILED_LIBRARY_DEFAULT_PARAMS,
|
||||
useHandleDetailedLibraryCollection,
|
||||
} from "@/app/(main)/(library)/_lib/handle-detailed-library-collection"
|
||||
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
|
||||
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
|
||||
import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { ADVANCED_SEARCH_FORMATS, ADVANCED_SEARCH_SEASONS, ADVANCED_SEARCH_STATUS } from "@/app/(main)/search/_lib/advanced-search-constants"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { StaticTabs } from "@/components/ui/tabs"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { ANIME_COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering"
|
||||
import { getLibraryCollectionTitle } from "@/lib/server/utils"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { getYear } from "date-fns"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { AiOutlineArrowLeft } from "react-icons/ai"
|
||||
import { BiTrash } from "react-icons/bi"
|
||||
import { FaSortAmountDown } from "react-icons/fa"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { LuCalendar, LuLeaf } from "react-icons/lu"
|
||||
import { MdPersonalVideo } from "react-icons/md"
|
||||
import { RiSignalTowerLine } from "react-icons/ri"
|
||||
|
||||
type LibraryViewProps = {
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
continueWatchingList: Anime_Episode[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
streamingMediaIds: number[]
|
||||
isNakamaLibrary: boolean
|
||||
}
|
||||
|
||||
export function DetailedLibraryView(props: LibraryViewProps) {
|
||||
|
||||
const {
|
||||
// collectionList: _collectionList,
|
||||
continueWatchingList,
|
||||
isLoading,
|
||||
hasEntries,
|
||||
streamingMediaIds,
|
||||
isNakamaLibrary,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const setView = useSetAtom(__library_viewAtom)
|
||||
const nakamaStatus = useNakamaStatus()
|
||||
|
||||
const {
|
||||
stats,
|
||||
libraryCollectionList,
|
||||
libraryGenres,
|
||||
} = useHandleDetailedLibraryCollection()
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
|
||||
if (!hasEntries) return null
|
||||
|
||||
return (
|
||||
<PageWrapper className="p-4 space-y-8 relative z-[4]" data-detailed-library-view-container>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
"absolute top-[-20rem] left-0 w-full h-[30rem] bg-gradient-to-t from-[--background] to-transparent z-[-1]",
|
||||
TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between" data-detailed-library-view-header-container>
|
||||
<div className="flex gap-4 items-center relative w-fit">
|
||||
<IconButton
|
||||
icon={<AiOutlineArrowLeft />}
|
||||
rounded
|
||||
intent="white-outline"
|
||||
size="sm"
|
||||
onClick={() => setView("base")}
|
||||
/>
|
||||
{!isNakamaLibrary && <h3 className="text-ellipsis truncate">Library</h3>}
|
||||
{isNakamaLibrary &&
|
||||
<h3 className="text-ellipsis truncate">{nakamaStatus?.hostConnectionStatus?.username || "Host"}'s Library</h3>}
|
||||
</div>
|
||||
|
||||
<SearchInput />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-3 lg:grid-cols-6 gap-4 [&>div]:text-center [&>div>p]:text-[--muted]",
|
||||
isNakamaLibrary && "lg:grid-cols-5",
|
||||
)}
|
||||
data-detailed-library-view-stats-container
|
||||
>
|
||||
{!isNakamaLibrary && <div>
|
||||
<h3>{stats?.totalSize}</h3>
|
||||
<p>Library</p>
|
||||
</div>}
|
||||
<div>
|
||||
<h3>{stats?.totalFiles}</h3>
|
||||
<p>Files</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalEntries}</h3>
|
||||
<p>Entries</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalShows}</h3>
|
||||
<p>TV Shows</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalMovies}</h3>
|
||||
<p>Movies</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalSpecials}</h3>
|
||||
<p>Specials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchOptions />
|
||||
|
||||
<GenreSelector genres={libraryGenres} />
|
||||
|
||||
{libraryCollectionList.map(collection => {
|
||||
if (!collection.entries?.length) return null
|
||||
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: { list: Anime_LibraryCollectionList, streamingMediaIds: number[] }) => {
|
||||
|
||||
const selectedList = useAtomValue(__library_selectedListAtom)
|
||||
|
||||
if (selectedList !== "-" && selectedList !== list.type) return null
|
||||
|
||||
return (
|
||||
<React.Fragment key={list.type}>
|
||||
<h2>{getLibraryCollectionTitle(list.type)} <span className="text-[--muted] font-medium ml-3">{list?.entries?.length ?? 0}</span></h2>
|
||||
<MediaCardLazyGrid itemCount={list?.entries?.length || 0}>
|
||||
{list.entries?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
|
||||
entry: Anime_LibraryCollectionEntry,
|
||||
streamingMediaIds: number[]
|
||||
}) => {
|
||||
return (
|
||||
<MediaEntryCard
|
||||
media={entry.media!}
|
||||
listData={entry.listData}
|
||||
libraryData={entry.libraryData}
|
||||
nakamaLibraryData={entry.nakamaLibraryData}
|
||||
showListDataButton
|
||||
withAudienceScore={false}
|
||||
type="anime"
|
||||
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const SearchInput = () => {
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const setDebouncedInput = useSetAtom(__library_debouncedSearchInputAtom)
|
||||
const debouncedInput = useDebounce(inputValue, 500)
|
||||
|
||||
React.useEffect(() => {
|
||||
setDebouncedInput(inputValue)
|
||||
}, [debouncedInput])
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full md:w-[300px]">
|
||||
<TextInput
|
||||
leftIcon={<FiSearch />}
|
||||
value={inputValue}
|
||||
onValueChange={v => {
|
||||
setInputValue(v)
|
||||
}}
|
||||
className="rounded-full bg-gray-900/50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchOptions() {
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const [params, setParams] = useAtom(__library_paramsAtom)
|
||||
const [selectedIndex, setSelectedIndex] = useAtom(__library_selectedListAtom)
|
||||
|
||||
return (
|
||||
<AppLayoutStack className="px-4 xl:px-0" data-detailed-library-view-search-options-container>
|
||||
<div className="flex w-full justify-center">
|
||||
<StaticTabs
|
||||
className="h-10 w-fit pb-6"
|
||||
triggerClass="px-4 py-1"
|
||||
items={[
|
||||
{ name: "All", isCurrent: selectedIndex === "-", onClick: () => setSelectedIndex("-") },
|
||||
{ name: "Watching", isCurrent: selectedIndex === "CURRENT", onClick: () => setSelectedIndex("CURRENT") },
|
||||
{ name: "Planning", isCurrent: selectedIndex === "PLANNING", onClick: () => setSelectedIndex("PLANNING") },
|
||||
{ name: "Paused", isCurrent: selectedIndex === "PAUSED", onClick: () => setSelectedIndex("PAUSED") },
|
||||
{ name: "Completed", isCurrent: selectedIndex === "COMPLETED", onClick: () => setSelectedIndex("COMPLETED") },
|
||||
{ name: "Dropped", isCurrent: selectedIndex === "DROPPED", onClick: () => setSelectedIndex("DROPPED") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-[1fr_1fr_1fr_1fr_1fr_auto_auto] gap-4"
|
||||
data-detailed-library-view-search-options-grid
|
||||
>
|
||||
<Select
|
||||
label="Sorting"
|
||||
leftAddon={<FaSortAmountDown className={cn(params.sorting !== "TITLE" && "text-indigo-300 font-bold text-xl")} />}
|
||||
className="w-full"
|
||||
fieldClass="flex items-center"
|
||||
inputContainerClass="w-full"
|
||||
options={ANIME_COLLECTION_SORTING_OPTIONS}
|
||||
value={params.sorting || "TITLE"}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.sorting = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
// disabled={!!params.title && params.title.length > 0}
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<MdPersonalVideo className={cn((params.format as any) !== null && (params.format as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Format" placeholder="All formats"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={ADVANCED_SEARCH_FORMATS}
|
||||
value={params.format || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.format = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<RiSignalTowerLine className={cn((params.status as any) !== null && (params.status as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Status" placeholder="All statuses"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={[
|
||||
...ADVANCED_SEARCH_STATUS,
|
||||
]}
|
||||
value={params.status || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.status = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<LuLeaf className={cn((params.season as any) !== null && (params.season as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Season"
|
||||
placeholder="All seasons"
|
||||
className="w-full"
|
||||
fieldClass="w-full flex items-center"
|
||||
inputContainerClass="w-full"
|
||||
options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))}
|
||||
value={params.season || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.season = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={<LuCalendar className={cn((params.year !== null && params.year !== "") && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Year" placeholder="Timeless"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx).map(year => ({
|
||||
value: String(year),
|
||||
label: String(year),
|
||||
}))}
|
||||
value={params.year || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.year = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<div className="flex gap-4 items-center w-full">
|
||||
<IconButton
|
||||
icon={<BiTrash />} intent="alert-subtle" className="flex-none" onClick={() => {
|
||||
setParams(DETAILED_LIBRARY_DEFAULT_PARAMS)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serverStatus?.settings?.anilist?.enableAdultContent && <div className="flex h-full items-center">
|
||||
<Switch
|
||||
label="Adult"
|
||||
value={params.isAdult}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.isAdult = v
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
</AppLayoutStack>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({ genres }: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__library_paramsAtom)
|
||||
return (
|
||||
<MediaGenreSelector
|
||||
items={[
|
||||
{
|
||||
name: "All",
|
||||
isCurrent: !params!.genre?.length,
|
||||
onClick: () => setParams(draft => {
|
||||
draft.genre = []
|
||||
return
|
||||
}),
|
||||
},
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { DiscoverPageHeader } from "@/app/(main)/discover/_components/discover-page-header"
|
||||
import { DiscoverTrending } from "@/app/(main)/discover/_containers/discover-trending"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll"
|
||||
import { StaticTabs } from "@/components/ui/tabs"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useSetAtom } from "jotai/index"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { LuCog } from "react-icons/lu"
|
||||
|
||||
type EmptyLibraryViewProps = {
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
}
|
||||
|
||||
export function EmptyLibraryView(props: EmptyLibraryViewProps) {
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
hasEntries,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
|
||||
|
||||
if (hasEntries || isLoading) return null
|
||||
|
||||
/**
|
||||
* Show empty library message and trending if library is empty
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<DiscoverPageHeader />
|
||||
<PageWrapper className="p-4 sm:p-8 pt-0 space-y-8 relative z-[4]" data-empty-library-view-container>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-fit mx-auto space-y-4">
|
||||
{!!serverStatus?.settings?.library?.libraryPath ? <>
|
||||
<h2>Empty library</h2>
|
||||
<Button
|
||||
intent="primary-outline"
|
||||
leftIcon={<FiSearch />}
|
||||
size="xl"
|
||||
rounded
|
||||
onClick={() => setScannerModalOpen(true)}
|
||||
>
|
||||
Scan your library
|
||||
</Button>
|
||||
</> : (
|
||||
<LuffyError
|
||||
title="Your library is empty"
|
||||
className=""
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<SeaLink href="/settings?tab=library">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Set the path to your local library and scan it
|
||||
</Button>
|
||||
</SeaLink>
|
||||
{serverStatus?.settings?.library?.enableOnlinestream && <p>
|
||||
<SeaLink href="/settings?tab=onlinestream">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include online streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
{serverStatus?.torrentstreamSettings?.enabled && <p>
|
||||
<SeaLink href="/settings?tab=torrentstream">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include torrent streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
{serverStatus?.debridSettings?.enabled && <p>
|
||||
<SeaLink href="/settings?tab=debrid">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include debrid streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
</div>
|
||||
</LuffyError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<h3>Trending this season</h3>
|
||||
<DiscoverTrending />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({
|
||||
genres,
|
||||
}: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
|
||||
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
|
||||
const debouncedParams = useDebounce(params, 500)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActualParams(params)
|
||||
}, [debouncedParams])
|
||||
|
||||
if (!genres.length) return null
|
||||
|
||||
return (
|
||||
<HorizontalDraggableScroll className="scroll-pb-1 pt-4 flex">
|
||||
<div className="flex flex-1"></div>
|
||||
<StaticTabs
|
||||
className="px-2 overflow-visible gap-2 py-4 w-fit"
|
||||
triggerClass="text-base rounded-[--radius-md] ring-2 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300"
|
||||
items={[
|
||||
// {
|
||||
// name: "All",
|
||||
// isCurrent: !params!.genre?.length,
|
||||
// onClick: () => setParams(draft => {
|
||||
// draft.genre = []
|
||||
// return
|
||||
// }),
|
||||
// },
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1"></div>
|
||||
</HorizontalDraggableScroll>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Anime_Episode, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { ContinueWatching } from "@/app/(main)/(library)/_containers/continue-watching"
|
||||
import { LibraryCollectionFilteredLists, LibraryCollectionLists } from "@/app/(main)/(library)/_containers/library-collection"
|
||||
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useSetAtom } from "jotai/index"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import React from "react"
|
||||
|
||||
|
||||
type LibraryViewProps = {
|
||||
genres: string[]
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
filteredCollectionList: Anime_LibraryCollectionList[]
|
||||
continueWatchingList: Anime_Episode[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
streamingMediaIds: number[]
|
||||
}
|
||||
|
||||
export function LibraryView(props: LibraryViewProps) {
|
||||
|
||||
const {
|
||||
genres,
|
||||
collectionList,
|
||||
continueWatchingList,
|
||||
filteredCollectionList,
|
||||
isLoading,
|
||||
hasEntries,
|
||||
streamingMediaIds,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
|
||||
|
||||
if (isLoading) return <React.Fragment>
|
||||
<div className="p-4 space-y-4 relative z-[4]">
|
||||
<Skeleton className="h-12 w-full max-w-lg relative" />
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-[22rem] min-[2000px]:h-[24rem] grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 min-[2000px]:grid-cols-8 gap-4",
|
||||
)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8]?.map((_, idx) => {
|
||||
return <Skeleton
|
||||
key={idx} className={cn(
|
||||
"h-[22rem] min-[2000px]:h-[24rem] col-span-1 aspect-[6/7] flex-none rounded-[--radius-md] relative overflow-hidden",
|
||||
"[&:nth-child(8)]:hidden min-[2000px]:[&:nth-child(8)]:block",
|
||||
"[&:nth-child(7)]:hidden 2xl:[&:nth-child(7)]:block",
|
||||
"[&:nth-child(6)]:hidden xl:[&:nth-child(6)]:block",
|
||||
"[&:nth-child(5)]:hidden xl:[&:nth-child(5)]:block",
|
||||
"[&:nth-child(4)]:hidden lg:[&:nth-child(4)]:block",
|
||||
"[&:nth-child(3)]:hidden md:[&:nth-child(3)]:block",
|
||||
)}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContinueWatching
|
||||
episodes={continueWatchingList}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{(
|
||||
!ts.disableLibraryScreenGenreSelector &&
|
||||
collectionList.flatMap(n => n.entries)?.length > 2
|
||||
) && <GenreSelector genres={genres} />}
|
||||
|
||||
<PageWrapper key="library-collection-lists" className="p-4 space-y-8 relative z-[4]" data-library-collection-lists-container>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!params.genre?.length ?
|
||||
<LibraryCollectionLists
|
||||
key="library-collection-lists"
|
||||
collectionList={collectionList}
|
||||
isLoading={isLoading}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
: <LibraryCollectionFilteredLists
|
||||
key="library-filtered-lists"
|
||||
collectionList={filteredCollectionList}
|
||||
isLoading={isLoading}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
}
|
||||
</AnimatePresence>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({
|
||||
genres,
|
||||
}: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
|
||||
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
|
||||
const debouncedParams = useDebounce(params, 200)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActualParams(params)
|
||||
}, [debouncedParams])
|
||||
|
||||
if (!genres.length) return null
|
||||
|
||||
return (
|
||||
<PageWrapper className="space-y-3 lg:space-y-6 relative z-[4]" data-library-genre-selector-container>
|
||||
<MediaGenreSelector
|
||||
items={[
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image"
|
||||
import React from "react"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*[CUSTOM UI]*/}
|
||||
<CustomBackgroundImage />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const dynamic = "force-static"
|
||||