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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,8 @@ from notifier import update_notifier # noqa E402
from urllib.parse import unquote # noqa E402
from get_languages import load_language_in_db # noqa E402
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context # noqa E402
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context, \
send_from_directory
from threading import Thread # noqa E402
import requests # noqa E402
@ -112,6 +113,12 @@ def catch_all(path):
return render_template("index.html", BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
@app.route('/assets/<path:filename>')
def web_assets(filename):
path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build', 'assets')
return send_from_directory(path, filename)
@check_login
@app.route('/bazarr.log')
def download_log():

View File

@ -1,27 +1,29 @@
# Override by duplicating me and rename to .env.local
# The following environment variables will only be used during development
# Required
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# VITE_API_KEY="YOUR_SERVER_API_KEY"
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
VITE_PROXY_URL=http://127.0.0.1:6767
# Optional
# Bazarr configuration path, must be absolute path
# Vite will use this variable to find your bazarr API key
VITE_BAZARR_CONFIG_FILE="../data/config/config.ini"
# Proxy Settings
# Allow Unsecured connection to your backend
REACT_APP_PROXY_SECURE=true
VITE_PROXY_SECURE=true
# Allow websocket connection in Socket.IO
REACT_APP_ALLOW_WEBSOCKET=true
VITE_ALLOW_WEBSOCKET=true
# Display update section in settings
REACT_APP_CAN_UPDATE=true
VITE_CAN_UPDATE=true
# Display update notification in notification center
REACT_APP_HAS_UPDATE=false
VITE_HAS_UPDATE=false
# Display React-Query devtools
REACT_APP_QUERY_DEV=false
VITE_QUERY_DEV=false

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
*.local
build
*.tsbuildinfo

View File

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

View File

@ -20,26 +20,26 @@
$ npm install
```
3. Duplicate `.env.development` file and rename to `.env.local`
3. (Optional) Duplicate `.env.development` file and rename to `.env.development.local`
```
$ cp .env .env.local
$ cp .env.development .env.development.local
```
4. Update your backend server's API key in `.env.local`
4. (Optional) Update your backend server's API key in `.env.development.local`
```
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
VITE_API_KEY="YOUR_SERVER_API_KEY"
```
5. Change the address of your backend server (Optional)
5. (Optional) Change the address of your backend server
> http://localhost:6767 will be used by default
> http://127.0.0.1:6767 will be used by default
```
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
VITE_PROXY_URL=http://localhost:6767
```
6. Run Bazarr backend
@ -74,9 +74,9 @@ Please ensure all tests are passed before uploading the code
### `npm run build`
Builds the app for production to the `build` folder.
Builds the app in production mode and save to the `build` folder.
### `npm run lint`
### `npm run format`
Format code for all files in `frontend` folder

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>
<base href="{{baseUrl}}" />
<meta charset="utf-8" />
<link
rel="icon"
type="image/x-icon"
href="%PUBLIC_URL%/static/favicon.ico"
/>
<link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
@ -17,7 +13,6 @@
name="description"
content="Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you."
/>
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@ -25,5 +20,6 @@
<script>
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
</script>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

35745
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,17 @@
"url": "https://github.com/morpheus65535/bazarr/issues"
},
"private": true,
"homepage": "./",
"dependencies": {
"axios": "^0.24",
"react": "^17",
"react-bootstrap": "^1",
"react-dom": "^17",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^6.2.1",
"socket.io-client": "^4"
},
"devDependencies": {
"@fontsource/roboto": "^4.5.1",
"@fortawesome/fontawesome-svg-core": "^1.2",
"@fortawesome/free-brands-svg-icons": "^5.15",
@ -21,48 +30,45 @@
"@fortawesome/free-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.16",
"@reduxjs/toolkit": "^1.6",
"axios": "^0.24",
"bootstrap": "^4",
"lodash": "^4",
"moment": "^2.29.1",
"rc-slider": "^9.7",
"react": "^17",
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5.7.1",
"socket.io-client": "^4"
},
"devDependencies": {
"@types/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@types/lodash": "^4",
"@types/node": "^15",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"@vitejs/plugin-react": "^1.1.4",
"bootstrap": "^4",
"clsx": "^1.1.1",
"eslint": "^8.7.0",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7",
"lodash": "^4",
"moment": "^2.29.1",
"prettier": "^2",
"prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1",
"rc-slider": "^9.7",
"react-helmet": "^6.1",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5.7.1",
"sass": "^1",
"typescript": "^4"
"typescript": "^4",
"vite": "^2.7.13",
"vite-plugin-checker": "^0.3.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"lint": "prettier --write --ignore-unknown .",
"start": "vite",
"build": "vite build",
"build:ci": "vite build -m development",
"check": "eslint --ext .ts,.tsx src",
"check:ts": "tsc --noEmit --incremental false",
"check:fmt": "prettier -c .",
"test": "exit 0",
"format": "prettier -w .",
"prepare": "cd .. && husky install frontend/.husky"
},
"browserslist": {

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

View File

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

View File

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

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 { Redirect, Route, Switch, useHistory } from "react-router";
import { useDidMount } from "rooks";
import { BuildKey, ScrollToTop } from "utilities";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { RouterEmptyPath } from "../pages/404";
import { useBadges } from "@/apis/hooks";
import App from "@/App";
import Lazy from "@/components/Lazy";
import { useEnabledStatus } from "@/modules/redux/hooks";
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import BlacklistSeriesView from "@/pages/Blacklist/Series";
import Episodes from "@/pages/Episodes";
import MoviesHistoryView from "@/pages/History/Movies";
import SeriesHistoryView from "@/pages/History/Series";
import MovieView from "@/pages/Movies";
import MovieDetailView from "@/pages/Movies/Details";
import MovieMassEditor from "@/pages/Movies/Editor";
import SeriesView from "@/pages/Series";
import SeriesMassEditor from "@/pages/Series/Editor";
import SettingsGeneralView from "@/pages/Settings/General";
import SettingsLanguagesView from "@/pages/Settings/Languages";
import SettingsNotificationsView from "@/pages/Settings/Notifications";
import SettingsProvidersView from "@/pages/Settings/Providers";
import SettingsRadarrView from "@/pages/Settings/Radarr";
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
import SettingsSonarrView from "@/pages/Settings/Sonarr";
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
import SettingsUIView from "@/pages/Settings/UI";
import SystemBackupsView from "@/pages/System/Backups";
import SystemLogsView from "@/pages/System/Logs";
import SystemProvidersView from "@/pages/System/Providers";
import SystemReleasesView from "@/pages/System/Releases";
import SystemTasksView from "@/pages/System/Tasks";
import WantedMoviesView from "@/pages/Wanted/Movies";
import WantedSeriesView from "@/pages/Wanted/Series";
import { Environment } from "@/utilities";
import {
faClock,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import React, {
createContext,
FunctionComponent,
lazy,
useContext,
useMemo,
} from "react";
import { BrowserRouter } from "react-router-dom";
import Redirector from "./Redirector";
import { CustomRouteObject } from "./type";
const Router: FunctionComponent = () => {
const navItems = useNavigationItems();
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
const Authentication = lazy(() => import("@/pages/Authentication"));
const NotFound = lazy(() => import("@/pages/404"));
const history = useHistory();
useDidMount(() => {
history.listen(() => {
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop);
});
});
function useRoutes(): CustomRouteObject[] {
const { data } = useBadges();
const { sonarr, radarr } = useEnabledStatus();
return useMemo(
() => [
{
path: "/",
element: <App></App>,
children: [
{
index: true,
element: <Redirector></Redirector>,
},
{
icon: faPlay,
name: "Series",
path: "series",
hidden: !sonarr,
children: [
{
index: true,
element: <SeriesView></SeriesView>,
},
{
path: "edit",
hidden: true,
element: <SeriesMassEditor></SeriesMassEditor>,
},
{
path: ":id",
element: <Episodes></Episodes>,
},
],
},
{
icon: faFilm,
name: "Movies",
path: "movies",
hidden: !radarr,
children: [
{
index: true,
element: <MovieView></MovieView>,
},
{
path: "edit",
hidden: true,
element: <MovieMassEditor></MovieMassEditor>,
},
{
path: ":id",
element: <MovieDetailView></MovieDetailView>,
},
],
},
{
icon: faClock,
name: "History",
path: "history",
hidden: !sonarr && !radarr,
children: [
{
path: "series",
name: "Episodes",
hidden: !sonarr,
element: <SeriesHistoryView></SeriesHistoryView>,
},
{
path: "movies",
name: "Movies",
hidden: !radarr,
element: <MoviesHistoryView></MoviesHistoryView>,
},
{
path: "stats",
name: "Statistics",
element: (
<Lazy>
<HistoryStats></HistoryStats>
</Lazy>
),
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
path: "wanted",
hidden: !sonarr && !radarr,
children: [
{
name: "Episodes",
path: "series",
badge: data?.episodes,
hidden: !sonarr,
element: <WantedSeriesView></WantedSeriesView>,
},
{
name: "Movies",
path: "movies",
badge: data?.movies,
hidden: !radarr,
element: <WantedMoviesView></WantedMoviesView>,
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
path: "blacklist",
hidden: !sonarr && !radarr,
children: [
{
path: "series",
name: "Episodes",
hidden: !sonarr,
element: <BlacklistSeriesView></BlacklistSeriesView>,
},
{
path: "movies",
name: "Movies",
hidden: !radarr,
element: <BlacklistMoviesView></BlacklistMoviesView>,
},
],
},
{
icon: faExclamationTriangle,
name: "Settings",
path: "settings",
children: [
{
path: "general",
name: "General",
element: <SettingsGeneralView></SettingsGeneralView>,
},
{
path: "languages",
name: "Languages",
element: <SettingsLanguagesView></SettingsLanguagesView>,
},
{
path: "providers",
name: "Providers",
element: <SettingsProvidersView></SettingsProvidersView>,
},
{
path: "subtitles",
name: "Subtitles",
element: <SettingsSubtitlesView></SettingsSubtitlesView>,
},
{
path: "sonarr",
name: "Sonarr",
element: <SettingsSonarrView></SettingsSonarrView>,
},
{
path: "radarr",
name: "Radarr",
element: <SettingsRadarrView></SettingsRadarrView>,
},
{
path: "notifications",
name: "Notifications",
element: (
<SettingsNotificationsView></SettingsNotificationsView>
),
},
{
path: "scheduler",
name: "Scheduler",
element: <SettingsSchedulerView></SettingsSchedulerView>,
},
{
path: "ui",
name: "UI",
element: <SettingsUIView></SettingsUIView>,
},
],
},
{
icon: faLaptop,
name: "System",
path: "system",
children: [
{
path: "tasks",
name: "Tasks",
element: <SystemTasksView></SystemTasksView>,
},
{
path: "logs",
name: "Logs",
element: <SystemLogsView></SystemLogsView>,
},
{
path: "providers",
name: "Providers",
badge: data?.providers,
element: <SystemProvidersView></SystemProvidersView>,
},
{
path: "backup",
name: "Backups",
element: <SystemBackupsView></SystemBackupsView>,
},
{
path: "status",
name: "Status",
element: (
<Lazy>
<SystemStatusView></SystemStatusView>
</Lazy>
),
},
{
path: "releases",
name: "Releases",
element: <SystemReleasesView></SystemReleasesView>,
},
],
},
],
},
{
path: "/login",
hidden: true,
element: (
<Lazy>
<Authentication></Authentication>
</Lazy>
),
},
{
path: "*",
hidden: true,
element: (
<Lazy>
<NotFound></NotFound>
</Lazy>
),
},
],
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
);
}
const RouterItemContext = createContext<CustomRouteObject[]>([]);
export const Router: FunctionComponent = ({ children }) => {
const routes = useRoutes();
return (
<div className="d-flex flex-row flex-grow-1 main-router">
<Switch>
{navItems.map((v, idx) => {
if ("routes" in v) {
return (
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
<ParentRouter {...v}></ParentRouter>
</Route>
);
} else if (v.enabled !== false) {
return (
<Route
key={BuildKey(idx, v.name, "root")}
exact
path={v.path}
component={v.component}
></Route>
);
} else {
return null;
}
})}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
</div>
<RouterItemContext.Provider value={routes}>
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
</RouterItemContext.Provider>
);
};
export default Router;
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
path,
enabled,
component,
routes,
}) => {
if (enabled === false || (component === undefined && routes.length === 0)) {
return null;
}
const ParentComponent =
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
return (
<Switch>
<Route exact path={path} component={ParentComponent}></Route>
{routes
.filter((v) => v.enabled !== false)
.map((v, idx) => (
<Route
key={BuildKey(idx, v.name, "route")}
exact
path={path + v.path}
component={v.component}
></Route>
))}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export function useRouteItems() {
return useContext(RouterItemContext);
}

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

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],
() => api.movies.movies(),
{
enabled: false,
onSuccess: (data) => {
cacheMovies(client, data);
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { FunctionComponent } from "react";
import { FunctionComponent } from "react";
type GroupPosition = "start" | "end";
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 ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
import ContentHeaderGroup from "./Group";
import "./style.scss";
interface Props {
scroll?: boolean;
@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => {
return rowCls.join(" ");
}, [scroll, className]);
let childItem: React.ReactNode;
let childItem: ReactNode;
if (scroll !== false) {
childItem = (

View File

@ -11,7 +11,7 @@ import {
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { isNull, isUndefined } from "lodash";
import React, { FunctionComponent, useMemo } from "react";
import { FunctionComponent, ReactElement } from "react";
import {
OverlayTrigger,
OverlayTriggerProps,
@ -97,44 +97,8 @@ export const LoadingIndicator: FunctionComponent<{
);
};
interface LanguageTextProps {
text: Language.Info;
className?: string;
long?: boolean;
}
export const LanguageText: FunctionComponent<LanguageTextProps> = ({
text,
className,
long,
}) => {
const result = useMemo(() => {
let lang = text.code2;
let hi = ":HI";
let forced = ":Forced";
if (long) {
lang = text.name;
hi = " HI";
forced = " Forced";
}
let res = lang;
if (text.hi) {
res += hi;
} else if (text.forced) {
res += forced;
}
return res;
}, [text, long]);
return (
<span title={text.name} className={className}>
{result}
</span>
);
};
interface TextPopoverProps {
children: React.ReactElement<any, any>;
children: ReactElement;
text: string | undefined | null;
placement?: OverlayTriggerProps["placement"];
delay?: number;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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 "./HistoryModal";
export * from "./hooks";
export { default as ItemEditorModal } from "./ItemEditorModal";
export { default as MovieUploadModal } from "./MovieUploadModal";
export { default as ModalProvider } from "./provider";
export { default as SeriesUploadModal } from "./SeriesUploadModal";
export { default as SubtitleToolModal } from "./SubtitleToolModal";

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 = {
af: "afrikaans",
sq: "albanian",

View File

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

View File

@ -1,6 +1,5 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import {
Cell,
HeaderGroup,
@ -13,7 +12,7 @@ import {
import { TableStyleProps } from "./BaseTable";
import SimpleTable from "./SimpleTable";
function renderCell<T extends object = {}>(cell: Cell<T, any>, row: Row<T>) {
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
if (cell.isGrouped) {
return (
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span>
@ -79,7 +78,7 @@ function renderHeaders<T extends object>(
type Props<T extends object> = TableOptions<T> & TableStyleProps<T>;
function GroupTable<T extends object = {}>(props: Props<T>) {
function GroupTable<T extends object = object>(props: Props<T>) {
const plugins = [useGroupBy, useSortBy, useExpanded];
return (
<SimpleTable

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 { PageControlAction } from "./types";
interface Props {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,69 +1,30 @@
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
import { useIsAnyMutationRunning, useLanguageProfiles } from "apis/hooks";
import { UsePaginationQueryResult } from "apis/queries/hooks";
import { TableStyleProps } from "components/tables/BaseTable";
import { useCustomSelection } from "components/tables/plugins";
import { uniqBy } from "lodash";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { UseMutationResult, UseQueryResult } from "react-query";
import { Column, TableOptions, TableUpdater, useRowSelect } from "react-table";
import { GetItemId } from "utilities";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
QueryPageTable,
SimpleTable,
useShowModal,
} from "..";
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { TableStyleProps } from "@/components/tables/BaseTable";
import { faList } from "@fortawesome/free-solid-svg-icons";
import { Row } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { Column, TableOptions } from "react-table";
import { ContentHeader, QueryPageTable } from "..";
interface Props<T extends Item.Base = Item.Base> {
name: string;
fullQuery: UseQueryResult<T[]>;
query: UsePaginationQueryResult<T>;
columns: Column<T>[];
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
}
function ItemView<T extends Item.Base>({
name,
fullQuery,
query,
columns,
mutation,
}: Props<T>) {
const [editMode, setEditMode] = useState(false);
const showModal = useShowModal();
const updateRow = useCallback<TableUpdater<T>>(
({ original }, modalKey: string) => {
showModal(modalKey, original);
},
[showModal]
);
function ItemView<T extends Item.Base>({ query, columns }: Props<T>) {
const navigate = useNavigate();
const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
emptyText: `No ${name} Found`,
update: updateRow,
emptyText: `No Items Found`,
};
const content = editMode ? (
<ItemMassEditor
query={fullQuery}
columns={columns}
mutation={mutation}
onEnded={() => setEditMode(false)}
></ItemMassEditor>
) : (
return (
<>
<ContentHeader scroll={false}>
<ContentHeader.Button
disabled={query.paginationStatus.totalCount === 0}
icon={faList}
onClick={() => setEditMode(true)}
onClick={() => navigate("edit")}
>
Mass Edit
</ContentHeader.Button>
@ -75,134 +36,6 @@ function ItemView<T extends Item.Base>({
query={query}
data={[]}
></QueryPageTable>
<ItemEditorModal modalKey="edit" mutation={mutation}></ItemEditorModal>
</Row>
</>
);
return (
<Container fluid>
<Helmet>
<title>{name} - Bazarr</title>
</Helmet>
{content}
</Container>
);
}
interface ItemMassEditorProps<T extends Item.Base> {
columns: Column<T>[];
query: UseQueryResult<T[]>;
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
onEnded: () => void;
}
function ItemMassEditor<T extends Item.Base = Item.Base>(
props: ItemMassEditorProps<T>
) {
const { columns, mutation, query, onEnded } = props;
const [selections, setSelections] = useState<T[]>([]);
const [dirties, setDirties] = useState<T[]>([]);
const hasTask = useIsAnyMutationRunning();
const { data: profiles } = useLanguageProfiles();
const { refetch } = query;
useEffect(() => {
refetch();
}, [refetch]);
const data = useMemo(
() => uniqBy([...dirties, ...(query?.data ?? [])], GetItemId),
[dirties, query?.data]
);
const profileOptions = useMemo<JSX.Element[]>(() => {
const items: JSX.Element[] = [];
if (profiles) {
items.push(
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
);
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
items.push(
...profiles.map((v) => (
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
{v.name}
</Dropdown.Item>
))
);
}
return items;
}, [profiles]);
const { mutateAsync } = mutation;
const save = useCallback(() => {
const form: FormType.ModifyItem = {
id: [],
profileid: [],
};
dirties.forEach((v) => {
const id = GetItemId(v);
if (id) {
form.id.push(id);
form.profileid.push(v.profileId);
}
});
return mutateAsync(form);
}, [dirties, mutateAsync]);
const setProfiles = useCallback(
(key: Nullable<string>) => {
const id = key ? parseInt(key) : null;
const newItems = selections.map((v) => ({ ...v, profileId: id }));
setDirties((dirty) => {
return uniqBy([...newItems, ...dirty], GetItemId);
});
},
[selections]
);
return (
<>
<ContentHeader scroll={false}>
<ContentHeader.Group pos="start">
<Dropdown onSelect={setProfiles}>
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
Change Profile
</Dropdown.Toggle>
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
</Dropdown>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
Cancel
</ContentHeader.Button>
<ContentHeader.AsyncButton
icon={faCheck}
disabled={dirties.length === 0 || hasTask}
promise={save}
onSuccess={onEnded}
>
Save
</ContentHeader.AsyncButton>
</ContentHeader.Group>
</ContentHeader>
<Row>
{query.data === undefined ? (
<LoadingIndicator></LoadingIndicator>
) : (
<SimpleTable
columns={columns}
data={data}
onSelect={setSelections}
isSelecting
plugins={[useRowSelect, useCustomSelection]}
></SimpleTable>
)}
</Row>
</>
);

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

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

View File

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

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