Frontend improvement and cleanup (#1690)

* Replace Create-React-App with Vite.js

* Update React-Router to v6

* Cleanup unused codes
This commit is contained in:
Liang Yi 2022-03-16 14:26:15 +08:00 committed by GitHub
parent f81972b291
commit 50a252fdd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
223 changed files with 7814 additions and 34262 deletions

View File

@ -15,8 +15,8 @@ on:
branches: [development] branches: [development]
env: env:
UI_DIRECTORY: ./frontend UI_DIRECTORY: ./frontend
UI_ARTIFACT_NAME: ui UI_ARTIFACT_NAME: ui
jobs: jobs:
Frontend: Frontend:
@ -30,27 +30,39 @@ jobs:
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: '${{ env.UI_DIRECTORY }}/node_modules' path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules- restore-keys: ${{ runner.os }}-modules-
- name: Setup NodeJS - name: Setup NodeJS
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: "15.x" node-version: "16"
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
working-directory: ${{ env.UI_DIRECTORY }} working-directory: ${{ env.UI_DIRECTORY }}
- name: Build - name: Check Types
run: npm run build 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 }} working-directory: ${{ env.UI_DIRECTORY }}
- name: Unit Test - name: Unit Test
run: npm test run: npm test
working-directory: ${{ env.UI_DIRECTORY }} working-directory: ${{ env.UI_DIRECTORY }}
- name: Build
run: npm run build:ci
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: ${{ env.UI_ARTIFACT_NAME }} name: ${{ env.UI_ARTIFACT_NAME }}
@ -69,7 +81,7 @@ jobs:
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: '3.8' python-version: "3.8"
- name: Install UI - name: Install UI
uses: actions/download-artifact@v2 uses: actions/download-artifact@v2

View File

@ -31,7 +31,7 @@ jobs:
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: '${{ env.UI_DIRECTORY }}/node_modules' path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules- restore-keys: ${{ runner.os }}-modules-
@ -69,4 +69,4 @@ jobs:
release-it --ci --increment prerelease --preRelease=beta release-it --ci --increment prerelease --preRelease=beta
else else
echo "**** Cannot find changes! Skipping... ****" echo "**** Cannot find changes! Skipping... ****"
fi fi

View File

@ -29,11 +29,11 @@ jobs:
- name: Setup Git - name: Setup Git
run: git config --global user.name "github-actions" run: git config --global user.name "github-actions"
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: '${{ env.UI_DIRECTORY }}/node_modules' path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules- restore-keys: ${{ runner.os }}-modules-

View File

@ -37,7 +37,7 @@ jobs:
- name: Set up Python 3.8 - name: Set up Python 3.8
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: '3.8' python-version: "3.8"
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |

View File

@ -24,7 +24,8 @@ from notifier import update_notifier # noqa E402
from urllib.parse import unquote # noqa E402 from urllib.parse import unquote # noqa E402
from get_languages import load_language_in_db # 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 from threading import Thread # noqa E402
import requests # 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) 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 @check_login
@app.route('/bazarr.log') @app.route('/bazarr.log')
def download_log(): def download_log():

View File

@ -1,27 +1,29 @@
# Override by duplicating me and rename to .env.local # Override by duplicating me and rename to .env.local
# The following environment variables will only be used during development # The following environment variables will only be used during development
# Required
# API key of your backend # API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY" # VITE_API_KEY="YOUR_SERVER_API_KEY"
# Address of your backend # 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 # Allow Unsecured connection to your backend
REACT_APP_PROXY_SECURE=true VITE_PROXY_SECURE=true
# Allow websocket connection in Socket.IO # Allow websocket connection in Socket.IO
REACT_APP_ALLOW_WEBSOCKET=true VITE_ALLOW_WEBSOCKET=true
# Display update section in settings # Display update section in settings
REACT_APP_CAN_UPDATE=true VITE_CAN_UPDATE=true
# Display update notification in notification center # Display update notification in notification center
REACT_APP_HAS_UPDATE=false VITE_HAS_UPDATE=false
# Display React-Query devtools # Display React-Query devtools
REACT_APP_QUERY_DEV=false VITE_QUERY_DEV=false

View File

@ -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
View File

@ -2,3 +2,5 @@ node_modules
dist dist
*.local *.local
build build
*.tsbuildinfo

View File

@ -1,4 +1,4 @@
build build
dist dist
converage converage
public

View File

@ -20,26 +20,26 @@
$ npm install $ 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 # 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 # Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767 VITE_PROXY_URL=http://localhost:6767
``` ```
6. Run Bazarr backend 6. Run Bazarr backend
@ -74,9 +74,9 @@ Please ensure all tests are passed before uploading the code
### `npm run build` ### `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 Format code for all files in `frontend` folder

View 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
View 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;

View File

@ -4,11 +4,7 @@
<title>Bazarr</title> <title>Bazarr</title>
<base href="{{baseUrl}}" /> <base href="{{baseUrl}}" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<link <link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
rel="icon"
type="image/x-icon"
href="%PUBLIC_URL%/static/favicon.ico"
/>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
@ -17,7 +13,6 @@
name="description" 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." 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> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
@ -25,5 +20,6 @@
<script> <script>
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}}; window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
</script> </script>
<script type="module" src="./src/index.tsx"></script>
</body> </body>
</html> </html>

35745
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,17 @@
"url": "https://github.com/morpheus65535/bazarr/issues" "url": "https://github.com/morpheus65535/bazarr/issues"
}, },
"private": true, "private": true,
"homepage": "./",
"dependencies": { "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", "@fontsource/roboto": "^4.5.1",
"@fortawesome/fontawesome-svg-core": "^1.2", "@fortawesome/fontawesome-svg-core": "^1.2",
"@fortawesome/free-brands-svg-icons": "^5.15", "@fortawesome/free-brands-svg-icons": "^5.15",
@ -21,48 +30,45 @@
"@fortawesome/free-solid-svg-icons": "^5.15", "@fortawesome/free-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.16", "@fortawesome/react-fontawesome": "^0.1.16",
"@reduxjs/toolkit": "^1.6", "@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/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@types/lodash": "^4", "@types/lodash": "^4",
"@types/node": "^15", "@types/node": "^15",
"@types/react": "^17", "@types/react": "^17",
"@types/react-dom": "^17", "@types/react-dom": "^17",
"@types/react-helmet": "^6.1", "@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-table": "^7", "@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", "husky": "^7",
"lodash": "^4",
"moment": "^2.29.1",
"prettier": "^2", "prettier": "^2",
"prettier-plugin-organize-imports": "^2", "prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1", "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", "sass": "^1",
"typescript": "^4" "typescript": "^4",
"vite": "^2.7.13",
"vite-plugin-checker": "^0.3.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test", "build:ci": "vite build -m development",
"lint": "prettier --write --ignore-unknown .", "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" "prepare": "cd .. && husky install frontend/.husky"
}, },
"browserslist": { "browserslist": {

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -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"
}

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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));
}
}

View File

@ -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>[];
};
}

View File

@ -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,
};
}

View File

@ -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]
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -1,6 +0,0 @@
$sidebar-width: 190px;
$header-height: 60px;
$theme-color-less-transparent: #911f9331;
$theme-color-transparent: #911f9313;
$theme-color-darked: #761977;

View File

