Frontend improvement and cleanup (#1690)

* Replace Create-React-App with Vite.js

* Update React-Router to v6

* Cleanup unused codes
pull/1773/head
Liang Yi 2 years ago committed by GitHub
parent f81972b291
commit 50a252fdd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -31,7 +31,7 @@ jobs:
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: '${{ env.UI_DIRECTORY }}/node_modules' path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules- restore-keys: ${{ runner.os }}-modules-

@ -33,7 +33,7 @@ jobs:
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: '${{ env.UI_DIRECTORY }}/node_modules' path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules- restore-keys: ${{ runner.os }}-modules-

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

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

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

@ -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,3 +2,5 @@ node_modules
dist dist
*.local *.local
build build
*.tsbuildinfo

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

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

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

@ -0,0 +1,30 @@
import { dependencies } from "../package.json";
const vendors = [
"react",
"react-redux",
"react-router-dom",
"react-dom",
"react-query",
"axios",
"socket.io-client",
];
function renderChunks() {
const chunks: Record<string, string[]> = {};
for (const key in dependencies) {
if (!vendors.includes(key)) {
chunks[key] = [key];
}
}
return chunks;
}
const chunks = {
vendors,
...renderChunks(),
};
export default chunks;

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

File diff suppressed because it is too large Load Diff

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

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -1,14 +0,0 @@
{
"short_name": "Bazarr",
"name": "Bazarr Frontend",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff"
}

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -1,109 +0,0 @@
import { keys } from "lodash";
import {
siteAddProgress,
siteRemoveProgress,
siteUpdateNotifier,
siteUpdateProgressCount,
} from "../../@redux/actions";
import store from "../../@redux/store";
// A background task manager, use for dispatching task one by one
class BackgroundTask {
private groups: Task.Group;
constructor() {
this.groups = {};
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
private onBeforeUnload(e: BeforeUnloadEvent) {
const message = "Background tasks are still running";
if (Object.keys(this.groups).length !== 0) {
e.preventDefault();
e.returnValue = message;
return;
}
delete e["returnValue"];
}
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
if (groupName in this.groups) {
this.groups[groupName].push(...tasks);
store.dispatch(
siteUpdateProgressCount({
id: groupName,
count: this.groups[groupName].length,
})
);
return;
}
this.groups[groupName] = tasks;
setTimeout(async () => {
for (let index = 0; index < tasks.length; index++) {
const task = tasks[index];
store.dispatch(
siteAddProgress([
{
id: groupName,
header: groupName,
name: task.name,
value: index,
count: tasks.length,
},
])
);
try {
await task.callable(...task.parameters);
} catch (error) {
// TODO
}
}
delete this.groups[groupName];
store.dispatch(siteRemoveProgress([groupName]));
});
}
find(groupName: string, id: number) {
if (groupName in this.groups) {
return this.groups[groupName].find((v) => v.id === id) !== undefined;
}
return false;
}
has(groupName: string) {
return groupName in this.groups;
}
hasId(ids: number[]) {
for (const id of ids) {
for (const key in this.groups) {
const tasks = this.groups[key];
if (tasks.find((v) => v.id === id) !== undefined) {
return true;
}
}
}
return false;
}
isRunning() {
return keys(this.groups).length > 0;
}
}
const BGT = new BackgroundTask();
export default BGT;
export function dispatchTask<T extends Task.Callable>(
groupName: string,
tasks: Task.Task<T>[],
comment?: string
) {
BGT.dispatch(groupName, tasks);
if (comment) {
store.dispatch(siteUpdateNotifier(comment));
}
}

@ -1,14 +0,0 @@
declare namespace Task {
type Callable = (...args: any[]) => Promise<void>;
interface Task<FN extends Callable> {
name: string;
id?: number;
callable: FN;
parameters: Parameters<FN>;
}
type Group = {
[category: string]: Task.Task<Callable>[];
};
}

@ -1,13 +0,0 @@
export function createTask<T extends Task.Callable>(
name: string,
id: number | undefined,
callable: T,
...parameters: Parameters<T>
): Task.Task<T> {
return {
name,
id,
callable,
parameters,
};
}

@ -1,24 +0,0 @@
import { ActionCreator } from "@reduxjs/toolkit";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "../store";
// function use
export function useReduxStore<T extends (store: RootState) => any>(
selector: T
) {
return useSelector<RootState, ReturnType<T>>(selector);
}
export function useAppDispatch() {
return useDispatch<AppDispatch>();
}
// TODO: Fix type
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
const dispatch = useAppDispatch();
return useCallback(
(...args: Parameters<T>) => dispatch(action(...args)),
[action, dispatch]
);
}

