Frontend improvement and cleanup (#1690)
* Replace Create-React-App with Vite.js * Update React-Router to v6 * Cleanup unused codespull/1773/head
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
converage
|
converage
|
||||||
|
public
|
||||||
|
@ -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;
|
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,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>,
|
||||||
);
|
},
|
||||||
} else if (v.enabled !== false) {
|
{
|
||||||
return (
|
icon: faPlay,
|
||||||
<Route
|
name: "Series",
|
||||||
key={BuildKey(idx, v.name, "root")}
|
path: "series",
|
||||||
exact
|
hidden: !sonarr,
|
||||||
path={v.path}
|
children: [
|
||||||
component={v.component}
|
{
|
||||||
></Route>
|
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 {
|
}
|
||||||
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,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);
|
|
||||||
});
|
|
@ -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;
|
@ -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,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,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>;
|