Frontend improvement and cleanup (#1690)
* Replace Create-React-App with Vite.js * Update React-Router to v6 * Cleanup unused codes
26
.github/workflows/ci.yml
vendored
|
@ -15,8 +15,8 @@ on:
|
|||
branches: [development]
|
||||
|
||||
env:
|
||||
UI_DIRECTORY: ./frontend
|
||||
UI_ARTIFACT_NAME: ui
|
||||
UI_DIRECTORY: ./frontend
|
||||
UI_ARTIFACT_NAME: ui
|
||||
|
||||
jobs:
|
||||
Frontend:
|
||||
|
@ -30,27 +30,39 @@ jobs:
|
|||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "15.x"
|
||||
node-version: "16"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check Types
|
||||
run: npm run check:ts
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Check Styles
|
||||
run: npm run check
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Check Format
|
||||
run: npm run check:fmt
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Unit Test
|
||||
run: npm test
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Build
|
||||
run: npm run build:ci
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.UI_ARTIFACT_NAME }}
|
||||
|
@ -69,7 +81,7 @@ jobs:
|
|||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Install UI
|
||||
uses: actions/download-artifact@v2
|
||||
|
|
4
.github/workflows/release_beta_to_dev.yaml
vendored
|
@ -31,7 +31,7 @@ jobs:
|
|||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
|
@ -69,4 +69,4 @@ jobs:
|
|||
release-it --ci --increment prerelease --preRelease=beta
|
||||
else
|
||||
echo "**** Cannot find changes! Skipping... ****"
|
||||
fi
|
||||
fi
|
||||
|
|
4
.github/workflows/release_dev_to_master.yaml
vendored
|
@ -29,11 +29,11 @@ jobs:
|
|||
|
||||
- name: Setup Git
|
||||
run: git config --global user.name "github-actions"
|
||||
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
|
|
2
.github/workflows/test_bazarr_execution.yml
vendored
|
@ -37,7 +37,7 @@ jobs:
|
|||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
|
|
@ -24,7 +24,8 @@ from notifier import update_notifier # noqa E402
|
|||
|
||||
from urllib.parse import unquote # noqa E402
|
||||
from get_languages import load_language_in_db # noqa E402
|
||||
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context # noqa E402
|
||||
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context, \
|
||||
send_from_directory
|
||||
from threading import Thread # noqa E402
|
||||
import requests # noqa E402
|
||||
|
||||
|
@ -112,6 +113,12 @@ def catch_all(path):
|
|||
return render_template("index.html", BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
|
||||
|
||||
|
||||
@app.route('/assets/<path:filename>')
|
||||
def web_assets(filename):
|
||||
path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build', 'assets')
|
||||
return send_from_directory(path, filename)
|
||||
|
||||
|
||||
@check_login
|
||||
@app.route('/bazarr.log')
|
||||
def download_log():
|
||||
|
|
|
@ -1,27 +1,29 @@
|
|||
# Override by duplicating me and rename to .env.local
|
||||
# The following environment variables will only be used during development
|
||||
|
||||
# Required
|
||||
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
# VITE_API_KEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
VITE_PROXY_URL=http://127.0.0.1:6767
|
||||
|
||||
# Optional
|
||||
# Bazarr configuration path, must be absolute path
|
||||
# Vite will use this variable to find your bazarr API key
|
||||
VITE_BAZARR_CONFIG_FILE="../data/config/config.ini"
|
||||
|
||||
# Proxy Settings
|
||||
|
||||
# Allow Unsecured connection to your backend
|
||||
REACT_APP_PROXY_SECURE=true
|
||||
VITE_PROXY_SECURE=true
|
||||
|
||||
# Allow websocket connection in Socket.IO
|
||||
REACT_APP_ALLOW_WEBSOCKET=true
|
||||
VITE_ALLOW_WEBSOCKET=true
|
||||
|
||||
# Display update section in settings
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
VITE_CAN_UPDATE=true
|
||||
|
||||
# Display update notification in notification center
|
||||
REACT_APP_HAS_UPDATE=false
|
||||
VITE_HAS_UPDATE=false
|
||||
|
||||
# Display React-Query devtools
|
||||
REACT_APP_QUERY_DEV=false
|
||||
VITE_QUERY_DEV=false
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
{
|
||||
"extends": "react-app"
|
||||
"rules": {
|
||||
"no-console": "error",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn"
|
||||
},
|
||||
"extends": [
|
||||
"react-app",
|
||||
"plugin:react-hooks/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
]
|
||||
}
|
||||
|
|
2
frontend/.gitignore
vendored
|
@ -2,3 +2,5 @@ node_modules
|
|||
dist
|
||||
*.local
|
||||
build
|
||||
|
||||
*.tsbuildinfo
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
build
|
||||
dist
|
||||
converage
|
||||
public
|
||||
|
|
|
@ -20,26 +20,26 @@
|
|||
$ npm install
|
||||
```
|
||||
|
||||
3. Duplicate `.env.development` file and rename to `.env.local`
|
||||
3. (Optional) Duplicate `.env.development` file and rename to `.env.development.local`
|
||||
|
||||
```
|
||||
$ cp .env .env.local
|
||||
$ cp .env.development .env.development.local
|
||||
```
|
||||
|
||||
4. Update your backend server's API key in `.env.local`
|
||||
4. (Optional) Update your backend server's API key in `.env.development.local`
|
||||
|
||||
```
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
VITE_API_KEY="YOUR_SERVER_API_KEY"
|
||||
```
|
||||
|
||||
5. Change the address of your backend server (Optional)
|
||||
5. (Optional) Change the address of your backend server
|
||||
|
||||
> http://localhost:6767 will be used by default
|
||||
> http://127.0.0.1:6767 will be used by default
|
||||
|
||||
```
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
VITE_PROXY_URL=http://localhost:6767
|
||||
```
|
||||
|
||||
6. Run Bazarr backend
|
||||
|
@ -74,9 +74,9 @@ Please ensure all tests are passed before uploading the code
|
|||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.
|
||||
Builds the app in production mode and save to the `build` folder.
|
||||
|
||||
### `npm run lint`
|
||||
### `npm run format`
|
||||
|
||||
Format code for all files in `frontend` folder
|
||||
|
||||
|
|
50
frontend/config/api-key.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { readFile } from "fs/promises";
|
||||
|
||||
async function parseConfig(path: string) {
|
||||
const config = await readFile(path, "utf8");
|
||||
|
||||
const targetSection = config
|
||||
.split("\n\n")
|
||||
.filter((section) => section.includes("[auth]"));
|
||||
|
||||
if (targetSection.length === 0) {
|
||||
throw new Error("Cannot find [auth] section in config");
|
||||
}
|
||||
|
||||
const section = targetSection[0];
|
||||
|
||||
for (const line of section.split("\n")) {
|
||||
const matched = line.startsWith("apikey");
|
||||
if (matched) {
|
||||
const results = line.split("=");
|
||||
if (results.length === 2) {
|
||||
const key = results[1].trim();
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Cannot find apikey in config");
|
||||
}
|
||||
|
||||
export async function findApiKey(
|
||||
env: Record<string, string>
|
||||
): Promise<string | undefined> {
|
||||
if (env["VITE_API_KEY"] !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (env["VITE_BAZARR_CONFIG_FILE"] !== undefined) {
|
||||
const path = env["VITE_BAZARR_CONFIG_FILE"];
|
||||
|
||||
try {
|
||||
const apiKey = await parseConfig(path);
|
||||
|
||||
return apiKey;
|
||||
} catch (err) {
|
||||
console.warn(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
30
frontend/config/chunks.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { dependencies } from "../package.json";
|
||||
|
||||
const vendors = [
|
||||
"react",
|
||||
"react-redux",
|
||||
"react-router-dom",
|
||||
"react-dom",
|
||||
"react-query",
|
||||
"axios",
|
||||
"socket.io-client",
|
||||
];
|
||||
|
||||
function renderChunks() {
|
||||
const chunks: Record<string, string[]> = {};
|
||||
|
||||
for (const key in dependencies) {
|
||||
if (!vendors.includes(key)) {
|
||||
chunks[key] = [key];
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const chunks = {
|
||||
vendors,
|
||||
...renderChunks(),
|
||||
};
|
||||
|
||||
export default chunks;
|
|
@ -4,11 +4,7 @@
|
|||
<title>Bazarr</title>
|
||||
<base href="{{baseUrl}}" />
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="%PUBLIC_URL%/static/favicon.ico"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
|
||||
|
@ -17,7 +13,6 @@
|
|||
name="description"
|
||||
content="Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you."
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
@ -25,5 +20,6 @@
|
|||
<script>
|
||||
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
|
||||
</script>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
35745
frontend/package-lock.json
generated
|
@ -12,8 +12,17 @@
|
|||
"url": "https://github.com/morpheus65535/bazarr/issues"
|
||||
},
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"dependencies": {
|
||||
"axios": "^0.24",
|
||||
"react": "^17",
|
||||
"react-bootstrap": "^1",
|
||||
"react-dom": "^17",
|
||||
"react-query": "^3.34",
|
||||
"react-redux": "^7.2",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"socket.io-client": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/roboto": "^4.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15",
|
||||
|
@ -21,48 +30,45 @@
|
|||
"@fortawesome/free-solid-svg-icons": "^5.15",
|
||||
"@fortawesome/react-fontawesome": "^0.1.16",
|
||||
"@reduxjs/toolkit": "^1.6",
|
||||
"axios": "^0.24",
|
||||
"bootstrap": "^4",
|
||||
"lodash": "^4",
|
||||
"moment": "^2.29.1",
|
||||
"rc-slider": "^9.7",
|
||||
"react": "^17",
|
||||
"react-bootstrap": "^1",
|
||||
"react-dom": "^17",
|
||||
"react-helmet": "^6.1",
|
||||
"react-query": "^3.34",
|
||||
"react-redux": "^7.2",
|
||||
"react-router-dom": "^5.3",
|
||||
"react-scripts": "^4",
|
||||
"react-select": "^5.0.1",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"rooks": "^5.7.1",
|
||||
"socket.io-client": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5",
|
||||
"@types/jest": "~26.0.24",
|
||||
"@types/lodash": "^4",
|
||||
"@types/node": "^15",
|
||||
"@types/react": "^17",
|
||||
"@types/react-dom": "^17",
|
||||
"@types/react-helmet": "^6.1",
|
||||
"@types/react-router-dom": "^5",
|
||||
"@types/react-table": "^7",
|
||||
"http-proxy-middleware": "^2",
|
||||
"@vitejs/plugin-react": "^1.1.4",
|
||||
"bootstrap": "^4",
|
||||
"clsx": "^1.1.1",
|
||||
"eslint": "^8.7.0",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"husky": "^7",
|
||||
"lodash": "^4",
|
||||
"moment": "^2.29.1",
|
||||
"prettier": "^2",
|
||||
"prettier-plugin-organize-imports": "^2",
|
||||
"pretty-quick": "^3.1",
|
||||
"rc-slider": "^9.7",
|
||||
"react-helmet": "^6.1",
|
||||
"react-select": "^5.0.1",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"rooks": "^5.7.1",
|
||||
"sass": "^1",
|
||||
"typescript": "^4"
|
||||
"typescript": "^4",
|
||||
"vite": "^2.7.13",
|
||||
"vite-plugin-checker": "^0.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"lint": "prettier --write --ignore-unknown .",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build:ci": "vite build -m development",
|
||||
"check": "eslint --ext .ts,.tsx src",
|
||||
"check:ts": "tsc --noEmit --incremental false",
|
||||
"check:fmt": "prettier -c .",
|
||||
"test": "exit 0",
|
||||
"format": "prettier -w .",
|
||||
"prepare": "cd .. && husky install frontend/.husky"
|
||||
},
|
||||
"browserslist": {
|
||||
|
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"short_name": "Bazarr",
|
||||
"name": "Bazarr Frontend",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff"
|
||||
}
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -1,109 +0,0 @@
|
|||
import { keys } from "lodash";
|
||||
import {
|
||||
siteAddProgress,
|
||||
siteRemoveProgress,
|
||||
siteUpdateNotifier,
|
||||
siteUpdateProgressCount,
|
||||
} from "../../@redux/actions";
|
||||
import store from "../../@redux/store";
|
||||
|
||||
// A background task manager, use for dispatching task one by one
|
||||
class BackgroundTask {
|
||||
private groups: Task.Group;
|
||||
constructor() {
|
||||
this.groups = {};
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
const message = "Background tasks are still running";
|
||||
if (Object.keys(this.groups).length !== 0) {
|
||||
e.preventDefault();
|
||||
e.returnValue = message;
|
||||
return;
|
||||
}
|
||||
delete e["returnValue"];
|
||||
}
|
||||
|
||||
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
|
||||
if (groupName in this.groups) {
|
||||
this.groups[groupName].push(...tasks);
|
||||
store.dispatch(
|
||||
siteUpdateProgressCount({
|
||||
id: groupName,
|
||||
count: this.groups[groupName].length,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.groups[groupName] = tasks;
|
||||
setTimeout(async () => {
|
||||
for (let index = 0; index < tasks.length; index++) {
|
||||
const task = tasks[index];
|
||||
|
||||
store.dispatch(
|
||||
siteAddProgress([
|
||||
{
|
||||
id: groupName,
|
||||
header: groupName,
|
||||
name: task.name,
|
||||
value: index,
|
||||
count: tasks.length,
|
||||
},
|
||||
])
|
||||
);
|
||||
try {
|
||||
await task.callable(...task.parameters);
|
||||
} catch (error) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
delete this.groups[groupName];
|
||||
store.dispatch(siteRemoveProgress([groupName]));
|
||||
});
|
||||
}
|
||||
|
||||
find(groupName: string, id: number) {
|
||||
if (groupName in this.groups) {
|
||||
return this.groups[groupName].find((v) => v.id === id) !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
has(groupName: string) {
|
||||
return groupName in this.groups;
|
||||
}
|
||||
|
||||
hasId(ids: number[]) {
|
||||
for (const id of ids) {
|
||||
for (const key in this.groups) {
|
||||
const tasks = this.groups[key];
|
||||
if (tasks.find((v) => v.id === id) !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return keys(this.groups).length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const BGT = new BackgroundTask();
|
||||
|
||||
export default BGT;
|
||||
|
||||
export function dispatchTask<T extends Task.Callable>(
|
||||
groupName: string,
|
||||
tasks: Task.Task<T>[],
|
||||
comment?: string
|
||||
) {
|
||||
BGT.dispatch(groupName, tasks);
|
||||
|
||||
if (comment) {
|
||||
store.dispatch(siteUpdateNotifier(comment));
|
||||
}
|
||||
}
|
14
frontend/src/@modules/task/task.d.ts
vendored
|
@ -1,14 +0,0 @@
|
|||
declare namespace Task {
|
||||
type Callable = (...args: any[]) => Promise<void>;
|
||||
|
||||
interface Task<FN extends Callable> {
|
||||
name: string;
|
||||
id?: number;
|
||||
callable: FN;
|
||||
parameters: Parameters<FN>;
|
||||
}
|
||||
|
||||
type Group = {
|
||||
[category: string]: Task.Task<Callable>[];
|
||||
};
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
export function createTask<T extends Task.Callable>(
|
||||
name: string,
|
||||
id: number | undefined,
|
||||
callable: T,
|
||||
...parameters: Parameters<T>
|
||||
): Task.Task<T> {
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
callable,
|
||||
parameters,
|
||||
};
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from "../store";
|
||||
|
||||
// function use
|
||||
export function useReduxStore<T extends (store: RootState) => any>(
|
||||
selector: T
|
||||
) {
|
||||
return useSelector<RootState, ReturnType<T>>(selector);
|
||||
}
|
||||
|
||||
export function useAppDispatch() {
|
||||
return useDispatch<AppDispatch>();
|
||||
}
|
||||
|
||||
// TODO: Fix type
|
||||
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
|
||||
const dispatch = useAppDispatch();
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => dispatch(action(...args)),
|
||||
[action, dispatch]
|
||||
);
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
// Override bootstrap primary color
|
||||
$theme-colors: (
|
||||
"primary": #911f93,
|
||||
"dark": #4f566f,
|
||||
);
|
||||
|
||||
body {
|
||||
font-family: "Roboto", "open sans", "Helvetica Neue", "Helvetica", "Arial",
|
||||
sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
}
|
||||
|
||||
// Reduce padding of cells in datatables
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.4rem !important;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
cursor: default;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
@import "./variable.scss";
|
||||
|
||||
:root {
|
||||
.form-control {
|
||||
&:focus {
|
||||
outline-color: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.dropdown-hidden {
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opacity-100 {
|
||||
opacity: 100% !important;
|
||||
}
|
||||
|
||||
.vh-100 {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.vh-75 {
|
||||
height: 75vh !important;
|
||||
}
|
||||
|
||||
.of-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.of-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vw-1 {
|
||||
width: 12rem;
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
@import "./global.scss";
|
||||
@import "./variable.scss";
|
||||
@import "./bazarr.scss";
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
|
||||
@mixin sidebar-animation {
|
||||
transition: {
|
||||
duration: 0.2s;
|
||||
timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.sidebar-container {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: calc(100% - #{$sidebar-width});
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-container {
|
||||
position: fixed !important;
|
||||
transform: translateX(-100%);
|
||||
|
||||
@include sidebar-animation();
|
||||
|
||||
&.open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
@include sidebar-animation();
|
||||
&.open {
|
||||
display: block !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
$sidebar-width: 190px;
|
||||
$header-height: 60px;
|
||||
|
||||
$theme-color-less-transparent: #911f9331;
|
||||
$theme-color-transparent: #911f9313;
|
||||
$theme-color-darked: #761977;
|
|
@ -1,3 +1,9 @@
|
|||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||
import { ActionButton, SearchBar } from "@/components";
|
||||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useIsOffline } from "@/modules/redux/hooks";
|
||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
||||
import { useGotoHomepage, useIsMobile } from "@/utilities";
|
||||
import {
|
||||
faBars,
|
||||
faHeart,
|
||||
|
@ -5,12 +11,7 @@ import {
|
|||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { setSidebar } from "@redux/actions";
|
||||
import { useIsOffline } from "@redux/hooks";
|
||||
import { useReduxAction } from "@redux/hooks/base";
|
||||
import logo from "@static/logo64.png";
|
||||
import { ActionButton, SearchBar } from "components";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
|
@ -21,14 +22,9 @@ import {
|
|||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useGotoHomepage, useIsMobile } from "utilities";
|
||||
import { useSystem, useSystemSettings } from "../apis/hooks";
|
||||
import "./header.scss";
|
||||
import NotificationCenter from "./Notification";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Header: FunctionComponent<Props> = () => {
|
||||
const Header: FunctionComponent = () => {
|
||||
const { data: settings } = useSystemSettings();
|
||||
|
||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
||||
|
@ -44,7 +40,7 @@ const Header: FunctionComponent<Props> = () => {
|
|||
const serverActions = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
|
||||
<Dropdown.Toggle className="hide-arrow" as={Button}>
|
||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
|
@ -87,7 +83,7 @@ const Header: FunctionComponent<Props> = () => {
|
|||
<div className="header-icon px-3 m-0 d-none d-md-block">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={logo}
|
||||
src="/static/logo64.png"
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { BuildKey, useIsArrayExtended } from "@/utilities";
|
||||
import {
|
||||
faBug,
|
||||
faCircleNotch,
|
||||
|
@ -10,9 +12,10 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { useReduxStore } from "@redux/hooks/base";
|
||||
import React, {
|
||||
import {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
@ -27,8 +30,6 @@ import {
|
|||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
||||
import { BuildKey, useIsArrayExtended } from "utilities";
|
||||
import "./notification.scss";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
|
@ -63,7 +64,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
|
|||
}
|
||||
|
||||
const NotificationCenter: FunctionComponent = () => {
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s);
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hasNew, setHasNew] = useState(false);
|
||||
|
@ -115,7 +116,7 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
}
|
||||
}, [btnState]);
|
||||
|
||||
const content = useMemo<React.ReactNode>(() => {
|
||||
const content = useMemo<ReactNode>(() => {
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
nodes.push(
|
||||
|
@ -163,14 +164,14 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
}, [notifier.timestamp]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Fragment>
|
||||
<Dropdown
|
||||
onClick={onToggleClick}
|
||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
||||
ref={dropdownRef}
|
||||
alignRight
|
||||
>
|
||||
<Dropdown.Toggle as={Button} className="dropdown-hidden">
|
||||
<Dropdown.Toggle as={Button} className="hide-arrow">
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
||||
|
@ -184,7 +185,7 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
);
|
||||
}}
|
||||
</Overlay>
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,26 +1,23 @@
|
|||
import Socketio from "@modules/socketio";
|
||||
import { useNotification } from "@redux/hooks";
|
||||
import { useReduxStore } from "@redux/hooks/base";
|
||||
import { LoadingIndicator, ModalProvider } from "components";
|
||||
import Authentication from "pages/Authentication";
|
||||
import LaunchError from "pages/LaunchError";
|
||||
import React, { FunctionComponent, useEffect } from "react";
|
||||
import { LoadingIndicator } from "@/components";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { useNotification } from "@/modules/redux/hooks";
|
||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import SocketIO from "@/modules/socketio";
|
||||
import LaunchError from "@/pages/LaunchError";
|
||||
import Sidebar from "@/Sidebar";
|
||||
import { Environment } from "@/utilities";
|
||||
import { FunctionComponent, useEffect } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { Route, Switch } from "react-router";
|
||||
import { BrowserRouter, Redirect } from "react-router-dom";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useEffectOnceWhen } from "rooks";
|
||||
import { Environment } from "utilities";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import Router from "../Router";
|
||||
import Sidebar from "../Sidebar";
|
||||
import Header from "./Header";
|
||||
|
||||
// Sidebar Toggle
|
||||
const App: FunctionComponent = () => {
|
||||
const { status } = useReduxStore((s) => s.site);
|
||||
|
||||
interface Props {}
|
||||
|
||||
const App: FunctionComponent<Props> = () => {
|
||||
const { status } = useReduxStore((s) => s);
|
||||
useEffect(() => {
|
||||
SocketIO.initialize();
|
||||
}, []);
|
||||
|
||||
const notify = useNotification("has-update", 10 * 1000);
|
||||
|
||||
|
@ -36,7 +33,7 @@ const App: FunctionComponent<Props> = () => {
|
|||
}, status === "initialized");
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return <Redirect to="/login"></Redirect>;
|
||||
return <Navigate to="/login"></Navigate>;
|
||||
} else if (status === "uninitialized") {
|
||||
return (
|
||||
<LoadingIndicator>
|
||||
|
@ -54,31 +51,10 @@ const App: FunctionComponent<Props> = () => {
|
|||
</Row>
|
||||
<Row noGutters className="flex-nowrap">
|
||||
<Sidebar></Sidebar>
|
||||
<ModalProvider>
|
||||
<Router></Router>
|
||||
</ModalProvider>
|
||||
<Outlet></Outlet>
|
||||
</Row>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const MainRouter: FunctionComponent = () => {
|
||||
useEffect(() => {
|
||||
Socketio.initialize();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={Environment.baseUrl}>
|
||||
<Switch>
|
||||
<Route exact path="/login">
|
||||
<Authentication></Authentication>
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<App></App>
|
||||
</Route>
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainRouter;
|
||||
export default App;
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
const RootRedirect: FunctionComponent = () => {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
|
||||
let path = "/settings";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "movies";
|
||||
}
|
||||
|
||||
return <Redirect to={path}></Redirect>;
|
||||
};
|
||||
|
||||
export default RootRedirect;
|
|
@ -1,251 +0,0 @@
|
|||
import {
|
||||
faClock,
|
||||
faCogs,
|
||||
faExclamationTriangle,
|
||||
faFileExcel,
|
||||
faFilm,
|
||||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
|
||||
import { useBadges } from "apis/hooks";
|
||||
import EmptyPage, { RouterEmptyPath } from "pages/404";
|
||||
import BlacklistMoviesView from "pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "pages/Blacklist/Series";
|
||||
import Episodes from "pages/Episodes";
|
||||
import MoviesHistoryView from "pages/History/Movies";
|
||||
import SeriesHistoryView from "pages/History/Series";
|
||||
import HistoryStats from "pages/History/Statistics";
|
||||
import MovieView from "pages/Movies";
|
||||
import MovieDetail from "pages/Movies/Details";
|
||||
import SeriesView from "pages/Series";
|
||||
import SettingsGeneralView from "pages/Settings/General";
|
||||
import SettingsLanguagesView from "pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "pages/Settings/Notifications";
|
||||
import SettingsProvidersView from "pages/Settings/Providers";
|
||||
import SettingsRadarrView from "pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "pages/Settings/Scheduler";
|
||||
import SettingsSonarrView from "pages/Settings/Sonarr";
|
||||
import SettingsSubtitlesView from "pages/Settings/Subtitles";
|
||||
import SettingsUIView from "pages/Settings/UI";
|
||||
import SystemLogsView from "pages/System/Logs";
|
||||
import SystemProvidersView from "pages/System/Providers";
|
||||
import SystemReleasesView from "pages/System/Releases";
|
||||
import SystemStatusView from "pages/System/Status";
|
||||
import SystemTasksView from "pages/System/Tasks";
|
||||
import WantedMoviesView from "pages/Wanted/Movies";
|
||||
import WantedSeriesView from "pages/Wanted/Series";
|
||||
import { useMemo } from "react";
|
||||
import SystemBackupsView from "../pages/System/Backups";
|
||||
import { Navigation } from "./nav";
|
||||
import RootRedirect from "./RootRedirect";
|
||||
|
||||
export function useNavigationItems() {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
const { data } = useBadges();
|
||||
|
||||
const items = useMemo<Navigation.RouteItem[]>(
|
||||
() => [
|
||||
{
|
||||
name: "404",
|
||||
path: RouterEmptyPath,
|
||||
component: EmptyPage,
|
||||
routeOnly: true,
|
||||
},
|
||||
{
|
||||
name: "Redirect",
|
||||
path: "/",
|
||||
component: RootRedirect,
|
||||
routeOnly: true,
|
||||
},
|
||||
{
|
||||
icon: faPlay,
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
component: SeriesView,
|
||||
enabled: sonarr,
|
||||
routes: [
|
||||
{
|
||||
name: "Episode",
|
||||
path: "/:id",
|
||||
component: Episodes,
|
||||
routeOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFilm,
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
component: MovieView,
|
||||
enabled: radarr,
|
||||
routes: [
|
||||
{
|
||||
name: "Movie Details",
|
||||
path: "/:id",
|
||||
component: MovieDetail,
|
||||
routeOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faClock,
|
||||
name: "History",
|
||||
path: "/history",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
enabled: sonarr,
|
||||
component: SeriesHistoryView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
enabled: radarr,
|
||||
component: MoviesHistoryView,
|
||||
},
|
||||
{
|
||||
name: "Statistics",
|
||||
path: "/stats",
|
||||
component: HistoryStats,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFileExcel,
|
||||
name: "Blacklist",
|
||||
path: "/blacklist",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
enabled: sonarr,
|
||||
component: BlacklistSeriesView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
enabled: radarr,
|
||||
component: BlacklistMoviesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Wanted",
|
||||
path: "/wanted",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
badge: data?.episodes,
|
||||
enabled: sonarr,
|
||||
component: WantedSeriesView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
badge: data?.movies,
|
||||
enabled: radarr,
|
||||
component: WantedMoviesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faCogs,
|
||||
name: "Settings",
|
||||
path: "/settings",
|
||||
routes: [
|
||||
{
|
||||
name: "General",
|
||||
path: "/general",
|
||||
component: SettingsGeneralView,
|
||||
},
|
||||
{
|
||||
name: "Languages",
|
||||
path: "/languages",
|
||||
component: SettingsLanguagesView,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: "/providers",
|
||||
component: SettingsProvidersView,
|
||||
},
|
||||
{
|
||||
name: "Subtitles",
|
||||
path: "/subtitles",
|
||||
component: SettingsSubtitlesView,
|
||||
},
|
||||
{
|
||||
name: "Sonarr",
|
||||
path: "/sonarr",
|
||||
component: SettingsSonarrView,
|
||||
},
|
||||
{
|
||||
name: "Radarr",
|
||||
path: "/radarr",
|
||||
component: SettingsRadarrView,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
path: "/notifications",
|
||||
component: SettingsNotificationsView,
|
||||
},
|
||||
{
|
||||
name: "Scheduler",
|
||||
path: "/scheduler",
|
||||
component: SettingsSchedulerView,
|
||||
},
|
||||
{
|
||||
name: "UI",
|
||||
path: "/ui",
|
||||
component: SettingsUIView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faLaptop,
|
||||
name: "System",
|
||||
path: "/system",
|
||||
routes: [
|
||||
{
|
||||
name: "Tasks",
|
||||
path: "/tasks",
|
||||
component: SystemTasksView,
|
||||
},
|
||||
{
|
||||
name: "Logs",
|
||||
path: "/logs",
|
||||
component: SystemLogsView,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: "/providers",
|
||||
badge: data?.providers,
|
||||
component: SystemProvidersView,
|
||||
},
|
||||
{
|
||||
name: "Backup",
|
||||
path: "/backups",
|
||||
component: SystemBackupsView,
|
||||
},
|
||||
{
|
||||
name: "Status",
|
||||
path: "/status",
|
||||
component: SystemStatusView,
|
||||
},
|
||||
{
|
||||
name: "Releases",
|
||||
path: "/releases",
|
||||
component: SystemReleasesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[data, radarr, sonarr]
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
26
frontend/src/Navigation/nav.d.ts
vendored
|
@ -1,26 +0,0 @@
|
|||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
export declare namespace Navigation {
|
||||
type RouteWithoutChild = {
|
||||
icon?: IconDefinition;
|
||||
name: string;
|
||||
path: string;
|
||||
component: FunctionComponent;
|
||||
badge?: number;
|
||||
enabled?: boolean;
|
||||
routeOnly?: boolean;
|
||||
};
|
||||
|
||||
type RouteWithChild = {
|
||||
icon: IconDefinition;
|
||||
name: string;
|
||||
path: string;
|
||||
component?: FunctionComponent;
|
||||
badge?: number;
|
||||
enabled?: boolean;
|
||||
routes: RouteWithoutChild[];
|
||||
};
|
||||
|
||||
type RouteItem = RouteWithChild | RouteWithoutChild;
|
||||
}
|
18
frontend/src/Router/Redirector.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
const Redirector: FunctionComponent = () => {
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
|
||||
let path = "/settings";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "/movies";
|
||||
}
|
||||
|
||||
return <Navigate to={path}></Navigate>;
|
||||
};
|
||||
|
||||
export default Redirector;
|
|
@ -1,83 +1,318 @@
|
|||
import { FunctionComponent } from "react";
|
||||
import { Redirect, Route, Switch, useHistory } from "react-router";
|
||||
import { useDidMount } from "rooks";
|
||||
import { BuildKey, ScrollToTop } from "utilities";
|
||||
import { useNavigationItems } from "../Navigation";
|
||||
import { Navigation } from "../Navigation/nav";
|
||||
import { RouterEmptyPath } from "../pages/404";
|
||||
import { useBadges } from "@/apis/hooks";
|
||||
import App from "@/App";
|
||||
import Lazy from "@/components/Lazy";
|
||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
||||
import Episodes from "@/pages/Episodes";
|
||||
import MoviesHistoryView from "@/pages/History/Movies";
|
||||
import SeriesHistoryView from "@/pages/History/Series";
|
||||
import MovieView from "@/pages/Movies";
|
||||
import MovieDetailView from "@/pages/Movies/Details";
|
||||
import MovieMassEditor from "@/pages/Movies/Editor";
|
||||
import SeriesView from "@/pages/Series";
|
||||
import SeriesMassEditor from "@/pages/Series/Editor";
|
||||
import SettingsGeneralView from "@/pages/Settings/General";
|
||||
import SettingsLanguagesView from "@/pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "@/pages/Settings/Notifications";
|
||||
import SettingsProvidersView from "@/pages/Settings/Providers";
|
||||
import SettingsRadarrView from "@/pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
|
||||
import SettingsSonarrView from "@/pages/Settings/Sonarr";
|
||||
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
|
||||
import SettingsUIView from "@/pages/Settings/UI";
|
||||
import SystemBackupsView from "@/pages/System/Backups";
|
||||
import SystemLogsView from "@/pages/System/Logs";
|
||||
import SystemProvidersView from "@/pages/System/Providers";
|
||||
import SystemReleasesView from "@/pages/System/Releases";
|
||||
import SystemTasksView from "@/pages/System/Tasks";
|
||||
import WantedMoviesView from "@/pages/Wanted/Movies";
|
||||
import WantedSeriesView from "@/pages/Wanted/Series";
|
||||
import { Environment } from "@/utilities";
|
||||
import {
|
||||
faClock,
|
||||
faExclamationTriangle,
|
||||
faFileExcel,
|
||||
faFilm,
|
||||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
lazy,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import Redirector from "./Redirector";
|
||||
import { CustomRouteObject } from "./type";
|
||||
|
||||
const Router: FunctionComponent = () => {
|
||||
const navItems = useNavigationItems();
|
||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||
const Authentication = lazy(() => import("@/pages/Authentication"));
|
||||
const NotFound = lazy(() => import("@/pages/404"));
|
||||
|
||||
const history = useHistory();
|
||||
useDidMount(() => {
|
||||
history.listen(() => {
|
||||
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
|
||||
setTimeout(ScrollToTop);
|
||||
});
|
||||
});
|
||||
function useRoutes(): CustomRouteObject[] {
|
||||
const { data } = useBadges();
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
path: "/",
|
||||
element: <App></App>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Redirector></Redirector>,
|
||||
},
|
||||
{
|
||||
icon: faPlay,
|
||||
name: "Series",
|
||||
path: "series",
|
||||
hidden: !sonarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SeriesView></SeriesView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <SeriesMassEditor></SeriesMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <Episodes></Episodes>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFilm,
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
hidden: !radarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <MovieView></MovieView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <MovieMassEditor></MovieMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <MovieDetailView></MovieDetailView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faClock,
|
||||
name: "History",
|
||||
path: "history",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <SeriesHistoryView></SeriesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <MoviesHistoryView></MoviesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "stats",
|
||||
name: "Statistics",
|
||||
element: (
|
||||
<Lazy>
|
||||
<HistoryStats></HistoryStats>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Wanted",
|
||||
path: "wanted",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
name: "Episodes",
|
||||
path: "series",
|
||||
badge: data?.episodes,
|
||||
hidden: !sonarr,
|
||||
element: <WantedSeriesView></WantedSeriesView>,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
badge: data?.movies,
|
||||
hidden: !radarr,
|
||||
element: <WantedMoviesView></WantedMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFileExcel,
|
||||
name: "Blacklist",
|
||||
path: "blacklist",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <BlacklistSeriesView></BlacklistSeriesView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <BlacklistMoviesView></BlacklistMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Settings",
|
||||
path: "settings",
|
||||
children: [
|
||||
{
|
||||
path: "general",
|
||||
name: "General",
|
||||
element: <SettingsGeneralView></SettingsGeneralView>,
|
||||
},
|
||||
{
|
||||
path: "languages",
|
||||
name: "Languages",
|
||||
element: <SettingsLanguagesView></SettingsLanguagesView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
element: <SettingsProvidersView></SettingsProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "subtitles",
|
||||
name: "Subtitles",
|
||||
element: <SettingsSubtitlesView></SettingsSubtitlesView>,
|
||||
},
|
||||
{
|
||||
path: "sonarr",
|
||||
name: "Sonarr",
|
||||
element: <SettingsSonarrView></SettingsSonarrView>,
|
||||
},
|
||||
{
|
||||
path: "radarr",
|
||||
name: "Radarr",
|
||||
element: <SettingsRadarrView></SettingsRadarrView>,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
name: "Notifications",
|
||||
element: (
|
||||
<SettingsNotificationsView></SettingsNotificationsView>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "scheduler",
|
||||
name: "Scheduler",
|
||||
element: <SettingsSchedulerView></SettingsSchedulerView>,
|
||||
},
|
||||
{
|
||||
path: "ui",
|
||||
name: "UI",
|
||||
element: <SettingsUIView></SettingsUIView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faLaptop,
|
||||
name: "System",
|
||||
path: "system",
|
||||
children: [
|
||||
{
|
||||
path: "tasks",
|
||||
name: "Tasks",
|
||||
element: <SystemTasksView></SystemTasksView>,
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
name: "Logs",
|
||||
element: <SystemLogsView></SystemLogsView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
badge: data?.providers,
|
||||
element: <SystemProvidersView></SystemProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "backup",
|
||||
name: "Backups",
|
||||
element: <SystemBackupsView></SystemBackupsView>,
|
||||
},
|
||||
{
|
||||
path: "status",
|
||||
name: "Status",
|
||||
element: (
|
||||
<Lazy>
|
||||
<SystemStatusView></SystemStatusView>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "releases",
|
||||
name: "Releases",
|
||||
element: <SystemReleasesView></SystemReleasesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<Authentication></Authentication>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<NotFound></NotFound>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
||||
);
|
||||
}
|
||||
|
||||
const RouterItemContext = createContext<CustomRouteObject[]>([]);
|
||||
|
||||
export const Router: FunctionComponent = ({ children }) => {
|
||||
const routes = useRoutes();
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row flex-grow-1 main-router">
|
||||
<Switch>
|
||||
{navItems.map((v, idx) => {
|
||||
if ("routes" in v) {
|
||||
return (
|
||||
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
|
||||
<ParentRouter {...v}></ParentRouter>
|
||||
</Route>
|
||||
);
|
||||
} else if (v.enabled !== false) {
|
||||
return (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "root")}
|
||||
exact
|
||||
path={v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
<RouterItemContext.Provider value={routes}>
|
||||
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
|
||||
</RouterItemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
||||
|
||||
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
|
||||
path,
|
||||
enabled,
|
||||
component,
|
||||
routes,
|
||||
}) => {
|
||||
if (enabled === false || (component === undefined && routes.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const ParentComponent =
|
||||
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path={path} component={ParentComponent}></Route>
|
||||
{routes
|
||||
.filter((v) => v.enabled !== false)
|
||||
.map((v, idx) => (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "route")}
|
||||
exact
|
||||
path={path + v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
))}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
export function useRouteItems() {
|
||||
return useContext(RouterItemContext);
|
||||
}
|
||||
|
|
14
frontend/src/Router/type.d.ts
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { RouteObject } from "react-router-dom";
|
||||
|
||||
declare namespace Route {
|
||||
export type Item = {
|
||||
icon?: IconDefinition;
|
||||
name?: string;
|
||||
badge?: number;
|
||||
hidden?: boolean;
|
||||
children?: Item[];
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomRouteObject = RouteObject & Route.Item;
|
|
@ -1,12 +1,18 @@
|
|||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
import { BuildKey, pathJoin } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { useGotoHomepage } from "@/utilities/hooks";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { setSidebar } from "@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "@redux/hooks/base";
|
||||
import logo from "@static/logo64.png";
|
||||
import React, {
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
@ -18,229 +24,232 @@ import {
|
|||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from "react-bootstrap";
|
||||
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { BuildKey } from "utilities";
|
||||
import { useGotoHomepage } from "utilities/hooks";
|
||||
import { useNavigationItems } from "../Navigation";
|
||||
import { Navigation } from "../Navigation/nav";
|
||||
import "./style.scss";
|
||||
import {
|
||||
matchPath,
|
||||
NavLink,
|
||||
RouteObject,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
const SelectionContext = createContext<{
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
select: (selection: string | null) => void;
|
||||
}>({ selection: null, select: () => {} });
|
||||
select: (path: string | null) => void;
|
||||
}>({
|
||||
selection: null,
|
||||
select: () => {
|
||||
LOG("error", "Selection context not initialized");
|
||||
},
|
||||
});
|
||||
|
||||
function useSelection() {
|
||||
return useContext(Selection);
|
||||
}
|
||||
|
||||
function useBadgeValue(route: Route.Item) {
|
||||
const { badge, children } = route;
|
||||
return useMemo(() => {
|
||||
let value = badge ?? 0;
|
||||
|
||||
if (children === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value +=
|
||||
children.reduce((acc, child: Route.Item) => {
|
||||
if (child.badge && child.hidden !== true) {
|
||||
return acc + (child.badge ?? 0);
|
||||
}
|
||||
return acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return value === 0 ? undefined : value;
|
||||
}, [badge, children]);
|
||||
}
|
||||
|
||||
function useIsActive(parent: string, route: RouteObject) {
|
||||
const { path, children } = route;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const paths = useMemo(
|
||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||
[root, children]
|
||||
);
|
||||
|
||||
const selection = useSelection().selection;
|
||||
return useMemo(
|
||||
() =>
|
||||
selection?.includes(root) ||
|
||||
paths.some((path) => matchPath(path, pathname)),
|
||||
[pathname, paths, root, selection]
|
||||
);
|
||||
}
|
||||
|
||||
// Actual sidebar
|
||||
const Sidebar: FunctionComponent = () => {
|
||||
const open = useReduxStore((s) => s.showSidebar);
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
const isShow = useReduxStore((s) => s.site.showSidebar);
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const cls = ["sidebar-container"];
|
||||
const overlay = ["sidebar-overlay"];
|
||||
|
||||
if (open) {
|
||||
cls.push("open");
|
||||
overlay.push("open");
|
||||
}
|
||||
const showSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const [selection, setSelection] = useState<string | null>(null);
|
||||
const routes = useRouteItems();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
select(null);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<SelectionContext.Provider
|
||||
value={{ selection: selection, select: setSelection }}
|
||||
>
|
||||
<aside className={cls.join(" ")}>
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<nav className={clsx("sidebar-container", { open: isShow })}>
|
||||
<Container className="sidebar-title d-flex align-items-center d-md-none">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={logo}
|
||||
src="/static/logo64.png"
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
className="cursor-pointer"
|
||||
></Image>
|
||||
</Container>
|
||||
<SidebarNavigation></SidebarNavigation>
|
||||
</aside>
|
||||
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
parent="/"
|
||||
route={route}
|
||||
></RouteItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
</nav>
|
||||
<div
|
||||
className={overlay.join(" ")}
|
||||
onClick={() => changeSidebar(false)}
|
||||
className={clsx("sidebar-overlay", { open: isShow })}
|
||||
onClick={() => showSidebar(false)}
|
||||
></div>
|
||||
</SelectionContext.Provider>
|
||||
</Selection.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarNavigation: FunctionComponent = () => {
|
||||
const navItems = useNavigationItems();
|
||||
const RouteItem: FunctionComponent<{
|
||||
route: CustomRouteObject;
|
||||
parent: string;
|
||||
}> = ({ route, parent }) => {
|
||||
const { children, name, path, icon, hidden, element } = route;
|
||||
|
||||
return (
|
||||
<ListGroup variant="flush">
|
||||
{navItems.map((v, idx) => {
|
||||
if ("routes" in v) {
|
||||
return (
|
||||
<SidebarParent key={BuildKey(idx, v.name)} {...v}></SidebarParent>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SidebarChild
|
||||
parent=""
|
||||
key={BuildKey(idx, v.name)}
|
||||
{...v}
|
||||
></SidebarChild>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
|
||||
icon,
|
||||
badge,
|
||||
name,
|
||||
path,
|
||||
routes,
|
||||
enabled,
|
||||
component,
|
||||
}) => {
|
||||
const computedBadge = useMemo(() => {
|
||||
let computed = badge ?? 0;
|
||||
|
||||
computed += routes.reduce((prev, curr) => {
|
||||
return prev + (curr.badge ?? 0);
|
||||
}, 0);
|
||||
|
||||
return computed !== 0 ? computed : undefined;
|
||||
}, [badge, routes]);
|
||||
|
||||
const enabledRoutes = useMemo(
|
||||
() => routes.filter((v) => v.enabled !== false && v.routeOnly !== true),
|
||||
[routes]
|
||||
const isValidated = useMemo(
|
||||
() =>
|
||||
element !== undefined ||
|
||||
children?.find((v) => v.index === true) !== undefined,
|
||||
[element, children]
|
||||
);
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
const { select } = useSelection();
|
||||
|
||||
const { selection, select } = useContext(SelectionContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const match = useRouteMatch({ path });
|
||||
const open = match !== null || selection === path;
|
||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const collapseBoxClass = useMemo(
|
||||
() => `sidebar-collapse-box ${open ? "active" : ""}`,
|
||||
[open]
|
||||
);
|
||||
const badge = useBadgeValue(route);
|
||||
|
||||
const history = useHistory();
|
||||
const isOpen = useIsActive(parent, route);
|
||||
|
||||
if (enabled === false) {
|
||||
if (hidden === true) {
|
||||
return null;
|
||||
} else if (enabledRoutes.length === 0) {
|
||||
if (component) {
|
||||
}
|
||||
|
||||
// Ignore path if it is using match
|
||||
if (path === undefined || path.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
const elements = children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
key={BuildKey(link, "nav", idx)}
|
||||
route={child}
|
||||
></RouteItem>
|
||||
));
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<NavLink
|
||||
activeClassName="sb-active"
|
||||
className="list-group-item list-group-item-action sidebar-button"
|
||||
to={path}
|
||||
onClick={() => changeSidebar(false)}
|
||||
>
|
||||
<SidebarContent
|
||||
icon={icon}
|
||||
name={name}
|
||||
badge={computedBadge}
|
||||
></SidebarContent>
|
||||
</NavLink>
|
||||
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
|
||||
<ListGroupItem
|
||||
action
|
||||
className={clsx("button", { active: isOpen })}
|
||||
onClick={() => {
|
||||
LOG("info", "clicked", link);
|
||||
|
||||
if (isValidated) {
|
||||
navigate(link);
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
select(null);
|
||||
} else {
|
||||
select(link);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</ListGroupItem>
|
||||
<Collapse in={isOpen}>
|
||||
<div className="indent">{elements}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
return <>{elements}</>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={collapseBoxClass}>
|
||||
<ListGroupItem
|
||||
action
|
||||
className="sidebar-button"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
select(null);
|
||||
} else {
|
||||
select(path);
|
||||
}
|
||||
if (component !== undefined) {
|
||||
history.push(path);
|
||||
}
|
||||
}}
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
to={link}
|
||||
className={({ isActive }) =>
|
||||
clsx("list-group-item list-group-item-action button sb-collapse", {
|
||||
active: isActive,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SidebarContent
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
name={name}
|
||||
badge={computedBadge}
|
||||
></SidebarContent>
|
||||
</ListGroupItem>
|
||||
<Collapse in={open}>
|
||||
<div className="sidebar-collapse">
|
||||
{enabledRoutes.map((v, idx) => (
|
||||
<SidebarChild
|
||||
key={BuildKey(idx, v.name, "child")}
|
||||
parent={path}
|
||||
{...v}
|
||||
></SidebarChild>
|
||||
))}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface SidebarChildProps {
|
||||
parent: string;
|
||||
interface ItemComponentProps {
|
||||
name: string;
|
||||
icon?: IconDefinition;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const SidebarChild: FunctionComponent<
|
||||
SidebarChildProps & Navigation.RouteWithoutChild
|
||||
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
const { select } = useContext(SelectionContext);
|
||||
|
||||
if (enabled === false || routeOnly === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
|
||||
icon,
|
||||
name,
|
||||
badge,
|
||||
}) => {
|
||||
return (
|
||||
<NavLink
|
||||
activeClassName="sb-active"
|
||||
className="list-group-item list-group-item-action sidebar-button sb-collapse"
|
||||
to={parent + path}
|
||||
onClick={() => {
|
||||
select(null);
|
||||
changeSidebar(false);
|
||||
}}
|
||||
>
|
||||
<SidebarContent icon={icon} name={name} badge={badge}></SidebarContent>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarContent: FunctionComponent<{
|
||||
icon?: IconDefinition;
|
||||
name: string;
|
||||
badge?: number;
|
||||
}> = ({ icon, name, badge }) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
size="1x"
|
||||
className="icon"
|
||||
icon={icon}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
<>
|
||||
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
|
||||
<span className="d-flex flex-grow-1 justify-content-between">
|
||||
{name} <Badge variant="secondary">{badge !== 0 ? badge : null}</Badge>
|
||||
{name}
|
||||
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
|
||||
{badge}
|
||||
</Badge>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { Entrance } from "index";
|
||||
import {} from "jest";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
it("renders", () => {
|
||||
const div = document.createElement("div");
|
||||
ReactDOM.render(<Entrance />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
|
@ -36,7 +36,6 @@ export function useMovies() {
|
|||
[QueryKeys.Movies, QueryKeys.All],
|
||||
() => api.movies.movies(),
|
||||
{
|
||||
enabled: false,
|
||||
onSuccess: (data) => {
|
||||
cacheMovies(client, data);
|
||||
},
|
||||
|
|
|
@ -36,7 +36,6 @@ export function useSeries() {
|
|||
[QueryKeys.Series, QueryKeys.All],
|
||||
() => api.series.series(),
|
||||
{
|
||||
enabled: false,
|
||||
onSuccess: (data) => {
|
||||
cacheSeries(client, data);
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { setUnauthenticated } from "../../@redux/actions";
|
||||
import store from "../../@redux/store";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import store from "../../modules/redux/store";
|
||||
import { QueryKeys } from "../queries/keys";
|
||||
import api from "../raw";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
||||
import { setUnauthenticated } from "../../@redux/actions";
|
||||
import { AppDispatch } from "../../@redux/store";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import { AppDispatch } from "../../modules/redux/store";
|
||||
import { Environment, isProdEnv } from "../../utilities";
|
||||
class BazarrClient {
|
||||
axios!: AxiosInstance;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { GetItemId } from "@/utilities";
|
||||
import { usePageSize } from "@/utilities/storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
QueryKey,
|
||||
|
@ -5,8 +7,6 @@ import {
|
|||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "react-query";
|
||||
import { GetItemId } from "utilities";
|
||||
import { usePageSize } from "utilities/storage";
|
||||
import { QueryKeys } from "./keys";
|
||||
|
||||
export type UsePaginationQueryResult<T extends object> = UseQueryResult<
|
||||
|
|
|
@ -10,7 +10,7 @@ class BaseApi {
|
|||
|
||||
private createFormdata(object?: LooseObject) {
|
||||
if (object) {
|
||||
let form = new FormData();
|
||||
const form = new FormData();
|
||||
|
||||
for (const key in object) {
|
||||
const data = object[key];
|
||||
|
@ -30,7 +30,7 @@ class BaseApi {
|
|||
}
|
||||
}
|
||||
|
||||
protected async get<T = unknown>(path: string, params?: any) {
|
||||
protected async get<T = unknown>(path: string, params?: LooseObject) {
|
||||
const response = await client.axios.get<T>(this.prefix + path, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ class BaseApi {
|
|||
protected post<T = void>(
|
||||
path: string,
|
||||
formdata?: LooseObject,
|
||||
params?: any
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.post(this.prefix + path, form, { params });
|
||||
|
@ -47,7 +47,7 @@ class BaseApi {
|
|||
protected patch<T = void>(
|
||||
path: string,
|
||||
formdata?: LooseObject,
|
||||
params?: any
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.patch(this.prefix + path, form, { params });
|
||||
|
@ -55,8 +55,8 @@ class BaseApi {
|
|||
|
||||
protected delete<T = void>(
|
||||
path: string,
|
||||
formdata?: any,
|
||||
params?: any
|
||||
formdata?: LooseObject,
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.delete(this.prefix + path, { params, data: form });
|
||||
|
|
|
@ -5,7 +5,7 @@ class ProviderApi extends BaseApi {
|
|||
super("/providers");
|
||||
}
|
||||
|
||||
async providers(history: boolean = false) {
|
||||
async providers(history = false) {
|
||||
const response = await this.get<DataWrapper<System.Provider[]>>("", {
|
||||
history,
|
||||
});
|
||||
|
|
|
@ -34,7 +34,7 @@ class SystemApi extends BaseApi {
|
|||
await this.post("/settings", data);
|
||||
}
|
||||
|
||||
async languages(history: boolean = false) {
|
||||
async languages(history = false) {
|
||||
const response = await this.get<Language.Server[]>("/languages", {
|
||||
history,
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ type UrlTestResponse =
|
|||
};
|
||||
|
||||
class RequestUtils {
|
||||
async urlTest(protocol: string, url: string, params?: any) {
|
||||
async urlTest(protocol: string, url: string, params?: LooseObject) {
|
||||
try {
|
||||
const result = await client.axios.get<UrlTestResponse>(
|
||||
`../test/${protocol}/${url}api/system/status`,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import UIError from "pages/UIError";
|
||||
import React from "react";
|
||||
import UIError from "@/pages/UIError";
|
||||
import { Component } from "react";
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<{}, State> {
|
||||
constructor(props: {}) {
|
||||
class ErrorBoundary extends Component<object, State> {
|
||||
constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import { BuildKey, isMovie } from "@/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import {
|
||||
faBookmark as farBookmark,
|
||||
faClone as fasClone,
|
||||
|
@ -12,7 +17,7 @@ import {
|
|||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Col,
|
||||
|
@ -22,12 +27,7 @@ import {
|
|||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { BuildKey, isMovie } from "utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "utilities/languages";
|
||||
import { LanguageText } from ".";
|
||||
import Language from "./bazarr/Language";
|
||||
|
||||
interface Props {
|
||||
item: Item.Base;
|
||||
|
@ -102,7 +102,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
|
|||
icon={faLanguage}
|
||||
desc="Language"
|
||||
>
|
||||
<LanguageText long text={v}></LanguageText>
|
||||
<Language.Text long value={v}></Language.Text>
|
||||
</DetailBadge>
|
||||
))
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Selector, SelectorProps } from "components";
|
||||
import React, { useMemo } from "react";
|
||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
options: readonly Language.Info[];
|
||||
|
|
8
frontend/src/components/Lazy.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { FunctionComponent, Suspense } from "react";
|
||||
import { LoadingIndicator } from ".";
|
||||
|
||||
const Lazy: FunctionComponent = ({ children }) => {
|
||||
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
|
||||
};
|
||||
|
||||
export default Lazy;
|
121
frontend/src/components/MassEditor.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
|
||||
import { GetItemId } from "@/utilities";
|
||||
import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Container, Dropdown, Row } from "react-bootstrap";
|
||||
import { UseMutationResult } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
import { ContentHeader, SimpleTable } from ".";
|
||||
import { useCustomSelection } from "./tables/plugins";
|
||||
|
||||
interface MassEditorProps<T extends Item.Base = Item.Base> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
|
||||
}
|
||||
|
||||
function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
|
||||
const { columns, data: raw, mutation } = props;
|
||||
|
||||
const [selections, setSelections] = useState<T[]>([]);
|
||||
const [dirties, setDirties] = useState<T[]>([]);
|
||||
const hasTask = useIsAnyMutationRunning();
|
||||
const { data: profiles } = useLanguageProfiles();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onEnded = useCallback(() => navigate(".."), [navigate]);
|
||||
|
||||
const data = useMemo(
|
||||
() => uniqBy([...dirties, ...(raw ?? [])], GetItemId),
|
||||
[dirties, raw]
|
||||
);
|
||||
|
||||
const profileOptions = useMemo(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
if (profiles) {
|
||||
items.push(
|
||||
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
|
||||
);
|
||||
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
items.push(
|
||||
...profiles.map((v) => (
|
||||
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
|
||||
{v.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [profiles]);
|
||||
|
||||
const { mutateAsync } = mutation;
|
||||
|
||||
const save = useCallback(() => {
|
||||
const form: FormType.ModifyItem = {
|
||||
id: [],
|
||||
profileid: [],
|
||||
};
|
||||
dirties.forEach((v) => {
|
||||
const id = GetItemId(v);
|
||||
if (id) {
|
||||
form.id.push(id);
|
||||
form.profileid.push(v.profileId);
|
||||
}
|
||||
});
|
||||
return mutateAsync(form);
|
||||
}, [dirties, mutateAsync]);
|
||||
|
||||
const setProfiles = useCallback(
|
||||
(key: Nullable<string>) => {
|
||||
const id = key ? parseInt(key) : null;
|
||||
|
||||
const newItems = selections.map((v) => ({ ...v, profileId: id }));
|
||||
|
||||
setDirties((dirty) => {
|
||||
return uniqBy([...newItems, ...dirty], GetItemId);
|
||||
});
|
||||
},
|
||||
[selections]
|
||||
);
|
||||
return (
|
||||
<Container fluid>
|
||||
<ContentHeader scroll={false}>
|
||||
<ContentHeader.Group pos="start">
|
||||
<Dropdown onSelect={setProfiles}>
|
||||
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
|
||||
Change Profile
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
|
||||
Cancel
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faCheck}
|
||||
disabled={dirties.length === 0 || hasTask}
|
||||
promise={save}
|
||||
onSuccess={onEnded}
|
||||
>
|
||||
Save
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
onSelect={setSelections}
|
||||
plugins={[useRowSelect, useCustomSelection]}
|
||||
></SimpleTable>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MassEditor;
|
|
@ -1,6 +1,6 @@
|
|||
import { useServerSearch } from "apis/hooks";
|
||||
import { useServerSearch } from "@/apis/hooks";
|
||||
import { uniqueId } from "lodash";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -8,7 +8,7 @@ import React, {
|
|||
useState,
|
||||
} from "react";
|
||||
import { Dropdown, Form } from "react-bootstrap";
|
||||
import { useHistory } from "react-router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useThrottle } from "rooks";
|
||||
|
||||
function useSearch(query: string) {
|
||||
|
@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({
|
|||
|
||||
const results = useSearch(query);
|
||||
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setDisplay("");
|
||||
|
@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({
|
|||
onSelect={(link) => {
|
||||
if (link) {
|
||||
clear();
|
||||
history.push(link);
|
||||
navigate(link);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -4,9 +4,10 @@ import {
|
|||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
|
@ -18,7 +19,7 @@ import { LoadingIndicator } from ".";
|
|||
|
||||
interface QueryOverlayProps {
|
||||
result: UseQueryResult<unknown, unknown>;
|
||||
children: React.ReactElement;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
|
||||
|
@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
|
|||
const [item, setItem] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
promise()
|
||||
.then(setItem)
|
||||
.catch(() => {});
|
||||
promise().then(setItem);
|
||||
}, [promise]);
|
||||
|
||||
if (item === null) {
|
||||
|
|
88
frontend/src/components/bazarr/Language.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { useLanguages } from "@/apis/hooks";
|
||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface TextProps {
|
||||
value: Language.Info;
|
||||
className?: string;
|
||||
long?: boolean;
|
||||
}
|
||||
|
||||
declare type LanguageComponent = {
|
||||
Text: typeof LanguageText;
|
||||
Selector: typeof LanguageSelector;
|
||||
};
|
||||
|
||||
const LanguageText: FunctionComponent<TextProps> = ({
|
||||
value,
|
||||
className,
|
||||
long,
|
||||
}) => {
|
||||
const result = useMemo(() => {
|
||||
let lang = value.code2;
|
||||
let hi = ":HI";
|
||||
let forced = ":Forced";
|
||||
if (long) {
|
||||
lang = value.name;
|
||||
hi = " HI";
|
||||
forced = " Forced";
|
||||
}
|
||||
|
||||
let res = lang;
|
||||
if (value.hi) {
|
||||
res += hi;
|
||||
} else if (value.forced) {
|
||||
res += forced;
|
||||
}
|
||||
return res;
|
||||
}, [value, long]);
|
||||
|
||||
return (
|
||||
<span title={value.name} className={className}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type LanguageSelectorProps<M extends boolean> = Omit<
|
||||
SelectorProps<Language.Info, M>,
|
||||
"label" | "options"
|
||||
> & {
|
||||
history?: boolean;
|
||||
};
|
||||
|
||||
function getLabel(lang: Language.Info) {
|
||||
return lang.name;
|
||||
}
|
||||
|
||||
export function LanguageSelector<M extends boolean = false>(
|
||||
props: LanguageSelectorProps<M>
|
||||
) {
|
||||
const { history, ...rest } = props;
|
||||
const { data: options } = useLanguages(history);
|
||||
|
||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
||||
() =>
|
||||
options?.map((v) => ({
|
||||
label: v.name,
|
||||
value: v,
|
||||
})) ?? [],
|
||||
[options]
|
||||
);
|
||||
|
||||
return (
|
||||
<Selector
|
||||
placeholder="Language..."
|
||||
options={items}
|
||||
label={getLabel}
|
||||
{...rest}
|
||||
></Selector>
|
||||
);
|
||||
}
|
||||
|
||||
const Components: LanguageComponent = {
|
||||
Text: LanguageText,
|
||||
Selector: LanguageSelector,
|
||||
};
|
||||
|
||||
export default Components;
|
25
frontend/src/components/bazarr/LanguageProfile.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { useLanguageProfiles } from "@/apis/hooks";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
index: number | null;
|
||||
className?: string;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
const LanguageProfile: FunctionComponent<Props> = ({
|
||||
index,
|
||||
className,
|
||||
empty = "Unknown Profile",
|
||||
}) => {
|
||||
const { data } = useLanguageProfiles();
|
||||
|
||||
const name = useMemo(
|
||||
() => data?.find((v) => v.profileId === index)?.name ?? empty,
|
||||
[data, empty, index]
|
||||
);
|
||||
|
||||
return <span className={className}>{name}</span>;
|
||||
};
|
||||
|
||||
export default LanguageProfile;
|
|
@ -1,7 +1,7 @@
|
|||
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, MouseEvent } from "react";
|
||||
import { FunctionComponent, MouseEvent } from "react";
|
||||
import { Badge, Button, ButtonProps } from "react-bootstrap";
|
||||
|
||||
export const ActionBadge: FunctionComponent<{
|
||||
|
@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
|||
}) => {
|
||||
const showText = alwaysShowText === true || loading !== true;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
style={{ width: "1rem" }}
|
||||
icon={loading ? faCircleNotch : icon}
|
||||
|
@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
|||
{children && showText ? (
|
||||
<span className="ml-2 font-weight-bold">{children}</span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
MouseEvent,
|
||||
PropsWithChildren,
|
||||
|
@ -46,13 +46,13 @@ const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
type CHAsyncButtonProps<T extends () => Promise<any>> = {
|
||||
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
|
||||
promise: T;
|
||||
onSuccess?: (item: PromiseType<ReturnType<T>>) => void;
|
||||
onSuccess?: (item: R) => void;
|
||||
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
|
||||
|
||||
export function ContentHeaderAsyncButton<T extends () => Promise<any>>(
|
||||
props: PropsWithChildren<CHAsyncButtonProps<T>>
|
||||
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
|
||||
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
|
||||
): JSX.Element {
|
||||
const { promise, onSuccess, ...button } = props;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
type GroupPosition = "start" | "end";
|
||||
interface GroupProps {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, ReactNode, useMemo } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
|
||||
import ContentHeaderGroup from "./Group";
|
||||
import "./style.scss";
|
||||
|
||||
interface Props {
|
||||
scroll?: boolean;
|
||||
|
@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => {
|
|||
return rowCls.join(" ");
|
||||
}, [scroll, className]);
|
||||
|
||||
let childItem: React.ReactNode;
|
||||
let childItem: ReactNode;
|
||||
|
||||
if (scroll !== false) {
|
||||
childItem = (
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, ReactElement } from "react";
|
||||
import {
|
||||
OverlayTrigger,
|
||||
OverlayTriggerProps,
|
||||
|
@ -97,44 +97,8 @@ export const LoadingIndicator: FunctionComponent<{
|
|||
);
|
||||
};
|
||||
|
||||
interface LanguageTextProps {
|
||||
text: Language.Info;
|
||||
className?: string;
|
||||
long?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageText: FunctionComponent<LanguageTextProps> = ({
|
||||
text,
|
||||
className,
|
||||
long,
|
||||
}) => {
|
||||
const result = useMemo(() => {
|
||||
let lang = text.code2;
|
||||
let hi = ":HI";
|
||||
let forced = ":Forced";
|
||||
if (long) {
|
||||
lang = text.name;
|
||||
hi = " HI";
|
||||
forced = " Forced";
|
||||
}
|
||||
|
||||
let res = lang;
|
||||
if (text.hi) {
|
||||
res += hi;
|
||||
} else if (text.forced) {
|
||||
res += forced;
|
||||
}
|
||||
return res;
|
||||
}, [text, long]);
|
||||
return (
|
||||
<span title={text.name} className={className}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextPopoverProps {
|
||||
children: React.ReactElement<any, any>;
|
||||
children: ReactElement;
|
||||
text: string | undefined | null;
|
||||
placement?: OverlayTriggerProps["placement"];
|
||||
delay?: number;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {
|
||||
import {
|
||||
FocusEvent,
|
||||
FunctionComponent,
|
||||
KeyboardEvent,
|
||||
|
@ -8,7 +8,6 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import "./chip.scss";
|
||||
|
||||
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { useFileSystem } from "@/apis/hooks";
|
||||
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faReply } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useFileSystem } from "apis/hooks";
|
||||
import React, {
|
||||
import {
|
||||
ChangeEvent,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
@ -147,7 +148,7 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
|||
placeholder="Click to start"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.currentTarget.value);
|
||||
}}
|
||||
ref={input}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {
|
||||
import {
|
||||
ChangeEvent,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
|
|
|
@ -1,8 +1,22 @@
|
|||
import { isArray } from "lodash";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import Select from "react-select";
|
||||
import clsx from "clsx";
|
||||
import { FocusEvent, useCallback, useMemo, useRef } from "react";
|
||||
import Select, { GroupBase, OnChangeValue } from "react-select";
|
||||
import { SelectComponents } from "react-select/dist/declarations/src/components";
|
||||
import "./selector.scss";
|
||||
|
||||
export type SelectorOption<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export type SelectorComponents<T, M extends boolean> = SelectComponents<
|
||||
SelectorOption<T>,
|
||||
M,
|
||||
GroupBase<SelectorOption<T>>
|
||||
>;
|
||||
|
||||
export type SelectorValueType<T, M extends boolean> = M extends true
|
||||
? ReadonlyArray<T>
|
||||
: Nullable<T>;
|
||||
|
||||
export interface SelectorProps<T, M extends boolean> {
|
||||
className?: string;
|
||||
|
@ -13,11 +27,13 @@ export interface SelectorProps<T, M extends boolean> {
|
|||
loading?: boolean;
|
||||
multiple?: M;
|
||||
onChange?: (k: SelectorValueType<T, M>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLElement>) => void;
|
||||
onFocus?: (e: FocusEvent<HTMLElement>) => void;
|
||||
label?: (item: T) => string;
|
||||
defaultValue?: SelectorValueType<T, M>;
|
||||
value?: SelectorValueType<T, M>;
|
||||
components?: Partial<SelectComponents<T, M, any>>;
|
||||
components?: Partial<
|
||||
SelectComponents<SelectorOption<T>, M, GroupBase<SelectorOption<T>>>
|
||||
>;
|
||||
}
|
||||
|
||||
export function Selector<T = string, M extends boolean = false>(
|
||||
|
@ -39,34 +55,45 @@ export function Selector<T = string, M extends boolean = false>(
|
|||
value,
|
||||
} = props;
|
||||
|
||||
const nameFromItems = useCallback(
|
||||
const labelRef = useRef(label);
|
||||
|
||||
const getName = useCallback(
|
||||
(item: T) => {
|
||||
return options.find((v) => v.value === item)?.label;
|
||||
if (labelRef.current) {
|
||||
return labelRef.current(item);
|
||||
}
|
||||
|
||||
return options.find((v) => v.value === item)?.label ?? "Unknown";
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
// TODO: Force as any
|
||||
const wrapper = useCallback(
|
||||
(value: SelectorValueType<T, M> | undefined | null): any => {
|
||||
if (value !== null && value !== undefined) {
|
||||
if (multiple) {
|
||||
(
|
||||
value: SelectorValueType<T, M> | undefined | null
|
||||
):
|
||||
| SelectorOption<T>
|
||||
| ReadonlyArray<SelectorOption<T>>
|
||||
| null
|
||||
| undefined => {
|
||||
if (value === null || value === undefined) {
|
||||
return value as null | undefined;
|
||||
} else {
|
||||
if (multiple === true) {
|
||||
return (value as SelectorValueType<T, true>).map((v) => ({
|
||||
label: label ? label(v) : nameFromItems(v) ?? "Unknown",
|
||||
label: getName(v),
|
||||
value: v,
|
||||
}));
|
||||
} else {
|
||||
const v = value as T;
|
||||
return {
|
||||
label: label ? label(v) : nameFromItems(v) ?? "Unknown",
|
||||
label: getName(v),
|
||||
value: v,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[label, multiple, nameFromItems]
|
||||
[multiple, getName]
|
||||
);
|
||||
|
||||
const defaultWrapper = useMemo(
|
||||
|
@ -89,21 +116,23 @@ export function Selector<T = string, M extends boolean = false>(
|
|||
isDisabled={disabled}
|
||||
options={options}
|
||||
components={components}
|
||||
className={`custom-selector w-100 ${className ?? ""}`}
|
||||
className={clsx("custom-selector w-100", className)}
|
||||
classNamePrefix="selector"
|
||||
onFocus={onFocus}
|
||||
onChange={(v: SelectorOption<T>[]) => {
|
||||
onChange={(newValue) => {
|
||||
if (onChange) {
|
||||
let res: T | T[] | null = null;
|
||||
if (isArray(v)) {
|
||||
res = (v as ReadonlyArray<SelectorOption<T>>).map(
|
||||
(val) => val.value
|
||||
);
|
||||
if (multiple === true) {
|
||||
const values = (
|
||||
newValue as OnChangeValue<SelectorOption<T>, true>
|
||||
).map((v) => v.value) as ReadonlyArray<T>;
|
||||
|
||||
onChange(values as SelectorValueType<T, M>);
|
||||
} else {
|
||||
res = (v as SelectorOption<T>)?.value ?? null;
|
||||
const value = (newValue as OnChangeValue<SelectorOption<T>, false>)
|
||||
?.value;
|
||||
|
||||
onChange(value as SelectorValueType<T, M>);
|
||||
}
|
||||
// TODO: Force as any
|
||||
onChange(res as any);
|
||||
}
|
||||
}}
|
||||
></Select>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import RcSlider from "rc-slider";
|
||||
import "rc-slider/assets/index.css";
|
||||
import React, { FunctionComponent, useMemo, useState } from "react";
|
||||
import "./slider.scss";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
|
||||
type TooltipsOptions = boolean | "Always";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { AsyncButton } from "..";
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { useIsShowed, useModalControl } from "@/modules/redux/hooks/modal";
|
||||
import clsx from "clsx";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { useModalInformation } from "./hooks";
|
||||
|
||||
export interface BaseModalProps {
|
||||
modalKey: string;
|
||||
|
@ -11,32 +12,34 @@ export interface BaseModalProps {
|
|||
}
|
||||
|
||||
export const BaseModal: FunctionComponent<BaseModalProps> = (props) => {
|
||||
const { size, modalKey, title, children, footer } = props;
|
||||
const { size, modalKey, title, children, footer, closeable = true } = props;
|
||||
const [needExit, setExit] = useState(false);
|
||||
|
||||
const { isShow, closeModal } = useModalInformation(modalKey);
|
||||
|
||||
const closeable = props.closeable !== false;
|
||||
const { hide: hideModal } = useModalControl();
|
||||
const showIndex = useIsShowed(modalKey);
|
||||
const isShowed = showIndex !== -1;
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setExit(true);
|
||||
}, []);
|
||||
|
||||
const exit = useCallback(() => {
|
||||
if (isShow) {
|
||||
closeModal(modalKey);
|
||||
if (isShowed) {
|
||||
hideModal(modalKey);
|
||||
}
|
||||
setExit(false);
|
||||
}, [closeModal, modalKey, isShow]);
|
||||
}, [isShowed, hideModal, modalKey]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
size={size}
|
||||
show={isShow && !needExit}
|
||||
show={isShowed && !needExit}
|
||||
onHide={hide}
|
||||
onExited={exit}
|
||||
backdrop={closeable ? undefined : "static"}
|
||||
className={clsx(`index-${showIndex}`)}
|
||||
backdropClassName={clsx(`index-${showIndex}`)}
|
||||
>
|
||||
<Modal.Header closeButton={closeable}>{title}</Modal.Header>
|
||||
<Modal.Body>{children}</Modal.Body>
|
||||
|
|
|
@ -3,24 +3,19 @@ import {
|
|||
useEpisodeHistory,
|
||||
useMovieAddBlacklist,
|
||||
useMovieHistory,
|
||||
} from "apis/hooks";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
} from "@/apis/hooks";
|
||||
import { usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import {
|
||||
HistoryIcon,
|
||||
LanguageText,
|
||||
PageTable,
|
||||
QueryOverlay,
|
||||
TextPopover,
|
||||
} from "..";
|
||||
import { HistoryIcon, PageTable, QueryOverlay, TextPopover } from "..";
|
||||
import Language from "../bazarr/Language";
|
||||
import { BlacklistButton } from "../inputs/blacklist";
|
||||
import BaseModal, { BaseModalProps } from "./BaseModal";
|
||||
import { useModalPayload } from "./hooks";
|
||||
|
||||
export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
|
||||
const { ...modal } = props;
|
||||
|
||||
const movie = useModalPayload<Item.Movie>(modal.modalKey);
|
||||
const movie = usePayload<Item.Movie>(modal.modalKey);
|
||||
|
||||
const history = useMovieHistory(movie?.radarrId);
|
||||
|
||||
|
@ -40,7 +35,7 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
|
|||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <LanguageText text={value} long></LanguageText>;
|
||||
return <Language.Text value={value} long></Language.Text>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -101,12 +96,10 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
interface EpisodeHistoryProps {}
|
||||
|
||||
export const EpisodeHistoryModal: FunctionComponent<
|
||||
BaseModalProps & EpisodeHistoryProps
|
||||
> = (props) => {
|
||||
const episode = useModalPayload<Item.Episode>(props.modalKey);
|
||||
export const EpisodeHistoryModal: FunctionComponent<BaseModalProps> = (
|
||||
props
|
||||
) => {
|
||||
const episode = usePayload<Item.Episode>(props.modalKey);
|
||||
|
||||
const history = useEpisodeHistory(episode?.sonarrEpisodeId);
|
||||
|
||||
|
@ -126,7 +119,7 @@ export const EpisodeHistoryModal: FunctionComponent<
|
|||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <LanguageText text={value} long></LanguageText>;
|
||||
return <Language.Text value={value} long></Language.Text>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks";
|
||||
import React, { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
import { useIsAnyActionRunning, useLanguageProfiles } from "@/apis/hooks";
|
||||
import { useModalControl, usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { GetItemId } from "@/utilities";
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
import { Container, Form } from "react-bootstrap";
|
||||
import { UseMutationResult } from "react-query";
|
||||
import { GetItemId } from "utilities";
|
||||
import { AsyncButton, Selector } from "../";
|
||||
import { AsyncButton, Selector, SelectorOption } from "..";
|
||||
import BaseModal, { BaseModalProps } from "./BaseModal";
|
||||
import { useModalInformation } from "./hooks";
|
||||
|
||||
interface Props {
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
|
||||
|
@ -16,9 +16,8 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
|
|||
|
||||
const { data: profiles } = useLanguageProfiles();
|
||||
|
||||
const { payload, closeModal } = useModalInformation<Item.Base>(
|
||||
modal.modalKey
|
||||
);
|
||||
const payload = usePayload<Item.Base>(modal.modalKey);
|
||||
const { hide } = useModalControl();
|
||||
|
||||
const { mutateAsync, isLoading } = mutation;
|
||||
|
||||
|
@ -57,7 +56,9 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
|
|||
return null;
|
||||
}
|
||||
}}
|
||||
onSuccess={() => closeModal()}
|
||||
onSuccess={() => {
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import { useEpisodesProvider, useMoviesProvider } from "@/apis/hooks";
|
||||
import { usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { createAndDispatchTask } from "@/modules/task/utilities";
|
||||
import { isMovie } from "@/utilities";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
|
@ -6,15 +10,7 @@ import {
|
|||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { dispatchTask } from "@modules/task";
|
||||
import { createTask } from "@modules/task/utilities";
|
||||
import { useEpisodesProvider, useMoviesProvider } from "apis/hooks";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
|
@ -26,16 +22,8 @@ import {
|
|||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Column } from "react-table";
|
||||
import { GetItemId, isMovie } from "utilities";
|
||||
import {
|
||||
BaseModal,
|
||||
BaseModalProps,
|
||||
LanguageText,
|
||||
LoadingIndicator,
|
||||
PageTable,
|
||||
useModalPayload,
|
||||
} from "..";
|
||||
import "./msmStyle.scss";
|
||||
import { BaseModal, BaseModalProps, LoadingIndicator, PageTable } from "..";
|
||||
import Language from "../bazarr/Language";
|
||||
|
||||
type SupportType = Item.Movie | Item.Episode;
|
||||
|
||||
|
@ -48,7 +36,7 @@ export function ManualSearchModal<T extends SupportType>(
|
|||
) {
|
||||
const { download, ...modal } = props;
|
||||
|
||||
const item = useModalPayload<T>(modal.modalKey);
|
||||
const item = usePayload<T>(modal.modalKey);
|
||||
|
||||
const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
|
||||
const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
|
||||
|
@ -95,7 +83,7 @@ export function ManualSearchModal<T extends SupportType>(
|
|||
};
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<LanguageText text={lang}></LanguageText>
|
||||
<Language.Text value={lang}></Language.Text>
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
@ -194,12 +182,12 @@ export function ManualSearchModal<T extends SupportType>(
|
|||
onClick={() => {
|
||||
if (!item) return;
|
||||
|
||||
const id = GetItemId(item);
|
||||
const task = createTask(item.title, id, download, item, result);
|
||||
dispatchTask(
|
||||
"Downloading subtitles...",
|
||||
[task],
|
||||
"Downloading..."
|
||||
createAndDispatchTask(
|
||||
item.title,
|
||||
"download-subtitles",
|
||||
download,
|
||||
item,
|
||||
result
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
@ -226,14 +214,14 @@ export function ManualSearchModal<T extends SupportType>(
|
|||
return <LoadingIndicator animation="grow"></LoadingIndicator>;
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<p className="mb-3 small">{item?.path ?? ""}</p>
|
||||
<PageTable
|
||||
emptyText="No Result"
|
||||
columns={columns}
|
||||
data={results}
|
||||
></PageTable>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,32 +1,27 @@
|
|||
import { dispatchTask } from "@modules/task";
|
||||
import { createTask } from "@modules/task/utilities";
|
||||
import { useMovieSubtitleModification } from "apis/hooks";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { useMovieSubtitleModification } from "@/apis/hooks";
|
||||
import { usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "utilities/languages";
|
||||
} from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback } from "react";
|
||||
import { BaseModalProps } from "./BaseModal";
|
||||
import { useModalInformation } from "./hooks";
|
||||
import SubtitleUploadModal, {
|
||||
PendingSubtitle,
|
||||
Validator,
|
||||
} from "./SubtitleUploadModal";
|
||||
|
||||
interface Payload {}
|
||||
|
||||
export const TaskGroupName = "Uploading Subtitles...";
|
||||
|
||||
const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
|
||||
const modal = props;
|
||||
|
||||
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
|
||||
const payload = usePayload<Item.Movie>(modal.modalKey);
|
||||
|
||||
const profile = useLanguageProfileBy(payload?.profileId);
|
||||
|
||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
||||
|
||||
const update = useCallback(async (list: PendingSubtitle<Payload>[]) => {
|
||||
const update = useCallback(async (list: PendingSubtitle<unknown>[]) => {
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
|
@ -34,7 +29,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
|
|||
upload: { mutateAsync },
|
||||
} = useMovieSubtitleModification();
|
||||
|
||||
const validate = useCallback<Validator<Payload>>(
|
||||
const validate = useCallback<Validator<unknown>>(
|
||||
(item) => {
|
||||
if (item.language === null) {
|
||||
return {
|
||||
|
@ -59,7 +54,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
|
|||
);
|
||||
|
||||
const upload = useCallback(
|
||||
(items: PendingSubtitle<Payload>[]) => {
|
||||
(items: PendingSubtitle<unknown>[]) => {
|
||||
if (payload === null) {
|
||||
return;
|
||||
}
|
||||
|
@ -71,18 +66,22 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
|
|||
.map((v) => {
|
||||
const { file, language, forced, hi } = v;
|
||||
|
||||
return createTask(file.name, radarrId, mutateAsync, {
|
||||
if (language === null) {
|
||||
throw new Error("Language is not selected");
|
||||
}
|
||||
|
||||
return createTask(file.name, mutateAsync, {
|
||||
radarrId,
|
||||
form: {
|
||||
file,
|
||||
forced,
|
||||
hi,
|
||||
language: language!.code2,
|
||||
language: language.code2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
dispatchTask(TaskGroupName, tasks, "Uploading...");
|
||||
dispatchTask(tasks, "upload-subtitles");
|
||||
},
|
||||
[mutateAsync, payload]
|
||||
);
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { dispatchTask } from "@modules/task";
|
||||
import { createTask } from "@modules/task/utilities";
|
||||
import { useEpisodeSubtitleModification } from "apis/hooks";
|
||||
import api from "apis/raw";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { useEpisodeSubtitleModification } from "@/apis/hooks";
|
||||
import api from "@/apis/raw";
|
||||
import { usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "utilities/languages";
|
||||
import { Selector } from "../inputs";
|
||||
} from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { Selector, SelectorOption } from "../inputs";
|
||||
import { BaseModalProps } from "./BaseModal";
|
||||
import { useModalInformation } from "./hooks";
|
||||
import SubtitleUploadModal, {
|
||||
PendingSubtitle,
|
||||
useRowMutation,
|
||||
Validator,
|
||||
} from "./SubtitleUploadModal";
|
||||
|
||||
|
@ -24,13 +24,11 @@ interface SeriesProps {
|
|||
episodes: readonly Item.Episode[];
|
||||
}
|
||||
|
||||
export const TaskGroupName = "Uploading Subtitles...";
|
||||
|
||||
const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
|
||||
episodes,
|
||||
...modal
|
||||
}) => {
|
||||
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
|
||||
const payload = usePayload<Item.Series>(modal.modalKey);
|
||||
|
||||
const profile = useLanguageProfileBy(payload?.profileId);
|
||||
|
||||
|
@ -98,9 +96,19 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
|
|||
const tasks = items
|
||||
.filter((v) => v.payload.instance !== undefined)
|
||||
.map((v) => {
|
||||
const { hi, forced, payload, language } = v;
|
||||
const { code2 } = language!;
|
||||
const { sonarrEpisodeId: episodeId } = payload.instance!;
|
||||
const {
|
||||
hi,
|
||||
forced,
|
||||
payload: { instance },
|
||||
language,
|
||||
} = v;
|
||||
|
||||
if (language === null || instance === null) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
|
||||
const { code2 } = language;
|
||||
const { sonarrEpisodeId: episodeId } = instance;
|
||||
|
||||
const form: FormType.UploadSubtitle = {
|
||||
file: v.file,
|
||||
|
@ -109,14 +117,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
|
|||
forced: forced,
|
||||
};
|
||||
|
||||
return createTask(v.file.name, episodeId, mutateAsync, {
|
||||
return createTask(v.file.name, mutateAsync, {
|
||||
seriesId,
|
||||
episodeId,
|
||||
form,
|
||||
});
|
||||
});
|
||||
|
||||
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
|
||||
dispatchTask(tasks, "upload-subtitles");
|
||||
},
|
||||
[mutateAsync, payload]
|
||||
);
|
||||
|
@ -128,29 +136,26 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
|
|||
Header: "Episode",
|
||||
accessor: "payload",
|
||||
className: "vw-1",
|
||||
Cell: ({ value, row, update }) => {
|
||||
Cell: ({ value, row }) => {
|
||||
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
|
||||
label: `(${ep.season}x${ep.episode}) ${ep.title}`,
|
||||
value: ep,
|
||||
}));
|
||||
|
||||
const change = useCallback(
|
||||
(ep: Nullable<Item.Episode>) => {
|
||||
if (ep) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.payload.instance = ep;
|
||||
update && update(row, newInfo);
|
||||
}
|
||||
},
|
||||
[row, update]
|
||||
);
|
||||
const mutate = useRowMutation();
|
||||
|
||||
return (
|
||||
<Selector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={options}
|
||||
value={value.instance}
|
||||
onChange={change}
|
||||
onChange={(ep: Nullable<Item.Episode>) => {
|
||||
if (ep) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.payload.instance = ep;
|
||||
mutate(row.index, newInfo);
|
||||
}
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import { useModalControl, usePayload } from "@/modules/redux/hooks/modal";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import { isMovie, submodProcessColor } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { useEnabledLanguages } from "@/utilities/languages";
|
||||
import {
|
||||
faClock,
|
||||
faCode,
|
||||
|
@ -14,10 +20,8 @@ import {
|
|||
faTextHeight,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { dispatchTask } from "@modules/task";
|
||||
import { createTask } from "@modules/task/utilities";
|
||||
import { useSubtitleAction } from "apis/hooks";
|
||||
import React, {
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
|
@ -32,22 +36,16 @@ import {
|
|||
InputGroup,
|
||||
} from "react-bootstrap";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
import { isMovie, submodProcessColor } from "utilities";
|
||||
import { useEnabledLanguages } from "utilities/languages";
|
||||
import { log } from "utilities/logger";
|
||||
import {
|
||||
ActionButton,
|
||||
ActionButtonItem,
|
||||
LanguageSelector,
|
||||
LanguageText,
|
||||
Selector,
|
||||
SimpleTable,
|
||||
useModalPayload,
|
||||
useShowModal,
|
||||
} from "..";
|
||||
import Language from "../bazarr/Language";
|
||||
import { useCustomSelection } from "../tables/plugins";
|
||||
import BaseModal, { BaseModalProps } from "./BaseModal";
|
||||
import { useCloseModal } from "./hooks";
|
||||
import { availableTranslation, colorOptions } from "./toolOptions";
|
||||
|
||||
type SupportType = Item.Episode | Item.Movie;
|
||||
|
@ -119,18 +117,15 @@ const FrameRateModal: FunctionComponent<BaseModalProps & ToolModalProps> = (
|
|||
|
||||
const submit = useCallback(() => {
|
||||
if (canSave) {
|
||||
const action = submodProcessFrameRate(from!, to!);
|
||||
const action = submodProcessFrameRate(from, to);
|
||||
process(action);
|
||||
}
|
||||
}, [canSave, from, to, process]);
|
||||
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
),
|
||||
[submit, canSave]
|
||||
const footer = (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -176,8 +171,8 @@ const AdjustTimesModal: FunctionComponent<BaseModalProps & ToolModalProps> = (
|
|||
]);
|
||||
|
||||
const updateOffset = useCallback(
|
||||
(idx: number) => {
|
||||
return (e: any) => {
|
||||
(idx: number): ChangeEventHandler<HTMLInputElement> => {
|
||||
return (e) => {
|
||||
let value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
|
@ -293,24 +288,22 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const TaskGroupName = "Modifying Subtitles";
|
||||
|
||||
const CanSelectSubtitle = (item: TableColumnType) => {
|
||||
return item.path.endsWith(".srt");
|
||||
};
|
||||
|
||||
const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
||||
const payload = useModalPayload<SupportType[]>(props.modalKey);
|
||||
const payload = usePayload<SupportType[]>(props.modalKey);
|
||||
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
||||
|
||||
const closeModal = useCloseModal();
|
||||
const { hide } = useModalControl();
|
||||
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
|
||||
const process = useCallback(
|
||||
(action: string, override?: Partial<FormType.ModifySubtitle>) => {
|
||||
log("info", "executing action", action);
|
||||
closeModal(props.modalKey);
|
||||
LOG("info", "executing action", action);
|
||||
hide(props.modalKey);
|
||||
|
||||
const tasks = selections.map((s) => {
|
||||
const form: FormType.ModifySubtitle = {
|
||||
|
@ -320,15 +313,15 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
|||
path: s.path,
|
||||
...override,
|
||||
};
|
||||
return createTask(s.path, s.id, mutateAsync, { action, form });
|
||||
return createTask(s.path, mutateAsync, { action, form });
|
||||
});
|
||||
|
||||
dispatchTask(TaskGroupName, tasks, "Modifying subtitles...");
|
||||
dispatchTask(tasks, "modify-subtitles");
|
||||
},
|
||||
[closeModal, props.modalKey, selections, mutateAsync]
|
||||
[hide, props.modalKey, selections, mutateAsync]
|
||||
);
|
||||
|
||||
const showModal = useShowModal();
|
||||
const { show } = useModalControl();
|
||||
|
||||
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
||||
() => [
|
||||
|
@ -337,7 +330,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
|||
accessor: "_language",
|
||||
Cell: ({ value }) => (
|
||||
<Badge variant="secondary">
|
||||
<LanguageText text={value} long></LanguageText>
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
@ -345,8 +338,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
|||
id: "file",
|
||||
Header: "File",
|
||||
accessor: "path",
|
||||
Cell: (row) => {
|
||||
const path = row.value!;
|
||||
Cell: ({ value }) => {
|
||||
const path = value;
|
||||
|
||||
let idx = path.lastIndexOf("/");
|
||||
|
||||
|
@ -431,29 +424,28 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
|||
Reverse RTL
|
||||
</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => showModal("add-color")}>
|
||||
<Dropdown.Item onSelect={() => show("add-color")}>
|
||||
<ActionButtonItem icon={faPaintBrush}>Add Color</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => showModal("change-frame-rate")}>
|
||||
<Dropdown.Item onSelect={() => show("change-frame-rate")}>
|
||||
<ActionButtonItem icon={faFilm}>Change Frame Rate</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => showModal("adjust-times")}>
|
||||
<Dropdown.Item onSelect={() => show("adjust-times")}>
|
||||
<ActionButtonItem icon={faClock}>Adjust Times</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => showModal("translate-sub")}>
|
||||
<Dropdown.Item onSelect={() => show("translate-sub")}>
|
||||
<ActionButtonItem icon={faLanguage}>Translate</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
),
|
||||
[showModal, selections.length, process]
|
||||
[selections.length, process, show]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<BaseModal title={"Subtitle Tools"} footer={footer} {...props}>
|
||||
<SimpleTable
|
||||
isSelecting={data.length !== 0}
|
||||
emptyText="No External Subtitles Found"
|
||||
plugins={plugins}
|
||||
columns={columns}
|
||||
|
@ -475,7 +467,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
|
|||
process={process}
|
||||
modalKey="translate-sub"
|
||||
></TranslateModal>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import { useModalControl } from "@/modules/redux/hooks/modal";
|
||||
import { BuildKey } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleNotch,
|
||||
|
@ -6,15 +9,31 @@ import {
|
|||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, Container, Form } from "react-bootstrap";
|
||||
import { Column, TableUpdater } from "react-table";
|
||||
import { BuildKey } from "utilities";
|
||||
import { Column } from "react-table";
|
||||
import { LanguageSelector, MessageIcon } from "..";
|
||||
import { FileForm } from "../inputs";
|
||||
import { SimpleTable } from "../tables";
|
||||
import BaseModal, { BaseModalProps } from "./BaseModal";
|
||||
import { useCloseModal } from "./hooks";
|
||||
|
||||
type ModifyFn<T> = (index: number, info?: PendingSubtitle<T>) => void;
|
||||
|
||||
const RowContext = createContext<ModifyFn<unknown>>(() => {
|
||||
LOG("error", "RowContext not initialized");
|
||||
});
|
||||
|
||||
export function useRowMutation() {
|
||||
return useContext(RowContext);
|
||||
}
|
||||
|
||||
export interface PendingSubtitle<P> {
|
||||
file: File;
|
||||
|
@ -30,7 +49,7 @@ export type Validator<T> = (
|
|||
item: PendingSubtitle<T>
|
||||
) => Pick<PendingSubtitle<T>, "state" | "messages">;
|
||||
|
||||
interface Props<T> {
|
||||
interface Props<T = unknown> {
|
||||
initial: T;
|
||||
availableLanguages: Language.Info[];
|
||||
upload: (items: PendingSubtitle<T>[]) => void;
|
||||
|
@ -40,9 +59,10 @@ interface Props<T> {
|
|||
hideAllLanguages?: boolean;
|
||||
}
|
||||
|
||||
export default function SubtitleUploadModal<T>(
|
||||
props: Props<T> & Omit<BaseModalProps, "footer" | "title" | "size">
|
||||
) {
|
||||
type ComponentProps<T> = Props<T> &
|
||||
Omit<BaseModalProps, "footer" | "title" | "size">;
|
||||
|
||||
function SubtitleUploadModal<T>(props: ComponentProps<T>) {
|
||||
const {
|
||||
initial,
|
||||
columns,
|
||||
|
@ -53,7 +73,7 @@ export default function SubtitleUploadModal<T>(
|
|||
hideAllLanguages,
|
||||
} = props;
|
||||
|
||||
const closeModal = useCloseModal();
|
||||
const { hide } = useModalControl();
|
||||
|
||||
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
|
||||
|
||||
|
@ -72,7 +92,7 @@ export default function SubtitleUploadModal<T>(
|
|||
language: initialLanguage,
|
||||
forced: false,
|
||||
hi: false,
|
||||
payload: { ...initialRef.current },
|
||||
payload: initialRef.current,
|
||||
}));
|
||||
|
||||
if (update) {
|
||||
|
@ -95,15 +115,15 @@ export default function SubtitleUploadModal<T>(
|
|||
[update, validate, availableLanguages]
|
||||
);
|
||||
|
||||
const modify = useCallback<TableUpdater<PendingSubtitle<T>>>(
|
||||
(row, info?: PendingSubtitle<T>) => {
|
||||
const modify = useCallback(
|
||||
(index: number, info?: PendingSubtitle<T>) => {
|
||||
setPending((pd) => {
|
||||
const newPending = [...pd];
|
||||
if (info) {
|
||||
info = { ...info, ...validate(info) };
|
||||
newPending[row.index] = info;
|
||||
newPending[index] = info;
|
||||
} else {
|
||||
newPending.splice(row.index, 1);
|
||||
newPending.splice(index, 1);
|
||||
}
|
||||
return newPending;
|
||||
});
|
||||
|
@ -174,8 +194,9 @@ export default function SubtitleUploadModal<T>(
|
|||
id: "hi",
|
||||
Header: "HI",
|
||||
accessor: "hi",
|
||||
Cell: ({ row, value, update }) => {
|
||||
Cell: ({ row, value }) => {
|
||||
const { original, index } = row;
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Form.Check
|
||||
custom
|
||||
|
@ -185,7 +206,7 @@ export default function SubtitleUploadModal<T>(
|
|||
onChange={(v) => {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.hi = v.target.checked;
|
||||
update && update(row, newInfo);
|
||||
mutate(row.index, newInfo);
|
||||
}}
|
||||
></Form.Check>
|
||||
);
|
||||
|
@ -195,8 +216,9 @@ export default function SubtitleUploadModal<T>(
|
|||
id: "forced",
|
||||
Header: "Forced",
|
||||
accessor: "forced",
|
||||
Cell: ({ row, value, update }) => {
|
||||
Cell: ({ row, value }) => {
|
||||
const { original, index } = row;
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Form.Check
|
||||
custom
|
||||
|
@ -206,7 +228,7 @@ export default function SubtitleUploadModal<T>(
|
|||
onChange={(v) => {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.forced = v.target.checked;
|
||||
update && update(row, newInfo);
|
||||
mutate(row.index, newInfo);
|
||||
}}
|
||||
></Form.Check>
|
||||
);
|
||||
|
@ -217,17 +239,18 @@ export default function SubtitleUploadModal<T>(
|
|||
Header: "Language",
|
||||
accessor: "language",
|
||||
className: "w-25",
|
||||
Cell: ({ row, update, value }) => {
|
||||
Cell: ({ row, value }) => {
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<LanguageSelector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={availableLanguages}
|
||||
value={value}
|
||||
onChange={(lang) => {
|
||||
if (lang && update) {
|
||||
if (lang) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.language = lang;
|
||||
update(row, newInfo);
|
||||
mutate(row.index, newInfo);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
|
@ -238,18 +261,21 @@ export default function SubtitleUploadModal<T>(
|
|||
{
|
||||
id: "action",
|
||||
accessor: "file",
|
||||
Cell: ({ row, update }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
disabled={row.original.state === "fetching"}
|
||||
onClick={() => {
|
||||
update && update(row);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</Button>
|
||||
),
|
||||
Cell: ({ row }) => {
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
disabled={row.original.state === "fetching"}
|
||||
onClick={() => {
|
||||
mutate(row.index);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[columns, availableLanguages]
|
||||
|
@ -280,7 +306,7 @@ export default function SubtitleUploadModal<T>(
|
|||
onClick={() => {
|
||||
upload(pending);
|
||||
setFiles([]);
|
||||
closeModal();
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
|
@ -325,14 +351,17 @@ export default function SubtitleUploadModal<T>(
|
|||
</Form.Group>
|
||||
</Form>
|
||||
<div hidden={!showTable}>
|
||||
<SimpleTable
|
||||
columns={columnsWithAction}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
update={modify}
|
||||
></SimpleTable>
|
||||
<RowContext.Provider value={modify as ModifyFn<unknown>}>
|
||||
<SimpleTable
|
||||
columns={columnsWithAction}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
></SimpleTable>
|
||||
</RowContext.Provider>
|
||||
</div>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SubtitleUploadModal;
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
import { useCallback, useContext, useMemo } from "react";
|
||||
import { useDidUpdate } from "rooks";
|
||||
import { log } from "utilities/logger";
|
||||
import { ModalContext } from "./provider";
|
||||
|
||||
interface ModalInformation<T> {
|
||||
isShow: boolean;
|
||||
payload: T | null;
|
||||
closeModal: ReturnType<typeof useCloseModal>;
|
||||
}
|
||||
|
||||
export function useModalInformation<T>(key: string): ModalInformation<T> {
|
||||
const isShow = useIsModalShow(key);
|
||||
const payload = useModalPayload<T>(key);
|
||||
const closeModal = useCloseModal();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isShow,
|
||||
payload,
|
||||
closeModal,
|
||||
}),
|
||||
[isShow, payload, closeModal]
|
||||
);
|
||||
}
|
||||
|
||||
export function useShowModal() {
|
||||
const {
|
||||
control: { push },
|
||||
} = useContext(ModalContext);
|
||||
|
||||
return useCallback(
|
||||
<T,>(key: string, payload?: T) => {
|
||||
log("info", `modal ${key} sending payload`, payload);
|
||||
|
||||
push({ key, payload });
|
||||
},
|
||||
[push]
|
||||
);
|
||||
}
|
||||
|
||||
export function useCloseModal() {
|
||||
const {
|
||||
control: { pop },
|
||||
} = useContext(ModalContext);
|
||||
return useCallback(
|
||||
(key?: string) => {
|
||||
pop(key);
|
||||
},
|
||||
[pop]
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsModalShow(key: string) {
|
||||
const {
|
||||
control: { peek },
|
||||
} = useContext(ModalContext);
|
||||
const modal = peek();
|
||||
return key === modal?.key;
|
||||
}
|
||||
|
||||
export function useOnModalShow<T>(
|
||||
callback: (payload: T | null) => void,
|
||||
key: string
|
||||
) {
|
||||
const {
|
||||
modals,
|
||||
control: { peek },
|
||||
} = useContext(ModalContext);
|
||||
useDidUpdate(() => {
|
||||
const modal = peek();
|
||||
if (modal && modal.key === key) {
|
||||
callback(modal.payload ?? null);
|
||||
}
|
||||
}, [modals.length, key]);
|
||||
}
|
||||
|
||||
export function useModalPayload<T>(key: string): T | null {
|
||||
const {
|
||||
control: { peek },
|
||||
} = useContext(ModalContext);
|
||||
return useMemo(() => {
|
||||
const modal = peek();
|
||||
if (modal && modal.key === key) {
|
||||
return (modal.payload as T) ?? null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [key, peek]);
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
export * from "./BaseModal";
|
||||
export * from "./HistoryModal";
|
||||
export * from "./hooks";
|
||||
export { default as ItemEditorModal } from "./ItemEditorModal";
|
||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
||||
export { default as ModalProvider } from "./provider";
|
||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
||||
export { default as SubtitleToolModal } from "./SubtitleToolModal";
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface Modal {
|
||||
key: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
interface ModalControl {
|
||||
push: (modal: Modal) => void;
|
||||
peek: () => Modal | undefined;
|
||||
pop: (key: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
modals: Modal[];
|
||||
control: ModalControl;
|
||||
}
|
||||
|
||||
export const ModalContext = React.createContext<ModalContextType>({
|
||||
modals: [],
|
||||
control: {
|
||||
push: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
pop: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
peek: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ModalProvider: FunctionComponent = ({ children }) => {
|
||||
const [stack, setStack] = useState<Modal[]>([]);
|
||||
|
||||
const push = useCallback<ModalControl["push"]>((model) => {
|
||||
setStack((old) => {
|
||||
return [...old, model];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pop = useCallback<ModalControl["pop"]>((key) => {
|
||||
setStack((old) => {
|
||||
if (old.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (key === undefined) {
|
||||
const newOld = old;
|
||||
newOld.pop();
|
||||
return newOld;
|
||||
}
|
||||
|
||||
// find key
|
||||
const index = old.findIndex((v) => v.key === key);
|
||||
if (index !== -1) {
|
||||
return old.slice(0, index);
|
||||
} else {
|
||||
return old;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const peek = useCallback<ModalControl["peek"]>(() => {
|
||||
return stack.length > 0 ? stack[stack.length - 1] : undefined;
|
||||
}, [stack]);
|
||||
|
||||
const context = useMemo<ModalContextType>(
|
||||
() => ({ modals: stack, control: { push, pop, peek } }),
|
||||
[stack, push, pop, peek]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={context}>{children}</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalProvider;
|
|
@ -1,3 +1,5 @@
|
|||
import { SelectorOption } from "..";
|
||||
|
||||
export const availableTranslation = {
|
||||
af: "afrikaans",
|
||||
sq: "albanian",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Table } from "react-bootstrap";
|
||||
import {
|
||||
HeaderGroup,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React from "react";
|
||||
import {
|
||||
Cell,
|
||||
HeaderGroup,
|
||||
|
@ -13,7 +12,7 @@ import {
|
|||
import { TableStyleProps } from "./BaseTable";
|
||||
import SimpleTable from "./SimpleTable";
|
||||
|
||||
function renderCell<T extends object = {}>(cell: Cell<T, any>, row: Row<T>) {
|
||||
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
|
||||
if (cell.isGrouped) {
|
||||
return (
|
||||
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span>
|
||||
|
@ -79,7 +78,7 @@ function renderHeaders<T extends object>(
|
|||
|
||||
type Props<T extends object> = TableOptions<T> & TableStyleProps<T>;
|
||||
|
||||
function GroupTable<T extends object = {}>(props: Props<T>) {
|
||||
function GroupTable<T extends object = object>(props: Props<T>) {
|
||||
const plugins = [useGroupBy, useSortBy, useExpanded];
|
||||
return (
|
||||
<SimpleTable
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { Col, Container, Pagination, Row } from "react-bootstrap";
|
||||
import { PageControlAction } from "./types";
|
||||
interface Props {
|
||||
|
|
|
@ -1,33 +1,22 @@
|
|||
import React, { useEffect } from "react";
|
||||
import {
|
||||
PluginHook,
|
||||
TableOptions,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useTable,
|
||||
} from "react-table";
|
||||
import { ScrollToTop } from "utilities";
|
||||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import PageControl from "./PageControl";
|
||||
import { useCustomSelection, useDefaultSettings } from "./plugins";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
canSelect?: boolean;
|
||||
autoScroll?: boolean;
|
||||
plugins?: PluginHook<T>[];
|
||||
};
|
||||
|
||||
export default function PageTable<T extends object>(props: Props<T>) {
|
||||
const { autoScroll, canSelect, plugins, ...remain } = props;
|
||||
const { autoScroll, plugins, ...remain } = props;
|
||||
const { style, options } = useStyleAndOptions(remain);
|
||||
|
||||
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
|
||||
|
||||
if (canSelect) {
|
||||
allPlugins.push(useRowSelect, useCustomSelection);
|
||||
}
|
||||
|
||||
if (plugins) {
|
||||
allPlugins.push(...plugins);
|
||||
}
|
||||
|
@ -60,7 +49,7 @@ export default function PageTable<T extends object>(props: Props<T>) {
|
|||
}, [pageIndex, autoScroll]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
|
@ -80,6 +69,6 @@ export default function PageTable<T extends object>(props: Props<T>) {
|
|||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { UsePaginationQueryResult } from "apis/queries/hooks";
|
||||
import React, { useEffect } from "react";
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
||||
import { ScrollToTop } from "utilities";
|
||||
import { LoadingIndicator } from "..";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import PageControl from "./PageControl";
|
||||
|
@ -52,7 +52,7 @@ export default function QueryPageTable<T extends object>(props: Props<T>) {
|
|||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
|
@ -72,6 +72,6 @@ export default function QueryPageTable<T extends object>(props: Props<T>) {
|
|||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,13 +13,8 @@ export default function SimpleTable<T extends object>(props: Props<T>) {
|
|||
|
||||
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
} = instance;
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
instance;
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { forwardRef, useEffect, useRef } from "react";
|
||||
import { forwardRef, useEffect, useRef } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import {
|
||||
CellProps,
|
||||
|
@ -52,10 +52,6 @@ const Checkbox = forwardRef<
|
|||
});
|
||||
|
||||
function useCustomSelection<T extends object>(hooks: Hooks<T>) {
|
||||
hooks.visibleColumnsDeps.push((deps, { instance }) => [
|
||||
...deps,
|
||||
instance.isSelecting,
|
||||
]);
|
||||
hooks.visibleColumns.push(visibleColumns);
|
||||
hooks.useInstance.push(useInstance);
|
||||
}
|
||||
|
@ -68,7 +64,6 @@ function useInstance<T extends object>(instance: TableInstance<T>) {
|
|||
rows,
|
||||
onSelect,
|
||||
canSelect,
|
||||
isSelecting,
|
||||
state: { selectedRowIds },
|
||||
} = instance;
|
||||
|
||||
|
@ -76,18 +71,16 @@ function useInstance<T extends object>(instance: TableInstance<T>) {
|
|||
|
||||
useEffect(() => {
|
||||
// Performance
|
||||
if (isSelecting) {
|
||||
let items = Object.keys(selectedRowIds).flatMap(
|
||||
(v) => rows.find((n) => n.id === v)?.original ?? []
|
||||
);
|
||||
let items = Object.keys(selectedRowIds).flatMap(
|
||||
(v) => rows.find((n) => n.id === v)?.original ?? []
|
||||
);
|
||||
|
||||
if (canSelect) {
|
||||
items = items.filter((v) => canSelect(v));
|
||||
}
|
||||
|
||||
onSelect && onSelect(items);
|
||||
if (canSelect) {
|
||||
items = items.filter((v) => canSelect(v));
|
||||
}
|
||||
}, [selectedRowIds, onSelect, rows, isSelecting, canSelect]);
|
||||
|
||||
onSelect && onSelect(items);
|
||||
}, [selectedRowIds, onSelect, rows, canSelect]);
|
||||
}
|
||||
|
||||
function visibleColumns<T extends object>(
|
||||
|
@ -95,31 +88,27 @@ function visibleColumns<T extends object>(
|
|||
meta: MetaBase<T>
|
||||
): Column<T>[] {
|
||||
const { instance } = meta;
|
||||
if (instance.isSelecting) {
|
||||
const checkbox: Column<T> = {
|
||||
id: checkboxId,
|
||||
Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<any>) => (
|
||||
const checkbox: Column<T> = {
|
||||
id: checkboxId,
|
||||
Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<T>) => (
|
||||
<Checkbox
|
||||
idIn="table-header-selection"
|
||||
{...getToggleAllRowsSelectedProps()}
|
||||
></Checkbox>
|
||||
),
|
||||
Cell: ({ row }: CellProps<T>) => {
|
||||
const canSelect = instance.canSelect;
|
||||
const disabled = (canSelect && !canSelect(row.original)) ?? false;
|
||||
return (
|
||||
<Checkbox
|
||||
idIn="table-header-selection"
|
||||
{...getToggleAllRowsSelectedProps()}
|
||||
idIn={`table-cell-${row.index}`}
|
||||
disabled={disabled}
|
||||
{...row.getToggleRowSelectedProps()}
|
||||
></Checkbox>
|
||||
),
|
||||
Cell: ({ row }: CellProps<any>) => {
|
||||
const canSelect = instance.canSelect;
|
||||
const disabled = (canSelect && !canSelect(row.original)) ?? false;
|
||||
return (
|
||||
<Checkbox
|
||||
idIn={`table-cell-${row.index}`}
|
||||
disabled={disabled}
|
||||
{...row.getToggleRowSelectedProps()}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
return [checkbox, ...columns.filter((v) => v.selectHide !== true)];
|
||||
} else {
|
||||
return columns;
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
return [checkbox, ...columns];
|
||||
}
|
||||
|
||||
export default useCustomSelection;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { usePageSize } from "@/utilities/storage";
|
||||
import { Hooks, TableOptions } from "react-table";
|
||||
import { usePageSize } from "utilities/storage";
|
||||
|
||||
const pluginName = "useLocalSettings";
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { UsePaginationQueryResult } from "apis/queries/hooks";
|
||||
import React from "react";
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Column } from "react-table";
|
||||
|
|
|
@ -1,69 +1,30 @@
|
|||
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIsAnyMutationRunning, useLanguageProfiles } from "apis/hooks";
|
||||
import { UsePaginationQueryResult } from "apis/queries/hooks";
|
||||
import { TableStyleProps } from "components/tables/BaseTable";
|
||||
import { useCustomSelection } from "components/tables/plugins";
|
||||
import { uniqBy } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Container, Dropdown, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { UseMutationResult, UseQueryResult } from "react-query";
|
||||
import { Column, TableOptions, TableUpdater, useRowSelect } from "react-table";
|
||||
import { GetItemId } from "utilities";
|
||||
import {
|
||||
ContentHeader,
|
||||
ItemEditorModal,
|
||||
LoadingIndicator,
|
||||
QueryPageTable,
|
||||
SimpleTable,
|
||||
useShowModal,
|
||||
} from "..";
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { TableStyleProps } from "@/components/tables/BaseTable";
|
||||
import { faList } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Column, TableOptions } from "react-table";
|
||||
import { ContentHeader, QueryPageTable } from "..";
|
||||
|
||||
interface Props<T extends Item.Base = Item.Base> {
|
||||
name: string;
|
||||
fullQuery: UseQueryResult<T[]>;
|
||||
query: UsePaginationQueryResult<T>;
|
||||
columns: Column<T>[];
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
|
||||
}
|
||||
|
||||
function ItemView<T extends Item.Base>({
|
||||
name,
|
||||
fullQuery,
|
||||
query,
|
||||
columns,
|
||||
mutation,
|
||||
}: Props<T>) {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const updateRow = useCallback<TableUpdater<T>>(
|
||||
({ original }, modalKey: string) => {
|
||||
showModal(modalKey, original);
|
||||
},
|
||||
[showModal]
|
||||
);
|
||||
function ItemView<T extends Item.Base>({ query, columns }: Props<T>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
|
||||
emptyText: `No ${name} Found`,
|
||||
update: updateRow,
|
||||
emptyText: `No Items Found`,
|
||||
};
|
||||
|
||||
const content = editMode ? (
|
||||
<ItemMassEditor
|
||||
query={fullQuery}
|
||||
columns={columns}
|
||||
mutation={mutation}
|
||||
onEnded={() => setEditMode(false)}
|
||||
></ItemMassEditor>
|
||||
) : (
|
||||
return (
|
||||
<>
|
||||
<ContentHeader scroll={false}>
|
||||
<ContentHeader.Button
|
||||
disabled={query.paginationStatus.totalCount === 0}
|
||||
icon={faList}
|
||||
onClick={() => setEditMode(true)}
|
||||
onClick={() => navigate("edit")}
|
||||
>
|
||||
Mass Edit
|
||||
</ContentHeader.Button>
|
||||
|
@ -75,134 +36,6 @@ function ItemView<T extends Item.Base>({
|
|||
query={query}
|
||||
data={[]}
|
||||
></QueryPageTable>
|
||||
<ItemEditorModal modalKey="edit" mutation={mutation}></ItemEditorModal>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{name} - Bazarr</title>
|
||||
</Helmet>
|
||||
{content}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemMassEditorProps<T extends Item.Base> {
|
||||
columns: Column<T>[];
|
||||
query: UseQueryResult<T[]>;
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
function ItemMassEditor<T extends Item.Base = Item.Base>(
|
||||
props: ItemMassEditorProps<T>
|
||||
) {
|
||||
const { columns, mutation, query, onEnded } = props;
|
||||
const [selections, setSelections] = useState<T[]>([]);
|
||||
const [dirties, setDirties] = useState<T[]>([]);
|
||||
const hasTask = useIsAnyMutationRunning();
|
||||
const { data: profiles } = useLanguageProfiles();
|
||||
|
||||
const { refetch } = query;
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const data = useMemo(
|
||||
() => uniqBy([...dirties, ...(query?.data ?? [])], GetItemId),
|
||||
[dirties, query?.data]
|
||||
);
|
||||
|
||||
const profileOptions = useMemo<JSX.Element[]>(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
if (profiles) {
|
||||
items.push(
|
||||
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
|
||||
);
|
||||
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
items.push(
|
||||
...profiles.map((v) => (
|
||||
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
|
||||
{v.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [profiles]);
|
||||
|
||||
const { mutateAsync } = mutation;
|
||||
|
||||
const save = useCallback(() => {
|
||||
const form: FormType.ModifyItem = {
|
||||
id: [],
|
||||
profileid: [],
|
||||
};
|
||||
dirties.forEach((v) => {
|
||||
const id = GetItemId(v);
|
||||
if (id) {
|
||||
form.id.push(id);
|
||||
form.profileid.push(v.profileId);
|
||||
}
|
||||
});
|
||||
return mutateAsync(form);
|
||||
}, [dirties, mutateAsync]);
|
||||
|
||||
const setProfiles = useCallback(
|
||||
(key: Nullable<string>) => {
|
||||
const id = key ? parseInt(key) : null;
|
||||
|
||||
const newItems = selections.map((v) => ({ ...v, profileId: id }));
|
||||
|
||||
setDirties((dirty) => {
|
||||
return uniqBy([...newItems, ...dirty], GetItemId);
|
||||
});
|
||||
},
|
||||
[selections]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader scroll={false}>
|
||||
<ContentHeader.Group pos="start">
|
||||
<Dropdown onSelect={setProfiles}>
|
||||
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
|
||||
Change Profile
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
|
||||
Cancel
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faCheck}
|
||||
disabled={dirties.length === 0 || hasTask}
|
||||
promise={save}
|
||||
onSuccess={onEnded}
|
||||
>
|
||||
Save
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
{query.data === undefined ? (
|
||||
<LoadingIndicator></LoadingIndicator>
|
||||
) : (
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
onSelect={setSelections}
|
||||
isSelecting
|
||||
plugins={[useRowSelect, useCustomSelection]}
|
||||
></SimpleTable>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { useIsAnyActionRunning } from "@/apis/hooks";
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { createAndDispatchTask } from "@/modules/task/utilities";
|
||||
import { faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { dispatchTask } from "@modules/task";
|
||||
import { createTask } from "@modules/task/utilities";
|
||||
import { useIsAnyActionRunning } from "apis/hooks";
|
||||
import { UsePaginationQueryResult } from "apis/queries/hooks";
|
||||
import React from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Column } from "react-table";
|
||||
|
@ -16,8 +14,6 @@ interface Props<T extends Wanted.Base> {
|
|||
searchAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskGroupName = "Searching wanted subtitles...";
|
||||
|
||||
function WantedView<T extends Wanted.Base>({
|
||||
name,
|
||||
columns,
|
||||
|
@ -37,8 +33,7 @@ function WantedView<T extends Wanted.Base>({
|
|||
<ContentHeader.Button
|
||||
disabled={hasTask || dataCount === 0}
|
||||
onClick={() => {
|
||||
const task = createTask(name, undefined, searchAll);
|
||||
dispatchTask(TaskGroupName, [task], "Searching...");
|
||||
createAndDispatchTask(name, "search-subtitles", searchAll);
|
||||
}}
|
||||
icon={faSearch}
|
||||
>
|
||||
|
|
|
@ -1,26 +1,33 @@
|
|||
import queryClient from "@/apis/queries";
|
||||
import store from "@/modules/redux/store";
|
||||
import "@/styles/index.scss";
|
||||
import "@fontsource/roboto/300.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./@redux/store";
|
||||
import "./@scss/index.scss";
|
||||
import queryClient from "./apis/queries";
|
||||
import App from "./App";
|
||||
import { Environment, isTestEnv } from "./utilities";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { Router, useRouteItems } from "./Router";
|
||||
import { Environment } from "./utilities";
|
||||
|
||||
const RouteApp = () => {
|
||||
const items = useRouteItems();
|
||||
|
||||
return useRoutes(items);
|
||||
};
|
||||
|
||||
export const Entrance = () => (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
||||
{/* <React.StrictMode> */}
|
||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<App></App>
|
||||
{/* </React.StrictMode> */}
|
||||
<Router>
|
||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
||||
{/* <StrictMode> */}
|
||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<RouteApp></RouteApp>
|
||||
{/* </StrictMode> */}
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
if (!isTestEnv) {
|
||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
||||
}
|
||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { waitFor } from "../../utilities";
|
||||
import { waitFor } from "../../../utilities";
|
||||
|
||||
export const setSiteStatus = createAction<Site.Status>("site/status/update");
|
||||
|
5
frontend/src/modules/redux/actions/modal.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { createAction } from "@reduxjs/toolkit";
|
||||
|
||||
export const showModalAction = createAction<Modal.Frame>("modal/show");
|
||||
|
||||
export const hideModalAction = createAction<string | undefined>("modal/hide");
|
4
frontend/src/modules/redux/actions/types.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyActionCreator = ActionCreator<any>;
|