@ -1,21 +0,0 @@
// Override bootstrap primary color
$theme-colors: (
"primary": #911f93,
"dark": #4f566f,
);
body {
font-family: "Roboto", "open sans", "Helvetica Neue", "Helvetica", "Arial",
sans-serif !important;
font-weight: 300 !important;
}
// Reduce padding of cells in datatables
.table td,
.table th {
padding: 0.4rem !important;
}
.progress-bar {
cursor: default;
}

@ -1,49 +0,0 @@
@import "./variable.scss";
:root {
.form-control {
&:focus {
outline-color: none !important;
box-shadow: none !important;
border-color: var(--primary) !important;
}
}
}
td {
vertical-align: middle !important;
}
.dropdown-hidden {
&::after {
display: none !important;
}
}
.cursor-pointer {
cursor: pointer;
}
.opacity-100 {
opacity: 100% !important;
}
.vh-100 {
height: 100vh !important;
}
.vh-75 {
height: 75vh !important;
}
.of-hidden {
overflow: hidden;
}
.of-auto {
overflow: auto;
}
.vw-1 {
width: 12rem;
}

@ -1,55 +0,0 @@
@import "./global.scss";
@import "./variable.scss";
@import "./bazarr.scss";
@import "../../node_modules/bootstrap/scss/bootstrap.scss";
@mixin sidebar-animation {
transition: {
duration: 0.2s;
timing-function: ease-in-out;
}
}
@include media-breakpoint-up(sm) {
.sidebar-container {
position: sticky;
}
.main-router {
max-width: calc(100% - #{$sidebar-width});
}
.header-icon {
min-width: $sidebar-width;
}
}
@include media-breakpoint-down(sm) {
.sidebar-container {
position: fixed !important;
transform: translateX(-100%);
@include sidebar-animation();
&.open {
transform: translateX(0) !important;
}
}
.main-router {
max-width: 100%;
}
.sidebar-overlay {
@include sidebar-animation();
&.open {
display: block !important;
opacity: 0.6;
}
}
.header-icon {
min-width: 0;
}
}

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

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

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

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

@ -1,19 +0,0 @@
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { FunctionComponent } from "react";
import { Redirect } from "react-router-dom";
const RootRedirect: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
let path = "/settings";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "movies";
}
return <Redirect to={path}></Redirect>;
};
export default RootRedirect;

@ -1,251 +0,0 @@
import {
faClock,
faCogs,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { useBadges } from "apis/hooks";
import EmptyPage, { RouterEmptyPath } from "pages/404";
import BlacklistMoviesView from "pages/Blacklist/Movies";
import BlacklistSeriesView from "pages/Blacklist/Series";
import Episodes from "pages/Episodes";
import MoviesHistoryView from "pages/History/Movies";
import SeriesHistoryView from "pages/History/Series";
import HistoryStats from "pages/History/Statistics";
import MovieView from "pages/Movies";
import MovieDetail from "pages/Movies/Details";
import SeriesView from "pages/Series";
import SettingsGeneralView from "pages/Settings/General";
import SettingsLanguagesView from "pages/Settings/Languages";
import SettingsNotificationsView from "pages/Settings/Notifications";
import SettingsProvidersView from "pages/Settings/Providers";
import SettingsRadarrView from "pages/Settings/Radarr";
import SettingsSchedulerView from "pages/Settings/Scheduler";
import SettingsSonarrView from "pages/Settings/Sonarr";
import SettingsSubtitlesView from "pages/Settings/Subtitles";
import SettingsUIView from "pages/Settings/UI";
import SystemLogsView from "pages/System/Logs";
import SystemProvidersView from "pages/System/Providers";
import SystemReleasesView from "pages/System/Releases";
import SystemStatusView from "pages/System/Status";
import SystemTasksView from "pages/System/Tasks";
import WantedMoviesView from "pages/Wanted/Movies";
import WantedSeriesView from "pages/Wanted/Series";
import { useMemo } from "react";
import SystemBackupsView from "../pages/System/Backups";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
export function useNavigationItems() {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const { data } = useBadges();
const items = useMemo<Navigation.RouteItem[]>(
() => [
{
name: "404",
path: RouterEmptyPath,
component: EmptyPage,
routeOnly: true,
},
{
name: "Redirect",
path: "/",
component: RootRedirect,
routeOnly: true,
},
{
icon: faPlay,
name: "Series",
path: "/series",
component: SeriesView,
enabled: sonarr,
routes: [
{
name: "Episode",
path: "/:id",
component: Episodes,
routeOnly: true,
},
],
},
{
icon: faFilm,
name: "Movies",
path: "/movies",
component: MovieView,
enabled: radarr,
routes: [
{
name: "Movie Details",
path: "/:id",
component: MovieDetail,
routeOnly: true,
},
],
},
{
icon: faClock,
name: "History",
path: "/history",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: SeriesHistoryView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: MoviesHistoryView,
},
{
name: "Statistics",
path: "/stats",
component: HistoryStats,
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
path: "/blacklist",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: BlacklistSeriesView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: BlacklistMoviesView,
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
path: "/wanted",
routes: [
{
name: "Series",
path: "/series",
badge: data?.episodes,
enabled: sonarr,
component: WantedSeriesView,
},
{
name: "Movies",
path: "/movies",
badge: data?.movies,
enabled: radarr,
component: WantedMoviesView,
},
],
},
{
icon: faCogs,
name: "Settings",
path: "/settings",
routes: [
{
name: "General",
path: "/general",
component: SettingsGeneralView,
},
{
name: "Languages",
path: "/languages",
component: SettingsLanguagesView,
},
{
name: "Providers",
path: "/providers",
component: SettingsProvidersView,
},
{
name: "Subtitles",
path: "/subtitles",
component: SettingsSubtitlesView,
},
{
name: "Sonarr",
path: "/sonarr",
component: SettingsSonarrView,
},
{
name: "Radarr",
path: "/radarr",
component: SettingsRadarrView,
},
{
name: "Notifications",
path: "/notifications",
component: SettingsNotificationsView,
},
{
name: "Scheduler",
path: "/scheduler",
component: SettingsSchedulerView,
},
{
name: "UI",
path: "/ui",
component: SettingsUIView,
},
],
},
{
icon: faLaptop,
name: "System",
path: "/system",
routes: [
{
name: "Tasks",
path: "/tasks",
component: SystemTasksView,
},
{
name: "Logs",
path: "/logs",
component: SystemLogsView,
},
{
name: "Providers",
path: "/providers",
badge: data?.providers,
component: SystemProvidersView,
},
{
name: "Backup",
path: "/backups",
component: SystemBackupsView,
},
{
name: "Status",
path: "/status",
component: SystemStatusView,
},
{
name: "Releases",
path: "/releases",
component: SystemReleasesView,
},
],
},
],
[data, radarr, sonarr]
);
return items;
}

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

@ -0,0 +1,18 @@
import { useEnabledStatus } from "@/modules/redux/hooks";
import { FunctionComponent } from "react";
import { Navigate } from "react-router-dom";
const Redirector: FunctionComponent = () => {
const { sonarr, radarr } = useEnabledStatus();
let path = "/settings";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "/movies";
}
return <Navigate to={path}></Navigate>;
};
export default Redirector;

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