@ -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 { import {
faBars, faBars,
faHeart, faHeart,
@ -5,12 +11,7 @@ import {
faUser, faUser,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions"; import { FunctionComponent, useMemo } from "react";
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 { import {
Button, Button,
Col, Col,
@ -21,14 +22,9 @@ import {
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useGotoHomepage, useIsMobile } from "utilities";
import { useSystem, useSystemSettings } from "../apis/hooks";
import "./header.scss";
import NotificationCenter from "./Notification"; import NotificationCenter from "./Notification";
interface Props {} const Header: FunctionComponent = () => {
const Header: FunctionComponent<Props> = () => {
const { data: settings } = useSystemSettings(); const { data: settings } = useSystemSettings();
const hasLogout = (settings?.auth.type ?? "none") === "form"; const hasLogout = (settings?.auth.type ?? "none") === "form";
@ -44,7 +40,7 @@ const Header: FunctionComponent<Props> = () => {
const serverActions = useMemo( const serverActions = useMemo(
() => ( () => (
<Dropdown alignRight> <Dropdown alignRight>
<Dropdown.Toggle className="dropdown-hidden" as={Button}> <Dropdown.Toggle className="hide-arrow" as={Button}>
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon> <FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
@ -87,7 +83,7 @@ const Header: FunctionComponent<Props> = () => {
<div className="header-icon px-3 m-0 d-none d-md-block"> <div className="header-icon px-3 m-0 d-none d-md-block">
<Image <Image
alt="brand" alt="brand"
src={logo} src="/static/logo64.png"
width="32" width="32"
height="32" height="32"
onClick={goHome} onClick={goHome}

View File

@ -1,3 +1,5 @@
import { useReduxStore } from "@/modules/redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "@/utilities";
import { import {
faBug, faBug,
faCircleNotch, faCircleNotch,
@ -10,9 +12,10 @@ import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconProps, FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome"; } from "@fortawesome/react-fontawesome";
import { useReduxStore } from "@redux/hooks/base"; import {
import React, { Fragment,
FunctionComponent, FunctionComponent,
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -27,8 +30,6 @@ import {
Tooltip, Tooltip,
} from "react-bootstrap"; } from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks"; import { useDidUpdate, useTimeoutWhen } from "rooks";
import { BuildKey, useIsArrayExtended } from "utilities";
import "./notification.scss";
enum State { enum State {
Idle, Idle,
@ -63,7 +64,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
} }
const NotificationCenter: FunctionComponent = () => { const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s); const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false); const [hasNew, setHasNew] = useState(false);
@ -115,7 +116,7 @@ const NotificationCenter: FunctionComponent = () => {
} }
}, [btnState]); }, [btnState]);
const content = useMemo<React.ReactNode>(() => { const content = useMemo<ReactNode>(() => {
const nodes: JSX.Element[] = []; const nodes: JSX.Element[] = [];
nodes.push( nodes.push(
@ -163,14 +164,14 @@ const NotificationCenter: FunctionComponent = () => {
}, [notifier.timestamp]); }, [notifier.timestamp]);
return ( return (
<React.Fragment> <Fragment>
<Dropdown <Dropdown
onClick={onToggleClick} onClick={onToggleClick}
className={`notification-btn ${hasNew ? "new-item" : ""}`} className={`notification-btn ${hasNew ? "new-item" : ""}`}
ref={dropdownRef} ref={dropdownRef}
alignRight alignRight
> >
<Dropdown.Toggle as={Button} className="dropdown-hidden"> <Dropdown.Toggle as={Button} className="hide-arrow">
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon> <FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu> <Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
@ -184,7 +185,7 @@ const NotificationCenter: FunctionComponent = () => {
); );
}} }}
</Overlay> </Overlay>
</React.Fragment> </Fragment>
); );
}; };

View File

@ -1,26 +1,23 @@
import Socketio from "@modules/socketio"; import { LoadingIndicator } from "@/components";
import { useNotification } from "@redux/hooks"; import ErrorBoundary from "@/components/ErrorBoundary";
import { useReduxStore } from "@redux/hooks/base"; import { useNotification } from "@/modules/redux/hooks";
import { LoadingIndicator, ModalProvider } from "components"; import { useReduxStore } from "@/modules/redux/hooks/base";
import Authentication from "pages/Authentication"; import SocketIO from "@/modules/socketio";
import LaunchError from "pages/LaunchError"; import LaunchError from "@/pages/LaunchError";
import React, { FunctionComponent, useEffect } from "react"; import Sidebar from "@/Sidebar";
import { Environment } from "@/utilities";
import { FunctionComponent, useEffect } from "react";
import { Row } from "react-bootstrap"; import { Row } from "react-bootstrap";
import { Route, Switch } from "react-router"; import { Navigate, Outlet } from "react-router-dom";
import { BrowserRouter, Redirect } from "react-router-dom";
import { useEffectOnceWhen } from "rooks"; 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"; import Header from "./Header";
// Sidebar Toggle const App: FunctionComponent = () => {
const { status } = useReduxStore((s) => s.site);
interface Props {} useEffect(() => {
SocketIO.initialize();
const App: FunctionComponent<Props> = () => { }, []);
const { status } = useReduxStore((s) => s);
const notify = useNotification("has-update", 10 * 1000); const notify = useNotification("has-update", 10 * 1000);
@ -36,7 +33,7 @@ const App: FunctionComponent<Props> = () => {
}, status === "initialized"); }, status === "initialized");
if (status === "unauthenticated") { if (status === "unauthenticated") {
return <Redirect to="/login"></Redirect>; return <Navigate to="/login"></Navigate>;
} else if (status === "uninitialized") { } else if (status === "uninitialized") {
return ( return (
<LoadingIndicator> <LoadingIndicator>
@ -54,31 +51,10 @@ const App: FunctionComponent<Props> = () => {
</Row> </Row>
<Row noGutters className="flex-nowrap"> <Row noGutters className="flex-nowrap">
<Sidebar></Sidebar> <Sidebar></Sidebar>
<ModalProvider> <Outlet></Outlet>
<Router></Router>
</ModalProvider>
</Row> </Row>
</ErrorBoundary> </ErrorBoundary>
); );
}; };
const MainRouter: FunctionComponent = () => { export default App;
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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View 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;

View File

@ -1,83 +1,318 @@
import { FunctionComponent } from "react"; import { useBadges } from "@/apis/hooks";
import { Redirect, Route, Switch, useHistory } from "react-router"; import App from "@/App";
import { useDidMount } from "rooks"; import Lazy from "@/components/Lazy";
import { BuildKey, ScrollToTop } from "utilities"; import { useEnabledStatus } from "@/modules/redux/hooks";
import { useNavigationItems } from "../Navigation"; import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import { Navigation } from "../Navigation/nav"; import BlacklistSeriesView from "@/pages/Blacklist/Series";
import { RouterEmptyPath } from "../pages/404"; 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 HistoryStats = lazy(() => import("@/pages/History/Statistics"));
const navItems = useNavigationItems(); const SystemStatusView = lazy(() => import("@/pages/System/Status"));
const Authentication = lazy(() => import("@/pages/Authentication"));
const NotFound = lazy(() => import("@/pages/404"));
const history = useHistory(); function useRoutes(): CustomRouteObject[] {
useDidMount(() => { const { data } = useBadges();
history.listen(() => { const { sonarr, radarr } = useEnabledStatus();
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop); 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 ( return (
<div className="d-flex flex-row flex-grow-1 main-router"> <RouterItemContext.Provider value={routes}>
<Switch> <BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
{navItems.map((v, idx) => { </RouterItemContext.Provider>
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>
); );
}; };
export default Router; export function useRouteItems() {
return useContext(RouterItemContext);
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>
);
};

14
frontend/src/Router/type.d.ts vendored Normal file
View 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;

View File

@ -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 { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions"; import clsx from "clsx";
import { useReduxAction, useReduxStore } from "@redux/hooks/base"; import {
import logo from "@static/logo64.png";
import React, {
createContext, createContext,
FunctionComponent, FunctionComponent,
useContext, useContext,
useEffect,
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
@ -18,229 +24,232 @@ import {
ListGroup, ListGroup,
ListGroupItem, ListGroupItem,
} from "react-bootstrap"; } from "react-bootstrap";
import { NavLink, useHistory, useRouteMatch } from "react-router-dom"; import {
import { BuildKey } from "utilities"; matchPath,
import { useGotoHomepage } from "utilities/hooks"; NavLink,
import { useNavigationItems } from "../Navigation"; RouteObject,
import { Navigation } from "../Navigation/nav"; useLocation,
import "./style.scss"; useNavigate,
} from "react-router-dom";
const SelectionContext = createContext<{ const Selection = createContext<{
selection: string | null; selection: string | null;
select: (selection: string | null) => void; select: (path: string | null) => void;
}>({ selection: null, select: () => {} }); }>({
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 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 showSidebar = useReduxAction(setSidebar);
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];
if (open) {
cls.push("open");
overlay.push("open");
}
const goHome = useGotoHomepage(); const goHome = useGotoHomepage();
const [selection, setSelection] = useState<string | null>(null); const routes = useRouteItems();
const { pathname } = useLocation();
useEffect(() => {
select(null);
}, [pathname]);
return ( return (
<SelectionContext.Provider <Selection.Provider value={{ selection, select }}>
value={{ selection: selection, select: setSelection }} <nav className={clsx("sidebar-container", { open: isShow })}>
>
<aside className={cls.join(" ")}>
<Container className="sidebar-title d-flex align-items-center d-md-none"> <Container className="sidebar-title d-flex align-items-center d-md-none">
<Image <Image
alt="brand" alt="brand"
src={logo} src="/static/logo64.png"
width="32" width="32"
height="32" height="32"
onClick={goHome} onClick={goHome}
className="cursor-pointer" className="cursor-pointer"
></Image> ></Image>
</Container> </Container>
<SidebarNavigation></SidebarNavigation> <ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
</aside> {routes.map((route, idx) => (
<RouteItem
key={BuildKey("nav", idx)}
parent="/"
route={route}
></RouteItem>
))}
</ListGroup>
</nav>
<div <div
className={overlay.join(" ")} className={clsx("sidebar-overlay", { open: isShow })}
onClick={() => changeSidebar(false)} onClick={() => showSidebar(false)}
></div> ></div>
</SelectionContext.Provider> </Selection.Provider>
); );
}; };
const SidebarNavigation: FunctionComponent = () => { const RouteItem: FunctionComponent<{
const navItems = useNavigationItems(); route: CustomRouteObject;
parent: string;
}> = ({ route, parent }) => {
const { children, name, path, icon, hidden, element } = route;
return ( const isValidated = useMemo(
<ListGroup variant="flush"> () =>
{navItems.map((v, idx) => { element !== undefined ||
if ("routes" in v) { children?.find((v) => v.index === true) !== undefined,
return ( [element, children]
<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 changeSidebar = useReduxAction(setSidebar); const { select } = useSelection();
const { selection, select } = useContext(SelectionContext); const navigate = useNavigate();
const match = useRouteMatch({ path }); const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const open = match !== null || selection === path;
const collapseBoxClass = useMemo( const badge = useBadgeValue(route);
() => `sidebar-collapse-box ${open ? "active" : ""}`,
[open]
);
const history = useHistory(); const isOpen = useIsActive(parent, route);
if (enabled === false) { if (hidden === true) {
return null; 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 ( return (
<NavLink <div className={clsx("sidebar-collapse-box", { active: isOpen })}>
activeClassName="sb-active" <ListGroupItem
className="list-group-item list-group-item-action sidebar-button" action
to={path} className={clsx("button", { active: isOpen })}
onClick={() => changeSidebar(false)} onClick={() => {
> LOG("info", "clicked", link);
<SidebarContent
icon={icon} if (isValidated) {
name={name} navigate(link);
badge={computedBadge} }
></SidebarContent>
</NavLink> 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 { } else {
return null; return <>{elements}</>;
} }
} } else {
return (
return ( <NavLink
<div className={collapseBoxClass}> to={link}
<ListGroupItem className={({ isActive }) =>
action clsx("list-group-item list-group-item-action button sb-collapse", {
className="sidebar-button" active: isActive,
onClick={() => { })
if (open) { }
select(null);
} else {
select(path);
}
if (component !== undefined) {
history.push(path);
}
}}
> >
<SidebarContent <RouteItemContent
name={name ?? link}
icon={icon} icon={icon}
name={name} badge={badge}
badge={computedBadge} ></RouteItemContent>
></SidebarContent> </NavLink>
</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>
);
}; };
interface SidebarChildProps { interface ItemComponentProps {
parent: string; name: string;
icon?: IconDefinition;
badge?: number;
} }
const SidebarChild: FunctionComponent< const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
SidebarChildProps & Navigation.RouteWithoutChild icon,
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => { name,
const changeSidebar = useReduxAction(setSidebar); badge,
const { select } = useContext(SelectionContext); }) => {
if (enabled === false || routeOnly === true) {
return null;
}
return ( return (
<NavLink <>
activeClassName="sb-active" {icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
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>
)}
<span className="d-flex flex-grow-1 justify-content-between"> <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> </span>
</React.Fragment> </>
); );
}; };

View File

@ -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);
});

View File

@ -36,7 +36,6 @@ export function useMovies() {
[QueryKeys.Movies, QueryKeys.All], [QueryKeys.Movies, QueryKeys.All],
() => api.movies.movies(), () => api.movies.movies(),
{ {
enabled: false,
onSuccess: (data) => { onSuccess: (data) => {
cacheMovies(client, data); cacheMovies(client, data);
}, },

View File

@ -36,7 +36,6 @@ export function useSeries() {
[QueryKeys.Series, QueryKeys.All], [QueryKeys.Series, QueryKeys.All],
() => api.series.series(), () => api.series.series(),
{ {
enabled: false,
onSuccess: (data) => { onSuccess: (data) => {
cacheSeries(client, data); cacheSeries(client, data);
}, },

View File

@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { setUnauthenticated } from "../../@redux/actions"; import { setUnauthenticated } from "../../modules/redux/actions";
import store from "../../@redux/store"; import store from "../../modules/redux/store";
import { QueryKeys } from "../queries/keys"; import { QueryKeys } from "../queries/keys";
import api from "../raw"; import api from "../raw";

View File

@ -1,6 +1,6 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios"; import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { setUnauthenticated } from "../../@redux/actions"; import { setUnauthenticated } from "../../modules/redux/actions";
import { AppDispatch } from "../../@redux/store"; import { AppDispatch } from "../../modules/redux/store";
import { Environment, isProdEnv } from "../../utilities"; import { Environment, isProdEnv } from "../../utilities";
class BazarrClient { class BazarrClient {
axios!: AxiosInstance; axios!: AxiosInstance;

View File

@ -1,3 +1,5 @@
import { GetItemId } from "@/utilities";
import { usePageSize } from "@/utilities/storage";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
QueryKey, QueryKey,
@ -5,8 +7,6 @@ import {
useQueryClient, useQueryClient,
UseQueryResult, UseQueryResult,
} from "react-query"; } from "react-query";
import { GetItemId } from "utilities";
import { usePageSize } from "utilities/storage";
import { QueryKeys } from "./keys"; import { QueryKeys } from "./keys";
export type UsePaginationQueryResult<T extends object> = UseQueryResult< export type UsePaginationQueryResult<T extends object> = UseQueryResult<

View File

@ -10,7 +10,7 @@ class BaseApi {
private createFormdata(object?: LooseObject) { private createFormdata(object?: LooseObject) {
if (object) { if (object) {
let form = new FormData(); const form = new FormData();
for (const key in object) { for (const key in object) {
const data = object[key]; 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 }); const response = await client.axios.get<T>(this.prefix + path, { params });
return response.data; return response.data;
} }
@ -38,7 +38,7 @@ class BaseApi {
protected post<T = void>( protected post<T = void>(
path: string, path: string,
formdata?: LooseObject, formdata?: LooseObject,
params?: any params?: LooseObject
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata); const form = this.createFormdata(formdata);
return client.axios.post(this.prefix + path, form, { params }); return client.axios.post(this.prefix + path, form, { params });
@ -47,7 +47,7 @@ class BaseApi {
protected patch<T = void>( protected patch<T = void>(
path: string, path: string,
formdata?: LooseObject, formdata?: LooseObject,
params?: any params?: LooseObject
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata); const form = this.createFormdata(formdata);
return client.axios.patch(this.prefix + path, form, { params }); return client.axios.patch(this.prefix + path, form, { params });
@ -55,8 +55,8 @@ class BaseApi {
protected delete<T = void>( protected delete<T = void>(
path: string, path: string,
formdata?: any, formdata?: LooseObject,
params?: any params?: LooseObject
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata); const form = this.createFormdata(formdata);
return client.axios.delete(this.prefix + path, { params, data: form }); return client.axios.delete(this.prefix + path, { params, data: form });

View File

@ -5,7 +5,7 @@ class ProviderApi extends BaseApi {
super("/providers"); super("/providers");
} }
async providers(history: boolean = false) { async providers(history = false) {
const response = await this.get<DataWrapper<System.Provider[]>>("", { const response = await this.get<DataWrapper<System.Provider[]>>("", {
history, history,
}); });

View File

@ -34,7 +34,7 @@ class SystemApi extends BaseApi {
await this.post("/settings", data); await this.post("/settings", data);
} }
async languages(history: boolean = false) { async languages(history = false) {
const response = await this.get<Language.Server[]>("/languages", { const response = await this.get<Language.Server[]>("/languages", {
history, history,
}); });

View File

@ -11,7 +11,7 @@ type UrlTestResponse =
}; };
class RequestUtils { class RequestUtils {
async urlTest(protocol: string, url: string, params?: any) { async urlTest(protocol: string, url: string, params?: LooseObject) {
try { try {
const result = await client.axios.get<UrlTestResponse>( const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/system/status`, `../test/${protocol}/${url}api/system/status`,

View File

@ -1,12 +1,12 @@
import UIError from "pages/UIError"; import UIError from "@/pages/UIError";
import React from "react"; import { Component } from "react";
interface State { interface State {
error: Error | null; error: Error | null;
} }
class ErrorBoundary extends React.Component<{}, State> { class ErrorBoundary extends Component<object, State> {
constructor(props: {}) { constructor(props: object) {
super(props); super(props);
this.state = { error: null }; this.state = { error: null };
} }

View File

@ -1,3 +1,8 @@
import { BuildKey, isMovie } from "@/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import { import {
faBookmark as farBookmark, faBookmark as farBookmark,
faClone as fasClone, faClone as fasClone,
@ -12,7 +17,7 @@ import {
IconDefinition, IconDefinition,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react"; import { FunctionComponent, useMemo } from "react";
import { import {
Badge, Badge,
Col, Col,
@ -22,12 +27,7 @@ import {
Popover, Popover,
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { BuildKey, isMovie } from "utilities"; import Language from "./bazarr/Language";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "utilities/languages";
import { LanguageText } from ".";
interface Props { interface Props {
item: Item.Base; item: Item.Base;
@ -102,7 +102,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
icon={faLanguage} icon={faLanguage}
desc="Language" desc="Language"
> >
<LanguageText long text={v}></LanguageText> <Language.Text long value={v}></Language.Text>
</DetailBadge> </DetailBadge>
)) ))
); );

View File

@ -1,5 +1,5 @@
import { Selector, SelectorProps } from "components"; import { Selector, SelectorOption, SelectorProps } from "@/components";
import React, { useMemo } from "react"; import { useMemo } from "react";
interface Props { interface Props {
options: readonly Language.Info[]; options: readonly Language.Info[];

View 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;

View 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;

View File

@ -1,6 +1,6 @@
import { useServerSearch } from "apis/hooks"; import { useServerSearch } from "@/apis/hooks";
import { uniqueId } from "lodash"; import { uniqueId } from "lodash";
import React, { import {
FunctionComponent, FunctionComponent,
useCallback, useCallback,
useEffect, useEffect,
@ -8,7 +8,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Dropdown, Form } from "react-bootstrap"; import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router"; import { useNavigate } from "react-router-dom";
import { useThrottle } from "rooks"; import { useThrottle } from "rooks";
function useSearch(query: string) { function useSearch(query: string) {
@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({
const results = useSearch(query); const results = useSearch(query);
const history = useHistory(); const navigate = useNavigate();
const clear = useCallback(() => { const clear = useCallback(() => {
setDisplay(""); setDisplay("");
@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({
onSelect={(link) => { onSelect={(link) => {
if (link) { if (link) {
clear(); clear();
history.push(link); navigate(link);
} }
}} }}
> >

View File

@ -4,9 +4,10 @@ import {
faTimes, faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { import {
FunctionComponent, FunctionComponent,
PropsWithChildren, PropsWithChildren,
ReactElement,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@ -18,7 +19,7 @@ import { LoadingIndicator } from ".";
interface QueryOverlayProps { interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>; result: UseQueryResult<unknown, unknown>;
children: React.ReactElement; children: ReactElement;
} }
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
const [item, setItem] = useState<T | null>(null); const [item, setItem] = useState<T | null>(null);
useEffect(() => { useEffect(() => {
promise() promise().then(setItem);
.then(setItem)
.catch(() => {});
}, [promise]); }, [promise]);
if (item === null) { if (item === null) {

View 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;

View 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;

View File

@ -1,7 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-common-types"; import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, MouseEvent } from "react"; import { FunctionComponent, MouseEvent } from "react";
import { Badge, Button, ButtonProps } from "react-bootstrap"; import { Badge, Button, ButtonProps } from "react-bootstrap";
export const ActionBadge: FunctionComponent<{ export const ActionBadge: FunctionComponent<{
@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
}) => { }) => {
const showText = alwaysShowText === true || loading !== true; const showText = alwaysShowText === true || loading !== true;
return ( return (
<React.Fragment> <>
<FontAwesomeIcon <FontAwesomeIcon
style={{ width: "1rem" }} style={{ width: "1rem" }}
icon={loading ? faCircleNotch : icon} icon={loading ? faCircleNotch : icon}
@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
{children && showText ? ( {children && showText ? (
<span className="ml-2 font-weight-bold">{children}</span> <span className="ml-2 font-weight-bold">{children}</span>
) : null} ) : null}
</React.Fragment> </>
); );
}; };

View File

@ -1,7 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faSpinner } from "@fortawesome/free-solid-svg-icons"; import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { import {
FunctionComponent, FunctionComponent,
MouseEvent, MouseEvent,
PropsWithChildren, 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; promise: T;
onSuccess?: (item: PromiseType<ReturnType<T>>) => void; onSuccess?: (item: R) => void;
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">; } & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
export function ContentHeaderAsyncButton<T extends () => Promise<any>>( export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
props: PropsWithChildren<CHAsyncButtonProps<T>> props: PropsWithChildren<CHAsyncButtonProps<R, T>>
): JSX.Element { ): JSX.Element {
const { promise, onSuccess, ...button } = props; const { promise, onSuccess, ...button } = props;

View File

@ -1,4 +1,4 @@
import React, { FunctionComponent } from "react"; import { FunctionComponent } from "react";
type GroupPosition = "start" | "end"; type GroupPosition = "start" | "end";
interface GroupProps { interface GroupProps {

View File

@ -1,8 +1,7 @@
import React, { FunctionComponent, useMemo } from "react"; import { FunctionComponent, ReactNode, useMemo } from "react";
import { Row } from "react-bootstrap"; import { Row } from "react-bootstrap";
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button"; import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
import ContentHeaderGroup from "./Group"; import ContentHeaderGroup from "./Group";
import "./style.scss";
interface Props { interface Props {
scroll?: boolean; scroll?: boolean;
@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => {
return rowCls.join(" "); return rowCls.join(" ");
}, [scroll, className]); }, [scroll, className]);
let childItem: React.ReactNode; let childItem: ReactNode;
if (scroll !== false) { if (scroll !== false) {
childItem = ( childItem = (

View File

@ -11,7 +11,7 @@ import {
FontAwesomeIconProps, FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome"; } from "@fortawesome/react-fontawesome";
import { isNull, isUndefined } from "lodash"; import { isNull, isUndefined } from "lodash";
import React, { FunctionComponent, useMemo } from "react"; import { FunctionComponent, ReactElement } from "react";
import { import {
OverlayTrigger, OverlayTrigger,
OverlayTriggerProps, 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 { interface TextPopoverProps {
children: React.ReactElement<any, any>; children: ReactElement;
text: string | undefined | null; text: string | undefined | null;
placement?: OverlayTriggerProps["placement"]; placement?: OverlayTriggerProps["placement"];
delay?: number; delay?: number;

View File

@ -1,4 +1,4 @@
import React, { import {
FocusEvent, FocusEvent,
FunctionComponent, FunctionComponent,
KeyboardEvent, KeyboardEvent,
@ -8,7 +8,6 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import "./chip.scss";
const SplitKeys = ["Tab", "Enter", " ", ",", ";"]; const SplitKeys = ["Tab", "Enter", " ", ",", ";"];

View File

@ -1,8 +1,9 @@
import { useFileSystem } from "@/apis/hooks";
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons"; import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
import { faReply } from "@fortawesome/free-solid-svg-icons"; import { faReply } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useFileSystem } from "apis/hooks"; import {
import React, { ChangeEvent,
FunctionComponent, FunctionComponent,
useEffect, useEffect,
useMemo, useMemo,
@ -147,7 +148,7 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
placeholder="Click to start" placeholder="Click to start"
type="text" type="text"
value={text} value={text}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value); setText(e.currentTarget.value);
}} }}
ref={input} ref={input}

View File

@ -1,4 +1,4 @@
import React, { import {
ChangeEvent, ChangeEvent,
FunctionComponent, FunctionComponent,
useEffect, useEffect,

View File

@ -1,8 +1,22 @@
import { isArray } from "lodash"; import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import { FocusEvent, useCallback, useMemo, useRef } from "react";
import Select from "react-select"; import Select, { GroupBase, OnChangeValue } from "react-select";
import { SelectComponents } from "react-select/dist/declarations/src/components"; 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> { export interface SelectorProps<T, M extends boolean> {
className?: string; className?: string;
@ -13,11 +27,13 @@ export interface SelectorProps<T, M extends boolean> {
loading?: boolean; loading?: boolean;
multiple?: M; multiple?: M;
onChange?: (k: SelectorValueType<T, M>) => void; onChange?: (k: SelectorValueType<T, M>) => void;
onFocus?: (e: React.FocusEvent<HTMLElement>) => void; onFocus?: (e: FocusEvent<HTMLElement>) => void;
label?: (item: T) => string; label?: (item: T) => string;
defaultValue?: SelectorValueType<T, M>; defaultValue?: SelectorValueType<T, M>;
value?: 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>( export function Selector<T = string, M extends boolean = false>(
@ -39,34 +55,45 @@ export function Selector<T = string, M extends boolean = false>(
value, value,
} = props; } = props;
const nameFromItems = useCallback( const labelRef = useRef(label);
const getName = useCallback(
(item: T) => { (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] [options]
); );
// TODO: Force as any
const wrapper = useCallback( const wrapper = useCallback(
(value: SelectorValueType<T, M> | undefined | null): any => { (
if (value !== null && value !== undefined) { value: SelectorValueType<T, M> | undefined | null
if (multiple) { ):
| 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) => ({ return (value as SelectorValueType<T, true>).map((v) => ({
label: label ? label(v) : nameFromItems(v) ?? "Unknown", label: getName(v),
value: v, value: v,
})); }));
} else { } else {
const v = value as T; const v = value as T;
return { return {
label: label ? label(v) : nameFromItems(v) ?? "Unknown", label: getName(v),
value: v, value: v,
}; };
} }
} }
return value;
}, },
[label, multiple, nameFromItems] [multiple, getName]
); );
const defaultWrapper = useMemo( const defaultWrapper = useMemo(
@ -89,21 +116,23 @@ export function Selector<T = string, M extends boolean = false>(
isDisabled={disabled} isDisabled={disabled}
options={options} options={options}
components={components} components={components}
className={`custom-selector w-100 ${className ?? ""}`} className={clsx("custom-selector w-100", className)}
classNamePrefix="selector" classNamePrefix="selector"
onFocus={onFocus} onFocus={onFocus}
onChange={(v: SelectorOption<T>[]) => { onChange={(newValue) => {
if (onChange) { if (onChange) {
let res: T | T[] | null = null; if (multiple === true) {
if (isArray(v)) { const values = (
res = (v as ReadonlyArray<SelectorOption<T>>).map( newValue as OnChangeValue<SelectorOption<T>, true>
(val) => val.value ).map((v) => v.value) as ReadonlyArray<T>;
);
onChange(values as SelectorValueType<T, M>);
} else { } 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> ></Select>

View File

@ -1,7 +1,6 @@
import RcSlider from "rc-slider"; import RcSlider from "rc-slider";
import "rc-slider/assets/index.css"; import "rc-slider/assets/index.css";
import React, { FunctionComponent, useMemo, useState } from "react"; import { FunctionComponent, useMemo, useState } from "react";
import "./slider.scss";
type TooltipsOptions = boolean | "Always"; type TooltipsOptions = boolean | "Always";

View File

@ -1,6 +1,6 @@
import { faFileExcel } from "@fortawesome/free-solid-svg-icons"; import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { AsyncButton } from ".."; import { AsyncButton } from "..";
interface Props { interface Props {

View File

@ -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 { Modal } from "react-bootstrap";
import { useModalInformation } from "./hooks";
export interface BaseModalProps { export interface BaseModalProps {
modalKey: string; modalKey: string;
@ -11,32 +12,34 @@ export interface BaseModalProps {
} }
export const BaseModal: FunctionComponent<BaseModalProps> = (props) => { 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 [needExit, setExit] = useState(false);
const { isShow, closeModal } = useModalInformation(modalKey); const { hide: hideModal } = useModalControl();
const showIndex = useIsShowed(modalKey);
const closeable = props.closeable !== false; const isShowed = showIndex !== -1;
const hide = useCallback(() => { const hide = useCallback(() => {
setExit(true); setExit(true);
}, []); }, []);
const exit = useCallback(() => { const exit = useCallback(() => {
if (isShow) { if (isShowed) {
closeModal(modalKey); hideModal(modalKey);
} }
setExit(false); setExit(false);
}, [closeModal, modalKey, isShow]); }, [isShowed, hideModal, modalKey]);
return ( return (
<Modal <Modal
centered centered
size={size} size={size}
show={isShow && !needExit} show={isShowed && !needExit}
onHide={hide} onHide={hide}
onExited={exit} onExited={exit}
backdrop={closeable ? undefined : "static"} backdrop={closeable ? undefined : "static"}
className={clsx(`index-${showIndex}`)}
backdropClassName={clsx(`index-${showIndex}`)}
> >
<Modal.Header closeButton={closeable}>{title}</Modal.Header> <Modal.Header closeButton={closeable}>{title}</Modal.Header>
<Modal.Body>{children}</Modal.Body> <Modal.Body>{children}</Modal.Body>

View File

@ -3,24 +3,19 @@ import {
useEpisodeHistory, useEpisodeHistory,
useMovieAddBlacklist, useMovieAddBlacklist,
useMovieHistory, useMovieHistory,
} from "apis/hooks"; } from "@/apis/hooks";
import React, { FunctionComponent, useMemo } from "react"; import { usePayload } from "@/modules/redux/hooks/modal";
import { FunctionComponent, useMemo } from "react";
import { Column } from "react-table"; import { Column } from "react-table";
import { import { HistoryIcon, PageTable, QueryOverlay, TextPopover } from "..";
HistoryIcon, import Language from "../bazarr/Language";
LanguageText,
PageTable,
QueryOverlay,
TextPopover,
} from "..";
import { BlacklistButton } from "../inputs/blacklist"; import { BlacklistButton } from "../inputs/blacklist";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalPayload } from "./hooks";
export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => { export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
const { ...modal } = props; const { ...modal } = props;
const movie = useModalPayload<Item.Movie>(modal.modalKey); const movie = usePayload<Item.Movie>(modal.modalKey);
const history = useMovieHistory(movie?.radarrId); const history = useMovieHistory(movie?.radarrId);
@ -40,7 +35,7 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
accessor: "language", accessor: "language",
Cell: ({ value }) => { Cell: ({ value }) => {
if (value) { if (value) {
return <LanguageText text={value} long></LanguageText>; return <Language.Text value={value} long></Language.Text>;
} else { } else {
return null; return null;
} }
@ -101,12 +96,10 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
); );
}; };
interface EpisodeHistoryProps {} export const EpisodeHistoryModal: FunctionComponent<BaseModalProps> = (
props
export const EpisodeHistoryModal: FunctionComponent< ) => {
BaseModalProps & EpisodeHistoryProps const episode = usePayload<Item.Episode>(props.modalKey);
> = (props) => {
const episode = useModalPayload<Item.Episode>(props.modalKey);
const history = useEpisodeHistory(episode?.sonarrEpisodeId); const history = useEpisodeHistory(episode?.sonarrEpisodeId);
@ -126,7 +119,7 @@ export const EpisodeHistoryModal: FunctionComponent<
accessor: "language", accessor: "language",
Cell: ({ value }) => { Cell: ({ value }) => {
if (value) { if (value) {
return <LanguageText text={value} long></LanguageText>; return <Language.Text value={value} long></Language.Text>;
} else { } else {
return null; return null;
} }

View File

@ -1,11 +1,11 @@
import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks"; import { useIsAnyActionRunning, useLanguageProfiles } from "@/apis/hooks";
import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; 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 { Container, Form } from "react-bootstrap";
import { UseMutationResult } from "react-query"; import { UseMutationResult } from "react-query";
import { GetItemId } from "utilities"; import { AsyncButton, Selector, SelectorOption } from "..";
import { AsyncButton, Selector } from "../";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
interface Props { interface Props {
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>; mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
@ -16,9 +16,8 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const { data: profiles } = useLanguageProfiles(); const { data: profiles } = useLanguageProfiles();
const { payload, closeModal } = useModalInformation<Item.Base>( const payload = usePayload<Item.Base>(modal.modalKey);
modal.modalKey const { hide } = useModalControl();
);
const { mutateAsync, isLoading } = mutation; const { mutateAsync, isLoading } = mutation;
@ -57,7 +56,9 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
return null; return null;
} }
}} }}
onSuccess={() => closeModal()} onSuccess={() => {
hide();
}}
> >
Save Save
</AsyncButton> </AsyncButton>

View File

@ -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 { import {
faCaretDown, faCaretDown,
faCheck, faCheck,
@ -6,15 +10,7 @@ import {
faTimes, faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { dispatchTask } from "@modules/task"; import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { createTask } from "@modules/task/utilities";
import { useEpisodesProvider, useMoviesProvider } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { import {
Badge, Badge,
Button, Button,
@ -26,16 +22,8 @@ import {
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Column } from "react-table"; import { Column } from "react-table";
import { GetItemId, isMovie } from "utilities"; import { BaseModal, BaseModalProps, LoadingIndicator, PageTable } from "..";
import { import Language from "../bazarr/Language";
BaseModal,
BaseModalProps,
LanguageText,
LoadingIndicator,
PageTable,
useModalPayload,
} from "..";
import "./msmStyle.scss";
type SupportType = Item.Movie | Item.Episode; type SupportType = Item.Movie | Item.Episode;
@ -48,7 +36,7 @@ export function ManualSearchModal<T extends SupportType>(
) { ) {
const { download, ...modal } = props; const { download, ...modal } = props;
const item = useModalPayload<T>(modal.modalKey); const item = usePayload<T>(modal.modalKey);
const [episodeId, setEpisodeId] = useState<number | undefined>(undefined); const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
const [radarrId, setRadarrId] = useState<number | undefined>(undefined); const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
@ -95,7 +83,7 @@ export function ManualSearchModal<T extends SupportType>(
}; };
return ( return (
<Badge variant="secondary"> <Badge variant="secondary">
<LanguageText text={lang}></LanguageText> <Language.Text value={lang}></Language.Text>
</Badge> </Badge>
); );
}, },
@ -194,12 +182,12 @@ export function ManualSearchModal<T extends SupportType>(
onClick={() => { onClick={() => {
if (!item) return; if (!item) return;
const id = GetItemId(item); createAndDispatchTask(
const task = createTask(item.title, id, download, item, result); item.title,
dispatchTask( "download-subtitles",
"Downloading subtitles...", download,
[task], item,
"Downloading..." result
); );
}} }}
> >
@ -226,14 +214,14 @@ export function ManualSearchModal<T extends SupportType>(
return <LoadingIndicator animation="grow"></LoadingIndicator>; return <LoadingIndicator animation="grow"></LoadingIndicator>;
} else { } else {
return ( return (
<React.Fragment> <>
<p className="mb-3 small">{item?.path ?? ""}</p> <p className="mb-3 small">{item?.path ?? ""}</p>
<PageTable <PageTable
emptyText="No Result" emptyText="No Result"
columns={columns} columns={columns}
data={results} data={results}
></PageTable> ></PageTable>
</React.Fragment> </>
); );
} }
}; };

View File

@ -1,32 +1,27 @@
import { dispatchTask } from "@modules/task"; import { useMovieSubtitleModification } from "@/apis/hooks";
import { createTask } from "@modules/task/utilities"; import { usePayload } from "@/modules/redux/hooks/modal";
import { useMovieSubtitleModification } from "apis/hooks"; import { createTask, dispatchTask } from "@/modules/task/utilities";
import React, { FunctionComponent, useCallback } from "react";
import { import {
useLanguageProfileBy, useLanguageProfileBy,
useProfileItemsToLanguages, useProfileItemsToLanguages,
} from "utilities/languages"; } from "@/utilities/languages";
import { FunctionComponent, useCallback } from "react";
import { BaseModalProps } from "./BaseModal"; import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, { import SubtitleUploadModal, {
PendingSubtitle, PendingSubtitle,
Validator, Validator,
} from "./SubtitleUploadModal"; } from "./SubtitleUploadModal";
interface Payload {}
export const TaskGroupName = "Uploading Subtitles...";
const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => { const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
const modal = props; const modal = props;
const { payload } = useModalInformation<Item.Movie>(modal.modalKey); const payload = usePayload<Item.Movie>(modal.modalKey);
const profile = useLanguageProfileBy(payload?.profileId); const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile); const availableLanguages = useProfileItemsToLanguages(profile);
const update = useCallback(async (list: PendingSubtitle<Payload>[]) => { const update = useCallback(async (list: PendingSubtitle<unknown>[]) => {
return list; return list;
}, []); }, []);
@ -34,7 +29,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
upload: { mutateAsync }, upload: { mutateAsync },
} = useMovieSubtitleModification(); } = useMovieSubtitleModification();
const validate = useCallback<Validator<Payload>>( const validate = useCallback<Validator<unknown>>(
(item) => { (item) => {
if (item.language === null) { if (item.language === null) {
return { return {
@ -59,7 +54,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
); );
const upload = useCallback( const upload = useCallback(
(items: PendingSubtitle<Payload>[]) => { (items: PendingSubtitle<unknown>[]) => {
if (payload === null) { if (payload === null) {
return; return;
} }
@ -71,18 +66,22 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
.map((v) => { .map((v) => {
const { file, language, forced, hi } = 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, radarrId,
form: { form: {
file, file,
forced, forced,
hi, hi,
language: language!.code2, language: language.code2,
}, },
}); });
}); });
dispatchTask(TaskGroupName, tasks, "Uploading..."); dispatchTask(tasks, "upload-subtitles");
}, },
[mutateAsync, payload] [mutateAsync, payload]
); );

View File

@ -1,18 +1,18 @@
import { dispatchTask } from "@modules/task"; import { useEpisodeSubtitleModification } from "@/apis/hooks";
import { createTask } from "@modules/task/utilities"; import api from "@/apis/raw";
import { useEpisodeSubtitleModification } from "apis/hooks"; import { usePayload } from "@/modules/redux/hooks/modal";
import api from "apis/raw"; import { createTask, dispatchTask } from "@/modules/task/utilities";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import { import {
useLanguageProfileBy, useLanguageProfileBy,
useProfileItemsToLanguages, useProfileItemsToLanguages,
} from "utilities/languages"; } from "@/utilities/languages";
import { Selector } from "../inputs"; import { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import { Selector, SelectorOption } from "../inputs";
import { BaseModalProps } from "./BaseModal"; import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, { import SubtitleUploadModal, {
PendingSubtitle, PendingSubtitle,
useRowMutation,
Validator, Validator,
} from "./SubtitleUploadModal"; } from "./SubtitleUploadModal";
@ -24,13 +24,11 @@ interface SeriesProps {
episodes: readonly Item.Episode[]; episodes: readonly Item.Episode[];
} }
export const TaskGroupName = "Uploading Subtitles...";
const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
episodes, episodes,
...modal ...modal
}) => { }) => {
const { payload } = useModalInformation<Item.Series>(modal.modalKey); const payload = usePayload<Item.Series>(modal.modalKey);
const profile = useLanguageProfileBy(payload?.profileId); const profile = useLanguageProfileBy(payload?.profileId);
@ -98,9 +96,19 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
const tasks = items const tasks = items
.filter((v) => v.payload.instance !== undefined) .filter((v) => v.payload.instance !== undefined)
.map((v) => { .map((v) => {
const { hi, forced, payload, language } = v; const {
const { code2 } = language!; hi,
const { sonarrEpisodeId: episodeId } = payload.instance!; 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 = { const form: FormType.UploadSubtitle = {
file: v.file, file: v.file,
@ -109,14 +117,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
forced: forced, forced: forced,
}; };
return createTask(v.file.name, episodeId, mutateAsync, { return createTask(v.file.name, mutateAsync, {
seriesId, seriesId,
episodeId, episodeId,
form, form,
}); });
}); });
dispatchTask(TaskGroupName, tasks, "Uploading subtitles..."); dispatchTask(tasks, "upload-subtitles");
}, },
[mutateAsync, payload] [mutateAsync, payload]
); );
@ -128,29 +136,26 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
Header: "Episode", Header: "Episode",
accessor: "payload", accessor: "payload",
className: "vw-1", className: "vw-1",
Cell: ({ value, row, update }) => { Cell: ({ value, row }) => {
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({ const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
label: `(${ep.season}x${ep.episode}) ${ep.title}`, label: `(${ep.season}x${ep.episode}) ${ep.title}`,
value: ep, value: ep,
})); }));
const change = useCallback( const mutate = useRowMutation();
(ep: Nullable<Item.Episode>) => {
if (ep) {
const newInfo = { ...row.original };
newInfo.payload.instance = ep;
update && update(row, newInfo);
}
},
[row, update]
);
return ( return (
<Selector <Selector
disabled={row.original.state === "fetching"} disabled={row.original.state === "fetching"}
options={options} options={options}
value={value.instance} 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> ></Selector>
); );
}, },

View File

@ -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 { import {
faClock, faClock,
faCode, faCode,
@ -14,10 +20,8 @@ import {
faTextHeight, faTextHeight,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { dispatchTask } from "@modules/task"; import {
import { createTask } from "@modules/task/utilities"; ChangeEventHandler,
import { useSubtitleAction } from "apis/hooks";
import React, {
FunctionComponent, FunctionComponent,
useCallback, useCallback,
useMemo, useMemo,
@ -32,22 +36,16 @@ import {
InputGroup, InputGroup,
} from "react-bootstrap"; } from "react-bootstrap";
import { Column, useRowSelect } from "react-table"; import { Column, useRowSelect } from "react-table";
import { isMovie, submodProcessColor } from "utilities";
import { useEnabledLanguages } from "utilities/languages";
import { log } from "utilities/logger";
import { import {
ActionButton, ActionButton,
ActionButtonItem, ActionButtonItem,
LanguageSelector, LanguageSelector,
LanguageText,
Selector, Selector,
SimpleTable, SimpleTable,
useModalPayload,
useShowModal,
} from ".."; } from "..";
import Language from "../bazarr/Language";
import { useCustomSelection } from "../tables/plugins"; import { useCustomSelection } from "../tables/plugins";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
import { availableTranslation, colorOptions } from "./toolOptions"; import { availableTranslation, colorOptions } from "./toolOptions";
type SupportType = Item.Episode | Item.Movie; type SupportType = Item.Episode | Item.Movie;
@ -119,18 +117,15 @@ const FrameRateModal: FunctionComponent<BaseModalProps & ToolModalProps> = (
const submit = useCallback(() => { const submit = useCallback(() => {
if (canSave) { if (canSave) {
const action = submodProcessFrameRate(from!, to!); const action = submodProcessFrameRate(from, to);
process(action); process(action);
} }
}, [canSave, from, to, process]); }, [canSave, from, to, process]);
const footer = useMemo( const footer = (
() => ( <Button disabled={!canSave} onClick={submit}>
<Button disabled={!canSave} onClick={submit}> Save
Save </Button>
</Button>
),
[submit, canSave]
); );
return ( return (
@ -176,8 +171,8 @@ const AdjustTimesModal: FunctionComponent<BaseModalProps & ToolModalProps> = (
]); ]);
const updateOffset = useCallback( const updateOffset = useCallback(
(idx: number) => { (idx: number): ChangeEventHandler<HTMLInputElement> => {
return (e: any) => { return (e) => {
let value = parseFloat(e.currentTarget.value); let value = parseFloat(e.currentTarget.value);
if (isNaN(value)) { if (isNaN(value)) {
value = 0; value = 0;
@ -293,24 +288,22 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
); );
}; };
const TaskGroupName = "Modifying Subtitles";
const CanSelectSubtitle = (item: TableColumnType) => { const CanSelectSubtitle = (item: TableColumnType) => {
return item.path.endsWith(".srt"); return item.path.endsWith(".srt");
}; };
const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
const payload = useModalPayload<SupportType[]>(props.modalKey); const payload = usePayload<SupportType[]>(props.modalKey);
const [selections, setSelections] = useState<TableColumnType[]>([]); const [selections, setSelections] = useState<TableColumnType[]>([]);
const closeModal = useCloseModal(); const { hide } = useModalControl();
const { mutateAsync } = useSubtitleAction(); const { mutateAsync } = useSubtitleAction();
const process = useCallback( const process = useCallback(
(action: string, override?: Partial<FormType.ModifySubtitle>) => { (action: string, override?: Partial<FormType.ModifySubtitle>) => {
log("info", "executing action", action); LOG("info", "executing action", action);
closeModal(props.modalKey); hide(props.modalKey);
const tasks = selections.map((s) => { const tasks = selections.map((s) => {
const form: FormType.ModifySubtitle = { const form: FormType.ModifySubtitle = {
@ -320,15 +313,15 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
path: s.path, path: s.path,
...override, ...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>[]>( const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
() => [ () => [
@ -337,7 +330,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
accessor: "_language", accessor: "_language",
Cell: ({ value }) => ( Cell: ({ value }) => (
<Badge variant="secondary"> <Badge variant="secondary">
<LanguageText text={value} long></LanguageText> <Language.Text value={value} long></Language.Text>
</Badge> </Badge>
), ),
}, },
@ -345,8 +338,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
id: "file", id: "file",
Header: "File", Header: "File",
accessor: "path", accessor: "path",
Cell: (row) => { Cell: ({ value }) => {
const path = row.value!; const path = value;
let idx = path.lastIndexOf("/"); let idx = path.lastIndexOf("/");
@ -431,29 +424,28 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
Reverse RTL Reverse RTL
</ActionButtonItem> </ActionButtonItem>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item onSelect={() => showModal("add-color")}> <Dropdown.Item onSelect={() => show("add-color")}>
<ActionButtonItem icon={faPaintBrush}>Add Color</ActionButtonItem> <ActionButtonItem icon={faPaintBrush}>Add Color</ActionButtonItem>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item onSelect={() => showModal("change-frame-rate")}> <Dropdown.Item onSelect={() => show("change-frame-rate")}>
<ActionButtonItem icon={faFilm}>Change Frame Rate</ActionButtonItem> <ActionButtonItem icon={faFilm}>Change Frame Rate</ActionButtonItem>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item onSelect={() => showModal("adjust-times")}> <Dropdown.Item onSelect={() => show("adjust-times")}>
<ActionButtonItem icon={faClock}>Adjust Times</ActionButtonItem> <ActionButtonItem icon={faClock}>Adjust Times</ActionButtonItem>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item onSelect={() => showModal("translate-sub")}> <Dropdown.Item onSelect={() => show("translate-sub")}>
<ActionButtonItem icon={faLanguage}>Translate</ActionButtonItem> <ActionButtonItem icon={faLanguage}>Translate</ActionButtonItem>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
), ),
[showModal, selections.length, process] [selections.length, process, show]
); );
return ( return (
<React.Fragment> <>
<BaseModal title={"Subtitle Tools"} footer={footer} {...props}> <BaseModal title={"Subtitle Tools"} footer={footer} {...props}>
<SimpleTable <SimpleTable
isSelecting={data.length !== 0}
emptyText="No External Subtitles Found" emptyText="No External Subtitles Found"
plugins={plugins} plugins={plugins}
columns={columns} columns={columns}
@ -475,7 +467,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
process={process} process={process}
modalKey="translate-sub" modalKey="translate-sub"
></TranslateModal> ></TranslateModal>
</React.Fragment> </>
); );
}; };

View File

@ -1,3 +1,6 @@
import { useModalControl } from "@/modules/redux/hooks/modal";
import { BuildKey } from "@/utilities";
import { LOG } from "@/utilities/console";
import { import {
faCheck, faCheck,
faCircleNotch, faCircleNotch,
@ -6,15 +9,31 @@ import {
faTrash, faTrash,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table"; import { Column } from "react-table";
import { BuildKey } from "utilities";
import { LanguageSelector, MessageIcon } from ".."; import { LanguageSelector, MessageIcon } from "..";
import { FileForm } from "../inputs"; import { FileForm } from "../inputs";
import { SimpleTable } from "../tables"; import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal"; 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> { export interface PendingSubtitle<P> {
file: File; file: File;
@ -30,7 +49,7 @@ export type Validator<T> = (
item: PendingSubtitle<T> item: PendingSubtitle<T>
) => Pick<PendingSubtitle<T>, "state" | "messages">; ) => Pick<PendingSubtitle<T>, "state" | "messages">;
interface Props<T> { interface Props<T = unknown> {
initial: T; initial: T;
availableLanguages: Language.Info[]; availableLanguages: Language.Info[];
upload: (items: PendingSubtitle<T>[]) => void; upload: (items: PendingSubtitle<T>[]) => void;
@ -40,9 +59,10 @@ interface Props<T> {
hideAllLanguages?: boolean; hideAllLanguages?: boolean;
} }
export default function SubtitleUploadModal<T>( type ComponentProps<T> = Props<T> &
props: Props<T> & Omit<BaseModalProps, "footer" | "title" | "size"> Omit<BaseModalProps, "footer" | "title" | "size">;
) {
function SubtitleUploadModal<T>(props: ComponentProps<T>) {
const { const {
initial, initial,
columns, columns,
@ -53,7 +73,7 @@ export default function SubtitleUploadModal<T>(
hideAllLanguages, hideAllLanguages,
} = props; } = props;
const closeModal = useCloseModal(); const { hide } = useModalControl();
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]); const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
@ -72,7 +92,7 @@ export default function SubtitleUploadModal<T>(
language: initialLanguage, language: initialLanguage,
forced: false, forced: false,
hi: false, hi: false,
payload: { ...initialRef.current }, payload: initialRef.current,
})); }));
if (update) { if (update) {
@ -95,15 +115,15 @@ export default function SubtitleUploadModal<T>(
[update, validate, availableLanguages] [update, validate, availableLanguages]
); );
const modify = useCallback<TableUpdater<PendingSubtitle<T>>>( const modify = useCallback(
(row, info?: PendingSubtitle<T>) => { (index: number, info?: PendingSubtitle<T>) => {
setPending((pd) => { setPending((pd) => {
const newPending = [...pd]; const newPending = [...pd];
if (info) { if (info) {
info = { ...info, ...validate(info) }; info = { ...info, ...validate(info) };
newPending[row.index] = info; newPending[index] = info;
} else { } else {
newPending.splice(row.index, 1); newPending.splice(index, 1);
} }
return newPending; return newPending;
}); });
@ -174,8 +194,9 @@ export default function SubtitleUploadModal<T>(
id: "hi", id: "hi",
Header: "HI", Header: "HI",
accessor: "hi", accessor: "hi",
Cell: ({ row, value, update }) => { Cell: ({ row, value }) => {
const { original, index } = row; const { original, index } = row;
const mutate = useRowMutation();
return ( return (
<Form.Check <Form.Check
custom custom
@ -185,7 +206,7 @@ export default function SubtitleUploadModal<T>(
onChange={(v) => { onChange={(v) => {
const newInfo = { ...row.original }; const newInfo = { ...row.original };
newInfo.hi = v.target.checked; newInfo.hi = v.target.checked;
update && update(row, newInfo); mutate(row.index, newInfo);
}} }}
></Form.Check> ></Form.Check>
); );
@ -195,8 +216,9 @@ export default function SubtitleUploadModal<T>(
id: "forced", id: "forced",
Header: "Forced", Header: "Forced",
accessor: "forced", accessor: "forced",
Cell: ({ row, value, update }) => { Cell: ({ row, value }) => {
const { original, index } = row; const { original, index } = row;
const mutate = useRowMutation();
return ( return (
<Form.Check <Form.Check
custom custom
@ -206,7 +228,7 @@ export default function SubtitleUploadModal<T>(
onChange={(v) => { onChange={(v) => {
const newInfo = { ...row.original }; const newInfo = { ...row.original };
newInfo.forced = v.target.checked; newInfo.forced = v.target.checked;
update && update(row, newInfo); mutate(row.index, newInfo);
}} }}
></Form.Check> ></Form.Check>
); );
@ -217,17 +239,18 @@ export default function SubtitleUploadModal<T>(
Header: "Language", Header: "Language",
accessor: "language", accessor: "language",
className: "w-25", className: "w-25",
Cell: ({ row, update, value }) => { Cell: ({ row, value }) => {
const mutate = useRowMutation();
return ( return (
<LanguageSelector <LanguageSelector
disabled={row.original.state === "fetching"} disabled={row.original.state === "fetching"}
options={availableLanguages} options={availableLanguages}
value={value} value={value}
onChange={(lang) => { onChange={(lang) => {
if (lang && update) { if (lang) {
const newInfo = { ...row.original }; const newInfo = { ...row.original };
newInfo.language = lang; newInfo.language = lang;
update(row, newInfo); mutate(row.index, newInfo);
} }
}} }}
></LanguageSelector> ></LanguageSelector>
@ -238,18 +261,21 @@ export default function SubtitleUploadModal<T>(
{ {
id: "action", id: "action",
accessor: "file", accessor: "file",
Cell: ({ row, update }) => ( Cell: ({ row }) => {
<Button const mutate = useRowMutation();
size="sm" return (
variant="light" <Button
disabled={row.original.state === "fetching"} size="sm"
onClick={() => { variant="light"
update && update(row); disabled={row.original.state === "fetching"}
}} onClick={() => {
> mutate(row.index);
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon> }}
</Button> >
), <FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</Button>
);
},
}, },
], ],
[columns, availableLanguages] [columns, availableLanguages]
@ -280,7 +306,7 @@ export default function SubtitleUploadModal<T>(
onClick={() => { onClick={() => {
upload(pending); upload(pending);
setFiles([]); setFiles([]);
closeModal(); hide();
}} }}
> >
Upload Upload
@ -325,14 +351,17 @@ export default function SubtitleUploadModal<T>(
</Form.Group> </Form.Group>
</Form> </Form>
<div hidden={!showTable}> <div hidden={!showTable}>
<SimpleTable <RowContext.Provider value={modify as ModifyFn<unknown>}>
columns={columnsWithAction} <SimpleTable
data={pending} columns={columnsWithAction}
responsive={false} data={pending}
update={modify} responsive={false}
></SimpleTable> ></SimpleTable>
</RowContext.Provider>
</div> </div>
</Container> </Container>
</BaseModal> </BaseModal>
); );
} }
export default SubtitleUploadModal;

View File

@ -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]);
}

View File

@ -1,8 +1,6 @@
export * from "./BaseModal"; export * from "./BaseModal";
export * from "./HistoryModal"; export * from "./HistoryModal";
export * from "./hooks";
export { default as ItemEditorModal } from "./ItemEditorModal"; export { default as ItemEditorModal } from "./ItemEditorModal";
export { default as MovieUploadModal } from "./MovieUploadModal"; export { default as MovieUploadModal } from "./MovieUploadModal";
export { default as ModalProvider } from "./provider";
export { default as SeriesUploadModal } from "./SeriesUploadModal"; export { default as SeriesUploadModal } from "./SeriesUploadModal";
export { default as SubtitleToolModal } from "./SubtitleToolModal"; export { default as SubtitleToolModal } from "./SubtitleToolModal";

View File

@ -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;

View File

@ -1,3 +1,5 @@
import { SelectorOption } from "..";
export const availableTranslation = { export const availableTranslation = {
af: "afrikaans", af: "afrikaans",
sq: "albanian", sq: "albanian",

View File

@ -1,4 +1,4 @@
import React, { useMemo } from "react"; import { useMemo } from "react";
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { import {
HeaderGroup, HeaderGroup,

View File

@ -1,6 +1,5 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons"; import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import { import {
Cell, Cell,
HeaderGroup, HeaderGroup,
@ -13,7 +12,7 @@ import {
import { TableStyleProps } from "./BaseTable"; import { TableStyleProps } from "./BaseTable";
import SimpleTable from "./SimpleTable"; 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) { if (cell.isGrouped) {
return ( return (
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span> <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>; 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]; const plugins = [useGroupBy, useSortBy, useExpanded];
return ( return (
<SimpleTable <SimpleTable

View File

@ -1,4 +1,4 @@
import React, { FunctionComponent, useMemo } from "react"; import { FunctionComponent, useMemo } from "react";
import { Col, Container, Pagination, Row } from "react-bootstrap"; import { Col, Container, Pagination, Row } from "react-bootstrap";
import { PageControlAction } from "./types"; import { PageControlAction } from "./types";
interface Props { interface Props {

View File

@ -1,33 +1,22 @@
import React, { useEffect } from "react"; import { ScrollToTop } from "@/utilities";
import { import { useEffect } from "react";
PluginHook, import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
TableOptions,
usePagination,
useRowSelect,
useTable,
} from "react-table";
import { ScrollToTop } from "utilities";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable"; import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl"; import PageControl from "./PageControl";
import { useCustomSelection, useDefaultSettings } from "./plugins"; import { useDefaultSettings } from "./plugins";
type Props<T extends object> = TableOptions<T> & type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & { TableStyleProps<T> & {
canSelect?: boolean;
autoScroll?: boolean; autoScroll?: boolean;
plugins?: PluginHook<T>[]; plugins?: PluginHook<T>[];
}; };
export default function PageTable<T extends object>(props: Props<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 { style, options } = useStyleAndOptions(remain);
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination]; const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
if (canSelect) {
allPlugins.push(useRowSelect, useCustomSelection);
}
if (plugins) { if (plugins) {
allPlugins.push(...plugins); allPlugins.push(...plugins);
} }
@ -60,7 +49,7 @@ export default function PageTable<T extends object>(props: Props<T>) {
}, [pageIndex, autoScroll]); }, [pageIndex, autoScroll]);
return ( return (
<React.Fragment> <>
<BaseTable <BaseTable
{...style} {...style}
headers={headerGroups} headers={headerGroups}
@ -80,6 +69,6 @@ export default function PageTable<T extends object>(props: Props<T>) {
next={nextPage} next={nextPage}
goto={gotoPage} goto={gotoPage}
></PageControl> ></PageControl>
</React.Fragment> </>
); );
} }

View File

@ -1,7 +1,7 @@
import { UsePaginationQueryResult } from "apis/queries/hooks"; import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import React, { useEffect } from "react"; import { ScrollToTop } from "@/utilities";
import { useEffect } from "react";
import { PluginHook, TableOptions, useTable } from "react-table"; import { PluginHook, TableOptions, useTable } from "react-table";
import { ScrollToTop } from "utilities";
import { LoadingIndicator } from ".."; import { LoadingIndicator } from "..";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable"; import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl"; import PageControl from "./PageControl";
@ -52,7 +52,7 @@ export default function QueryPageTable<T extends object>(props: Props<T>) {
} }
return ( return (
<React.Fragment> <>
<BaseTable <BaseTable
{...style} {...style}
headers={headerGroups} headers={headerGroups}
@ -72,6 +72,6 @@ export default function QueryPageTable<T extends object>(props: Props<T>) {
next={nextPage} next={nextPage}
goto={gotoPage} goto={gotoPage}
></PageControl> ></PageControl>
</React.Fragment> </>
); );
} }

View File

@ -13,13 +13,8 @@ export default function SimpleTable<T extends object>(props: Props<T>) {
const instance = useTable(options, useDefaultSettings, ...(plugins ?? [])); const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
const { const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
getTableProps, instance;
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = instance;
return ( return (
<BaseTable <BaseTable

View File

@ -1,4 +1,4 @@
import React, { forwardRef, useEffect, useRef } from "react"; import { forwardRef, useEffect, useRef } from "react";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { import {
CellProps, CellProps,
@ -52,10 +52,6 @@ const Checkbox = forwardRef<
}); });
function useCustomSelection<T extends object>(hooks: Hooks<T>) { function useCustomSelection<T extends object>(hooks: Hooks<T>) {
hooks.visibleColumnsDeps.push((deps, { instance }) => [
...deps,
instance.isSelecting,
]);
hooks.visibleColumns.push(visibleColumns); hooks.visibleColumns.push(visibleColumns);
hooks.useInstance.push(useInstance); hooks.useInstance.push(useInstance);
} }
@ -68,7 +64,6 @@ function useInstance<T extends object>(instance: TableInstance<T>) {
rows, rows,
onSelect, onSelect,
canSelect, canSelect,
isSelecting,
state: { selectedRowIds }, state: { selectedRowIds },
} = instance; } = instance;
@ -76,18 +71,16 @@ function useInstance<T extends object>(instance: TableInstance<T>) {
useEffect(() => { useEffect(() => {
// Performance // Performance
if (isSelecting) { let items = Object.keys(selectedRowIds).flatMap(
let items = Object.keys(selectedRowIds).flatMap( (v) => rows.find((n) => n.id === v)?.original ?? []
(v) => rows.find((n) => n.id === v)?.original ?? [] );
);
if (canSelect) { if (canSelect) {
items = items.filter((v) => canSelect(v)); items = items.filter((v) => canSelect(v));
}
onSelect && onSelect(items);
} }
}, [selectedRowIds, onSelect, rows, isSelecting, canSelect]);
onSelect && onSelect(items);
}, [selectedRowIds, onSelect, rows, canSelect]);
} }
function visibleColumns<T extends object>( function visibleColumns<T extends object>(
@ -95,31 +88,27 @@ function visibleColumns<T extends object>(
meta: MetaBase<T> meta: MetaBase<T>
): Column<T>[] { ): Column<T>[] {
const { instance } = meta; const { instance } = meta;
if (instance.isSelecting) { const checkbox: Column<T> = {
const checkbox: Column<T> = { id: checkboxId,
id: checkboxId, Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<T>) => (
Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<any>) => ( <Checkbox
idIn="table-header-selection"
{...getToggleAllRowsSelectedProps()}
></Checkbox>
),
Cell: ({ row }: CellProps<T>) => {
const canSelect = instance.canSelect;
const disabled = (canSelect && !canSelect(row.original)) ?? false;
return (
<Checkbox <Checkbox
idIn="table-header-selection" idIn={`table-cell-${row.index}`}
{...getToggleAllRowsSelectedProps()} disabled={disabled}
{...row.getToggleRowSelectedProps()}
></Checkbox> ></Checkbox>
), );
Cell: ({ row }: CellProps<any>) => { },
const canSelect = instance.canSelect; };
const disabled = (canSelect && !canSelect(row.original)) ?? false; return [checkbox, ...columns];
return (
<Checkbox
idIn={`table-cell-${row.index}`}
disabled={disabled}
{...row.getToggleRowSelectedProps()}
></Checkbox>
);
},
};
return [checkbox, ...columns.filter((v) => v.selectHide !== true)];
} else {
return columns;
}
} }
export default useCustomSelection; export default useCustomSelection;

View File

@ -1,5 +1,5 @@
import { usePageSize } from "@/utilities/storage";
import { Hooks, TableOptions } from "react-table"; import { Hooks, TableOptions } from "react-table";
import { usePageSize } from "utilities/storage";
const pluginName = "useLocalSettings"; const pluginName = "useLocalSettings";

View File

@ -1,5 +1,4 @@
import { UsePaginationQueryResult } from "apis/queries/hooks"; import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import React from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";

View File

@ -1,69 +1,30 @@
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons"; import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { useIsAnyMutationRunning, useLanguageProfiles } from "apis/hooks"; import { TableStyleProps } from "@/components/tables/BaseTable";
import { UsePaginationQueryResult } from "apis/queries/hooks"; import { faList } from "@fortawesome/free-solid-svg-icons";
import { TableStyleProps } from "components/tables/BaseTable"; import { Row } from "react-bootstrap";
import { useCustomSelection } from "components/tables/plugins"; import { useNavigate } from "react-router-dom";
import { uniqBy } from "lodash"; import { Column, TableOptions } from "react-table";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ContentHeader, QueryPageTable } from "..";
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 "..";
interface Props<T extends Item.Base = Item.Base> { interface Props<T extends Item.Base = Item.Base> {
name: string;
fullQuery: UseQueryResult<T[]>;
query: UsePaginationQueryResult<T>; query: UsePaginationQueryResult<T>;
columns: Column<T>[]; columns: Column<T>[];
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
} }
function ItemView<T extends Item.Base>({ function ItemView<T extends Item.Base>({ query, columns }: Props<T>) {
name, const navigate = useNavigate();
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]
);
const options: Partial<TableOptions<T> & TableStyleProps<T>> = { const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
emptyText: `No ${name} Found`, emptyText: `No Items Found`,
update: updateRow,
}; };
const content = editMode ? ( return (
<ItemMassEditor
query={fullQuery}
columns={columns}
mutation={mutation}
onEnded={() => setEditMode(false)}
></ItemMassEditor>
) : (
<> <>
<ContentHeader scroll={false}> <ContentHeader scroll={false}>
<ContentHeader.Button <ContentHeader.Button
disabled={query.paginationStatus.totalCount === 0} disabled={query.paginationStatus.totalCount === 0}
icon={faList} icon={faList}
onClick={() => setEditMode(true)} onClick={() => navigate("edit")}
> >
Mass Edit Mass Edit
</ContentHeader.Button> </ContentHeader.Button>
@ -75,134 +36,6 @@ function ItemView<T extends Item.Base>({
query={query} query={query}
data={[]} data={[]}
></QueryPageTable> ></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> </Row>
</> </>
); );

View File

@ -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 { 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 { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";
@ -16,8 +14,6 @@ interface Props<T extends Wanted.Base> {
searchAll: () => Promise<void>; searchAll: () => Promise<void>;
} }
const TaskGroupName = "Searching wanted subtitles...";
function WantedView<T extends Wanted.Base>({ function WantedView<T extends Wanted.Base>({
name, name,
columns, columns,
@ -37,8 +33,7 @@ function WantedView<T extends Wanted.Base>({
<ContentHeader.Button <ContentHeader.Button
disabled={hasTask || dataCount === 0} disabled={hasTask || dataCount === 0}
onClick={() => { onClick={() => {
const task = createTask(name, undefined, searchAll); createAndDispatchTask(name, "search-subtitles", searchAll);
dispatchTask(TaskGroupName, [task], "Searching...");
}} }}
icon={faSearch} icon={faSearch}
> >

View File

@ -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 "@fontsource/roboto/300.css";
import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { QueryClientProvider } from "react-query"; import { QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools"; import { ReactQueryDevtools } from "react-query/devtools";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import store from "./@redux/store"; import { useRoutes } from "react-router-dom";
import "./@scss/index.scss"; import { Router, useRouteItems } from "./Router";
import queryClient from "./apis/queries"; import { Environment } from "./utilities";
import App from "./App";
import { Environment, isTestEnv } from "./utilities"; const RouteApp = () => {
const items = useRouteItems();
return useRoutes(items);
};
export const Entrance = () => ( export const Entrance = () => (
<Provider store={store}> <Provider store={store}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */} <Router>
{/* <React.StrictMode> */} {/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />} {/* <StrictMode> */}
<App></App> {Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
{/* </React.StrictMode> */} <RouteApp></RouteApp>
{/* </StrictMode> */}
</Router>
</QueryClientProvider> </QueryClientProvider>
</Provider> </Provider>
); );
if (!isTestEnv) { ReactDOM.render(<Entrance />, document.getElementById("root"));
ReactDOM.render(<Entrance />, document.getElementById("root"));
}

View File

@ -1,5 +1,5 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { waitFor } from "../../utilities"; import { waitFor } from "../../../utilities";
export const setSiteStatus = createAction<Site.Status>("site/status/update"); export const setSiteStatus = createAction<Site.Status>("site/status/update");

View 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");

View File

@ -0,0 +1,4 @@
import { ActionCreator } from "@reduxjs/toolkit";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyActionCreator = ActionCreator<any>;

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