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
|
||||
# The following environment variables will only be used during development
|
||||
|
||||
# Required
|
||||
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
# VITE_API_KEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
VITE_PROXY_URL=http://127.0.0.1:6767
|
||||
|
||||
# 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
|
||||
REACT_APP_PROXY_SECURE=true
|
||||
VITE_PROXY_SECURE=true
|
||||
|
||||
# Allow websocket connection in Socket.IO
|
||||
REACT_APP_ALLOW_WEBSOCKET=true
|
||||
VITE_ALLOW_WEBSOCKET=true
|
||||
|
||||
# Display update section in settings
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
VITE_CAN_UPDATE=true
|
||||
|
||||
# Display update notification in notification center
|
||||
REACT_APP_HAS_UPDATE=false
|
||||
VITE_HAS_UPDATE=false
|
||||
|
||||
# Display React-Query devtools
|
||||
REACT_APP_QUERY_DEV=false
|
||||
VITE_QUERY_DEV=false
|
||||
|
@ -1,3 +1,15 @@
|
||||
{
|
||||
"extends": "react-app"
|
||||
"rules": {
|
||||
"no-console": "error",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn"
|
||||
},
|
||||
"extends": [
|
||||
"react-app",
|
||||
"plugin:react-hooks/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
]
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
build
|
||||
dist
|
||||
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 { Redirect, Route, Switch, useHistory } from "react-router";
|
||||
import { useDidMount } from "rooks";
|
||||
import { BuildKey, ScrollToTop } from "utilities";
|
||||
import { useNavigationItems } from "../Navigation";
|
||||
import { Navigation } from "../Navigation/nav";
|
||||
import { RouterEmptyPath } from "../pages/404";
|
||||
import { useBadges } from "@/apis/hooks";
|
||||
import App from "@/App";
|
||||
import Lazy from "@/components/Lazy";
|
||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
||||
import Episodes from "@/pages/Episodes";
|
||||
import MoviesHistoryView from "@/pages/History/Movies";
|
||||
import SeriesHistoryView from "@/pages/History/Series";
|
||||
import MovieView from "@/pages/Movies";
|
||||
import MovieDetailView from "@/pages/Movies/Details";
|
||||
import MovieMassEditor from "@/pages/Movies/Editor";
|
||||
import SeriesView from "@/pages/Series";
|
||||
import SeriesMassEditor from "@/pages/Series/Editor";
|
||||
import SettingsGeneralView from "@/pages/Settings/General";
|
||||
import SettingsLanguagesView from "@/pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "@/pages/Settings/Notifications";
|
||||
import SettingsProvidersView from "@/pages/Settings/Providers";
|
||||
import SettingsRadarrView from "@/pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
|
||||
import SettingsSonarrView from "@/pages/Settings/Sonarr";
|
||||
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
|
||||
import SettingsUIView from "@/pages/Settings/UI";
|
||||
import SystemBackupsView from "@/pages/System/Backups";
|
||||
import SystemLogsView from "@/pages/System/Logs";
|
||||
import SystemProvidersView from "@/pages/System/Providers";
|
||||
import SystemReleasesView from "@/pages/System/Releases";
|
||||
import SystemTasksView from "@/pages/System/Tasks";
|
||||
import WantedMoviesView from "@/pages/Wanted/Movies";
|
||||
import WantedSeriesView from "@/pages/Wanted/Series";
|
||||
import { Environment } from "@/utilities";
|
||||
import {
|
||||
faClock,
|
||||
faExclamationTriangle,
|
||||
faFileExcel,
|
||||
faFilm,
|
||||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
lazy,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import Redirector from "./Redirector";
|
||||
import { CustomRouteObject } from "./type";
|
||||
|
||||
const Router: FunctionComponent = () => {
|
||||
const navItems = useNavigationItems();
|
||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||
const Authentication = lazy(() => import("@/pages/Authentication"));
|
||||
const NotFound = lazy(() => import("@/pages/404"));
|
||||
|
||||
const history = useHistory();
|
||||
useDidMount(() => {
|
||||
history.listen(() => {
|
||||
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
|
||||
setTimeout(ScrollToTop);
|
||||
});
|
||||
});
|
||||
function useRoutes(): CustomRouteObject[] {
|
||||
const { data } = useBadges();
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row flex-grow-1 main-router">
|
||||
<Switch>
|
||||
{navItems.map((v, idx) => {
|
||||
if ("routes" in v) {
|
||||
return (
|
||||
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
|
||||
<ParentRouter {...v}></ParentRouter>
|
||||
</Route>
|
||||
);
|
||||
} else if (v.enabled !== false) {
|
||||
return (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "root")}
|
||||
exact
|
||||
path={v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
path: "/",
|
||||
element: <App></App>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Redirector></Redirector>,
|
||||
},
|
||||
{
|
||||
icon: faPlay,
|
||||
name: "Series",
|
||||
path: "series",
|
||||
hidden: !sonarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SeriesView></SeriesView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <SeriesMassEditor></SeriesMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <Episodes></Episodes>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFilm,
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
hidden: !radarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <MovieView></MovieView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <MovieMassEditor></MovieMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <MovieDetailView></MovieDetailView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faClock,
|
||||
name: "History",
|
||||
path: "history",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <SeriesHistoryView></SeriesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <MoviesHistoryView></MoviesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "stats",
|
||||
name: "Statistics",
|
||||
element: (
|
||||
<Lazy>
|
||||
<HistoryStats></HistoryStats>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Wanted",
|
||||
path: "wanted",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
name: "Episodes",
|
||||
path: "series",
|
||||
badge: data?.episodes,
|
||||
hidden: !sonarr,
|
||||
element: <WantedSeriesView></WantedSeriesView>,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
badge: data?.movies,
|
||||
hidden: !radarr,
|
||||
element: <WantedMoviesView></WantedMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFileExcel,
|
||||
name: "Blacklist",
|
||||
path: "blacklist",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <BlacklistSeriesView></BlacklistSeriesView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <BlacklistMoviesView></BlacklistMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Settings",
|
||||
path: "settings",
|
||||
children: [
|
||||
{
|
||||
path: "general",
|
||||
name: "General",
|
||||
element: <SettingsGeneralView></SettingsGeneralView>,
|
||||
},
|
||||
{
|
||||
path: "languages",
|
||||
name: "Languages",
|
||||
element: <SettingsLanguagesView></SettingsLanguagesView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
element: <SettingsProvidersView></SettingsProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "subtitles",
|
||||
name: "Subtitles",
|
||||
element: <SettingsSubtitlesView></SettingsSubtitlesView>,
|
||||
},
|
||||
{
|
||||
path: "sonarr",
|
||||
name: "Sonarr",
|
||||
element: <SettingsSonarrView></SettingsSonarrView>,
|
||||
},
|
||||
{
|
||||
path: "radarr",
|
||||
name: "Radarr",
|
||||
element: <SettingsRadarrView></SettingsRadarrView>,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
name: "Notifications",
|
||||
element: (
|
||||
<SettingsNotificationsView></SettingsNotificationsView>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "scheduler",
|
||||
name: "Scheduler",
|
||||
element: <SettingsSchedulerView></SettingsSchedulerView>,
|
||||
},
|
||||
{
|
||||
path: "ui",
|
||||
name: "UI",
|
||||
element: <SettingsUIView></SettingsUIView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faLaptop,
|
||||
name: "System",
|
||||
path: "system",
|
||||
children: [
|
||||
{
|
||||
path: "tasks",
|
||||
name: "Tasks",
|
||||
element: <SystemTasksView></SystemTasksView>,
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
name: "Logs",
|
||||
element: <SystemLogsView></SystemLogsView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
badge: data?.providers,
|
||||
element: <SystemProvidersView></SystemProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "backup",
|
||||
name: "Backups",
|
||||
element: <SystemBackupsView></SystemBackupsView>,
|
||||
},
|
||||
{
|
||||
path: "status",
|
||||
name: "Status",
|
||||
element: (
|
||||
<Lazy>
|
||||
<SystemStatusView></SystemStatusView>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "releases",
|
||||
name: "Releases",
|
||||
element: <SystemReleasesView></SystemReleasesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<Authentication></Authentication>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<NotFound></NotFound>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Router;
|
||||
const RouterItemContext = createContext<CustomRouteObject[]>([]);
|
||||
|
||||
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
|
||||
path,
|
||||
enabled,
|
||||
component,
|
||||
routes,
|
||||
}) => {
|
||||
if (enabled === false || (component === undefined && routes.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const ParentComponent =
|
||||
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
|
||||
export const Router: FunctionComponent = ({ children }) => {
|
||||
const routes = useRoutes();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path={path} component={ParentComponent}></Route>
|
||||
{routes
|
||||
.filter((v) => v.enabled !== false)
|
||||
.map((v, idx) => (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "route")}
|
||||
exact
|
||||
path={path + v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
))}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
<RouterItemContext.Provider value={routes}>
|
||||
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
|
||||
</RouterItemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 "./HistoryModal";
|
||||
export * from "./hooks";
|
||||
export { default as ItemEditorModal } from "./ItemEditorModal";
|
||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
||||
export { default as ModalProvider } from "./provider";
|
||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
||||
export { default as SubtitleToolModal } from "./SubtitleToolModal";
|
||||
|
@ -1,84 +0,0 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface Modal {
|
||||
key: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
interface ModalControl {
|
||||
push: (modal: Modal) => void;
|
||||
peek: () => Modal | undefined;
|
||||
pop: (key: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
modals: Modal[];
|
||||
control: ModalControl;
|
||||
}
|
||||
|
||||
export const ModalContext = React.createContext<ModalContextType>({
|
||||
modals: [],
|
||||
control: {
|
||||
push: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
pop: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
peek: () => {
|
||||
throw new Error("Unimplemented");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ModalProvider: FunctionComponent = ({ children }) => {
|
||||
const [stack, setStack] = useState<Modal[]>([]);
|
||||
|
||||
const push = useCallback<ModalControl["push"]>((model) => {
|
||||
setStack((old) => {
|
||||
return [...old, model];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pop = useCallback<ModalControl["pop"]>((key) => {
|
||||
setStack((old) => {
|
||||
if (old.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (key === undefined) {
|
||||
const newOld = old;
|
||||
newOld.pop();
|
||||
return newOld;
|
||||
}
|
||||
|
||||
// find key
|
||||
const index = old.findIndex((v) => v.key === key);
|
||||
if (index !== -1) {
|
||||
return old.slice(0, index);
|
||||
} else {
|
||||
return old;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const peek = useCallback<ModalControl["peek"]>(() => {
|
||||
return stack.length > 0 ? stack[stack.length - 1] : undefined;
|
||||
}, [stack]);
|
||||
|
||||
const context = useMemo<ModalContextType>(
|
||||
() => ({ modals: stack, control: { push, pop, peek } }),
|
||||
[stack, push, pop, peek]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={context}>{children}</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalProvider;
|
@ -1,26 +1,33 @@
|
||||
import queryClient from "@/apis/queries";
|
||||
import store from "@/modules/redux/store";
|
||||
import "@/styles/index.scss";
|
||||
import "@fontsource/roboto/300.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./@redux/store";
|
||||
import "./@scss/index.scss";
|
||||
import queryClient from "./apis/queries";
|
||||
import App from "./App";
|
||||
import { Environment, isTestEnv } from "./utilities";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { Router, useRouteItems } from "./Router";
|
||||
import { Environment } from "./utilities";
|
||||
|
||||
const RouteApp = () => {
|
||||
const items = useRouteItems();
|
||||
|
||||
return useRoutes(items);
|
||||
};
|
||||
|
||||
export const Entrance = () => (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
||||
{/* <React.StrictMode> */}
|
||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<App></App>
|
||||
{/* </React.StrictMode> */}
|
||||
<Router>
|
||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
||||
{/* <StrictMode> */}
|
||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<RouteApp></RouteApp>
|
||||
{/* </StrictMode> */}
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
if (!isTestEnv) {
|
||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
||||
}
|
||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { waitFor } from "../../utilities";
|
||||
import { waitFor } from "../../../utilities";
|
||||
|
||||
export const setSiteStatus = createAction<Site.Status>("site/status/update");
|
||||
|
@ -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>;
|