@ -0,0 +1,14 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { RouteObject } from "react-router-dom";
declare namespace Route {
export type Item = {
icon?: IconDefinition;
name?: string;
badge?: number;
hidden?: boolean;
children?: Item[];
};
}
export type CustomRouteObject = RouteObject & Route.Item;

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

@ -1,9 +0,0 @@
import { Entrance } from "index";
import {} from "jest";
import ReactDOM from "react-dom";
it("renders", () => {
const div = document.createElement("div");
ReactDOM.render(<Entrance />, div);
ReactDOM.unmountComponentAtNode(div);
});

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,121 @@
import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
import { GetItemId } from "@/utilities";
import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
import { uniqBy } from "lodash";
import { useCallback, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap";
import { UseMutationResult } from "react-query";
import { useNavigate } from "react-router-dom";
import { Column, useRowSelect } from "react-table";
import { ContentHeader, SimpleTable } from ".";
import { useCustomSelection } from "./tables/plugins";
interface MassEditorProps<T extends Item.Base = Item.Base> {
columns: Column<T>[];
data: T[];
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
}
function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
const { columns, data: raw, mutation } = props;
const [selections, setSelections] = useState<T[]>([]);
const [dirties, setDirties] = useState<T[]>([]);
const hasTask = useIsAnyMutationRunning();
const { data: profiles } = useLanguageProfiles();
const navigate = useNavigate();
const onEnded = useCallback(() => navigate(".."), [navigate]);
const data = useMemo(
() => uniqBy([...dirties, ...(raw ?? [])], GetItemId),
[dirties, raw]
);
const profileOptions = useMemo(() => {
const items: JSX.Element[] = [];
if (profiles) {
items.push(
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
);
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
items.push(
...profiles.map((v) => (
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
{v.name}
</Dropdown.Item>
))
);
}
return items;
}, [profiles]);
const { mutateAsync } = mutation;
const save = useCallback(() => {
const form: FormType.ModifyItem = {
id: [],
profileid: [],
};
dirties.forEach((v) => {
const id = GetItemId(v);
if (id) {
form.id.push(id);
form.profileid.push(v.profileId);
}
});
return mutateAsync(form);
}, [dirties, mutateAsync]);
const setProfiles = useCallback(
(key: Nullable<string>) => {
const id = key ? parseInt(key) : null;
const newItems = selections.map((v) => ({ ...v, profileId: id }));
setDirties((dirty) => {
return uniqBy([...newItems, ...dirty], GetItemId);
});
},
[selections]
);
return (
<Container fluid>
<ContentHeader scroll={false}>
<ContentHeader.Group pos="start">
<Dropdown onSelect={setProfiles}>
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
Change Profile
</Dropdown.Toggle>
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
</Dropdown>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
Cancel
</ContentHeader.Button>
<ContentHeader.AsyncButton
icon={faCheck}
disabled={dirties.length === 0 || hasTask}
promise={save}
onSuccess={onEnded}
>
Save
</ContentHeader.AsyncButton>
</ContentHeader.Group>
</ContentHeader>
<Row>
<SimpleTable
columns={columns}
data={data}
onSelect={setSelections}
plugins={[useRowSelect, useCustomSelection]}
></SimpleTable>
</Row>
</Container>
);
}
export default MassEditor;

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

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

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

@ -0,0 +1,25 @@
import { useLanguageProfiles } from "@/apis/hooks";
import { FunctionComponent, useMemo } from "react";
interface Props {
index: number | null;
className?: string;
empty?: string;
}
const LanguageProfile: FunctionComponent<Props> = ({
index,
className,
empty = "Unknown Profile",
}) => {
const { data } = useLanguageProfiles();
const name = useMemo(
() => data?.find((v) => v.profileId === index)?.name ?? empty,
[data, empty, index]
);
return <span className={className}>{name}</span>;
};
export default LanguageProfile;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,90 +0,0 @@
import { useCallback, useContext, useMemo } from "react";
import { useDidUpdate } from "rooks";
import { log } from "utilities/logger";
import { ModalContext } from "./provider";
interface ModalInformation<T> {
isShow: boolean;
payload: T | null;
closeModal: ReturnType<typeof useCloseModal>;
}
export function useModalInformation<T>(key: string): ModalInformation<T> {
const isShow = useIsModalShow(key);
const payload = useModalPayload<T>(key);
const closeModal = useCloseModal();
return useMemo(
() => ({
isShow,
payload,
closeModal,
}),
[isShow, payload, closeModal]
);
}
export function useShowModal() {
const {
control: { push },
} = useContext(ModalContext);
return useCallback(
<T,>(key: string, payload?: T) => {
log("info", `modal ${key} sending payload`, payload);
push({ key, payload });
},
[push]
);
}
export function useCloseModal() {
const {
control: { pop },
} = useContext(ModalContext);
return useCallback(
(key?: string) => {
pop(key);
},
[pop]
);
}
export function useIsModalShow(key: string) {
const {
control: { peek },
} = useContext(ModalContext);
const modal = peek();
return key === modal?.key;
}
export function useOnModalShow<T>(
callback: (payload: T | null) => void,
key: string
) {
const {
modals,
control: { peek },
} = useContext(ModalContext);
useDidUpdate(() => {
const modal = peek();
if (modal && modal.key === key) {
callback(modal.payload ?? null);
}
}, [modals.length, key]);
}
export function useModalPayload<T>(key: string): T | null {
const {
control: { peek },
} = useContext(ModalContext);
return useMemo(() => {
const modal = peek();
if (modal && modal.key === key) {
return (modal.payload as T) ?? null;
} else {
return null;
}
}, [key, peek]);
}

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

@ -1,84 +0,0 @@
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
interface Modal {
key: string;
payload: any;
}
interface ModalControl {
push: (modal: Modal) => void;
peek: () => Modal | undefined;
pop: (key: string | undefined) => void;
}
interface ModalContextType {
modals: Modal[];
control: ModalControl;
}
export const ModalContext = React.createContext<ModalContextType>({
modals: [],
control: {
push: () => {
throw new Error("Unimplemented");
},
pop: () => {
throw new Error("Unimplemented");
},
peek: () => {
throw new Error("Unimplemented");
},
},
});
const ModalProvider: FunctionComponent = ({ children }) => {
const [stack, setStack] = useState<Modal[]>([]);
const push = useCallback<ModalControl["push"]>((model) => {
setStack((old) => {
return [...old, model];
});
}, []);
const pop = useCallback<ModalControl["pop"]>((key) => {
setStack((old) => {
if (old.length === 0) {
return [];
}
if (key === undefined) {
const newOld = old;
newOld.pop();
return newOld;
}
// find key
const index = old.findIndex((v) => v.key === key);
if (index !== -1) {
return old.slice(0, index);
} else {
return old;
}
});
}, []);
const peek = useCallback<ModalControl["peek"]>(() => {
return stack.length > 0 ? stack[stack.length - 1] : undefined;
}, [stack]);
const context = useMemo<ModalContextType>(
() => ({ modals: stack, control: { push, pop, peek } }),
[stack, push, pop, peek]
);
return (
<ModalContext.Provider value={context}>{children}</ModalContext.Provider>
);
};
export default ModalProvider;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save