Replace Bootstrap with Mantine (#1795)

pull/1867/head
Liang Yi 2 years ago committed by GitHub
parent 6515c42f26
commit 2cecb4c5b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,12 +1,15 @@
From newest to oldest:
{{#each releases}}
{{#each merges}}
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
-
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each fixes}}
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
-
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each commits}}
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
-
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/each}}
{{/each}}

@ -1,12 +1,15 @@
From newest to oldest:
{{#each releases}}
{{#each merges}}
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
-
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each fixes}}
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
-
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each commits}}
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
-
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/each}}
{{/each}}

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

@ -2,7 +2,6 @@ import { dependencies } from "../package.json";
const vendors = [
"react",
"react-redux",
"react-router-dom",
"react-dom",
"react-query",

@ -18,7 +18,9 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
try {
window.Bazarr = JSON.parse(`{{BAZARR_SERVER_INJECT | tojson | safe}}`);
} catch (error) {}
</script>
<script type="module" src="./src/dom.tsx"></script>
</body>

File diff suppressed because it is too large Load Diff

@ -13,12 +13,12 @@
},
"private": true,
"dependencies": {
"@mantine/core": "^4",
"@mantine/hooks": "^4",
"axios": "^0.26",
"react": "^17",
"react-bootstrap": "^1",
"react-dom": "^17",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^6.2.1",
"socket.io-client": "^4"
},
@ -29,37 +29,32 @@
"@fortawesome/free-regular-svg-icons": "^6",
"@fortawesome/free-solid-svg-icons": "^6",
"@fortawesome/react-fontawesome": "^0.1",
"@reduxjs/toolkit": "^1",
"@mantine/dropzone": "^4",
"@mantine/modals": "^4",
"@mantine/notifications": "^4",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "12",
"@testing-library/react-hooks": "latest",
"@testing-library/user-event": "latest",
"@types/bootstrap": "^4",
"@types/lodash": "^4",
"@types/node": "^17",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-table": "^7",
"@vitejs/plugin-react": "^1.3",
"bootstrap": "^4",
"clsx": "^1.1.1",
"clsx": "^1",
"eslint": "^8",
"eslint-config-react-app": "^7.0.0",
"eslint-config-react-app": "^7",
"eslint-plugin-react-hooks": "^4",
"husky": "^7",
"husky": "^8",
"jsdom": "latest",
"lodash": "^4",
"moment": "^2.29.1",
"prettier": "^2",
"prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1",
"rc-slider": "^9.7",
"react-helmet": "^6.1",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5",
"sass": "^1",
"typescript": "^4",
"vite": "latest",

@ -1,132 +1,118 @@
import { useSystem, useSystemSettings } from "@/apis/hooks";
import { ActionButton, SearchBar } from "@/components";
import { setSidebar } from "@/modules/redux/actions";
import { useIsOffline } from "@/modules/redux/hooks";
import { useReduxAction } from "@/modules/redux/hooks/base";
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
import { Action, Search } from "@/components";
import { Layout } from "@/constants";
import { useNavbar } from "@/contexts/Navbar";
import { useIsOnline } from "@/contexts/Online";
import { Environment, useGotoHomepage } from "@/utilities";
import {
faBars,
faHeart,
faNetworkWired,
faUser,
faArrowRotateLeft,
faGear,
faPowerOff,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FunctionComponent, useMemo } from "react";
import {
Button,
Col,
Container,
Dropdown,
Image,
Navbar,
Row,
} from "react-bootstrap";
import { Helmet } from "react-helmet";
import NotificationCenter from "./Notification";
Anchor,
Avatar,
Badge,
Burger,
createStyles,
Divider,
Group,
Header,
MediaQuery,
Menu,
} from "@mantine/core";
import { FunctionComponent } from "react";
const Header: FunctionComponent = () => {
const useStyles = createStyles((theme) => {
const headerBackgroundColor =
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
return {
header: {
backgroundColor: headerBackgroundColor,
},
};
});
const AppHeader: FunctionComponent = () => {
const { data: settings } = useSystemSettings();
const hasLogout = settings?.auth.type === "form";
const hasLogout = (settings?.auth.type ?? "none") === "form";
const { show, showed } = useNavbar();
const changeSidebar = useReduxAction(setSidebar);
const online = useIsOnline();
const offline = !online;
const offline = useIsOffline();
const { shutdown, restart, logout } = useSystem();
const isMobile = useIsMobile();
const goHome = useGotoHomepage();
const { shutdown, restart, logout } = useSystem();
const { classes } = useStyles();
const serverActions = useMemo(
() => (
<Dropdown alignRight>
<Dropdown.Toggle className="hide-arrow" as={Button}>
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={() => {
restart();
}}
return (
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
<Group position="apart" noWrap>
<Group noWrap>
<MediaQuery
smallerThan={Layout.MOBILE_BREAKPOINT}
styles={{ display: "none" }}
>
Restart
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
shutdown();
}}
<Anchor onClick={goHome}>
<Avatar
alt="brand"
size={32}
src={`${Environment.baseUrl}/static/logo64.png`}
></Avatar>
</Anchor>
</MediaQuery>
<MediaQuery
largerThan={Layout.MOBILE_BREAKPOINT}
styles={{ display: "none" }}
>
Shutdown
</Dropdown.Item>
<Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
<Dropdown.Item
hidden={!hasLogout}
onClick={() => {
logout();
}}
<Burger
opened={showed}
onClick={() => show(!showed)}
size="sm"
></Burger>
</MediaQuery>
<Badge size="lg" radius="sm">
Bazarr
</Badge>
</Group>
<Group spacing="xs" position="right" noWrap>
<Search></Search>
<Menu
control={
<Action
loading={offline}
color={offline ? "yellow" : undefined}
icon={faGear}
size="lg"
variant="light"
></Action>
}
>
Logout
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
),
[hasLogout, logout, restart, shutdown]
);
const goHome = useGotoHomepage();
return (
<Navbar bg="primary" className="flex-grow-1 px-0">
<Helmet>
<meta name="theme-color" content="#911f93" />
</Helmet>
<div className="header-icon px-3 m-0 d-none d-md-block">
<Image
alt="brand"
src={`${Environment.baseUrl}/static/logo64.png`}
width="32"
height="32"
onClick={goHome}
role="button"
></Image>
</div>
<Button
className="mx-2 m-0 d-md-none"
onClick={() => changeSidebar(true)}
>
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
</Button>
<Container fluid>
<Row noGutters className="flex-grow-1">
<Col xs={4} sm={6} className="d-flex align-items-center">
<SearchBar></SearchBar>
</Col>
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
<NotificationCenter></NotificationCenter>
<Button
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
target="_blank"
<Menu.Item
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
onClick={() => restart()}
>
Restart
</Menu.Item>
<Menu.Item
icon={<FontAwesomeIcon icon={faPowerOff} />}
onClick={() => shutdown()}
>
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
</Button>
{offline ? (
<ActionButton
loading
alwaysShowText
className="ml-2"
variant="warning"
icon={faNetworkWired}
>
{isMobile ? "" : "Connecting..."}
</ActionButton>
) : (
serverActions
)}
</Col>
</Row>
</Container>
</Navbar>
Shutdown
</Menu.Item>
<Divider hidden={!hasLogout}></Divider>
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
Logout
</Menu.Item>
</Menu>
</Group>
</Group>
</Header>
);
};
export default Header;
export default AppHeader;

@ -0,0 +1,344 @@
import { Action } from "@/components";
import { Layout } from "@/constants";
import { useNavbar } from "@/contexts/Navbar";
import { useRouteItems } from "@/Router";
import { CustomRouteObject, Route } from "@/Router/type";
import { BuildKey, pathJoin } from "@/utilities";
import { LOG } from "@/utilities/console";
import {
faHeart,
faMoon,
faSun,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Anchor,
Badge,
Collapse,
createStyles,
Divider,
Group,
Navbar as MantineNavbar,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { useHover } from "@mantine/hooks";
import clsx from "clsx";
import {
createContext,
FunctionComponent,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
const Selection = createContext<{
selection: string | null;
select: (path: string | null) => void;
}>({
selection: null,
select: () => {
LOG("error", "Selection context not initialized");
},
});
function useSelection() {
return useContext(Selection);
}
function useBadgeValue(route: Route.Item) {
const { badge, children } = route;
return useMemo(() => {
let value = badge ?? 0;
if (children === undefined) {
return value;
}
value +=
children.reduce((acc, child: Route.Item) => {
if (child.badge && child.hidden !== true) {
return acc + (child.badge ?? 0);
}
return acc;
}, 0) ?? 0;
return value === 0 ? undefined : value;
}, [badge, children]);
}
function useIsActive(parent: string, route: RouteObject) {
const { path, children } = route;
const { pathname } = useLocation();
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const paths = useMemo(
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
[root, children]
);
const selection = useSelection().selection;
return useMemo(
() =>
selection?.includes(root) ||
paths.some((path) => matchPath(path, pathname)),
[pathname, paths, root, selection]
);
}
const AppNavbar: FunctionComponent = () => {
const { showed } = useNavbar();
const [selection, select] = useState<string | null>(null);
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const routes = useRouteItems();
const { pathname } = useLocation();
useEffect(() => {
select(null);
}, [pathname]);
return (
<MantineNavbar
p="xs"
hiddenBreakpoint={Layout.MOBILE_BREAKPOINT}
hidden={!showed}
width={{ [Layout.MOBILE_BREAKPOINT]: Layout.NAVBAR_WIDTH }}
styles={(theme) => ({
root: {
backgroundColor:
theme.colorScheme === "light"
? theme.colors.gray[2]
: theme.colors.dark[6],
},
})}
>
<Selection.Provider value={{ selection, select }}>
<MantineNavbar.Section grow>
<Stack spacing={0}>
{routes.map((route, idx) => (
<RouteItem
key={BuildKey("nav", idx)}
parent="/"
route={route}
></RouteItem>
))}
</Stack>
</MantineNavbar.Section>
<Divider></Divider>
<MantineNavbar.Section mt="xs">
<Group spacing="xs">
<Action
color={dark ? "yellow" : "indigo"}
variant="hover"
onClick={() => toggleColorScheme()}
icon={dark ? faSun : faMoon}
></Action>
<Anchor
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
target="_blank"
>
<Action icon={faHeart} variant="hover" color="red"></Action>
</Anchor>
</Group>
</MantineNavbar.Section>
</Selection.Provider>
</MantineNavbar>
);
};
const RouteItem: FunctionComponent<{
route: CustomRouteObject;
parent: string;
}> = ({ route, parent }) => {
const { children, name, path, icon, hidden, element } = route;
const { select } = useSelection();
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const badge = useBadgeValue(route);
const isOpen = useIsActive(parent, route);
// Ignore path if it is using match
if (hidden === true || path === undefined || path.includes(":")) {
return null;
}
if (children !== undefined) {
const elements = (
<Stack spacing={0}>
{children.map((child, idx) => (
<RouteItem
parent={link}
key={BuildKey(link, "nav", idx)}
route={child}
></RouteItem>
))}
</Stack>
);
if (name) {
return (
<Stack spacing={0}>
<NavbarItem
primary
name={name}
link={link}
icon={icon}
badge={badge}
onClick={(event) => {
LOG("info", "clicked", link);
const validated =
element !== undefined ||
children?.find((v) => v.index === true) !== undefined;
if (!validated) {
event.preventDefault();
}
if (isOpen) {
select(null);
} else {
select(link);
}
}}
></NavbarItem>
<Collapse hidden={children.length === 0} in={isOpen}>
{elements}
</Collapse>
</Stack>
);
} else {
return elements;
}
} else {
return (
<NavbarItem
name={name ?? link}
link={link}
icon={icon}
badge={badge}
></NavbarItem>
);
}
};
const useStyles = createStyles((theme) => {
const borderColor =
theme.colorScheme === "light" ? theme.colors.gray[5] : theme.colors.dark[4];
const activeBorderColor =
theme.colorScheme === "light"
? theme.colors.brand[4]
: theme.colors.brand[8];
const activeBackgroundColor =
theme.colorScheme === "light" ? theme.colors.gray[1] : theme.colors.dark[8];
const hoverBackgroundColor =
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
return {
text: { display: "inline-flex", alignItems: "center", width: "100%" },
anchor: {
textDecoration: "none",
borderLeft: `2px solid ${borderColor}`,
},
active: {
backgroundColor: activeBackgroundColor,
borderLeft: `2px solid ${activeBorderColor}`,
boxShadow: theme.shadows.xs,
},
hover: {
backgroundColor: hoverBackgroundColor,
},
icon: { width: "1.4rem", marginRight: theme.spacing.xs },
badge: {
marginLeft: "auto",
textDecoration: "none",
boxShadow: theme.shadows.xs,
},
};
});
interface NavbarItemProps {
name: string;
link: string;
icon?: IconDefinition;
badge?: number;
primary?: boolean;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const NavbarItem: FunctionComponent<NavbarItemProps> = ({
icon,
link,
name,
badge,
onClick,
primary = false,
}) => {
const { classes } = useStyles();
const { show } = useNavbar();
const { ref, hovered } = useHover();
return (
<NavLink
to={link}
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (!event.isDefaultPrevented()) {
show(false);
}
}}
className={({ isActive }) =>
clsx(
clsx(classes.anchor, {
[classes.active]: isActive,
[classes.hover]: hovered,
})
)
}
>
<Text
ref={ref}
inline
p="xs"
size="sm"
color="gray"
weight={primary ? "bold" : "normal"}
className={classes.text}
>
{icon && (
<FontAwesomeIcon
className={classes.icon}
icon={icon}
></FontAwesomeIcon>
)}
{name}
<Badge
className={classes.badge}
color="gray"
radius="xs"
hidden={badge === undefined || badge === 0}
>
{badge}
</Badge>
</Text>
</NavLink>
);
};
export default AppNavbar;

@ -1,241 +0,0 @@
import { useReduxStore } from "@/modules/redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "@/utilities";
import {
faBug,
faCircleNotch,
faExclamationTriangle,
faInfoCircle,
faStream,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import {
Fragment,
FunctionComponent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Dropdown,
Overlay,
ProgressBar,
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
enum State {
Idle,
Working,
Failed,
}
function useTotalProgress(progress: Site.Progress[]) {
return useMemo(() => {
const { value, count } = progress.reduce(
(prev, { value, count }) => {
prev.value += value;
prev.count += count;
return prev;
},
{ value: 0, count: 0 }
);
if (count === 0) {
return 0;
} else {
return (value + 0.001) / count;
}
}, [progress]);
}
function useHasErrorNotification(notifications: Server.Notification[]) {
return useMemo(
() => notifications.find((v) => v.type !== "info") !== undefined,
[notifications]
);
}
const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);
const hasNewProgress = useIsArrayExtended(progress);
const hasNewNotifications = useIsArrayExtended(notifications);
useDidUpdate(() => {
if (hasNewNotifications || hasNewProgress) {
setHasNew(true);
}
}, [hasNewProgress, hasNewNotifications]);
useDidUpdate(() => {
if (progress.length === 0 && notifications.length === 0) {
setHasNew(false);
}
}, [progress.length, notifications.length]);
const [btnState, setBtnState] = useState(State.Idle);
const totalProgress = useTotalProgress(progress);
const hasError = useHasErrorNotification(notifications);
useEffect(() => {
if (hasError) {
setBtnState(State.Failed);
} else if (totalProgress > 0 && totalProgress < 1.0) {
setBtnState(State.Working);
} else {
setBtnState(State.Idle);
}
}, [totalProgress, hasError]);
const iconProps = useMemo<FontAwesomeIconProps>(() => {
switch (btnState) {
case State.Idle:
return {
icon: faStream,
};
case State.Working:
return {
icon: faCircleNotch,
spin: true,
};
default:
return {
icon: faExclamationTriangle,
};
}
}, [btnState]);
const content = useMemo<ReactNode>(() => {
const nodes: JSX.Element[] = [];
nodes.push(
<Dropdown.Header key="notifications-header">
{notifications.length > 0 ? "Notifications" : "No Notifications"}
</Dropdown.Header>
);
nodes.push(
...notifications.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
<Notification {...v}></Notification>
</Dropdown.Item>
))
);
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
nodes.push(
<Dropdown.Header key="background-task-header">
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
</Dropdown.Header>
);
nodes.push(
...progress.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
<Progress {...v}></Progress>
</Dropdown.Item>
))
);
return nodes;
}, [progress, notifications]);
const onToggleClick = useCallback(() => {
setHasNew(false);
}, []);
// Tooltip Controller
const [showTooltip, setTooltip] = useState(false);
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
useDidUpdate(() => {
if (notifier.content) {
setTooltip(true);
}
}, [notifier.timestamp]);
return (
<Fragment>
<Dropdown
onClick={onToggleClick}
className={`notification-btn ${hasNew ? "new-item" : ""}`}
ref={dropdownRef}
alignRight
>
<Dropdown.Toggle as={Button} className="hide-arrow">
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
</Dropdown>
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
{(props) => {
return (
<Tooltip id="new-notification-tip" {...props}>
{notifier.content}
</Tooltip>
);
}}
</Overlay>
</Fragment>
);
};
const Notification: FunctionComponent<Server.Notification> = ({
type,
message,
}) => {
const icon = useMemo<IconDefinition>(() => {
switch (type) {
case "info":
return faInfoCircle;
case "warning":
return faExclamationTriangle;
default:
return faBug;
}
}, [type]);
return (
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
<span className="text-dark small">{message}</span>
</div>
);
};
const Progress: FunctionComponent<Site.Progress> = ({
name,
value,
count,
header,
}) => {
const isCompleted = value / count > 1;
const displayValue = Math.min(count, value + 1);
return (
<div className="notification-center-progress d-flex flex-column">
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
{header}
</p>
<p className="progress-name m-0 small text-secondary">
{isCompleted ? "Completed successfully" : name}
</p>
<ProgressBar
className="mt-2"
animated={!isCompleted}
now={displayValue / count}
max={1}
label={`${displayValue}/${count}`}
></ProgressBar>
</div>
);
};
export default NotificationCenter;

@ -1,58 +1,67 @@
import { LoadingIndicator } from "@/components";
import AppNavbar from "@/App/Navbar";
import ErrorBoundary from "@/components/ErrorBoundary";
import { useNotification } from "@/modules/redux/hooks";
import { useReduxStore } from "@/modules/redux/hooks/base";
import SocketIO from "@/modules/socketio";
import LaunchError from "@/pages/LaunchError";
import Sidebar from "@/Sidebar";
import { Layout } from "@/constants";
import NavbarProvider from "@/contexts/Navbar";
import OnlineProvider from "@/contexts/Online";
import { notification } from "@/modules/task";
import CriticalError from "@/pages/CriticalError";
import { Environment } from "@/utilities";
import { FunctionComponent, useEffect } from "react";
import { Row } from "react-bootstrap";
import { Navigate, Outlet } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
import Header from "./Header";
import { AppShell } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { FunctionComponent, useEffect, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import AppHeader from "./Header";
const App: FunctionComponent = () => {
const { status } = useReduxStore((s) => s.site);
const navigate = useNavigate();
useEffect(() => {
SocketIO.initialize();
}, []);
const [criticalError, setCriticalError] = useState<string | null>(null);
const [navbar, setNavbar] = useState(false);
const [online, setOnline] = useState(true);
useWindowEvent("app-critical-error", ({ detail }) => {
setCriticalError(detail.message);
});
useWindowEvent("app-login-required", () => {
navigate("/login");
});
const notify = useNotification("has-update", 10 * 1000);
useWindowEvent("app-online-status", ({ detail }) => {
setOnline(detail.online);
});
// Has any update?
useEffectOnceWhen(() => {
useEffect(() => {
if (Environment.hasUpdate) {
notify({
type: "info",
message: "A new version of Bazarr is ready, restart is required",
// TODO: Restart action
});
showNotification(
notification.info(
"Update available",
"A new version of Bazarr is ready, restart is required"
)
);
}
}, status === "initialized");
if (status === "unauthenticated") {
return <Navigate to="/login"></Navigate>;
} else if (status === "uninitialized") {
return (
<LoadingIndicator>
<span>Please wait</span>
</LoadingIndicator>
);
} else if (status === "error") {
return <LaunchError>Cannot Initialize Bazarr</LaunchError>;
}, []);
if (criticalError !== null) {
return <CriticalError message={criticalError}></CriticalError>;
}
return (
<ErrorBoundary>
<Row noGutters className="header-container">
<Header></Header>
</Row>
<Row noGutters className="flex-nowrap">
<Sidebar></Sidebar>
<Outlet></Outlet>
</Row>
<NavbarProvider value={{ showed: navbar, show: setNavbar }}>
<OnlineProvider value={{ online, setOnline }}>
<AppShell
navbarOffsetBreakpoint={Layout.MOBILE_BREAKPOINT}
header={<AppHeader></AppHeader>}
navbar={<AppNavbar></AppNavbar>}
padding={0}
fixed
>
<Outlet></Outlet>
</AppShell>
</OnlineProvider>
</NavbarProvider>
</ErrorBoundary>
);
};

@ -0,0 +1,72 @@
import {
ColorScheme,
ColorSchemeProvider,
MantineProvider,
MantineThemeOverride,
} from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { FunctionComponent, useCallback, useEffect, useState } from "react";
const theme: MantineThemeOverride = {
fontFamily: [
"Roboto",
"open sans",
"Helvetica Neue",
"Helvetica",
"Arial",
"sans-serif",
],
colors: {
brand: [
"#F8F0FC",
"#F3D9FA",
"#EEBEFA",
"#E599F7",
"#DA77F2",
"#CC5DE8",
"#BE4BDB",
"#AE3EC9",
"#9C36B5",
"#862E9C",
],
},
primaryColor: "brand",
};
function useAutoColorScheme() {
const preferredColorScheme = useColorScheme();
const [colorScheme, setColorScheme] = useState(preferredColorScheme);
// automatically switch dark/light theme
useEffect(() => {
setColorScheme(preferredColorScheme);
}, [preferredColorScheme]);
const toggleColorScheme = useCallback((value?: ColorScheme) => {
setColorScheme((scheme) => value || (scheme === "dark" ? "light" : "dark"));
}, []);
return { colorScheme, setColorScheme, toggleColorScheme };
}
const ThemeProvider: FunctionComponent = ({ children }) => {
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
return (
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{ colorScheme, ...theme }}
emotionOptions={{ key: "bazarr" }}
>
{children}
</MantineProvider>
</ColorSchemeProvider>
);
};
export default ThemeProvider;

@ -1,18 +1,27 @@
import { useEnabledStatus } from "@/modules/redux/hooks";
import { FunctionComponent } from "react";
import { Navigate } from "react-router-dom";
import { useSystemSettings } from "@/apis/hooks";
import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, useEffect } from "react";
import { useNavigate } from "react-router-dom";
const Redirector: FunctionComponent = () => {
const { sonarr, radarr } = useEnabledStatus();
const { data } = useSystemSettings();
let path = "/settings/general";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "/movies";
}
const navigate = useNavigate();
return <Navigate to={path}></Navigate>;
useEffect(() => {
if (data) {
const { use_sonarr, use_radarr } = data.general;
if (use_sonarr) {
navigate("/series");
} else if (use_radarr) {
navigate("/movies");
} else {
navigate("/settings/general");
}
}
}, [data, navigate]);
return <LoadingOverlay visible></LoadingOverlay>;
};
export default Redirector;

@ -1,7 +1,8 @@
import { useBadges } from "@/apis/hooks";
import { useEnabledStatus } from "@/apis/hooks/site";
import App from "@/App";
import Lazy from "@/components/Lazy";
import { useEnabledStatus } from "@/modules/redux/hooks";
import { Lazy } from "@/components/async";
import Authentication from "@/pages/Authentication";
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import BlacklistSeriesView from "@/pages/Blacklist/Series";
import Episodes from "@/pages/Episodes";
@ -10,6 +11,7 @@ import SeriesHistoryView from "@/pages/History/Series";
import MovieView from "@/pages/Movies";
import MovieDetailView from "@/pages/Movies/Details";
import MovieMassEditor from "@/pages/Movies/Editor";
import NotFound from "@/pages/NotFound";
import SeriesView from "@/pages/Series";
import SeriesMassEditor from "@/pages/Series/Editor";
import SettingsGeneralView from "@/pages/Settings/General";
@ -38,7 +40,7 @@ import {
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import React, {
import {
createContext,
FunctionComponent,
lazy,
@ -51,8 +53,6 @@ import { CustomRouteObject } from "./type";
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"));
function useRoutes(): CustomRouteObject[] {
const { data } = useBadges();
@ -277,25 +277,17 @@ function useRoutes(): CustomRouteObject[] {
},
],
},
{
path: "*",
hidden: true,
element: <NotFound></NotFound>,
},
],
},
{
path: "/login",
hidden: true,
element: (
<Lazy>
<Authentication></Authentication>
</Lazy>
),
},
{
path: "*",
hidden: true,
element: (
<Lazy>
<NotFound></NotFound>
</Lazy>
),
element: <Authentication></Authentication>,
},
],
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]

@ -1,256 +0,0 @@
import { setSidebar } from "@/modules/redux/actions";
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
import { useRouteItems } from "@/Router";
import { CustomRouteObject, Route } from "@/Router/type";
import { BuildKey, Environment, pathJoin } from "@/utilities";
import { LOG } from "@/utilities/console";
import { useGotoHomepage } from "@/utilities/hooks";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import {
createContext,
FunctionComponent,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
Badge,
Collapse,
Container,
Image,
ListGroup,
ListGroupItem,
} from "react-bootstrap";
import {
matchPath,
NavLink,
RouteObject,
useLocation,
useNavigate,
} from "react-router-dom";
const Selection = createContext<{
selection: string | null;
select: (path: string | null) => void;
}>({
selection: null,
select: () => {
LOG("error", "Selection context not initialized");
},
});
function useSelection() {
return useContext(Selection);
}
function useBadgeValue(route: Route.Item) {
const { badge, children } = route;
return useMemo(() => {
let value = badge ?? 0;
if (children === undefined) {
return value;
}
value +=
children.reduce((acc, child: Route.Item) => {
if (child.badge && child.hidden !== true) {
return acc + (child.badge ?? 0);
}
return acc;
}, 0) ?? 0;
return value === 0 ? undefined : value;
}, [badge, children]);
}
function useIsActive(parent: string, route: RouteObject) {
const { path, children } = route;
const { pathname } = useLocation();
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const paths = useMemo(
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
[root, children]
);
const selection = useSelection().selection;
return useMemo(
() =>
selection?.includes(root) ||
paths.some((path) => matchPath(path, pathname)),
[pathname, paths, root, selection]
);
}
// Actual sidebar
const Sidebar: FunctionComponent = () => {
const [selection, select] = useState<string | null>(null);
const isShow = useReduxStore((s) => s.site.showSidebar);
const showSidebar = useReduxAction(setSidebar);
const goHome = useGotoHomepage();
const routes = useRouteItems();
const { pathname } = useLocation();
useEffect(() => {
select(null);
}, [pathname]);
return (
<Selection.Provider value={{ selection, select }}>
<nav className={clsx("sidebar-container", { open: isShow })}>
<Container className="sidebar-title d-flex align-items-center d-md-none">
<Image
alt="brand"
src={`${Environment.baseUrl}/static/logo64.png`}
width="32"
height="32"
onClick={goHome}
className="cursor-pointer"
></Image>
</Container>
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
{routes.map((route, idx) => (
<RouteItem
key={BuildKey("nav", idx)}
parent="/"
route={route}
></RouteItem>
))}
</ListGroup>
</nav>
<div
className={clsx("sidebar-overlay", { open: isShow })}
onClick={() => showSidebar(false)}
></div>
</Selection.Provider>
);
};
const RouteItem: FunctionComponent<{
route: CustomRouteObject;
parent: string;
}> = ({ route, parent }) => {
const { children, name, path, icon, hidden, element } = route;
const isValidated = useMemo(
() =>
element !== undefined ||
children?.find((v) => v.index === true) !== undefined,
[element, children]
);
const { select } = useSelection();
const navigate = useNavigate();
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const badge = useBadgeValue(route);
const isOpen = useIsActive(parent, route);
if (hidden === true) {
return null;
}
// Ignore path if it is using match
if (path === undefined || path.includes(":")) {
return null;
}
if (children !== undefined) {
const elements = children.map((child, idx) => (
<RouteItem
parent={link}
key={BuildKey(link, "nav", idx)}
route={child}
></RouteItem>
));
if (name) {
return (
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
<ListGroupItem
action
className={clsx("button", { active: isOpen })}
onClick={() => {
LOG("info", "clicked", link);
if (isValidated) {
navigate(link);
}
if (isOpen) {
select(null);
} else {
select(link);
}
}}
>
<RouteItemContent
name={name ?? link}
icon={icon}
badge={badge}
></RouteItemContent>
</ListGroupItem>
<Collapse in={isOpen}>
<div className="indent">{elements}</div>
</Collapse>
</div>
);
} else {
return <>{elements}</>;
}
} else {
return (
<NavLink
to={link}
className={({ isActive }) =>
clsx("list-group-item list-group-item-action button sb-collapse", {
active: isActive,
})
}
>
<RouteItemContent
name={name ?? link}
icon={icon}
badge={badge}
></RouteItemContent>
</NavLink>
);
}
};
interface ItemComponentProps {
name: string;
icon?: IconDefinition;
badge?: number;
}
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
icon,
name,
badge,
}) => {
return (
<>
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
<span className="d-flex flex-grow-1 justify-content-between">
{name}
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
{badge}
</Badge>
</span>
</>
);
};
export default Sidebar;

@ -0,0 +1,15 @@
import { useSystemSettings } from ".";
export function useEnabledStatus() {
const { data } = useSystemSettings();
return {
sonarr: data?.general.use_sonarr ?? false,
radarr: data?.general.use_radarr ?? false,
};
}
export function useShowOnlyDesired() {
const { data } = useSystemSettings();
return data?.general.embedded_subs_show_desired ?? false;
}

@ -1,7 +1,7 @@
import { Environment } from "@/utilities";
import { setLoginRequired } from "@/utilities/event";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { setUnauthenticated } from "../../modules/redux/actions";
import store from "../../modules/redux/store";
import { QueryKeys } from "../queries/keys";
import api from "../raw";
@ -173,7 +173,7 @@ export function useSystem() {
() => api.system.logout(),
{
onSuccess: () => {
store.dispatch(setUnauthenticated());
setLoginRequired();
client.clear();
},
}
@ -185,7 +185,8 @@ export function useSystem() {
api.system.login(param.username, param.password),
{
onSuccess: () => {
window.location.reload();
// TODO: Hard-coded value
window.location.replace(`/${Environment.baseUrl}`);
},
}
);
@ -216,7 +217,7 @@ export function useSystem() {
shutdown,
restart,
login,
isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
isMutating: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
}),
[
isLoggingIn,

@ -1,15 +1,15 @@
import SocketIO from "@/modules/socketio";
import { setLoginRequired } from "@/utilities/event";
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { setUnauthenticated } from "../../modules/redux/actions";
import { AppDispatch } from "../../modules/redux/store";
import { Environment, isProdEnv } from "../../utilities";
import { Environment } from "../../utilities";
class BazarrClient {
axios!: AxiosInstance;
source!: CancelTokenSource;
dispatch!: AppDispatch;
constructor() {
const baseUrl = `${Environment.baseUrl}/api/`;
this.initialize(baseUrl, Environment.apiKey);
SocketIO.initialize();
}
initialize(url: string, apikey?: string) {
@ -48,16 +48,10 @@ class BazarrClient {
);
}
_resetApi(apikey: string) {
if (!isProdEnv) {
this.axios.defaults.headers.common["X-API-KEY"] = apikey;
}
}
handleError(code: number) {
switch (code) {
case 401:
this.dispatch(setUnauthenticated());
setLoginRequired();
break;
case 500:
break;

@ -1,4 +1,4 @@
import { GetItemId } from "@/utilities";
import { GetItemId, useOnValueChange } from "@/utilities";
import { usePageSize } from "@/utilities/storage";
import { useCallback, useEffect, useState } from "react";
import {
@ -13,17 +13,14 @@ export type UsePaginationQueryResult<T extends object> = UseQueryResult<
DataWrapperWithTotal<T>
> & {
controls: {
previousPage: () => void;
nextPage: () => void;
gotoPage: (index: number) => void;
};
paginationStatus: {
isPageLoading: boolean;
totalCount: number;
pageSize: number;
pageCount: number;
page: number;
canPrevious: boolean;
canNext: boolean;
};
};
@ -67,16 +64,6 @@ export function usePaginationQuery<
const totalCount = data?.total ?? 0;
const pageCount = Math.ceil(totalCount / pageSize);
const previousPage = useCallback(() => {
setIndex((index) => Math.max(0, index - 1));
}, []);
const nextPage = useCallback(() => {
if (pageCount > 0) {
setIndex((index) => Math.min(pageCount - 1, index + 1));
}
}, [pageCount]);
const gotoPage = useCallback(
(idx: number) => {
if (idx >= 0 && idx < pageCount) {
@ -86,6 +73,20 @@ export function usePaginationQuery<
[pageCount]
);
const [isPageLoading, setIsPageLoading] = useState(false);
useOnValueChange(page, () => {
if (results.isFetching) {
setIsPageLoading(true);
}
});
useEffect(() => {
if (!results.isFetching) {
setIsPageLoading(false);
}
}, [results.isFetching]);
// Reset page index if we out of bound
useEffect(() => {
if (pageCount === 0) return;
@ -100,17 +101,14 @@ export function usePaginationQuery<
return {
...results,
paginationStatus: {
isPageLoading,
totalCount,
pageCount,
pageSize,
page,
canPrevious: page > 0,
canNext: page < pageCount - 1,
},
controls: {
gotoPage,
previousPage,
nextPage,
},
};
}

@ -1,204 +0,0 @@
import { BuildKey, isMovie } from "@/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import {
faBookmark as farBookmark,
faClone as fasClone,
faFolder,
} from "@fortawesome/free-regular-svg-icons";
import {
faBookmark,
faLanguage,
faMusic,
faStream,
faTags,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FunctionComponent, useMemo } from "react";
import {
Badge,
Col,
Container,
Image,
OverlayTrigger,
Popover,
Row,
} from "react-bootstrap";
import Language from "./bazarr/Language";
interface Props {
item: Item.Base;
details?: { icon: IconDefinition; text: string }[];
}
const ItemOverview: FunctionComponent<Props> = (props) => {
const { item, details } = props;
const detailBadges = useMemo(() => {
const badges: (JSX.Element | null)[] = [];
badges.push(
<DetailBadge key="file-path" icon={faFolder} desc="File Path">
{item.path}
</DetailBadge>
);
badges.push(
...(details?.map((val, idx) => (
<DetailBadge key={BuildKey(idx, "detail", val.text)} icon={val.icon}>
{val.text}
</DetailBadge>
)) ?? [])
);
if (item.tags.length > 0) {
badges.push(
<DetailBadge key="tags" icon={faTags} desc="Tags">
{item.tags.join("|")}
</DetailBadge>
);
}
return badges;
}, [details, item.path, item.tags]);
const audioBadges = useMemo(
() =>
item.audio_language.map((v, idx) => (
<DetailBadge
key={BuildKey(idx, "audio", v.code2)}
icon={faMusic}
desc="Audio Language"
>
{v.name}
</DetailBadge>
)),
[item.audio_language]
);
const profile = useLanguageProfileBy(item.profileId);
const profileItems = useProfileItemsToLanguages(profile);
const languageBadges = useMemo(() => {
const badges: (JSX.Element | null)[] = [];
if (profile) {
badges.push(
<DetailBadge
key="language-profile"
icon={faStream}
desc="Languages Profile"
>
{profile.name}
</DetailBadge>
);
badges.push(
...profileItems.map((v, idx) => (
<DetailBadge
key={BuildKey(idx, "lang", v.code2)}
icon={faLanguage}
desc="Language"
>
<Language.Text long value={v}></Language.Text>
</DetailBadge>
))
);
}
return badges;
}, [profile, profileItems]);
const alternativePopover = useMemo(
() => (
<Popover id="item-overview-alternative">
<Popover.Title>Alternate Titles</Popover.Title>
<Popover.Content>
{item.alternativeTitles.map((v, idx) => (
<li key={idx}>{v}</li>
))}
</Popover.Content>
</Popover>
),
[item.alternativeTitles]
);
return (
<Container
fluid
style={{
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "top center",
backgroundImage: `url('${item.fanart}')`,
}}
>
<Row
className="p-4 pb-4"
style={{
backgroundColor: "rgba(0,0,0,0.7)",
}}
>
<Col sm="auto">
<Image
className="d-none d-sm-block my-2"
style={{
maxHeight: 250,
}}
src={item.poster}
></Image>
</Col>
<Col>
<Container fluid className="text-white">
<Row>
{isMovie(item) ? (
<FontAwesomeIcon
className="mx-2 mt-2"
title={item.monitored ? "monitored" : "unmonitored"}
icon={item.monitored ? faBookmark : farBookmark}
size="2x"
></FontAwesomeIcon>
) : null}
<h1>{item.title}</h1>
<span hidden={item.alternativeTitles.length === 0}>
<OverlayTrigger overlay={alternativePopover}>
<FontAwesomeIcon
className="mx-2"
icon={fasClone}
></FontAwesomeIcon>
</OverlayTrigger>
</span>
</Row>
<Row>{detailBadges}</Row>
<Row>{audioBadges}</Row>
<Row>{languageBadges}</Row>
<Row>
<span>{item.overview}</span>
</Row>
</Container>
</Col>
</Row>
</Container>
);
};
interface ItemBadgeProps {
icon: IconDefinition;
children: string | JSX.Element;
desc?: string;
}
const DetailBadge: FunctionComponent<ItemBadgeProps> = ({
icon,
desc,
children,
}) => (
<Badge title={desc} variant="secondary" className="mr-2 my-1 text-truncate">
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
<span className="ml-1">{children}</span>
</Badge>
);
export default ItemOverview;

@ -1,44 +0,0 @@
import { Selector, SelectorOption, SelectorProps } from "@/components";
import { useMemo } from "react";
interface Props {
options: readonly Language.Info[];
}
type RemovedSelectorProps<M extends boolean> = Omit<
SelectorProps<Language.Info, M>,
"label"
>;
export type LanguageSelectorProps<M extends boolean> = Override<
Props,
RemovedSelectorProps<M>
>;
function getLabel(lang: Language.Info) {
return lang.name;
}
export function LanguageSelector<M extends boolean = false>(
props: LanguageSelectorProps<M>
) {
const { options, ...selector } = props;
const items = useMemo<SelectorOption<Language.Info>[]>(
() =>
options.map((v) => ({
label: v.name,
value: v,
})),
[options]
);
return (
<Selector
placeholder="Language..."
options={items}
label={getLabel}
{...selector}
></Selector>
);
}

@ -0,0 +1,70 @@
import { useServerSearch } from "@/apis/hooks";
import { useDebouncedValue } from "@/utilities";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Anchor, Autocomplete, SelectItemProps } from "@mantine/core";
import { forwardRef, FunctionComponent, useMemo, useState } from "react";
import { Link } from "react-router-dom";
type SearchResultItem = {
value: string;
link: string;
};
function useSearch(query: string) {
const debouncedQuery = useDebouncedValue(query, 500);
const { data } = useServerSearch(debouncedQuery, debouncedQuery.length > 0);
return useMemo<SearchResultItem[]>(
() =>
data?.map((v) => {
let link: string;
if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`;
} else if (v.radarrId) {
link = `/movies/${v.radarrId}`;
} else {
throw new Error("Unknown search result");
}
return {
value: `${v.title} (${v.year})`,
link,
};
}) ?? [],
[data]
);
}
type ResultCompProps = SelectItemProps & SearchResultItem;
const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>(
({ link, value }, ref) => {
return (
<Anchor component={Link} to={link} underline={false} color="gray" p="sm">
{value}
</Anchor>
);
}
);
const Search: FunctionComponent = () => {
const [query, setQuery] = useState("");
const results = useSearch(query);
return (
<Autocomplete
icon={<FontAwesomeIcon icon={faSearch} />}
itemComponent={ResultComponent}
placeholder="Search"
size="sm"
data={results}
value={query}
onChange={setQuery}
onBlur={() => setQuery("")}
></Autocomplete>
);
};
export default Search;

@ -1,119 +0,0 @@
import { useServerSearch } from "@/apis/hooks";
import { uniqueId } from "lodash";
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Dropdown, Form } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { useThrottle } from "rooks";
function useSearch(query: string) {
const { data } = useServerSearch(query, query.length > 0);
return useMemo(
() =>
data?.map((v) => {
let link: string;
let id: string;
if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`;
id = `series-${v.sonarrSeriesId}`;
} else if (v.radarrId) {
link = `/movies/${v.radarrId}`;
id = `movie-${v.radarrId}`;
} else {
link = "";
id = uniqueId("unknown");
}
return {
name: `${v.title} (${v.year})`,
link,
id,
};
}) ?? [],
[data]
);
}
export interface SearchResult {
id: string;
name: string;
link?: string;
}
interface Props {
className?: string;
onFocus?: () => void;
onBlur?: () => void;
}
export const SearchBar: FunctionComponent<Props> = ({
onFocus,
onBlur,
className,
}) => {
const [display, setDisplay] = useState("");
const [query, setQuery] = useState("");
const [debounce] = useThrottle(setQuery, 500);
useEffect(() => {
debounce(display);
}, [debounce, display]);
const results = useSearch(query);
const navigate = useNavigate();
const clear = useCallback(() => {
setDisplay("");
setQuery("");
}, []);
const items = useMemo(() => {
const its = results.map((v) => (
<Dropdown.Item
key={v.id}
eventKey={v.link}
disabled={v.link === undefined}
>
<span>{v.name}</span>
</Dropdown.Item>
));
if (its.length === 0) {
its.push(<Dropdown.Header key="notify">No Found</Dropdown.Header>);
}
return its;
}, [results]);
return (
<Dropdown
show={query.length !== 0}
className={className}
onFocus={onFocus}
onBlur={onBlur}
onSelect={(link) => {
if (link) {
clear();
navigate(link);
}
}}
>
<Form.Control
type="text"
size="sm"
placeholder="Search..."
value={display}
onChange={(e) => setDisplay(e.currentTarget.value)}
></Form.Control>
<Dropdown.Menu style={{ maxHeight: 256, overflowY: "auto" }}>
{items}
</Dropdown.Menu>
</Dropdown>
);
};

@ -0,0 +1,209 @@
import { useSubtitleAction } from "@/apis/hooks";
import { ColorToolModal } from "@/components/forms/ColorToolForm";
import { FrameRateModal } from "@/components/forms/FrameRateForm";
import { TimeOffsetModal } from "@/components/forms/TimeOffsetForm";
import { TranslationModal } from "@/components/forms/TranslationForm";
import { useModals } from "@/modules/modals";
import { ModalComponent } from "@/modules/modals/WithModal";
import { task } from "@/modules/task";
import {
faClock,
faCode,
faDeaf,
faExchangeAlt,
faFilm,
faImage,
faLanguage,
faMagic,
faPaintBrush,
faPlay,
faSearch,
faTextHeight,
faTrash,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
export interface ToolOptions {
key: string;
icon: IconDefinition;
name: string;
modal?: ModalComponent<{
selections: FormType.ModifySubtitle[];
}>;
}
export function useTools() {
return useMemo<ToolOptions[]>(
() => [
{
key: "sync",
icon: faPlay,
name: "Sync",
},
{
key: "remove_HI",
icon: faDeaf,
name: "Remove HI Tags",
},
{
key: "remove_tags",
icon: faCode,
name: "Remove Style Tags",
},
{
key: "OCR_fixes",
icon: faImage,
name: "OCR Fixes",
},
{
key: "common",
icon: faMagic,
name: "Common Fixes",
},
{
key: "fix_uppercase",
icon: faTextHeight,
name: "Fix Uppercase",
},
{
key: "reverse_rtl",
icon: faExchangeAlt,
name: "Reverse RTL",
},
{
key: "add_color",
icon: faPaintBrush,
name: "Add Color...",
modal: ColorToolModal,
},
{
key: "change_frame_rate",
icon: faFilm,
name: "Change Frame Rate...",
modal: FrameRateModal,
},
{
key: "adjust_time",
icon: faClock,
name: "Adjust Times...",
modal: TimeOffsetModal,
},
{
key: "translation",
icon: faLanguage,
name: "Translate...",
modal: TranslationModal,
},
],
[]
);
}
interface Props {
selections: FormType.ModifySubtitle[];
children?: ReactElement;
menu?: Omit<MenuProps, "control" | "children">;
onAction?: (action: "delete" | "search") => void;
}
const SubtitleToolsMenu: FunctionComponent<Props> = ({
selections,
children,
menu,
onAction,
}) => {
const { mutateAsync } = useSubtitleAction();
const process = useCallback(
(action: string, name: string) => {
selections.forEach((s) => {
const form: FormType.ModifySubtitle = {
id: s.id,
type: s.type,
language: s.language,
path: s.path,
};
task.create(s.path, name, mutateAsync, { action, form });
});
},
[mutateAsync, selections]
);
const tools = useTools();
const modals = useModals();
const disabledTools = selections.length === 0;
return (
<Menu
control={children}
withArrow
placement="end"
position="left"
{...menu}
>
<Menu.Label>Tools</Menu.Label>
{tools.map((tool) => (
<Menu.Item
key={tool.key}
disabled={disabledTools}
icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
onClick={() => {
if (tool.modal) {
modals.openContextModal(tool.modal, { selections });
} else {
process(tool.key, tool.name);
}
}}
>
{tool.name}
</Menu.Item>
))}
<Divider></Divider>
<Menu.Label>Actions</Menu.Label>
<Menu.Item
disabled={selections.length !== 0 || onAction === undefined}
icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
onClick={() => {
onAction?.("search");
}}
>
Search
</Menu.Item>
<Menu.Item
disabled={selections.length === 0 || onAction === undefined}
color="red"
icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
onClick={() => {
modals.openConfirmModal({
title: "The following subtitles will be deleted",
size: "lg",
children: (
<ScrollArea style={{ maxHeight: "20rem" }}>
<List>
{selections.map((s) => (
<List.Item my="md" key={s.path}>
{s.path}
</List.Item>
))}
</List>
</ScrollArea>
),
onConfirm: () => {
onAction?.("delete");
},
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
});
}}
>
Delete...
</Menu.Item>
</Menu>
);
};
export default SubtitleToolsMenu;

@ -0,0 +1,30 @@
import { Tooltip, TooltipProps } from "@mantine/core";
import { useHover } from "@mantine/hooks";
import { isNull, isUndefined } from "lodash";
import { FunctionComponent, ReactElement } from "react";
interface TextPopoverProps {
children: ReactElement;
text: string | undefined | null;
tooltip?: Omit<TooltipProps, "opened" | "label" | "children">;
}
const TextPopover: FunctionComponent<TextPopoverProps> = ({
children,
text,
tooltip,
}) => {
const { hovered, ref } = useHover();
if (isNull(text) || isUndefined(text)) {
return children;
}
return (
<Tooltip opened={hovered} label={text} {...tooltip}>
<div ref={ref}>{children}</div>
</Tooltip>
);
};
export default TextPopover;

@ -1,161 +0,0 @@
import {
faCheck,
faCircleNotch,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
FunctionComponent,
PropsWithChildren,
ReactElement,
useCallback,
useEffect,
useState,
} from "react";
import { Button, ButtonProps } from "react-bootstrap";
import { UseQueryResult } from "react-query";
import { useTimeoutWhen } from "rooks";
import { LoadingIndicator } from ".";
interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>;
children: ReactElement;
}
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children,
result: { isLoading, isError, error },
}) => {
if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
} else if (isError) {
return <p>{error as string}</p>;
}
return children;
};
interface PromiseProps<T> {
promise: () => Promise<T>;
children: FunctionComponent<T>;
}
export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
const [item, setItem] = useState<T | null>(null);
useEffect(() => {
promise().then(setItem);
}, [promise]);
if (item === null) {
return <LoadingIndicator></LoadingIndicator>;
} else {
return children(item);
}
}
interface AsyncButtonProps<T> {
as?: ButtonProps["as"];
variant?: ButtonProps["variant"];
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
onChange?: (v: boolean) => void;
noReset?: boolean;
animation?: boolean;
promise: () => Promise<T> | null;
onSuccess?: (result: T) => void;
error?: () => void;
}
enum RequestState {
Success,
Error,
Invalid,
}
export function AsyncButton<T>(
props: PropsWithChildren<AsyncButtonProps<T>>
): JSX.Element {
const {
children: propChildren,
className,
promise,
onSuccess,
noReset,
animation,
error,
onChange,
disabled,
...button
} = props;
const [loading, setLoading] = useState(false);
const [state, setState] = useState(RequestState.Invalid);
const needFire = state !== RequestState.Invalid && !noReset;
useTimeoutWhen(
() => {
setState(RequestState.Invalid);
},
2 * 1000,
needFire
);
const click = useCallback(() => {
if (state !== RequestState.Invalid) {
return;
}
const result = promise();
if (result) {
setLoading(true);
onChange && onChange(true);
result
.then((res) => {
setState(RequestState.Success);
onSuccess && onSuccess(res);
})
.catch(() => {
setState(RequestState.Error);
error && error();
})
.finally(() => {
setLoading(false);
onChange && onChange(false);
});
}
}, [error, onChange, promise, onSuccess, state]);
const showAnimation = animation ?? true;
let children = propChildren;
if (showAnimation) {
if (loading) {
children = <FontAwesomeIcon icon={faCircleNotch} spin></FontAwesomeIcon>;
}
if (state === RequestState.Success) {
children = <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>;
} else if (state === RequestState.Error) {
children = <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>;
}
}
return (
<Button
className={className}
disabled={loading || disabled || state !== RequestState.Invalid}
{...button}
onClick={click}
>
{children}
</Button>
);
}

@ -1,8 +1,8 @@
import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, Suspense } from "react";
import { LoadingIndicator } from ".";
const Lazy: FunctionComponent = ({ children }) => {
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>;
};
export default Lazy;

@ -0,0 +1,48 @@
import { useCallback, useState } from "react";
import { UseMutationResult } from "react-query";
import { Action } from "../inputs";
import { ActionProps } from "../inputs/Action";
type MutateActionProps<DATA, VAR> = Omit<
ActionProps,
"onClick" | "loading" | "color"
> & {
mutation: UseMutationResult<DATA, unknown, VAR>;
args: () => VAR | null;
onSuccess?: (args: DATA) => void;
onError?: () => void;
noReset?: boolean;
};
function MutateAction<DATA, VAR>({
mutation,
noReset,
onSuccess,
onError,
args,
...props
}: MutateActionProps<DATA, VAR>) {
const { mutateAsync } = mutation;
const [isLoading, setLoading] = useState(false);
const onClick = useCallback(async () => {
setLoading(true);
try {
const argument = args();
if (argument !== null) {
const data = await mutateAsync(argument);
onSuccess?.(data);
} else {
onError?.();
}
} catch (error) {
onError?.();
}
setLoading(false);
}, [args, mutateAsync, onError, onSuccess]);
return <Action {...props} loading={isLoading} onClick={onClick}></Action>;
}
export default MutateAction;

@ -0,0 +1,47 @@
import { Button, ButtonProps } from "@mantine/core";
import { useCallback, useState } from "react";
import { UseMutationResult } from "react-query";
type MutateButtonProps<DATA, VAR> = Omit<
ButtonProps<"button">,
"onClick" | "loading" | "color"
> & {
mutation: UseMutationResult<DATA, unknown, VAR>;
args: () => VAR | null;
onSuccess?: (args: DATA) => void;
onError?: () => void;
noReset?: boolean;
};
function MutateButton<DATA, VAR>({
mutation,
noReset,
onSuccess,
onError,
args,
...props
}: MutateButtonProps<DATA, VAR>) {
const { mutateAsync } = mutation;
const [isLoading, setLoading] = useState(false);
const onClick = useCallback(async () => {
setLoading(true);
try {
const argument = args();
if (argument !== null) {
const data = await mutateAsync(argument);
onSuccess?.(data);
} else {
onError?.();
}
} catch (error) {
onError?.();
}
setLoading(false);
}, [args, mutateAsync, onError, onSuccess]);
return <Button {...props} loading={isLoading} onClick={onClick}></Button>;
}
export default MutateButton;

@ -0,0 +1,25 @@
import { LoadingProvider } from "@/contexts";
import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, ReactNode } from "react";
import { UseQueryResult } from "react-query";
interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>;
global?: boolean;
children: ReactNode;
}
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children,
global = false,
result: { isLoading, isError, error },
}) => {
return (
<LoadingProvider value={isLoading}>
<LoadingOverlay visible={global && isLoading}></LoadingOverlay>
{children}
</LoadingProvider>
);
};
export default QueryOverlay;

@ -0,0 +1,3 @@
export { default as Lazy } from "./Lazy";
export { default as MutateAction } from "./MutateAction";
export { default as QueryOverlay } from "./QueryOverlay";

@ -0,0 +1,26 @@
import { BuildKey } from "@/utilities";
import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
import { FunctionComponent } from "react";
export type AudioListProps = GroupProps & {
audios: Language.Info[];
badgeProps?: BadgeProps<"div">;
};
const AudioList: FunctionComponent<AudioListProps> = ({
audios,
badgeProps,
...group
}) => {
return (
<Group spacing="xs" {...group}>
{audios.map((audio, idx) => (
<Badge color="teal" key={BuildKey(idx, audio.code2)} {...badgeProps}>
{audio.name}
</Badge>
))}
</Group>
);
};
export default AudioList;

@ -0,0 +1,54 @@
import {
faClock,
faCloudUploadAlt,
faDownload,
faRecycle,
faTrash,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FunctionComponent } from "react";
enum HistoryAction {
Delete = 0,
Download,
Manual,
Upgrade,
Upload,
Sync,
}
const HistoryIcon: FunctionComponent<{
action: number;
title?: string;
}> = ({ action, title }) => {
let icon = null;
switch (action) {
case HistoryAction.Delete:
icon = faTrash;
break;
case HistoryAction.Download:
icon = faDownload;
break;
case HistoryAction.Manual:
icon = faUser;
break;
case HistoryAction.Sync:
icon = faClock;
break;
case HistoryAction.Upgrade:
icon = faRecycle;
break;
case HistoryAction.Upload:
icon = faCloudUploadAlt;
break;
}
if (icon) {
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
} else {
return null;
}
};
export default HistoryIcon;

@ -1,22 +1,21 @@
import { useLanguages } from "@/apis/hooks";
import { Selector, SelectorOption, SelectorProps } from "@/components";
import { BuildKey } from "@/utilities";
import { Badge, Group, Text, TextProps } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
interface TextProps {
type LanguageTextProps = TextProps<"div"> & {
value: Language.Info;
className?: string;
long?: boolean;
}
};
declare type LanguageComponent = {
Text: typeof LanguageText;
Selector: typeof LanguageSelector;
List: typeof LanguageList;
};
const LanguageText: FunctionComponent<TextProps> = ({
const LanguageText: FunctionComponent<LanguageTextProps> = ({
value,
className,
long,
...props
}) => {
const result = useMemo(() => {
let lang = value.code2;
@ -38,51 +37,29 @@ const LanguageText: FunctionComponent<TextProps> = ({
}, [value, long]);
return (
<span title={value.name} className={className}>
<Text inherit {...props}>
{result}
</span>
</Text>
);
};
type LanguageSelectorProps<M extends boolean> = Omit<
SelectorProps<Language.Info, M>,
"label" | "options"
> & {
history?: boolean;
type LanguageListProps = {
value: Language.Info[];
};
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]
);
const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => {
return (
<Selector
placeholder="Language..."
options={items}
label={getLabel}
{...rest}
></Selector>
<Group spacing="xs">
{value.map((v) => (
<Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge>
))}
</Group>
);
}
};
const Components: LanguageComponent = {
Text: LanguageText,
Selector: LanguageSelector,
List: LanguageList,
};
export default Components;

@ -3,13 +3,11 @@ import { FunctionComponent, useMemo } from "react";
interface Props {
index: number | null;
className?: string;
empty?: string;
}
const LanguageProfile: FunctionComponent<Props> = ({
const LanguageProfileName: FunctionComponent<Props> = ({
index,
className,
empty = "Unknown Profile",
}) => {
const { data } = useLanguageProfiles();
@ -19,7 +17,7 @@ const LanguageProfile: FunctionComponent<Props> = ({
[data, empty, index]
);
return <span className={className}>{name}</span>;
return <>{name}</>;
};
export default LanguageProfile;
export default LanguageProfileName;

@ -0,0 +1,4 @@
export { default as AudioList } from "./AudioList";
export { default as HistoryIcon } from "./HistoryIcon";
export { default as Language } from "./Language";
export { default as LanguageProfile } from "./LanguageProfile";

@ -1,80 +0,0 @@
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FunctionComponent, MouseEvent } from "react";
import { Badge, Button, ButtonProps } from "react-bootstrap";
export const ActionBadge: FunctionComponent<{
icon: IconDefinition;
onClick?: (e: MouseEvent) => void;
}> = ({ icon, onClick }) => {
return (
<Button
as={Badge}
className="mx-1 p-1"
variant="secondary"
onClick={onClick}
>
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
</Button>
);
};
interface ActionButtonProps extends ActionButtonItemProps {
disabled?: boolean;
destructive?: boolean;
variant?: string;
onClick?: (e: MouseEvent) => void;
className?: string;
size?: ButtonProps["size"];
}
export const ActionButton: FunctionComponent<ActionButtonProps> = ({
onClick,
destructive,
disabled,
variant,
className,
size,
...other
}) => {
return (
<Button
disabled={other.loading || disabled}
size={size ?? "sm"}
variant={variant ?? "light"}
className={`text-nowrap ${className ?? ""}`}
onClick={onClick}
>
<ActionButtonItem {...other}></ActionButtonItem>
</Button>
);
};
interface ActionButtonItemProps {
loading?: boolean;
alwaysShowText?: boolean;
icon: IconDefinition;
children?: string;
}
export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
icon,
children,
loading,
alwaysShowText,
}) => {
const showText = alwaysShowText === true || loading !== true;
return (
<>
<FontAwesomeIcon
style={{ width: "1rem" }}
icon={loading ? faCircleNotch : icon}
spin={loading}
></FontAwesomeIcon>
{children && showText ? (
<span className="ml-2 font-weight-bold">{children}</span>
) : null}
</>
);
};

@ -0,0 +1,133 @@
import { useSubtitleAction } from "@/apis/hooks";
import { Selector, SelectorOption } from "@/components";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { Button, Divider, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { FunctionComponent } from "react";
const TaskName = "Changing Color";
function convertToAction(color: string) {
return `color(name=${color})`;
}
export const colorOptions: SelectorOption<string>[] = [
{
label: "White",
value: "white",
},
{
label: "Light Gray",
value: "light-gray",
},
{
label: "Red",
value: "red",
},
{
label: "Green",
value: "green",
},
{
label: "Yellow",
value: "yellow",
},
{
label: "Blue",
value: "blue",
},
{
label: "Magenta",
value: "magenta",
},
{
label: "Cyan",
value: "cyan",
},
{
label: "Black",
value: "black",
},
{
label: "Dark Red",
value: "dark-red",
},
{
label: "Dark Green",
value: "dark-green",
},
{
label: "Dark Yellow",
value: "dark-yellow",
},
{
label: "Dark Blue",
value: "dark-blue",
},
{
label: "Dark Magenta",
value: "dark-magenta",
},
{
label: "Dark Cyan",
value: "dark-cyan",
},
{
label: "Dark Grey",
value: "dark-grey",
},
];
interface Props {
selections: FormType.ModifySubtitle[];
onSubmit?: VoidFunction;
}
const ColorToolForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
const form = useForm({
initialValues: {
color: "",
},
validationRules: {
color: (c) => colorOptions.find((op) => op.value === c) !== undefined,
},
});
return (
<form
onSubmit={form.onSubmit(({ color }) => {
const action = convertToAction(color);
selections.forEach((s) =>
task.create(s.path, TaskName, mutateAsync, {
action,
form: s,
})
);
onSubmit?.();
modals.closeSelf();
})}
>
<Stack>
<Selector
required
options={colorOptions}
{...form.getInputProps("color")}
></Selector>
<Divider></Divider>
<Button type="submit">Start</Button>
</Stack>
</form>
);
};
export const ColorToolModal = withModal(ColorToolForm, "color-tool", {
title: "Change Color",
});
export default ColorToolForm;

@ -0,0 +1,72 @@
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { FunctionComponent } from "react";
const TaskName = "Changing Frame Rate";
function convertToAction(from: number, to: number) {
return `change_FPS(from=${from},to=${to})`;
}
interface Props {
selections: FormType.ModifySubtitle[];
onSubmit?: VoidFunction;
}
const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
const form = useForm({
initialValues: {
from: 0,
to: 0,
},
validationRules: {
from: (v) => v > 0,
to: (v) => v > 0,
},
});
return (
<form
onSubmit={form.onSubmit(({ from, to }) => {
const action = convertToAction(from, to);
selections.forEach((s) =>
task.create(s.path, TaskName, mutateAsync, {
action,
form: s,
})
);
onSubmit?.();
modals.closeSelf();
})}
>
<Stack>
<Group spacing="xs" grow>
<NumberInput
placeholder="From"
{...form.getInputProps("from")}
></NumberInput>
<NumberInput
placeholder="To"
{...form.getInputProps("to")}
></NumberInput>
</Group>
<Divider></Divider>
<Button type="submit">Start</Button>
</Stack>
</form>
);
};
export const FrameRateModal = withModal(FrameRateForm, "frame-rate-tool", {
title: "Change Frame Rate",
});
export default FrameRateForm;

@ -0,0 +1,109 @@
import { useLanguageProfiles } from "@/apis/hooks";
import { MultiSelector, Selector } from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
import { GetItemId, useSelectorOptions } from "@/utilities";
import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { FunctionComponent, useMemo } from "react";
import { UseMutationResult } from "react-query";
interface Props {
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
item: Item.Base | null;
onComplete?: () => void;
onCancel?: () => void;
}
const ItemEditForm: FunctionComponent<Props> = ({
mutation,
item,
onComplete,
onCancel,
}) => {
const { data, isFetching } = useLanguageProfiles();
const { isLoading, mutate } = mutation;
const modals = useModals();
const profileOptions = useSelectorOptions(
data ?? [],
(v) => v.name ?? "Unknown",
(v) => v.profileId.toString() ?? "-1"
);
const profile = useMemo(
() => data?.find((v) => v.profileId === item?.profileId) ?? null,
[data, item?.profileId]
);
const form = useForm({
initialValues: {
profile: profile ?? null,
},
});
const options = useSelectorOptions(
item?.audio_language ?? [],
(v) => v.name,
(v) => v.code2
);
const isOverlayVisible = isLoading || isFetching || item === null;
return (
<form
onSubmit={form.onSubmit(({ profile }) => {
if (item) {
const itemId = GetItemId(item);
if (itemId) {
mutate({ id: [itemId], profileid: [profile?.profileId ?? null] });
onComplete?.();
modals.closeSelf();
return;
}
}
form.setErrors({ profile: "Invalid profile" });
})}
>
<LoadingOverlay visible={isOverlayVisible}></LoadingOverlay>
<Stack>
<MultiSelector
label="Audio Languages"
disabled
{...options}
value={item?.audio_language ?? []}
></MultiSelector>
<Selector
{...profileOptions}
{...form.getInputProps("profile")}
clearable
label="Languages Profiles"
></Selector>
<Divider></Divider>
<Group position="right">
<Button
disabled={isOverlayVisible}
onClick={() => {
onCancel?.();
modals.closeSelf();
}}
color="gray"
variant="subtle"
>
Cancel
</Button>
<Button disabled={isOverlayVisible} type="submit">
Save
</Button>
</Group>
</Stack>
</form>
);
};
export const ItemEditModal = withModal(ItemEditForm, "item-editor", {
title: "Editor",
size: "md",
});
export default ItemEditForm;

@ -0,0 +1,276 @@
import { useMovieSubtitleModification } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import {
faCheck,
faCircleNotch,
faInfoCircle,
faTimes,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { isString } from "lodash";
import { FunctionComponent, useEffect, useMemo } from "react";
import { Column } from "react-table";
import { Action, Selector } from "../inputs";
import { SimpleTable } from "../tables";
import TextPopover from "../TextPopover";
type SubtitleFile = {
file: File;
language: Language.Info | null;
forced: boolean;
hi: boolean;
validateResult?: SubtitleValidateResult;
};
type SubtitleValidateResult = {
state: "valid" | "warning" | "error";
messages?: string;
};
const validator = (
movie: Item.Movie,
file: SubtitleFile
): SubtitleValidateResult => {
if (file.language === null) {
return {
state: "error",
messages: "Language is not selected",
};
} else {
const { subtitles } = movie;
const existing = subtitles.find(
(v) => v.code2 === file.language?.code2 && isString(v.path)
);
if (existing !== undefined) {
return {
state: "warning",
messages: "Override existing subtitle",
};
}
}
return {
state: "valid",
};
};
interface Props {
files: File[];
movie: Item.Movie;
onComplete?: () => void;
}
const MovieUploadForm: FunctionComponent<Props> = ({
files,
movie,
onComplete,
}) => {
const modals = useModals();
const profile = useLanguageProfileBy(movie.profileId);
const languages = useProfileItemsToLanguages(profile);
const languageOptions = useSelectorOptions(
languages,
(v) => v.name,
(v) => v.code2
);
const defaultLanguage = useMemo(
() => (languages.length > 0 ? languages[0] : null),
[languages]
);
const form = useForm({
initialValues: {
files: files
.map<SubtitleFile>((file) => ({
file,
language: defaultLanguage,
forced: defaultLanguage?.forced ?? false,
hi: defaultLanguage?.hi ?? false,
}))
.map<SubtitleFile>((v) => ({
...v,
validateResult: validator(movie, v),
})),
},
validationRules: {
files: (values) => {
return (
values.find(
(v) =>
v.language === null ||
v.validateResult === undefined ||
v.validateResult.state === "error"
) === undefined
);
},
},
});
useEffect(() => {
if (form.values.files.length <= 0) {
modals.closeSelf();
}
}, [form.values.files.length, modals]);
const action = useArrayAction<SubtitleFile>((fn) => {
form.setValues(({ files, ...rest }) => {
const newFiles = fn(files);
newFiles.forEach((v) => {
v.validateResult = validator(movie, v);
});
return { ...rest, files: newFiles };
});
});
const columns = useMemo<Column<SubtitleFile>[]>(
() => [
{
accessor: "validateResult",
Cell: ({ cell: { value } }) => {
const icon = useMemo(() => {
switch (value?.state) {
case "valid":
return faCheck;
case "warning":
return faInfoCircle;
case "error":
return faTimes;
default:
return faCircleNotch;
}
}, [value?.state]);
return (
<TextPopover text={value?.messages}>
{/* TODO: Color */}
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
</TextPopover>
);
},
},
{
Header: "File",
id: "filename",
accessor: "file",
Cell: ({ value }) => {
const { classes } = useTableStyles();
return <Text className={classes.primary}>{value.name}</Text>;
},
},
{
Header: "Forced",
accessor: "forced",
Cell: ({ row: { original, index }, value }) => {
return (
<Checkbox
checked={value}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, forced: checked });
}}
></Checkbox>
);
},
},
{
Header: "HI",
accessor: "hi",
Cell: ({ row: { original, index }, value }) => {
return (
<Checkbox
checked={value}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, hi: checked });
}}
></Checkbox>
);
},
},
{
Header: "Language",
accessor: "language",
Cell: ({ row: { original, index }, value }) => {
const { classes } = useTableStyles();
return (
<Selector
{...languageOptions}
className={classes.select}
value={value}
onChange={(item) => {
action.mutate(index, { ...original, language: item });
}}
></Selector>
);
},
},
{
id: "action",
accessor: "file",
Cell: ({ row: { index } }) => {
return (
<Action
icon={faXmark}
color="red"
onClick={() => action.remove(index)}
></Action>
);
},
},
],
[action, languageOptions]
);
const { upload } = useMovieSubtitleModification();
return (
<form
onSubmit={form.onSubmit(({ files }) => {
const { radarrId } = movie;
files.forEach(({ file, language, hi, forced }) => {
if (language === null) {
throw new Error("Language is not selected");
}
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
radarrId,
form: { file, language: language.code2, hi, forced },
});
});
onComplete?.();
modals.closeSelf();
})}
>
<Stack>
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
<Divider></Divider>
<Button type="submit">Upload</Button>
</Stack>
</form>
);
};
export const MovieUploadModal = withModal(
MovieUploadForm,
"upload-movie-subtitle",
{
title: "Upload Subtitles",
size: "xl",
}
);
export default MovieUploadForm;

@ -0,0 +1,311 @@
import { Action, Selector, SelectorOption, SimpleTable } from "@/components";
import { useModals, withModal } from "@/modules/modals";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import {
Accordion,
Alert,
Button,
Checkbox,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import ChipInput from "../inputs/ChipInput";
export const anyCutoff = 65535;
const defaultCutoffOptions: SelectorOption<Language.ProfileItem>[] = [
{
label: "Any",
value: {
id: anyCutoff,
audio_exclude: "False",
forced: "False",
hi: "False",
language: "any",
},
},
];
interface Props {
onComplete?: (profile: Language.Profile) => void;
languages: readonly Language.Info[];
profile: Language.Profile;
}
const ProfileEditForm: FunctionComponent<Props> = ({
onComplete,
languages,
profile,
}) => {
const modals = useModals();
const form = useForm({
initialValues: profile,
validationRules: {
name: (value) => value.length > 0,
items: (value) => value.length > 0,
},
errorMessages: {
items: (
<Alert color="yellow" variant="outline">
Must contain at lease 1 language
</Alert>
),
},
});
const languageOptions = useSelectorOptions(languages, (l) => l.name);
const itemCutoffOptions = useSelectorOptions(
form.values.items,
(v) => v.language
);
const cutoffOptions = useMemo(
() => ({
...itemCutoffOptions,
options: [...itemCutoffOptions.options, ...defaultCutoffOptions],
}),
[itemCutoffOptions]
);
const mustContainOptions = useSelectorOptions(
form.values.mustContain,
(v) => v
);
const mustNotContainOptions = useSelectorOptions(
form.values.mustNotContain,
(v) => v
);
const action = useArrayAction<Language.ProfileItem>((fn) => {
form.setValues((values) => ({ ...values, items: fn(values.items) }));
});
const addItem = useCallback(() => {
const id =
1 +
form.values.items.reduce<number>(
(val, item) => Math.max(item.id, val),
0
);
if (languages.length > 0) {
const language = languages[0].code2;
const item: Language.ProfileItem = {
id,
language,
audio_exclude: "False",
hi: "False",
forced: "False",
};
const list = [...form.values.items, item];
form.setValues((values) => ({ ...values, items: list }));
}
}, [form, languages]);
const columns = useMemo<Column<Language.ProfileItem>[]>(
() => [
{
Header: "ID",
accessor: "id",
},
{
Header: "Language",
accessor: "language",
Cell: ({ value: code, row: { original: item, index } }) => {
const language = useMemo(
() =>
languageOptions.options.find((l) => l.value.code2 === code)
?.value ?? null,
[code]
);
const { classes } = useTableStyles();
return (
<Selector
{...languageOptions}
className={classes.select}
value={language}
onChange={(value) => {
if (value) {
item.language = value.code2;
action.mutate(index, { ...item, language: value.code2 });
}
}}
></Selector>
);
},
},
{
Header: "Forced",
accessor: "forced",
Cell: ({ row: { original: item, index }, value }) => {
return (
<Checkbox
checked={value === "True"}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...item,
forced: checked ? "True" : "False",
hi: checked ? "False" : item.hi,
});
}}
></Checkbox>
);
},
},
{
Header: "HI",
accessor: "hi",
Cell: ({ row: { original: item, index }, value }) => {
return (
<Checkbox
checked={value === "True"}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...item,
hi: checked ? "True" : "False",
forced: checked ? "False" : item.forced,
});
}}
></Checkbox>
);
},
},
{
Header: "Exclude Audio",
accessor: "audio_exclude",
Cell: ({ row: { original: item, index }, value }) => {
return (
<Checkbox
checked={value === "True"}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...item,
audio_exclude: checked ? "True" : "False",
});
}}
></Checkbox>
);
},
},
{
id: "action",
accessor: "id",
Cell: ({ row }) => {
return (
<Action
icon={faXmark}
color="red"
onClick={() => action.remove(row.index)}
></Action>
);
},
},
],
[action, languageOptions]
);
return (
<form
onSubmit={form.onSubmit((value) => {
onComplete?.(value);
modals.closeSelf();
})}
>
<Stack>
<TextInput label="Name" {...form.getInputProps("name")}></TextInput>
<Accordion
offsetIcon={false}
multiple
iconPosition="right"
initialItem={0}
styles={(theme) => ({
contentInner: {
[theme.fn.smallerThan("md")]: {
padding: 0,
},
},
})}
>
<Accordion.Item label="Languages">
<Stack>
{form.errors.items}
<SimpleTable
columns={columns}
data={form.values.items}
></SimpleTable>
<Button fullWidth color="light" onClick={addItem}>
Add Language
</Button>
<Selector
clearable
label="Cutoff"
{...cutoffOptions}
{...form.getInputProps("cutoff")}
></Selector>
</Stack>
</Accordion.Item>
<Accordion.Item label="Release Info">
<Stack>
<ChipInput
label="Must contain"
{...mustContainOptions}
{...form.getInputProps("mustContain")}
></ChipInput>
<Text size="sm">
Subtitles release info must include one of those words or they
will be excluded from search results (regex supported).
</Text>
<ChipInput
label="Must not contain"
{...mustNotContainOptions}
{...form.getInputProps("mustNotContain")}
></ChipInput>
<Text size="sm">
Subtitles release info including one of those words (case
insensitive) will be excluded from search results (regex
supported).
</Text>
</Stack>
</Accordion.Item>
<Accordion.Item label="Subtitles">
<Stack my="xs">
<Switch
label="Use Original Format"
{...form.getInputProps("originalFormat")}
></Switch>
<Text size="sm">
Download subtitle file without format conversion
</Text>
</Stack>
</Accordion.Item>
</Accordion>
<Button type="submit">Save</Button>
</Stack>
</form>
);
};
export const ProfileEditModal = withModal(
ProfileEditForm,
"languages-profile-editor",
{
title: "Edit Languages Profile",
size: "lg",
}
);
export default ProfileEditForm;

@ -0,0 +1,349 @@
import {
useEpisodesBySeriesId,
useEpisodeSubtitleModification,
useSubtitleInfos,
} from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import {
faCheck,
faCircleNotch,
faInfoCircle,
faTimes,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { isString } from "lodash";
import { FunctionComponent, useEffect, useMemo } from "react";
import { Column } from "react-table";
import { Action, Selector } from "../inputs";
import { SimpleTable } from "../tables";
import TextPopover from "../TextPopover";
type SubtitleFile = {
file: File;
language: Language.Info | null;
forced: boolean;
hi: boolean;
episode: Item.Episode | null;
validateResult?: SubtitleValidateResult;
};
type SubtitleValidateResult = {
state: "valid" | "warning" | "error";
messages?: string;
};
const validator = (file: SubtitleFile): SubtitleValidateResult => {
if (file.language === null) {
return {
state: "error",
messages: "Language is not selected",
};
} else if (file.episode === null) {
return {
state: "error",
messages: "Episode is not selected",
};
} else {
const { subtitles } = file.episode;
const existing = subtitles.find(
(v) => v.code2 === file.language?.code2 && isString(v.path)
);
if (existing !== undefined) {
return {
state: "warning",
messages: "Override existing subtitle",
};
}
}
return {
state: "valid",
};
};
interface Props {
files: File[];
series: Item.Series;
onComplete?: VoidFunction;
}
const SeriesUploadForm: FunctionComponent<Props> = ({
series,
files,
onComplete,
}) => {
const modals = useModals();
const episodes = useEpisodesBySeriesId(series.sonarrSeriesId);
const episodeOptions = useSelectorOptions(
episodes.data ?? [],
(v) => `(${v.season}x${v.episode}) ${v.title}`,
(v) => v.sonarrEpisodeId.toString()
);
const profile = useLanguageProfileBy(series.profileId);
const languages = useProfileItemsToLanguages(profile);
const languageOptions = useSelectorOptions(
languages,
(v) => v.name,
(v) => v.code2
);
const defaultLanguage = useMemo(
() => (languages.length > 0 ? languages[0] : null),
[languages]
);
const form = useForm({
initialValues: {
files: files
.map<SubtitleFile>((file) => ({
file,
language: defaultLanguage,
forced: defaultLanguage?.forced ?? false,
hi: defaultLanguage?.hi ?? false,
episode: null,
}))
.map<SubtitleFile>((file) => ({
...file,
validateResult: validator(file),
})),
},
validationRules: {
files: (values) =>
values.find(
(v) =>
v.language === null ||
v.episode === null ||
v.validateResult === undefined ||
v.validateResult.state === "error"
) === undefined,
},
});
const action = useArrayAction<SubtitleFile>((fn) => {
form.setValues(({ files, ...rest }) => {
const newFiles = fn(files);
newFiles.forEach((v) => {
v.validateResult = validator(v);
});
return { ...rest, files: newFiles };
});
});
const names = useMemo(() => files.map((v) => v.name), [files]);
const infos = useSubtitleInfos(names);
// Auto assign episode if available
useEffect(() => {
if (infos.data !== undefined) {
action.update((item) => {
const info = infos.data.find((v) => v.filename === item.file.name);
if (info) {
item.episode =
episodes.data?.find(
(v) => v.season === info.season && v.episode === info.episode
) ?? item.episode;
}
return item;
});
}
}, [action, episodes.data, infos.data]);
const columns = useMemo<Column<SubtitleFile>[]>(
() => [
{
accessor: "validateResult",
Cell: ({ cell: { value } }) => {
const icon = useMemo(() => {
switch (value?.state) {
case "valid":
return faCheck;
case "warning":
return faInfoCircle;
case "error":
return faTimes;
default:
return faCircleNotch;
}
}, [value?.state]);
return (
<TextPopover text={value?.messages}>
{/* TODO: Color */}
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
</TextPopover>
);
},
},
{
Header: "File",
id: "filename",
accessor: "file",
Cell: ({ value: { name } }) => {
const { classes } = useTableStyles();
return <Text className={classes.primary}>{name}</Text>;
},
},
{
Header: "Forced",
accessor: "forced",
Cell: ({ row: { original, index }, value }) => {
return (
<Checkbox
checked={value}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
forced: checked,
hi: checked ? false : original.hi,
});
}}
></Checkbox>
);
},
},
{
Header: "HI",
accessor: "hi",
Cell: ({ row: { original, index }, value }) => {
return (
<Checkbox
checked={value}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
hi: checked,
forced: checked ? false : original.forced,
});
}}
></Checkbox>
);
},
},
{
Header: (
<Selector
{...languageOptions}
value={null}
placeholder="Language"
onChange={(value) => {
if (value) {
action.update((item) => {
item.language = value;
return item;
});
}
}}
></Selector>
),
accessor: "language",
Cell: ({ row: { original, index }, value }) => {
const { classes } = useTableStyles();
return (
<Selector
{...languageOptions}
className={classes.select}
value={value}
onChange={(item) => {
action.mutate(index, { ...original, language: item });
}}
></Selector>
);
},
},
{
id: "episode",
Header: "Episode",
accessor: "episode",
Cell: ({ value, row }) => {
const { classes } = useTableStyles();
return (
<Selector
{...episodeOptions}
className={classes.select}
value={value}
onChange={(item) => {
action.mutate(row.index, { ...row.original, episode: item });
}}
></Selector>
);
},
},
{
id: "action",
accessor: "file",
Cell: ({ row: { index } }) => {
return (
<Action
icon={faXmark}
color="red"
onClick={() => action.remove(index)}
></Action>
);
},
},
],
[action, episodeOptions, languageOptions]
);
const { upload } = useEpisodeSubtitleModification();
return (
<form
onSubmit={form.onSubmit(({ files }) => {
const { sonarrSeriesId: seriesId } = series;
files.forEach((value) => {
const { file, hi, forced, language, episode } = value;
if (language === null || episode === null) {
throw new Error(
"Invalid language or episode. This shouldn't happen, please report this bug."
);
}
const { code2 } = language;
const { sonarrEpisodeId: episodeId } = episode;
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
seriesId,
episodeId,
form: {
file,
language: code2,
hi,
forced,
},
});
});
onComplete?.();
modals.closeSelf();
})}
>
<Stack>
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
<Divider></Divider>
<Button type="submit">Upload</Button>
</Stack>
</form>
);
};
export const SeriesUploadModal = withModal(
SeriesUploadForm,
"upload-series-subtitles",
{ title: "Upload Subtitles", size: "xl" }
);
export default SeriesUploadForm;

@ -0,0 +1,97 @@
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { FunctionComponent } from "react";
const TaskName = "Changing Time";
function convertToAction(h: number, m: number, s: number, ms: number) {
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
}
interface Props {
selections: FormType.ModifySubtitle[];
onSubmit?: VoidFunction;
}
const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
const form = useForm({
initialValues: {
positive: true,
hour: 0,
min: 0,
sec: 0,
ms: 0,
},
validationRules: {
hour: (v) => v >= 0,
min: (v) => v >= 0,
sec: (v) => v >= 0,
ms: (v) => v >= 0,
},
});
const enabled =
form.values.hour > 0 ||
form.values.min > 0 ||
form.values.sec > 0 ||
form.values.ms > 0;
return (
<form
onSubmit={form.onSubmit(({ positive, hour, min, sec, ms }) => {
const action = convertToAction(hour, min, sec, ms);
selections.forEach((s) =>
task.create(s.path, TaskName, mutateAsync, {
action,
form: s,
})
);
onSubmit?.();
modals.closeSelf();
})}
>
<Stack>
<Group align="end" spacing="xs" noWrap>
<Button
color="gray"
variant="filled"
onClick={() =>
form.setValues((f) => ({ ...f, positive: !f.positive }))
}
>
<FontAwesomeIcon
icon={form.values.positive ? faPlus : faMinus}
></FontAwesomeIcon>
</Button>
<NumberInput
label="hour"
{...form.getInputProps("hour")}
></NumberInput>
<NumberInput label="min" {...form.getInputProps("min")}></NumberInput>
<NumberInput label="sec" {...form.getInputProps("sec")}></NumberInput>
<NumberInput label="ms" {...form.getInputProps("ms")}></NumberInput>
</Group>
<Divider></Divider>
<Button disabled={!enabled} type="submit">
Start
</Button>
</Stack>
</form>
);
};
export const TimeOffsetModal = withModal(TimeOffsetForm, "time-offset", {
title: "Change Time",
});
export default TimeOffsetForm;

@ -0,0 +1,192 @@
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { useSelectorOptions } from "@/utilities";
import { useEnabledLanguages } from "@/utilities/languages";
import { Alert, Button, Divider, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { isObject } from "lodash";
import { FunctionComponent, useMemo } from "react";
import { Selector } from "../inputs";
const TaskName = "Translating Subtitles";
const translations = {
af: "afrikaans",
sq: "albanian",
am: "amharic",
ar: "arabic",
hy: "armenian",
az: "azerbaijani",
eu: "basque",
be: "belarusian",
bn: "bengali",
bs: "bosnian",
bg: "bulgarian",
ca: "catalan",
ceb: "cebuano",
ny: "chichewa",
zh: "chinese (simplified)",
zt: "chinese (traditional)",
co: "corsican",
hr: "croatian",
cs: "czech",
da: "danish",
nl: "dutch",
en: "english",
eo: "esperanto",
et: "estonian",
tl: "filipino",
fi: "finnish",
fr: "french",
fy: "frisian",
gl: "galician",
ka: "georgian",
de: "german",
el: "greek",
gu: "gujarati",
ht: "haitian creole",
ha: "hausa",
haw: "hawaiian",
iw: "hebrew",
hi: "hindi",
hmn: "hmong",
hu: "hungarian",
is: "icelandic",
ig: "igbo",
id: "indonesian",
ga: "irish",
it: "italian",
ja: "japanese",
jw: "javanese",
kn: "kannada",
kk: "kazakh",
km: "khmer",
ko: "korean",
ku: "kurdish (kurmanji)",
ky: "kyrgyz",
lo: "lao",
la: "latin",
lv: "latvian",
lt: "lithuanian",
lb: "luxembourgish",
mk: "macedonian",
mg: "malagasy",
ms: "malay",
ml: "malayalam",
mt: "maltese",
mi: "maori",
mr: "marathi",
mn: "mongolian",
my: "myanmar (burmese)",
ne: "nepali",
no: "norwegian",
ps: "pashto",
fa: "persian",
pl: "polish",
pt: "portuguese",
pa: "punjabi",
ro: "romanian",
ru: "russian",
sm: "samoan",
gd: "scots gaelic",
sr: "serbian",
st: "sesotho",
sn: "shona",
sd: "sindhi",
si: "sinhala",
sk: "slovak",
sl: "slovenian",
so: "somali",
es: "spanish",
su: "sundanese",
sw: "swahili",
sv: "swedish",
tg: "tajik",
ta: "tamil",
te: "telugu",
th: "thai",
tr: "turkish",
uk: "ukrainian",
ur: "urdu",
uz: "uzbek",
vi: "vietnamese",
cy: "welsh",
xh: "xhosa",
yi: "yiddish",
yo: "yoruba",
zu: "zulu",
fil: "Filipino",
he: "Hebrew",
};
interface Props {
selections: FormType.ModifySubtitle[];
onSubmit?: VoidFunction;
}
const TranslationForm: FunctionComponent<Props> = ({
selections,
onSubmit,
}) => {
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
const { data: languages } = useEnabledLanguages();
const form = useForm({
initialValues: {
language: null as Language.Info | null,
},
validationRules: {
language: isObject,
},
});
const available = useMemo(
() => languages.filter((v) => v.code2 in translations),
[languages]
);
const options = useSelectorOptions(
available,
(v) => v.name,
(v) => v.code2
);
return (
<form
onSubmit={form.onSubmit(({ language }) => {
if (language) {
selections.forEach((s) =>
task.create(s.path, TaskName, mutateAsync, {
action: "translate",
form: {
...s,
language: language.code2,
},
})
);
onSubmit?.();
modals.closeSelf();
}
})}
>
<Stack>
<Alert variant="outline">
Enabled languages not listed here are unsupported by Google Translate.
</Alert>
<Selector {...options} {...form.getInputProps("language")}></Selector>
<Divider></Divider>
<Button type="submit">Start</Button>
</Stack>
</form>
);
};
export const TranslationModal = withModal(TranslationForm, "translation-tool", {
title: "Translate Subtitle(s)",
});
export default TranslationForm;

@ -1,78 +0,0 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
FunctionComponent,
MouseEvent,
PropsWithChildren,
useCallback,
useState,
} from "react";
import { Button } from "react-bootstrap";
interface CHButtonProps {
disabled?: boolean;
hidden?: boolean;
icon: IconDefinition;
updating?: boolean;
updatingIcon?: IconDefinition;
onClick?: (e: MouseEvent) => void;
}
const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
const { children, icon, disabled, updating, updatingIcon, onClick } = props;
let displayIcon = icon;
if (updating) {
displayIcon = updatingIcon ? updatingIcon : faSpinner;
}
return (
<Button
variant="dark"
className="d-flex flex-column text-nowrap py-1"
disabled={disabled || updating}
onClick={onClick}
>
<FontAwesomeIcon
className="mx-auto my-1"
icon={displayIcon}
spin={updating}
></FontAwesomeIcon>
<span className="align-bottom text-themecolor small text-center">
{children}
</span>
</Button>
);
};
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
promise: T;
onSuccess?: (item: R) => void;
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
): JSX.Element {
const { promise, onSuccess, ...button } = props;
const [updating, setUpdate] = useState(false);
const click = useCallback(() => {
setUpdate(true);
promise().then((val) => {
setUpdate(false);
onSuccess && onSuccess(val);
});
}, [onSuccess, promise]);
return (
<ContentHeaderButton
updating={updating}
onClick={click}
{...button}
></ContentHeaderButton>
);
}
export default ContentHeaderButton;

@ -1,15 +0,0 @@
import { FunctionComponent } from "react";
type GroupPosition = "start" | "end";
interface GroupProps {
pos: GroupPosition;
}
const ContentHeaderGroup: FunctionComponent<GroupProps> = (props) => {
const { children, pos } = props;
const className = `d-flex flex-grow-1 align-items-center justify-content-${pos}`;
return <div className={className}>{children}</div>;
};
export default ContentHeaderGroup;

@ -1,47 +0,0 @@
import { FunctionComponent, ReactNode, useMemo } from "react";
import { Row } from "react-bootstrap";
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
import ContentHeaderGroup from "./Group";
interface Props {
scroll?: boolean;
className?: string;
}
declare type Header = FunctionComponent<Props> & {
Button: typeof ContentHeaderButton;
AsyncButton: typeof ContentHeaderAsyncButton;
Group: typeof ContentHeaderGroup;
};
export const ContentHeader: Header = ({ children, scroll, className }) => {
const cls = useMemo(() => {
const rowCls = ["content-header", "bg-dark", "p-2"];
if (className !== undefined) {
rowCls.push(className);
}
if (scroll !== false) {
rowCls.push("scroll");
}
return rowCls.join(" ");
}, [scroll, className]);
let childItem: ReactNode;
if (scroll !== false) {
childItem = (
<div className="d-flex flex-nowrap flex-grow-1">{children}</div>
);
} else {
childItem = children;
}
return <Row className={cls}>{childItem}</Row>;
};
ContentHeader.Button = ContentHeaderButton;
ContentHeader.Group = ContentHeaderGroup;
ContentHeader.AsyncButton = ContentHeaderAsyncButton;
export default ContentHeader;

@ -1,135 +1,4 @@
import {
faClock,
faCloudUploadAlt,
faDownload,
faRecycle,
faTrash,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { isNull, isUndefined } from "lodash";
import { FunctionComponent, ReactElement } from "react";
import {
OverlayTrigger,
OverlayTriggerProps,
Popover,
Spinner,
SpinnerProps,
} from "react-bootstrap";
enum HistoryAction {
Delete = 0,
Download,
Manual,
Upgrade,
Upload,
Sync,
}
export const HistoryIcon: FunctionComponent<{
action: number;
title?: string;
}> = (props) => {
const { action, title } = props;
let icon = null;
switch (action) {
case HistoryAction.Delete:
icon = faTrash;
break;
case HistoryAction.Download:
icon = faDownload;
break;
case HistoryAction.Manual:
icon = faUser;
break;
case HistoryAction.Sync:
icon = faClock;
break;
case HistoryAction.Upgrade:
icon = faRecycle;
break;
case HistoryAction.Upload:
icon = faCloudUploadAlt;
break;
}
if (icon) {
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
} else {
return null;
}
};
interface MessageIconProps extends FontAwesomeIconProps {
messages: string[];
}
export const MessageIcon: FunctionComponent<MessageIconProps> = (props) => {
const { messages, ...iconProps } = props;
const popover = (
<Popover hidden={messages.length === 0} id="overlay-icon">
<Popover.Content>
{messages.map((m) => (
<li key={m}>{m}</li>
))}
</Popover.Content>
</Popover>
);
return (
<OverlayTrigger overlay={popover}>
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</OverlayTrigger>
);
};
export const LoadingIndicator: FunctionComponent<{
animation?: SpinnerProps["animation"];
}> = ({ children, animation: style }) => {
return (
<div className="d-flex flex-column flex-grow-1 align-items-center py-5">
<Spinner animation={style ?? "border"} className="mb-2"></Spinner>
{children}
</div>
);
};
interface TextPopoverProps {
children: ReactElement;
text: string | undefined | null;
placement?: OverlayTriggerProps["placement"];
delay?: number;
}
export const TextPopover: FunctionComponent<TextPopoverProps> = ({
children,
text,
placement,
delay,
}) => {
if (isNull(text) || isUndefined(text)) {
return children;
}
const popover = (
<Popover className="mw-100 py-1" id={text}>
<span className="mx-2">{text}</span>
</Popover>
);
return (
<OverlayTrigger delay={delay} overlay={popover} placement={placement}>
{children}
</OverlayTrigger>
);
};
export * from "./async";
export * from "./buttons";
export * from "./header";
export * from "./inputs";
export * from "./LanguageSelector";
export * from "./SearchBar";
export { default as Search } from "./Search";
export * from "./tables";
export { default as Toolbox } from "./toolbox";

@ -0,0 +1,24 @@
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { ActionIcon, ActionIconProps } from "@mantine/core";
import { forwardRef } from "react";
export type ActionProps = ActionIconProps<"button"> & {
icon: IconDefinition;
iconProps?: Omit<FontAwesomeIconProps, "icon">;
};
const Action = forwardRef<HTMLButtonElement, ActionProps>(
({ icon, iconProps, ...props }, ref) => {
return (
<ActionIcon {...props} ref={ref}>
<FontAwesomeIcon icon={icon} {...iconProps}></FontAwesomeIcon>
</ActionIcon>
);
}
);
export default Action;

@ -0,0 +1,34 @@
import { useSelectorOptions } from "@/utilities";
import { FunctionComponent } from "react";
import { MultiSelector, MultiSelectorProps } from "./Selector";
export type ChipInputProps = Omit<
MultiSelectorProps<string>,
| "searchable"
| "creatable"
| "getCreateLabel"
| "onCreate"
| "options"
| "getkey"
>;
const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => {
const { value, onChange } = props;
const options = useSelectorOptions(value ?? [], (v) => v);
return (
<MultiSelector
{...props}
{...options}
creatable
searchable
getCreateLabel={(query) => `Add "${query}"`}
onCreate={(query) => {
onChange?.([...(value ?? []), query]);
}}
></MultiSelector>
);
};
export default ChipInput;

@ -1,147 +0,0 @@
import {
FocusEvent,
FunctionComponent,
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];
export interface ChipsProps {
disabled?: boolean;
defaultValue?: readonly string[];
value?: readonly string[];
onChange?: (v: string[]) => void;
}
export const Chips: FunctionComponent<ChipsProps> = ({
defaultValue,
value,
disabled,
onChange,
}) => {
const [chips, setChips] = useState<Readonly<string[]>>(() => {
if (value) {
return value;
}
if (defaultValue) {
return defaultValue;
}
return [];
});
useEffect(() => {
if (value) {
setChips(value);
}
}, [value]);
const input = useRef<HTMLInputElement>(null);
const addChip = useCallback(
(value: string) => {
setChips((cp) => {
const newChips = [...cp, value];
onChange && onChange(newChips);
return newChips;
});
},
[onChange]
);
const removeChip = useCallback(
(idx?: number) => {
setChips((cp) => {
const index = idx ?? cp.length - 1;
if (index !== -1) {
const newChips = [...cp];
newChips.splice(index, 1);
onChange && onChange(newChips);
return newChips;
} else {
return cp;
}
});
},
[onChange]
);
const clearInput = useCallback(() => {
if (input.current) {
input.current.value = "";
}
}, [input]);
const onKeyUp = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
const pressed = event.key;
const value = event.currentTarget.value;
if (SplitKeys.includes(pressed) && value.length !== 0) {
event.preventDefault();
addChip(value);
clearInput();
} else if (pressed === "Backspace" && value.length === 0) {
event.preventDefault();
removeChip();
}
},
[addChip, removeChip, clearInput]
);
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
const pressed = event.key;
const value = event.currentTarget.value;
if (SplitKeys.includes(pressed) && value.length !== 0) {
event.preventDefault();
}
}, []);
const onBlur = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
if (value.length !== 0) {
event.preventDefault();
addChip(value);
clearInput();
}
},
[addChip, clearInput]
);
const chipElements = useMemo(
() =>
chips.map((v, idx) => (
<span
key={idx}
title={v}
className={`custom-chip ${disabled ? "" : "active"}`}
onClick={() => {
if (!disabled) {
removeChip(idx);
}
}}
>
{v}
</span>
)),
[chips, removeChip, disabled]
);
return (
<div className="form-control custom-chip-input d-flex">
<div className="chip-container">{chipElements}</div>
<input
disabled={disabled}
className="main-input p-0"
ref={input}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
onBlur={onBlur}
></input>
</div>
);
};

@ -0,0 +1,78 @@
import {
faArrowUp,
faFileCirclePlus,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Box, Stack, Text } from "@mantine/core";
import {
Dropzone,
DropzoneProps,
DropzoneStatus,
FullScreenDropzone,
FullScreenDropzoneProps,
} from "@mantine/dropzone";
import { FunctionComponent, useMemo } from "react";
export type FileProps = Omit<DropzoneProps, "children"> & {
inner?: FileInnerComponent;
};
const File: FunctionComponent<FileProps> = ({
inner: Inner = FileInner,
...props
}) => {
return (
<Dropzone {...props}>
{(status) => <Inner status={status}></Inner>}
</Dropzone>
);
};
export type FileOverlayProps = Omit<FullScreenDropzoneProps, "children"> & {
inner?: FileInnerComponent;
};
export const FileOverlay: FunctionComponent<FileOverlayProps> = ({
inner: Inner = FileInner,
...props
}) => {
return (
<FullScreenDropzone {...props}>
{(status) => <Inner status={status}></Inner>}
</FullScreenDropzone>
);
};
export type FileInnerProps = {
status: DropzoneStatus;
};
type FileInnerComponent = FunctionComponent<FileInnerProps>;
const FileInner: FileInnerComponent = ({ status }) => {
const { accepted, rejected } = status;
const icon = useMemo(() => {
if (accepted) {
return faArrowUp;
} else if (rejected) {
return faXmark;
} else {
return faFileCirclePlus;
}
}, [accepted, rejected]);
return (
<Stack m="lg" align="center" spacing="xs" style={{ pointerEvents: "none" }}>
<Box mb="md">
<FontAwesomeIcon size="3x" icon={icon}></FontAwesomeIcon>
</Box>
<Text size="lg">Upload files here</Text>
<Text color="dimmed" size="sm">
Drag and drop, or click to select
</Text>
</Stack>
);
};
export default File;

@ -1,18 +1,11 @@
import { useFileSystem } from "@/apis/hooks";
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
import { faReply } from "@fortawesome/free-solid-svg-icons";
import { faFolder } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
ChangeEvent,
FunctionComponent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dropdown, DropdownProps, Form, Spinner } from "react-bootstrap";
import { Autocomplete, AutocompleteProps } from "@mantine/core";
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
const backKey = "--back--";
// TODO: use fortawesome icons
const backKey = "⏎ Back";
function getLastSeparator(path: string): number {
let idx = path.lastIndexOf("/");
@ -31,134 +24,81 @@ function extractPath(raw: string) {
}
}
export interface FileBrowserProps {
defaultValue?: string;
export type FileBrowserProps = Omit<AutocompleteProps, "data"> & {
type: "sonarr" | "radarr" | "bazarr";
onChange?: (path: string) => void;
drop?: DropdownProps["drop"];
}
};
type FileTreeItem = {
value: string;
item?: FileTree;
};
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
defaultValue,
type,
onChange,
drop,
...props
}) => {
const [show, canShow] = useState(false);
const [text, setText] = useState(defaultValue ?? "");
const [path, setPath] = useState(() => extractPath(text));
const { data: tree, isFetching } = useFileSystem(type, path, show);
const filter = useMemo(() => {
const idx = getLastSeparator(text);
return text.slice(idx + 1);
}, [text]);
const [isShow, setIsShow] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
const [path, setPath] = useState(() => extractPath(value));
const { data: tree } = useFileSystem(type, path, isShow);
const data = useMemo<FileTreeItem[]>(
() => [
{ value: backKey },
...(tree?.map((v) => ({
value: v.path,
item: v,
})) ?? []),
],
[tree]
);
const previous = useMemo(() => {
const parent = useMemo(() => {
const idx = getLastSeparator(path.slice(0, -1));
return path.slice(0, idx + 1);
}, [path]);
const requestItems = () => {
if (isFetching) {
return (
<Dropdown.Item>
<Spinner size="sm" animation="border"></Spinner>
</Dropdown.Item>
);
}
const elements = [];
if (tree) {
elements.push(
...tree
.filter((v) => v.name.startsWith(filter))
.map((v) => (
<Dropdown.Item eventKey={v.path} key={v.name}>
<FontAwesomeIcon
icon={v.children ? faFolder : faFile}
className="mr-2"
></FontAwesomeIcon>
<span>{v.name}</span>
</Dropdown.Item>
))
);
}
if (elements.length === 0) {
elements.push(<Dropdown.Header key="no-files">No Files</Dropdown.Header>);
}
if (previous.length !== 0) {
return [
<Dropdown.Item eventKey={backKey} key="back">
<FontAwesomeIcon icon={faReply} className="mr-2"></FontAwesomeIcon>
<span>Back</span>
</Dropdown.Item>,
<Dropdown.Divider key="back-divider"></Dropdown.Divider>,
...elements,
];
} else {
return elements;
}
};
useEffect(() => {
if (text === path) {
if (value === path) {
return;
}
const newPath = extractPath(text);
const newPath = extractPath(value);
if (newPath !== path) {
setPath(newPath);
onChange && onChange(newPath);
}
}, [path, text, onChange]);
}, [path, value, onChange]);
const input = useRef<HTMLInputElement>(null);
const ref = useRef<HTMLInputElement>(null);
return (
<Dropdown
show={show}
drop={drop}
onSelect={(key) => {
if (!key) {
return;
}
if (key !== backKey) {
setText(key);
<Autocomplete
{...props}
ref={ref}
icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
placeholder="Click to start"
data={data}
value={value}
filter={(value, item) => {
if (item.value === backKey) {
return true;
} else {
setText(previous);
return item.value.includes(value);
}
input.current?.focus();
}}
onToggle={(open, _, meta) => {
if (!open && meta.source !== "select") {
canShow(false);
} else if (open) {
canShow(true);
onChange={(val) => {
if (val !== backKey) {
setValue(val);
} else {
setValue(parent);
}
}}
>
<Dropdown.Toggle
as={Form.Control}
placeholder="Click to start"
type="text"
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
}}
ref={input}
></Dropdown.Toggle>
<Dropdown.Menu
className="w-100"
style={{ maxHeight: 256, overflowY: "auto" }}
>
{requestItems()}
</Dropdown.Menu>
</Dropdown>
onFocus={() => setIsShow(true)}
onBlur={() => setIsShow(false)}
></Autocomplete>
);
};

@ -1,73 +0,0 @@
import {
ChangeEvent,
FunctionComponent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Form } from "react-bootstrap";
export interface FileFormProps {
disabled?: boolean;
multiple?: boolean;
emptyText: string;
value?: File[];
onChange?: (files: File[]) => void;
}
export const FileForm: FunctionComponent<FileFormProps> = ({
value: files,
emptyText,
multiple,
disabled,
onChange,
}) => {
const [fileList, setFileList] = useState<File[]>([]);
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
if (files) {
setFileList(files);
if (files.length === 0 && input.current) {
// Manual reset file input
input.current.value = "";
}
}
}, [files]);
const label = useMemo(() => {
if (fileList.length === 0) {
return emptyText;
} else {
if (multiple) {
return `${fileList.length} Files`;
} else {
return fileList[0].name;
}
}
}, [fileList, emptyText, multiple]);
return (
<Form.File
disabled={disabled}
custom
label={label}
multiple={multiple}
ref={input}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const { files } = e.target;
if (files) {
const list: File[] = [];
for (const file of files) {
list.push(file);
}
setFileList(list);
onChange && onChange(list);
}
}}
></Form.File>
);
};

@ -1,141 +1,169 @@
import clsx from "clsx";
import { FocusEvent, useCallback, useMemo, useRef } from "react";
import Select, { GroupBase, OnChangeValue } from "react-select";
import { SelectComponents } from "react-select/dist/declarations/src/components";
export type SelectorOption<T> = {
label: string;
value: T;
};
import { LOG } from "@/utilities/console";
import {
MultiSelect,
MultiSelectProps,
Select,
SelectItem,
SelectProps,
} from "@mantine/core";
import { isNull, isUndefined } from "lodash";
import { useCallback, useMemo, useRef } from "react";
export type SelectorComponents<T, M extends boolean> = SelectComponents<
SelectorOption<T>,
M,
GroupBase<SelectorOption<T>>
export type SelectorOption<T> = Override<
{
value: T;
label: string;
},
SelectItem
>;
export type SelectorValueType<T, M extends boolean> = M extends true
? ReadonlyArray<T>
: Nullable<T>;
export interface SelectorProps<T, M extends boolean> {
className?: string;
placeholder?: string;
options: readonly SelectorOption<T>[];
disabled?: boolean;
clearable?: boolean;
loading?: boolean;
multiple?: M;
onChange?: (k: SelectorValueType<T, M>) => void;
onFocus?: (e: FocusEvent<HTMLElement>) => void;
label?: (item: T) => string;
defaultValue?: SelectorValueType<T, M>;
value?: SelectorValueType<T, M>;
components?: Partial<
SelectComponents<SelectorOption<T>, M, GroupBase<SelectorOption<T>>>
>;
type SelectItemWithPayload<T> = SelectItem & {
payload: T;
};
function DefaultKeyBuilder<T>(value: T) {
if (typeof value === "string") {
return value;
} else if (typeof value === "number") {
return value.toString();
} else {
LOG("error", "Unknown value type", value);
throw new Error(
`Invalid type (${typeof value}) in the SelectorOption, please provide a label builder`
);
}
}
export function Selector<T = string, M extends boolean = false>(
props: SelectorProps<T, M>
) {
const {
className,
placeholder,
label,
disabled,
clearable,
loading,
options,
multiple,
onChange,
onFocus,
defaultValue,
components,
value,
} = props;
const labelRef = useRef(label);
const getName = useCallback(
(item: T) => {
if (labelRef.current) {
return labelRef.current(item);
}
export type SelectorProps<T> = Override<
{
value?: T | null;
defaultValue?: T | null;
options: SelectorOption<T>[];
onChange?: (value: T | null) => void;
getkey?: (value: T) => string;
},
Omit<SelectProps, "data">
>;
export function Selector<T>({
value,
defaultValue,
options,
onChange,
getkey = DefaultKeyBuilder,
...select
}: SelectorProps<T>) {
const keyRef = useRef(getkey);
keyRef.current = getkey;
const data = useMemo(
() =>
options.map<SelectItemWithPayload<T>>(({ value, label, ...option }) => ({
label,
value: keyRef.current(value),
payload: value,
...option,
})),
[keyRef, options]
);
return options.find((v) => v.value === item)?.label ?? "Unknown";
const wrappedValue = useMemo(() => {
if (isNull(value) || isUndefined(value)) {
return value;
} else {
return keyRef.current(value);
}
}, [keyRef, value]);
const wrappedDefaultValue = useMemo(() => {
if (isNull(defaultValue) || isUndefined(defaultValue)) {
return defaultValue;
} else {
return keyRef.current(defaultValue);
}
}, [defaultValue, keyRef]);
const wrappedOnChange = useCallback(
(value: string) => {
const payload = data.find((v) => v.value === value)?.payload ?? null;
onChange?.(payload);
},
[data, onChange]
);
return (
<Select
data={data}
defaultValue={wrappedDefaultValue}
value={wrappedValue}
onChange={wrappedOnChange}
{...select}
></Select>
);
}
export type MultiSelectorProps<T> = Override<
{
value?: readonly T[];
defaultValue?: readonly T[];
options: readonly SelectorOption<T>[];
onChange?: (value: T[]) => void;
getkey?: (value: T) => string;
},
Omit<MultiSelectProps, "data">
>;
export function MultiSelector<T>({
value,
defaultValue,
options,
onChange,
getkey = DefaultKeyBuilder,
...select
}: MultiSelectorProps<T>) {
const labelRef = useRef(getkey);
labelRef.current = getkey;
const data = useMemo(
() =>
options.map<SelectItemWithPayload<T>>(({ value, ...option }) => ({
value: labelRef.current(value),
payload: value,
...option,
})),
[options]
);
const wrapper = useCallback(
(
value: SelectorValueType<T, M> | undefined | null
):
| SelectorOption<T>
| ReadonlyArray<SelectorOption<T>>
| null
| undefined => {
if (value === null || value === undefined) {
return value as null | undefined;
} else {
if (multiple === true) {
return (value as SelectorValueType<T, true>).map((v) => ({
label: getName(v),
value: v,
}));
} else {
const v = value as T;
return {
label: getName(v),
value: v,
};
const wrappedValue = useMemo(
() => value && value.map(labelRef.current),
[value]
);
const wrappedDefaultValue = useMemo(
() => defaultValue && defaultValue.map(labelRef.current),
[defaultValue]
);
const wrappedOnChange = useCallback(
(values: string[]) => {
const payloads: T[] = [];
for (const value of values) {
const payload = data.find((v) => v.value === value)?.payload;
if (payload) {
payloads.push(payload);
}
}
onChange?.(payloads);
},
[multiple, getName]
);
const defaultWrapper = useMemo(
() => wrapper(defaultValue),
[defaultValue, wrapper]
[data, onChange]
);
const valueWrapper = useMemo(() => wrapper(value), [wrapper, value]);
return (
<Select
isLoading={loading}
placeholder={placeholder}
isSearchable={options.length >= 10}
isMulti={multiple}
closeMenuOnSelect={!multiple}
defaultValue={defaultWrapper}
value={valueWrapper}
isClearable={clearable}
isDisabled={disabled}
options={options}
components={components}
className={clsx("custom-selector w-100", className)}
classNamePrefix="selector"
onFocus={onFocus}
onChange={(newValue) => {
if (onChange) {
if (multiple === true) {
const values = (
newValue as OnChangeValue<SelectorOption<T>, true>
).map((v) => v.value) as ReadonlyArray<T>;
onChange(values as SelectorValueType<T, M>);
} else {
const value =
(newValue as OnChangeValue<SelectorOption<T>, false>)?.value ??
null;
onChange(value as SelectorValueType<T, M>);
}
}
}}
></Select>
<MultiSelect
value={wrappedValue}
defaultValue={wrappedDefaultValue}
onChange={wrappedOnChange}
{...select}
data={data}
></MultiSelect>
);
}

@ -1,83 +0,0 @@
import RcSlider from "rc-slider";
import "rc-slider/assets/index.css";
import { FunctionComponent, useMemo, useState } from "react";
type TooltipsOptions = boolean | "Always";
export interface SliderProps {
tooltips?: TooltipsOptions;
min?: number;
max?: number;
step?: number;
start?: number;
defaultValue?: number;
onAfterChange?: (value: number) => void;
onChange?: (value: number) => void;
}
export const Slider: FunctionComponent<SliderProps> = ({
min,
max,
step,
tooltips,
defaultValue,
onChange,
onAfterChange,
}) => {
max = max ?? 100;
min = min ?? 0;
step = step ?? 1;
const [curr, setValue] = useState(defaultValue);
return (
<div className="d-flex flex-row align-items-center py-2">
<span className="text-muted text-nowrap pr-3">{`${min} / ${curr}`}</span>
<RcSlider
min={min}
max={max}
className="custom-rc-slider"
step={step}
defaultValue={defaultValue}
onChange={(v) => {
setValue(v);
onChange && onChange(v);
}}
onAfterChange={onAfterChange}
handle={(props) => (
<div
className="rc-slider-handle"
style={{
left: `${props.offset}%`,
}}
>
<SliderTooltips
tooltips={tooltips}
value={props.value}
></SliderTooltips>
</div>
)}
></RcSlider>
<span className="text-muted pl-3">{max}</span>
</div>
);
};
const SliderTooltips: FunctionComponent<{
tooltips?: TooltipsOptions;
value: number;
}> = ({ tooltips, value }) => {
const cls = useMemo(() => {
const tipsCls = ["rc-slider-handle-tips"];
if (tooltips !== undefined) {
if (typeof tooltips === "string") {
tipsCls.push("rc-slider-handle-tips-always");
} else if (tooltips === false) {
tipsCls.push("rc-slider-handle-tips-hidden");
}
}
return tipsCls.join(" ");
}, [tooltips]);
return <span className={cls}>{value}</span>;
};

@ -1,44 +0,0 @@
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FunctionComponent } from "react";
import { AsyncButton } from "..";
interface Props {
history: History.Base;
update?: () => void;
promise: (form: FormType.AddBlacklist) => Promise<void>;
}
export const BlacklistButton: FunctionComponent<Props> = ({
history,
update,
promise,
}) => {
const { provider, subs_id, language, subtitles_path, blacklisted } = history;
if (subs_id && provider && language) {
return (
<AsyncButton
size="sm"
variant="light"
noReset
disabled={blacklisted}
promise={() => {
const { code2 } = language;
const form: FormType.AddBlacklist = {
provider,
subs_id,
subtitles_path,
language: code2,
};
return promise(form);
}}
onSuccess={update}
>
<FontAwesomeIcon icon={faFileExcel}></FontAwesomeIcon>
</AsyncButton>
);
} else {
return null;
}
};

@ -1,5 +1,3 @@
export * from "./Chips";
export { default as Action } from "./Action";
export * from "./FileBrowser";
export * from "./FileForm";
export * from "./Selector";
export * from "./Slider";

@ -4,19 +4,26 @@ import {
useMovieAddBlacklist,
useMovieHistory,
} from "@/apis/hooks";
import { useModal, usePayload, withModal } from "@/modules/modals";
import { withModal } from "@/modules/modals";
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { Badge, Center, Text } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
import { HistoryIcon, PageTable, QueryOverlay, TextPopover } from "..";
import { PageTable } from "..";
import MutateAction from "../async/MutateAction";
import QueryOverlay from "../async/QueryOverlay";
import { HistoryIcon } from "../bazarr";
import Language from "../bazarr/Language";
import { BlacklistButton } from "../inputs/blacklist";
import TextPopover from "../TextPopover";
const MovieHistoryView: FunctionComponent = () => {
const movie = usePayload<Item.Movie>();
interface MovieHistoryViewProps {
movie: Item.Movie;
}
const Modal = useModal({ size: "lg" });
const history = useMovieHistory(movie?.radarrId);
const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
movie,
}) => {
const history = useMovieHistory(movie.radarrId);
const { data } = history;
@ -24,17 +31,22 @@ const MovieHistoryView: FunctionComponent = () => {
() => [
{
accessor: "action",
className: "text-center",
Cell: (row) => {
return <HistoryIcon action={row.value}></HistoryIcon>;
},
Cell: (row) => (
<Center>
<HistoryIcon action={row.value}></HistoryIcon>
</Center>
),
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return <Language.Text value={value} long></Language.Text>;
return (
<Badge>
<Language.Text value={value} long></Language.Text>
</Badge>
);
} else {
return null;
}
@ -51,58 +63,72 @@ const MovieHistoryView: FunctionComponent = () => {
{
Header: "Date",
accessor: "timestamp",
Cell: (row) => {
if (row.value) {
return (
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
<span>{row.value}</span>
</TextPopover>
);
} else {
return null;
}
Cell: ({ value, row }) => {
return (
<TextPopover text={row.original.parsed_timestamp}>
<Text>{value}</Text>
</TextPopover>
);
},
},
{
// Actions
accessor: "blacklisted",
Cell: ({ row }) => {
const { radarrId } = row.original;
const { mutateAsync } = useMovieAddBlacklist();
return (
<BlacklistButton
update={history.refetch}
promise={(form) => mutateAsync({ id: radarrId, form })}
history={row.original}
></BlacklistButton>
);
Cell: ({ row, value }) => {
const add = useMovieAddBlacklist();
const { radarrId, provider, subs_id, language, subtitles_path } =
row.original;
if (subs_id && provider && language) {
return (
<MutateAction
disabled={value}
icon={faFileExcel}
mutation={add}
args={() => ({
id: radarrId,
form: {
provider,
subs_id,
subtitles_path,
language: language.code2,
},
})}
></MutateAction>
);
} else {
return null;
}
},
},
],
[history.refetch]
[]
);
return (
<Modal title={`History - ${movie?.title ?? ""}`}>
<QueryOverlay result={history}>
<PageTable
emptyText="No History Found"
columns={columns}
data={data ?? []}
></PageTable>
</QueryOverlay>
</Modal>
<QueryOverlay result={history}>
<PageTable
columns={columns}
data={data ?? []}
tableStyles={{ emptyText: "No history found" }}
></PageTable>
</QueryOverlay>
);
};
export const MovieHistoryModal = withModal(MovieHistoryView, "movie-history");
const EpisodeHistoryView: FunctionComponent = () => {
const episode = usePayload<Item.Episode>();
export const MovieHistoryModal = withModal(MovieHistoryView, "movie-history", {
size: "xl",
title: "Movie History",
});
const Modal = useModal({ size: "lg" });
interface EpisodeHistoryViewProps {
episode: Item.Episode;
}
const history = useEpisodeHistory(episode?.sonarrEpisodeId);
const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
episode,
}) => {
const history = useEpisodeHistory(episode.sonarrEpisodeId);
const { data } = history;
@ -110,17 +136,22 @@ const EpisodeHistoryView: FunctionComponent = () => {
() => [
{
accessor: "action",
className: "text-center",
Cell: (row) => {
return <HistoryIcon action={row.value}></HistoryIcon>;
},
Cell: (row) => (
<Center>
<HistoryIcon action={row.value}></HistoryIcon>
</Center>
),
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return <Language.Text value={value} long></Language.Text>;
return (
<Badge>
<Language.Text value={value} long></Language.Text>
</Badge>
);
} else {
return null;
}
@ -137,38 +168,49 @@ const EpisodeHistoryView: FunctionComponent = () => {
{
Header: "Date",
accessor: "timestamp",
Cell: (row) => {
if (row.value) {
return (
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
<span>{row.value}</span>
</TextPopover>
);
} else {
return null;
}
Cell: ({ row, value }) => {
return (
<TextPopover text={row.original.parsed_timestamp}>
<Text>{value}</Text>
</TextPopover>
);
},
},
{
// Actions
accessor: "blacklisted",
Cell: ({ row }) => {
const original = row.original;
Cell: ({ row, value }) => {
const {
sonarrEpisodeId,
sonarrSeriesId,
provider,
subs_id,
language,
subtitles_path,
} = row.original;
const add = useEpisodeAddBlacklist();
const { sonarrEpisodeId, sonarrSeriesId } = original;
const { mutateAsync } = useEpisodeAddBlacklist();
return (
<BlacklistButton
history={original}
promise={(form) =>
mutateAsync({
if (subs_id && provider && language) {
return (
<MutateAction
disabled={value}
icon={faFileExcel}
mutation={add}
args={() => ({
seriesId: sonarrSeriesId,
episodeId: sonarrEpisodeId,
form,
})
}
></BlacklistButton>
);
form: {
provider,
subs_id,
subtitles_path,
language: language.code2,
},
})}
></MutateAction>
);
} else {
return null;
}
},
},
],
@ -176,19 +218,18 @@ const EpisodeHistoryView: FunctionComponent = () => {
);
return (
<Modal title={`History - ${episode?.title ?? ""}`}>
<QueryOverlay result={history}>
<PageTable
emptyText="No History Found"
columns={columns}
data={data ?? []}
></PageTable>
</QueryOverlay>
</Modal>
<QueryOverlay result={history}>
<PageTable
tableStyles={{ emptyText: "No history found", placeholder: 5 }}
columns={columns}
data={data ?? []}
></PageTable>
</QueryOverlay>
);
};
export const EpisodeHistoryModal = withModal(
EpisodeHistoryView,
"episode-history"
"episode-history",
{ size: "xl" }
);

@ -1,100 +0,0 @@
import { useIsAnyActionRunning, useLanguageProfiles } from "@/apis/hooks";
import {
useModal,
useModalControl,
usePayload,
withModal,
} from "@/modules/modals";
import { GetItemId } from "@/utilities";
import { FunctionComponent, useMemo, useState } from "react";
import { Container, Form } from "react-bootstrap";
import { UseMutationResult } from "react-query";
import { AsyncButton, Selector, SelectorOption } from "..";
interface Props {
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
}
const Editor: FunctionComponent<Props> = ({ mutation }) => {
const { data: profiles } = useLanguageProfiles();
const payload = usePayload<Item.Base>();
const { mutateAsync, isLoading } = mutation;
const { hide } = useModalControl();
const hasTask = useIsAnyActionRunning();
const profileOptions = useMemo<SelectorOption<number>[]>(
() =>
profiles?.map((v) => {
return { label: v.name, value: v.profileId };
}) ?? [],
[profiles]
);
const [id, setId] = useState<Nullable<number>>(payload?.profileId ?? null);
const Modal = useModal({
closeable: !isLoading,
onMounted: () => {
setId(payload?.profileId ?? null);
},
});
const footer = (
<AsyncButton
noReset
disabled={hasTask}
promise={() => {
if (payload) {
const itemId = GetItemId(payload);
if (!itemId) {
return null;
}
return mutateAsync({
id: [itemId],
profileid: [id],
});
} else {
return null;
}
}}
onSuccess={() => hide()}
>
Save
</AsyncButton>
);
return (
<Modal title={payload?.title ?? "Item Editor"} footer={footer}>
<Container fluid>
<Form>
<Form.Group>
<Form.Label>Audio</Form.Label>
<Form.Control
type="text"
disabled
defaultValue={payload?.audio_language
.map((v) => v.name)
.join(", ")}
></Form.Control>
</Form.Group>
<Form.Group>
<Form.Label>Languages Profiles</Form.Label>
<Selector
clearable
disabled={hasTask}
options={profileOptions}
value={id}
onChange={(v) => setId(v === undefined ? null : v)}
></Selector>
</Form.Group>
</Form>
</Container>
</Modal>
);
};
export default withModal(Editor, "edit");

@ -1,29 +1,35 @@
import { useModal, usePayload, withModal } from "@/modules/modals";
import { createAndDispatchTask } from "@/modules/task/utilities";
import { GetItemId, isMovie } from "@/utilities";
import { withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
import { useTableStyles } from "@/styles";
import { BuildKey, GetItemId } from "@/utilities";
import {
faCaretDown,
faCheck,
faCheckCircle,
faDownload,
faExclamationCircle,
faInfoCircle,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import {
Alert,
Anchor,
Badge,
Button,
Col,
Collapse,
Container,
OverlayTrigger,
Divider,
Group,
List,
Popover,
Row,
} from "react-bootstrap";
Stack,
Text,
} from "@mantine/core";
import { useHover } from "@mantine/hooks";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { UseQueryResult } from "react-query";
import { Column } from "react-table";
import { LoadingIndicator, PageTable } from "..";
import { Action, PageTable } from "..";
import Language from "../bazarr/Language";
type SupportType = Item.Movie | Item.Episode;
@ -33,12 +39,11 @@ interface Props<T extends SupportType> {
query: (
id?: number
) => UseQueryResult<SearchResultType[] | undefined, unknown>;
item: T;
}
function ManualSearchView<T extends SupportType>(props: Props<T>) {
const { download, query: useSearch } = props;
const item = usePayload<T>();
const { download, query: useSearch, item } = props;
const itemId = useMemo(() => GetItemId(item ?? {}), [item]);
@ -49,17 +54,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
const isStale = results.data === undefined;
const search = useCallback(() => {
if (itemId !== undefined) {
setId(itemId);
results.refetch();
}
setId(itemId);
results.refetch();
}, [itemId, results]);
const columns = useMemo<Column<SearchResultType>[]>(
() => [
{
Header: "Score",
accessor: (d) => `${d.score}%`,
accessor: "score",
Cell: ({ value }) => {
const { classes } = useTableStyles();
return <Text className={classes.noWrap}>{value}%</Text>;
},
},
{
accessor: "language",
@ -71,7 +78,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
name: "",
};
return (
<Badge variant="secondary">
<Badge>
<Language.Text value={lang}></Language.Text>
</Badge>
);
@ -81,13 +88,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
Header: "Provider",
accessor: "provider",
Cell: (row) => {
const { classes } = useTableStyles();
const value = row.value;
const { url } = row.row.original;
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer">
<Anchor
className={classes.noWrap}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{value}
</a>
</Anchor>
);
} else {
return value;
@ -97,55 +110,44 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
{
Header: "Release",
accessor: "release_info",
className: "text-nowrap",
Cell: (row) => {
const value = row.value;
Cell: ({ value }) => {
const { classes } = useTableStyles();
const [open, setOpen] = useState(false);
const items = useMemo(
() =>
value.slice(1).map((v, idx) => (
<span className="release-text hidden-item" key={idx}>
{v}
</span>
)),
() => value.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>),
[value]
);
if (value.length === 0) {
return <span className="text-muted">Cannot get release info</span>;
return <Text color="dimmed">Cannot get release info</Text>;
}
return (
<div
className={clsx(
"release-container d-flex justify-content-between align-items-center",
{ "release-multi": value.length > 1 }
)}
onClick={() => setOpen((o) => !o)}
>
<div className="text-container">
<span className="release-text">{value[0]}</span>
<Collapse in={open}>
<div>{items}</div>
</Collapse>
</div>
{value.length > 1 && (
<FontAwesomeIcon
className="release-icon"
icon={faCaretDown}
rotation={open ? 180 : undefined}
></FontAwesomeIcon>
)}
</div>
<Stack spacing={0} onClick={() => setOpen((o) => !o)}>
<Text className={classes.primary}>
{value[0]}
{value.length > 1 && (
<FontAwesomeIcon
icon={faCaretDown}
rotation={open ? 180 : undefined}
></FontAwesomeIcon>
)}
</Text>
<Collapse in={open}>
<>{items}</>
</Collapse>
</Stack>
);
},
},
{
Header: "Upload",
accessor: (d) => d.uploader ?? "-",
accessor: "uploader",
Cell: ({ value }) => {
const { classes } = useTableStyles();
return <Text className={classes.noWrap}>{value ?? "-"}</Text>;
},
},
{
accessor: "matches",
@ -159,24 +161,23 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
Cell: ({ row }) => {
const result = row.original;
return (
<Button
size="sm"
<Action
icon={faDownload}
color="brand"
variant="light"
disabled={item === null}
onClick={() => {
if (!item) return;
createAndDispatchTask(
task.create(
item.title,
"download-subtitles",
TaskGroup.DownloadSubtitle,
download,
item,
result
);
}}
>
<FontAwesomeIcon icon={faDownload}></FontAwesomeIcon>
</Button>
></Action>
);
},
},
@ -184,141 +185,84 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
[download, item]
);
const content = () => {
if (results.isFetching) {
return <LoadingIndicator animation="grow"></LoadingIndicator>;
} else if (isStale) {
return (
<div className="px-4 py-5">
<p className="mb-3 small">{item?.path ?? ""}</p>
<Button variant="primary" block onClick={search}>
Start Search
</Button>
</div>
);
} else {
return (
<>
<p className="mb-3 small">{item?.path ?? ""}</p>
<PageTable
emptyText="No Result"
columns={columns}
data={results.data ?? []}
></PageTable>
</>
);
}
};
const title = useMemo(() => {
let title = "Unknown";
if (item) {
if (item.sceneName) {
title = item.sceneName;
} else if (isMovie(item)) {
title = item.title;
} else {
title = item.title;
}
}
return `Search - ${title}`;
}, [item]);
const Modal = useModal({
size: "xl",
closeable: results.isFetching === false,
onMounted: () => {
// Cleanup the ID when user switches episode / movie
if (itemId !== id) {
setId(undefined);
}
},
});
const footer = (
<Button variant="light" hidden={isStale} onClick={search}>
Search Again
</Button>
);
return (
<Modal title={title} footer={footer}>
{content()}
</Modal>
<Stack>
<Alert
title="Resource"
color="gray"
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
>
{item?.path}
</Alert>
<Collapse in={!isStale && !results.isFetching}>
<PageTable
tableStyles={{ emptyText: "No result", placeholder: 10 }}
columns={columns}
data={results.data ?? []}
></PageTable>
</Collapse>
<Divider></Divider>
<Button loading={results.isFetching} fullWidth onClick={search}>
{isStale ? "Search" : "Search Again"}
</Button>
</Stack>
);
}
export const MovieSearchModal = withModal<Props<Item.Movie>>(
ManualSearchView,
"movie-manual-search"
"movie-manual-search",
{ title: "Search Subtitles", size: "xl" }
);
export const EpisodeSearchModal = withModal<Props<Item.Episode>>(
ManualSearchView,
"episode-manual-search"
"episode-manual-search",
{ title: "Search Subtitles", size: "xl" }
);
const StateIcon: FunctionComponent<{ matches: string[]; dont: string[] }> = ({
matches,
dont,
}) => {
let icon = faCheck;
let color = "var(--success)";
if (dont.length > 0) {
icon = faInfoCircle;
color = "var(--warning)";
}
const hasIssues = dont.length > 0;
const matchElements = useMemo(
() =>
matches.map((v, idx) => (
<p key={`match-${idx}`} className="text-nowrap m-0">
{v}
</p>
)),
[matches]
);
const dontElements = useMemo(
() =>
dont.map((v, idx) => (
<p key={`dont-${idx}`} className="text-nowrap m-0">
{v}
</p>
)),
[dont]
);
const popover = useMemo(
() => (
<Popover className="w-100" id="manual-search-matches-info">
<Popover.Content>
<Container fluid>
<Row>
<Col xs={6}>
<FontAwesomeIcon
color="var(--success)"
icon={faCheck}
></FontAwesomeIcon>
{matchElements}
</Col>
<Col xs={6}>
<FontAwesomeIcon
color="var(--danger)"
icon={faTimes}
></FontAwesomeIcon>
{dontElements}
</Col>
</Row>
</Container>
</Popover.Content>
</Popover>
),
[matchElements, dontElements]
);
const { ref, hovered } = useHover();
return (
<OverlayTrigger overlay={popover} placement={"left"}>
<FontAwesomeIcon icon={icon} color={color}></FontAwesomeIcon>
</OverlayTrigger>
<Popover
opened={hovered}
placement="center"
position="top"
target={
<Text color={hasIssues ? "yellow" : "green"} ref={ref}>
<FontAwesomeIcon
icon={hasIssues ? faExclamationCircle : faCheckCircle}
></FontAwesomeIcon>
</Text>
}
>
<Group align="flex-start" spacing="xl">
<Stack align="flex-start" spacing="xs">
<Text color="green">
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
</Text>
<List>
{matches.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "match")}>{v}</List.Item>
))}
</List>
</Stack>
<Stack align="flex-start" spacing="xs">
<Text color="yellow">
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
</Text>
<List>
{dont.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "miss")}>{v}</List.Item>
))}
</List>
</Stack>
</Group>
</Popover>
);
};

@ -1,99 +0,0 @@
import { useMovieSubtitleModification } from "@/apis/hooks";
import { usePayload, withModal } from "@/modules/modals";
import { createTask, dispatchTask } from "@/modules/task/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import { FunctionComponent, useCallback } from "react";
import SubtitleUploader, {
PendingSubtitle,
Validator,
} from "./SubtitleUploadModal";
const MovieUploadModal: FunctionComponent = () => {
const payload = usePayload<Item.Movie>();
const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
const update = useCallback(async (list: PendingSubtitle<unknown>[]) => {
return list;
}, []);
const {
upload: { mutateAsync },
} = useMovieSubtitleModification();
const validate = useCallback<Validator<unknown>>(
(item) => {
if (item.language === null) {
return {
state: "error",
messages: ["Language is not selected"],
};
} else if (
payload?.subtitles.find((v) => v.code2 === item.language?.code2) !==
undefined
) {
return {
state: "warning",
messages: ["Override existing subtitle"],
};
}
return {
state: "valid",
messages: [],
};
},
[payload?.subtitles]
);
const upload = useCallback(
(items: PendingSubtitle<unknown>[]) => {
if (payload === null) {
return;
}
const { radarrId } = payload;
const tasks = items
.filter((v) => v.language !== null)
.map((v) => {
const { file, language, forced, hi } = v;
if (language === null) {
throw new Error("Language is not selected");
}
return createTask(file.name, mutateAsync, {
radarrId,
form: {
file,
forced,
hi,
language: language.code2,
},
});
});
dispatchTask(tasks, "upload-subtitles");
},
[mutateAsync, payload]
);
return (
<SubtitleUploader
hideAllLanguages
initial={{ forced: false }}
availableLanguages={availableLanguages}
columns={[]}
upload={upload}
update={update}
validate={validate}
></SubtitleUploader>
);
};
export default withModal(MovieUploadModal, "movie-upload");

@ -1,175 +0,0 @@
import { useEpisodeSubtitleModification } from "@/apis/hooks";
import api from "@/apis/raw";
import { usePayload, withModal } from "@/modules/modals";
import { createTask, dispatchTask } from "@/modules/task/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import { Selector, SelectorOption } from "../inputs";
import SubtitleUploader, {
PendingSubtitle,
useRowMutation,
Validator,
} from "./SubtitleUploadModal";
interface Payload {
instance: Item.Episode | null;
}
interface SeriesProps {
episodes: readonly Item.Episode[];
}
const SeriesUploadModal: FunctionComponent<SeriesProps> = ({ episodes }) => {
const payload = usePayload<Item.Series>();
const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
const {
upload: { mutateAsync },
} = useEpisodeSubtitleModification();
const update = useCallback(
async (list: PendingSubtitle<Payload>[]) => {
const newList = [...list];
const names = list.map((v) => v.file.name);
if (names.length > 0) {
const results = await api.subtitles.info(names);
// TODO: Optimization
newList.forEach((v) => {
const info = results.find((f) => f.filename === v.file.name);
if (info) {
v.payload.instance =
episodes.find(
(e) => e.season === info.season && e.episode === info.episode
) ?? null;
}
});
}
return newList;
},
[episodes]
);
const validate = useCallback<Validator<Payload>>((item) => {
const { language } = item;
const { instance } = item.payload;
if (language === null || instance === null) {
return {
state: "error",
messages: ["Language or Episode is not selected"],
};
} else if (
instance.subtitles.find((v) => v.code2 === language.code2) !== undefined
) {
return {
state: "warning",
messages: ["Override existing subtitle"],
};
}
return {
state: "valid",
messages: [],
};
}, []);
const upload = useCallback(
(items: PendingSubtitle<Payload>[]) => {
if (payload === null) {
return;
}
const { sonarrSeriesId: seriesId } = payload;
const tasks = items
.filter((v) => v.payload.instance !== undefined)
.map((v) => {
const {
hi,
forced,
payload: { instance },
language,
} = v;
if (language === null || instance === null) {
throw new Error("Invalid state");
}
const { code2 } = language;
const { sonarrEpisodeId: episodeId } = instance;
const form: FormType.UploadSubtitle = {
file: v.file,
language: code2,
hi: hi,
forced: forced,
};
return createTask(v.file.name, mutateAsync, {
seriesId,
episodeId,
form,
});
});
dispatchTask(tasks, "upload-subtitles");
},
[mutateAsync, payload]
);
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
() => [
{
id: "instance",
Header: "Episode",
accessor: "payload",
className: "vw-1",
Cell: ({ value, row }) => {
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
label: `(${ep.season}x${ep.episode}) ${ep.title}`,
value: ep,
}));
const mutate = useRowMutation();
return (
<Selector
disabled={row.original.state === "fetching"}
options={options}
value={value.instance}
onChange={(ep: Nullable<Item.Episode>) => {
if (ep) {
const newInfo = { ...row.original };
newInfo.payload.instance = ep;
mutate(row.index, newInfo);
}
}}
></Selector>
);
},
},
],
[episodes]
);
return (
<SubtitleUploader
columns={columns}
initial={{ instance: null }}
availableLanguages={availableLanguages}
upload={upload}
update={update}
validate={validate}
></SubtitleUploader>
);
};
export default withModal(SeriesUploadModal, "series-upload");

@ -0,0 +1,123 @@
import Language from "@/components/bazarr/Language";
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
import { SimpleTable } from "@/components/tables";
import { useCustomSelection } from "@/components/tables/plugins";
import { withModal } from "@/modules/modals";
import { isMovie } from "@/utilities";
import { Badge, Button, Divider, Group, Stack } from "@mantine/core";
import { FunctionComponent, useMemo, useState } from "react";
import { Column, useRowSelect } from "react-table";
type SupportType = Item.Episode | Item.Movie;
type TableColumnType = FormType.ModifySubtitle & {
raw_language: Language.Info;
};
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
if (isMovie(item)) {
return [item.radarrId, "movie"];
} else {
return [item.sonarrEpisodeId, "episode"];
}
}
const CanSelectSubtitle = (item: TableColumnType) => {
return item.path.endsWith(".srt");
};
interface SubtitleToolViewProps {
payload: SupportType[];
}
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
payload,
}) => {
const [selections, setSelections] = useState<TableColumnType[]>([]);
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
() => [
{
Header: "Language",
accessor: "raw_language",
Cell: ({ value }) => (
<Badge color="secondary">
<Language.Text value={value} long></Language.Text>
</Badge>
),
},
{
id: "file",
Header: "File",
accessor: "path",
Cell: ({ value }) => {
const path = value;
let idx = path.lastIndexOf("/");
if (idx === -1) {
idx = path.lastIndexOf("\\");
}
if (idx !== -1) {
return path.slice(idx + 1);
} else {
return path;
}
},
},
],
[]
);
const data = useMemo<TableColumnType[]>(
() =>
payload.flatMap((item) => {
const [id, type] = getIdAndType(item);
return item.subtitles.flatMap((v) => {
if (v.path) {
return [
{
id,
type,
language: v.code2,
path: v.path,
raw_language: v,
},
];
} else {
return [];
}
});
}),
[payload]
);
const plugins = [useRowSelect, useCustomSelection];
return (
<Stack>
<SimpleTable
tableStyles={{ emptyText: "No external subtitles found" }}
plugins={plugins}
columns={columns}
onSelect={setSelections}
canSelect={CanSelectSubtitle}
data={data}
></SimpleTable>
<Divider></Divider>
<Group>
<SubtitleToolsMenu selections={selections}>
<Button disabled={selections.length === 0} variant="light">
Select Action
</Button>
</SubtitleToolsMenu>
</Group>
</Stack>
);
};
export default withModal(SubtitleToolView, "subtitle-tools", {
title: "Subtitle Tools",
size: "xl",
});

@ -1,362 +0,0 @@
import { useModal, useModalControl } from "@/modules/modals";
import { BuildKey } from "@/utilities";
import { LOG } from "@/utilities/console";
import {
faCheck,
faCircleNotch,
faInfoCircle,
faTimes,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column } from "react-table";
import { LanguageSelector, MessageIcon } from "..";
import { FileForm } from "../inputs";
import { SimpleTable } from "../tables";
type ModifyFn<T> = (index: number, info?: PendingSubtitle<T>) => void;
const RowContext = createContext<ModifyFn<unknown>>(() => {
LOG("error", "RowContext not initialized");
});
export function useRowMutation() {
return useContext(RowContext);
}
export interface PendingSubtitle<P> {
file: File;
state: "valid" | "fetching" | "warning" | "error";
messages: string[];
language: Language.Info | null;
forced: boolean;
hi: boolean;
payload: P;
}
export type Validator<T> = (
item: PendingSubtitle<T>
) => Pick<PendingSubtitle<T>, "state" | "messages">;
interface Props<T = unknown> {
initial: T;
availableLanguages: Language.Info[];
upload: (items: PendingSubtitle<T>[]) => void;
update: (items: PendingSubtitle<T>[]) => Promise<PendingSubtitle<T>[]>;
validate: Validator<T>;
columns: Column<PendingSubtitle<T>>[];
hideAllLanguages?: boolean;
}
function SubtitleUploader<T>(props: Props<T>) {
const {
initial,
columns,
upload,
update,
validate,
availableLanguages,
hideAllLanguages,
} = props;
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
const showTable = pending.length > 0;
const Modal = useModal({
size: showTable ? "xl" : "lg",
});
const { hide } = useModalControl();
const fileList = useMemo(() => pending.map((v) => v.file), [pending]);
const initialRef = useRef(initial);
const setFiles = useCallback(
async (files: File[]) => {
const initialLanguage =
availableLanguages.length > 0 ? availableLanguages[0] : null;
let list = files.map<PendingSubtitle<T>>((file) => ({
file,
state: "fetching",
messages: [],
language: initialLanguage,
forced: false,
hi: false,
payload: { ...initialRef.current },
}));
if (update) {
setPending(list);
list = await update(list);
} else {
list = list.map<PendingSubtitle<T>>((v) => ({
...v,
state: "valid",
}));
}
list = list.map((v) => ({
...v,
...validate(v),
}));
setPending(list);
},
[update, validate, availableLanguages]
);
const modify = useCallback(
(index: number, info?: PendingSubtitle<T>) => {
setPending((pd) => {
const newPending = [...pd];
if (info) {
info = { ...info, ...validate(info) };
newPending[index] = info;
} else {
newPending.splice(index, 1);
}
return newPending;
});
},
[validate]
);
useEffect(() => {
setPending((pd) => {
const newPd = pd.map((v) => {
if (v.state !== "fetching") {
return { ...v, ...validate(v) };
} else {
return v;
}
});
return newPd;
});
}, [validate]);
const columnsWithAction = useMemo<Column<PendingSubtitle<T>>[]>(
() => [
{
id: "icon",
accessor: "state",
className: "text-center",
Cell: ({ value, row }) => {
let icon = faCircleNotch;
let color: string | undefined = undefined;
let spin = false;
switch (value) {
case "fetching":
spin = true;
break;
case "warning":
icon = faInfoCircle;
color = "var(--warning)";
break;
case "valid":
icon = faCheck;
color = "var(--success)";
break;
default:
icon = faTimes;
color = "var(--danger)";
break;
}
const messages = row.original.messages;
return (
<MessageIcon
messages={messages}
color={color}
icon={icon}
spin={spin}
></MessageIcon>
);
},
},
{
Header: "File",
accessor: (d) => d.file.name,
},
{
id: "hi",
Header: "HI",
accessor: "hi",
Cell: ({ row, value }) => {
const { original, index } = row;
const mutate = useRowMutation();
return (
<Form.Check
custom
disabled={original.state === "fetching"}
id={BuildKey(index, original.file.name, "hi")}
checked={value}
onChange={(v) => {
const newInfo = { ...row.original };
newInfo.hi = v.target.checked;
mutate(row.index, newInfo);
}}
></Form.Check>
);
},
},
{
id: "forced",
Header: "Forced",
accessor: "forced",
Cell: ({ row, value }) => {
const { original, index } = row;
const mutate = useRowMutation();
return (
<Form.Check
custom
disabled={original.state === "fetching"}
id={BuildKey(index, original.file.name, "forced")}
checked={value}
onChange={(v) => {
const newInfo = { ...row.original };
newInfo.forced = v.target.checked;
mutate(row.index, newInfo);
}}
></Form.Check>
);
},
},
{
id: "language",
Header: "Language",
accessor: "language",
className: "w-25",
Cell: ({ row, value }) => {
const mutate = useRowMutation();
return (
<LanguageSelector
disabled={row.original.state === "fetching"}
options={availableLanguages}
value={value}
onChange={(lang) => {
if (lang) {
const newInfo = { ...row.original };
newInfo.language = lang;
mutate(row.index, newInfo);
}
}}
></LanguageSelector>
);
},
},
...columns,
{
id: "action",
accessor: "file",
Cell: ({ row }) => {
const mutate = useRowMutation();
return (
<Button
size="sm"
variant="light"
disabled={row.original.state === "fetching"}
onClick={() => {
mutate(row.index);
}}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</Button>
);
},
},
],
[columns, availableLanguages]
);
const canUpload = useMemo(
() =>
pending.length > 0 &&
pending.every((v) => v.state === "valid" || v.state === "warning"),
[pending]
);
const footer = (
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
<div>
<Button
hidden={!showTable}
variant="outline-secondary"
className="mr-2"
onClick={() => setFiles([])}
>
Clean
</Button>
<Button
disabled={!canUpload || !showTable}
onClick={() => {
upload(pending);
setFiles([]);
hide();
}}
>
Upload
</Button>
</div>
<div className="w-25" hidden={hideAllLanguages}>
<LanguageSelector
options={availableLanguages}
value={null}
disabled={!showTable}
onChange={(lang) => {
if (lang) {
setPending((pd) =>
pd
.map((v) => ({ ...v, language: lang }))
.map((v) => ({ ...v, ...validate(v) }))
);
}
}}
></LanguageSelector>
</div>
</div>
);
return (
<Modal title="Update Subtitles" footer={footer}>
<Container fluid className="flex-column">
<Form>
<Form.Group>
<FileForm
disabled={showTable}
emptyText="Select..."
multiple
value={fileList}
onChange={setFiles}
></FileForm>
</Form.Group>
</Form>
<div hidden={!showTable}>
<RowContext.Provider value={modify as ModifyFn<unknown>}>
<SimpleTable
columns={columnsWithAction}
data={pending}
responsive={false}
></SimpleTable>
</RowContext.Provider>
</div>
</Container>
</Modal>
);
}
export default SubtitleUploader;

@ -1,4 +1,2 @@
export * from "./HistoryModal";
export { default as ItemEditorModal } from "./ItemEditorModal";
export { default as MovieUploadModal } from "./MovieUploadModal";
export { default as SeriesUploadModal } from "./SeriesUploadModal";
export { default as SubtitleToolsModal } from "./SubtitleToolsModal";

@ -1,36 +0,0 @@
import { Selector } from "@/components";
import { useModal, withModal } from "@/modules/modals";
import { submodProcessColor } from "@/utilities";
import { FunctionComponent, useCallback, useState } from "react";
import { Button } from "react-bootstrap";
import { useProcess } from "./ToolContext";
import { colorOptions } from "./tools";
const ColorTool: FunctionComponent = () => {
const [selection, setSelection] = useState<Nullable<string>>(null);
const Modal = useModal();
const process = useProcess();
const submit = useCallback(() => {
if (selection) {
const action = submodProcessColor(selection);
process(action);
}
}, [process, selection]);
const footer = (
<Button disabled={selection === null} onClick={submit}>
Save
</Button>
);
return (
<Modal title="Choose Color" footer={footer}>
<Selector options={colorOptions} onChange={setSelection}></Selector>
</Modal>
);
};
export default withModal(ColorTool, "color-tool");

@ -1,65 +0,0 @@
import { useModal, withModal } from "@/modules/modals";
import { FunctionComponent, useCallback, useState } from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import { useProcess } from "./ToolContext";
function submodProcessFrameRate(from: number, to: number) {
return `change_FPS(from=${from},to=${to})`;
}
const FrameRateTool: FunctionComponent = () => {
const [from, setFrom] = useState<Nullable<number>>(null);
const [to, setTo] = useState<Nullable<number>>(null);
const canSave = from !== null && to !== null && from !== to;
const Modal = useModal();
const process = useProcess();
const submit = useCallback(() => {
if (canSave) {
const action = submodProcessFrameRate(from, to);
process(action);
}
}, [canSave, from, process, to]);
const footer = (
<Button disabled={!canSave} onClick={submit}>
Save
</Button>
);
return (
<Modal title="Change Frame Rate" footer={footer}>
<InputGroup className="px-2">
<Form.Control
placeholder="From"
type="number"
onChange={(e) => {
const value = parseFloat(e.currentTarget.value);
if (isNaN(value)) {
setFrom(null);
} else {
setFrom(value);
}
}}
></Form.Control>
<Form.Control
placeholder="To"
type="number"
onChange={(e) => {
const value = parseFloat(e.currentTarget.value);
if (isNaN(value)) {
setTo(null);
} else {
setTo(value);
}
}}
></Form.Control>
</InputGroup>
</Modal>
);
};
export default withModal(FrameRateTool, "frame-rate-tool");

@ -1,100 +0,0 @@
import { useModal, withModal } from "@/modules/modals";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
ChangeEventHandler,
FunctionComponent,
useCallback,
useState,
} from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import { useProcess } from "./ToolContext";
function submodProcessOffset(h: number, m: number, s: number, ms: number) {
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
}
const TimeAdjustmentTool: FunctionComponent = () => {
const [isPlus, setPlus] = useState(true);
const [offset, setOffset] = useState<[number, number, number, number]>([
0, 0, 0, 0,
]);
const Modal = useModal();
const updateOffset = useCallback(
(idx: number): ChangeEventHandler<HTMLInputElement> => {
return (e) => {
let value = parseFloat(e.currentTarget.value);
if (isNaN(value)) {
value = 0;
}
const newOffset = [...offset] as [number, number, number, number];
newOffset[idx] = value;
setOffset(newOffset);
};
},
[offset]
);
const canSave = offset.some((v) => v !== 0);
const process = useProcess();
const submit = useCallback(() => {
if (canSave) {
const newOffset = offset.map((v) => (isPlus ? v : -v));
const action = submodProcessOffset(
newOffset[0],
newOffset[1],
newOffset[2],
newOffset[3]
);
process(action);
}
}, [canSave, offset, process, isPlus]);
const footer = (
<Button disabled={!canSave} onClick={submit}>
Save
</Button>
);
return (
<Modal title="Adjust Times" footer={footer}>
<InputGroup>
<InputGroup.Prepend>
<Button
variant="secondary"
title={isPlus ? "Later" : "Earlier"}
onClick={() => setPlus(!isPlus)}
>
<FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
</Button>
</InputGroup.Prepend>
<Form.Control
type="number"
placeholder="hour"
onChange={updateOffset(0)}
></Form.Control>
<Form.Control
type="number"
placeholder="min"
onChange={updateOffset(1)}
></Form.Control>
<Form.Control
type="number"
placeholder="sec"
onChange={updateOffset(2)}
></Form.Control>
<Form.Control
type="number"
placeholder="ms"
onChange={updateOffset(3)}
></Form.Control>
</InputGroup>
</Modal>
);
};
export default withModal(TimeAdjustmentTool, "time-adjustment");

@ -1,14 +0,0 @@
import { createContext, useContext } from "react";
export type ProcessSubtitleType = (
action: string,
override?: Partial<FormType.ModifySubtitle>
) => void;
export const ProcessSubtitleContext = createContext<ProcessSubtitleType>(() => {
throw new Error("ProcessSubtitleContext not initialized");
});
export function useProcess() {
return useContext(ProcessSubtitleContext);
}

@ -1,48 +0,0 @@
import { LanguageSelector } from "@/components/LanguageSelector";
import { useModal, withModal } from "@/modules/modals";
import { useEnabledLanguages } from "@/utilities/languages";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { useProcess } from "./ToolContext";
import { availableTranslation } from "./tools";
const TranslationTool: FunctionComponent = () => {
const { data: languages } = useEnabledLanguages();
const available = useMemo(
() => languages.filter((v) => v.code2 in availableTranslation),
[languages]
);
const Modal = useModal();
const [selectedLanguage, setLanguage] =
useState<Nullable<Language.Info>>(null);
const process = useProcess();
const submit = useCallback(() => {
if (selectedLanguage) {
process("translate", { language: selectedLanguage.code2 });
}
}, [process, selectedLanguage]);
const footer = (
<Button disabled={!selectedLanguage} onClick={submit}>
Translate
</Button>
);
return (
<Modal title="Translation" footer={footer}>
<Form.Label>
Enabled languages not listed here are unsupported by Google Translate.
</Form.Label>
<LanguageSelector
options={available}
onChange={setLanguage}
></LanguageSelector>
</Modal>
);
};
export default withModal(TranslationTool, "translation-tool");

@ -1,230 +0,0 @@
import { useSubtitleAction } from "@/apis/hooks";
import Language from "@/components/bazarr/Language";
import { ActionButton, ActionButtonItem } from "@/components/buttons";
import { SimpleTable } from "@/components/tables";
import { useCustomSelection } from "@/components/tables/plugins";
import {
useModal,
useModalControl,
usePayload,
withModal,
} from "@/modules/modals";
import { createTask, dispatchTask } from "@/modules/task/utilities";
import { isMovie } from "@/utilities";
import { LOG } from "@/utilities/console";
import { isObject } from "lodash";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { Badge, ButtonGroup, Dropdown } from "react-bootstrap";
import { Column, useRowSelect } from "react-table";
import {
ProcessSubtitleContext,
ProcessSubtitleType,
useProcess,
} from "./ToolContext";
import { tools } from "./tools";
import { ToolOptions } from "./types";
type SupportType = Item.Episode | Item.Movie;
type TableColumnType = FormType.ModifySubtitle & {
raw_language: Language.Info;
};
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
if (isMovie(item)) {
return [item.radarrId, "movie"];
} else {
return [item.sonarrEpisodeId, "episode"];
}
}
const CanSelectSubtitle = (item: TableColumnType) => {
return item.path.endsWith(".srt");
};
function isElement(value: unknown): value is JSX.Element {
return isObject(value);
}
interface SubtitleToolViewProps {
count: number;
tools: ToolOptions[];
select: (items: TableColumnType[]) => void;
}
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
tools,
count,
select,
}) => {
const payload = usePayload<SupportType[]>();
const Modal = useModal({
size: "lg",
});
const { show } = useModalControl();
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
() => [
{
Header: "Language",
accessor: "raw_language",
Cell: ({ value }) => (
<Badge variant="secondary">
<Language.Text value={value} long></Language.Text>
</Badge>
),
},
{
id: "file",
Header: "File",
accessor: "path",
Cell: ({ value }) => {
const path = value;
let idx = path.lastIndexOf("/");
if (idx === -1) {
idx = path.lastIndexOf("\\");
}
if (idx !== -1) {
return path.slice(idx + 1);
} else {
return path;
}
},
},
],
[]
);
const data = useMemo<TableColumnType[]>(
() =>
payload?.flatMap((item) => {
const [id, type] = getIdAndType(item);
return item.subtitles.flatMap((v) => {
if (v.path !== null) {
return [
{
id,
type,
language: v.code2,
path: v.path,
raw_language: v,
},
];
} else {
return [];
}
});
}) ?? [],
[payload]
);
const plugins = [useRowSelect, useCustomSelection];
const process = useProcess();
const footer = useMemo(() => {
const action = tools[0];
const others = tools.slice(1);
return (
<Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
<ActionButton
size="sm"
disabled={count === 0}
icon={action.icon}
onClick={() => process(action.key)}
>
{action.name}
</ActionButton>
<Dropdown.Toggle
disabled={count === 0}
split
variant="light"
size="sm"
className="px-2"
></Dropdown.Toggle>
<Dropdown.Menu>
{others.map((v) => (
<Dropdown.Item
key={v.key}
eventKey={v.modal ? undefined : v.key}
onSelect={() => {
if (v.modal) {
show(v.modal);
}
}}
>
<ActionButtonItem icon={v.icon}>{v.name}</ActionButtonItem>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
}, [count, process, show, tools]);
return (
<Modal title="Subtitle Tools" footer={footer}>
<SimpleTable
emptyText="No External Subtitles Found"
plugins={plugins}
columns={columns}
onSelect={select}
canSelect={CanSelectSubtitle}
data={data}
></SimpleTable>
</Modal>
);
};
export const SubtitleToolModal = withModal(SubtitleToolView, "subtitle-tools");
const SubtitleTools: FunctionComponent = () => {
const modals = useMemo(
() =>
tools
.map((t) => t.modal && <t.modal key={t.key}></t.modal>)
.filter(isElement),
[]
);
const { hide } = useModalControl();
const [selections, setSelections] = useState<TableColumnType[]>([]);
const { mutateAsync } = useSubtitleAction();
const process = useCallback<ProcessSubtitleType>(
(action, override) => {
LOG("info", "executing action", action);
hide(SubtitleToolModal.modalKey);
const tasks = selections.map((s) => {
const form: FormType.ModifySubtitle = {
id: s.id,
type: s.type,
language: s.language,
path: s.path,
...override,
};
return createTask(s.path, mutateAsync, { action, form });
});
dispatchTask(tasks, "modify-subtitles");
},
[hide, selections, mutateAsync]
);
return (
<ProcessSubtitleContext.Provider value={process}>
<SubtitleToolModal
count={selections.length}
tools={tools}
select={setSelections}
></SubtitleToolModal>
{modals}
</ProcessSubtitleContext.Provider>
);
};
export default SubtitleTools;

@ -1,257 +0,0 @@
import { SelectorOption } from "@/components";
import {
faClock,
faCode,
faDeaf,
faExchangeAlt,
faFilm,
faImage,
faLanguage,
faMagic,
faPaintBrush,
faPlay,
faTextHeight,
} from "@fortawesome/free-solid-svg-icons";
import ColorTool from "./ColorTool";
import FrameRateTool from "./FrameRateTool";
import TimeTool from "./TimeTool";
import Translation from "./Translation";
import { ToolOptions } from "./types";
export const tools: ToolOptions[] = [
{
key: "sync",
icon: faPlay,
name: "Sync",
},
{
key: "remove_HI",
icon: faDeaf,
name: "Remove HI Tags",
},
{
key: "remove_tags",
icon: faCode,
name: "Remove Style Tags",
},
{
key: "OCR_fixes",
icon: faImage,
name: "OCR Fixes",
},
{
key: "common",
icon: faMagic,
name: "Common Fixes",
},
{
key: "fix_uppercase",
icon: faTextHeight,
name: "Fix Uppercase",
},
{
key: "reverse_rtl",
icon: faExchangeAlt,
name: "Reverse RTL",
},
{
key: "add_color",
icon: faPaintBrush,
name: "Add Color",
modal: ColorTool,
},
{
key: "change_frame_rate",
icon: faFilm,
name: "Change Frame Rate",
modal: FrameRateTool,
},
{
key: "adjust_time",
icon: faClock,
name: "Adjust Times",
modal: TimeTool,
},
{
key: "translation",
icon: faLanguage,
name: "Translate",
modal: Translation,
},
];
export const availableTranslation = {
af: "afrikaans",
sq: "albanian",
am: "amharic",
ar: "arabic",
hy: "armenian",
az: "azerbaijani",
eu: "basque",
be: "belarusian",
bn: "bengali",
bs: "bosnian",
bg: "bulgarian",
ca: "catalan",
ceb: "cebuano",
ny: "chichewa",
zh: "chinese (simplified)",
zt: "chinese (traditional)",
co: "corsican",
hr: "croatian",
cs: "czech",
da: "danish",
nl: "dutch",
en: "english",
eo: "esperanto",
et: "estonian",
tl: "filipino",
fi: "finnish",
fr: "french",
fy: "frisian",
gl: "galician",
ka: "georgian",
de: "german",
el: "greek",
gu: "gujarati",
ht: "haitian creole",
ha: "hausa",
haw: "hawaiian",
iw: "hebrew",
hi: "hindi",
hmn: "hmong",
hu: "hungarian",
is: "icelandic",
ig: "igbo",
id: "indonesian",
ga: "irish",
it: "italian",
ja: "japanese",
jw: "javanese",
kn: "kannada",
kk: "kazakh",
km: "khmer",
ko: "korean",
ku: "kurdish (kurmanji)",
ky: "kyrgyz",
lo: "lao",
la: "latin",
lv: "latvian",
lt: "lithuanian",
lb: "luxembourgish",
mk: "macedonian",
mg: "malagasy",
ms: "malay",
ml: "malayalam",
mt: "maltese",
mi: "maori",
mr: "marathi",
mn: "mongolian",
my: "myanmar (burmese)",
ne: "nepali",
no: "norwegian",
ps: "pashto",
fa: "persian",
pl: "polish",
pt: "portuguese",
pa: "punjabi",
ro: "romanian",
ru: "russian",
sm: "samoan",
gd: "scots gaelic",
sr: "serbian",
st: "sesotho",
sn: "shona",
sd: "sindhi",
si: "sinhala",
sk: "slovak",
sl: "slovenian",
so: "somali",
es: "spanish",
su: "sundanese",
sw: "swahili",
sv: "swedish",
tg: "tajik",
ta: "tamil",
te: "telugu",
th: "thai",
tr: "turkish",
uk: "ukrainian",
ur: "urdu",
uz: "uzbek",
vi: "vietnamese",
cy: "welsh",
xh: "xhosa",
yi: "yiddish",
yo: "yoruba",
zu: "zulu",
fil: "Filipino",
he: "Hebrew",
};
export const colorOptions: SelectorOption<string>[] = [
{
label: "White",
value: "white",
},
{
label: "Light Gray",
value: "light-gray",
},
{
label: "Red",
value: "red",
},
{
label: "Green",
value: "green",
},
{
label: "Yellow",
value: "yellow",
},
{
label: "Blue",
value: "blue",
},
{
label: "Magenta",
value: "magenta",
},
{
label: "Cyan",
value: "cyan",
},
{
label: "Black",
value: "black",
},
{
label: "Dark Red",
value: "dark-red",
},
{
label: "Dark Green",
value: "dark-green",
},
{
label: "Dark Yellow",
value: "dark-yellow",
},
{
label: "Dark Blue",
value: "dark-blue",
},
{
label: "Dark Magenta",
value: "dark-magenta",
},
{
label: "Dark Cyan",
value: "dark-cyan",
},
{
label: "Dark Grey",
value: "dark-grey",
},
];

@ -1,9 +0,0 @@
import { ModalComponent } from "@/modules/modals/WithModal";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
export interface ToolOptions {
key: string;
icon: IconDefinition;
name: string;
modal?: ModalComponent<unknown>;
}

@ -1,77 +1,44 @@
import { useMemo } from "react";
import { Table } from "react-bootstrap";
import {
HeaderGroup,
Row,
TableBodyProps,
TableOptions,
TableProps,
} from "react-table";
import { useIsLoading } from "@/contexts";
import { usePageSize } from "@/utilities/storage";
import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core";
import { ReactNode, useMemo } from "react";
import { HeaderGroup, Row, TableInstance } from "react-table";
export interface BaseTableProps<T extends object> extends TableStyleProps<T> {
// Table Options
headers: HeaderGroup<T>[];
rows: Row<T>[];
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
prepareRow: (row: Row<T>) => void;
tableProps: TableProps;
tableBodyProps: TableBodyProps;
}
export type BaseTableProps<T extends object> = TableInstance<T> & {
tableStyles?: TableStyleProps<T>;
};
export interface TableStyleProps<T extends object> {
emptyText?: string;
responsive?: boolean;
hoverable?: boolean;
striped?: boolean;
borderless?: boolean;
small?: boolean;
placeholder?: number;
hideHeader?: boolean;
fixHeader?: boolean;
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
}
interface ExtractResult<T extends object> {
style: TableStyleProps<T>;
options: TableOptions<T>;
}
export function useStyleAndOptions<T extends object>(
props: TableStyleProps<T> & TableOptions<T>
): ExtractResult<T> {
const {
emptyText,
responsive,
hoverable,
striped,
borderless,
small,
hideHeader,
headersRenderer,
rowRenderer,
...options
} = props;
const useStyles = createStyles((theme) => {
return {
style: {
emptyText,
responsive,
hoverable,
striped,
borderless,
small,
hideHeader,
headersRenderer,
rowRenderer,
container: {
display: "block",
maxWidth: "100%",
overflowX: "auto",
},
table: {
borderCollapse: "collapse",
},
options,
header: {},
};
}
});
function DefaultHeaderRenderer<T extends object>(
headers: HeaderGroup<T>[]
): JSX.Element[] {
return headers.map((col) => (
<th {...col.getHeaderProps()}>{col.render("Header")}</th>
<th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}>
{col.render("Header")}
</th>
));
}
@ -79,9 +46,7 @@ function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => (
<td className={cell.column.className} {...cell.getCellProps()}>
{cell.render("Cell")}
</td>
<td {...cell.getCellProps()}>{cell.render("Cell")}</td>
))}
</tr>
);
@ -89,65 +54,73 @@ function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
const {
emptyText,
responsive,
hoverable,
striped,
borderless,
small,
hideHeader,
headers,
headerGroups,
rows,
headersRenderer,
rowRenderer,
prepareRow,
tableProps,
tableBodyProps,
getTableProps,
getTableBodyProps,
tableStyles,
} = props;
const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer;
const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer;
const { classes } = useStyles();
const colCount = useMemo(() => {
return headers.reduce(
return headerGroups.reduce(
(prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
0
);
}, [headers]);
}, [headerGroups]);
const empty = rows.length === 0;
const hRenderer = headersRenderer ?? DefaultHeaderRenderer;
const rRenderer = rowRenderer ?? DefaultRowRenderer;
const [pageSize] = usePageSize();
const isLoading = useIsLoading();
let body: ReactNode;
if (isLoading) {
body = Array(tableStyles?.placeholder ?? pageSize)
.fill(0)
.map((_, i) => (
<tr key={i}>
<td colSpan={colCount}>
<Skeleton height={24}></Skeleton>
</td>
</tr>
));
} else if (empty && tableStyles?.emptyText) {
body = (
<tr>
<td colSpan={colCount}>
<Text align="center">{tableStyles.emptyText}</Text>
</td>
</tr>
);
} else {
body = rows.map((row) => {
prepareRow(row);
return rowRenderer(row);
});
}
return (
<Table
size={small ? "sm" : undefined}
striped={striped ?? true}
borderless={borderless ?? true}
hover={hoverable}
responsive={responsive ?? true}
{...tableProps}
>
<thead hidden={hideHeader}>
{headers.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{hRenderer(headerGroup.headers)}
</tr>
))}
</thead>
<tbody {...tableBodyProps}>
{emptyText && empty ? (
<tr>
<td colSpan={colCount} className="text-center">
{emptyText}
</td>
</tr>
) : (
rows.map((row) => {
prepareRow(row);
return rRenderer(row);
})
)}
</tbody>
</Table>
<Box className={classes.container}>
<Table
className={classes.table}
striped={tableStyles?.striped ?? true}
{...getTableProps()}
>
<thead className={classes.header} hidden={tableStyles?.hideHeader}>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headersRenderer(headerGroup.headers)}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>{body}</tbody>
</Table>
</Box>
);
}

@ -1,21 +1,20 @@
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Box, Text } from "@mantine/core";
import {
Cell,
HeaderGroup,
Row,
TableOptions,
useExpanded,
useGroupBy,
useSortBy,
} from "react-table";
import { TableStyleProps } from "./BaseTable";
import SimpleTable from "./SimpleTable";
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
if (cell.isGrouped) {
return (
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span>
<div {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</div>
);
} else if (row.canExpand || cell.isAggregated) {
return null;
@ -31,22 +30,16 @@ function renderRow<T extends object>(row: Row<T>) {
const rotation = row.isExpanded ? 90 : undefined;
return (
<tr {...row.getRowProps()}>
<td
className="p-0"
{...cell.getCellProps()}
colSpan={row.cells.length}
>
<span
{...row.getToggleRowExpandedProps()}
className="d-flex align-items-center p-2"
>
<td {...cell.getCellProps()} colSpan={row.cells.length}>
<Text {...row.getToggleRowExpandedProps()} p={2}>
{cell.render("Cell")}
<FontAwesomeIcon
className="mx-2"
icon={faChevronCircleRight}
rotation={rotation}
></FontAwesomeIcon>
</span>
<Box component="span" mx={12}>
<FontAwesomeIcon
icon={faChevronCircleRight}
rotation={rotation}
></FontAwesomeIcon>
</Box>
</Text>
</td>
</tr>
);
@ -59,9 +52,7 @@ function renderRow<T extends object>(row: Row<T>) {
{row.cells
.filter((cell) => !cell.isPlaceholder)
.map((cell) => (
<td className={cell.column.className} {...cell.getCellProps()}>
{renderCell(cell, row)}
</td>
<td {...cell.getCellProps()}>{renderCell(cell, row)}</td>
))}
</tr>
);
@ -76,16 +67,19 @@ function renderHeaders<T extends object>(
.map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>);
}
type Props<T extends object> = TableOptions<T> & TableStyleProps<T>;
type Props<T extends object> = Omit<
SimpleTableProps<T>,
"plugins" | "headersRenderer" | "rowRenderer"
>;
const plugins = [useGroupBy, useSortBy, useExpanded];
function GroupTable<T extends object = object>(props: Props<T>) {
const plugins = [useGroupBy, useSortBy, useExpanded];
return (
<SimpleTable
{...props}
plugins={plugins}
headersRenderer={renderHeaders}
rowRenderer={renderRow}
tableStyles={{ headersRenderer: renderHeaders, rowRenderer: renderRow }}
></SimpleTable>
);
}

@ -1,17 +1,12 @@
import { FunctionComponent, useMemo } from "react";
import { Col, Container, Pagination, Row } from "react-bootstrap";
import { PageControlAction } from "./types";
import { useIsLoading } from "@/contexts";
import { Group, Pagination, Text } from "@mantine/core";
import { FunctionComponent } from "react";
interface Props {
count: number;
index: number;
size: number;
total: number;
canPrevious: boolean;
previous: () => void;
canNext: boolean;
next: () => void;
goto: (idx: number) => void;
loadState?: PageControlAction;
}
const PageControl: FunctionComponent<Props> = ({
@ -19,77 +14,28 @@ const PageControl: FunctionComponent<Props> = ({
index,
size,
total,
canPrevious,
previous,
canNext,
next,
goto,
loadState,
}) => {
const empty = total === 0;
const start = empty ? 0 : size * index + 1;
const end = Math.min(size * (index + 1), total);
const loading = loadState !== undefined;
const pageButtons = useMemo(
() =>
[...Array(count).keys()]
.map((idx) => {
if (Math.abs(idx - index) >= 4 && idx !== 0 && idx !== count - 1) {
return null;
} else {
return (
<Pagination.Item
key={idx}
disabled={loading}
active={index === idx}
onClick={() => goto(idx)}
>
{idx + 1}
</Pagination.Item>
);
}
})
.flatMap((item, idx, arr) => {
if (item === null) {
if (arr[idx + 1] === null) {
return [];
} else {
return (
<Pagination.Ellipsis key={idx} disabled></Pagination.Ellipsis>
);
}
} else {
return [item];
}
}),
[count, index, goto, loading]
);
const isLoading = useIsLoading();
return (
<Container fluid className="mb-3">
<Row>
<Col className="d-flex align-items-center justify-content-start">
<span>
Show {start} to {end} of {total} entries
</span>
</Col>
<Col className="d-flex justify-content-end">
<Pagination className="m-0" hidden={count <= 1}>
<Pagination.Prev
onClick={previous}
disabled={!canPrevious || loading}
></Pagination.Prev>
{pageButtons}
<Pagination.Next
onClick={next}
disabled={!canNext || loading}
></Pagination.Next>
</Pagination>
</Col>
</Row>
</Container>
<Group p={16} position="apart">
<Text size="sm">
Show {start} to {end} of {total} entries
</Text>
<Pagination
size="sm"
color={isLoading ? "gray" : "primary"}
page={index + 1}
onChange={(page) => goto(page - 1)}
hidden={count <= 1}
total={count}
></Pagination>
</Group>
);
};

@ -1,74 +1,44 @@
import { ScrollToTop } from "@/utilities";
import { useEffect } from "react";
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import { useEffect, useRef } from "react";
import { TableInstance, usePagination } from "react-table";
import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins";
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
autoScroll?: boolean;
plugins?: PluginHook<T>[];
};
type Props<T extends object> = SimpleTableProps<T> & {
autoScroll?: boolean;
};
const tablePlugins = [useDefaultSettings, usePagination];
export default function PageTable<T extends object>(props: Props<T>) {
const { autoScroll, plugins, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
if (plugins) {
allPlugins.push(...plugins);
}
const instance = useTable(options, ...allPlugins);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
// page
page,
canNextPage,
canPreviousPage,
pageCount,
gotoPage,
nextPage,
previousPage,
state: { pageIndex, pageSize },
} = instance;
const instance = useRef<TableInstance<T> | null>(null);
// Scroll to top when page is changed
useEffect(() => {
if (autoScroll) {
ScrollToTop();
}
}, [pageIndex, autoScroll]);
}, [instance.current?.state.pageIndex, autoScroll]);
return (
<>
<BaseTable
{...style}
headers={headerGroups}
rows={page}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
<PageControl
count={pageCount}
index={pageIndex}
size={pageSize}
total={rows.length}
canPrevious={canPreviousPage}
canNext={canNextPage}
previous={previousPage}
next={nextPage}
goto={gotoPage}
></PageControl>
<SimpleTable
{...remain}
instanceRef={instance}
plugins={[...tablePlugins, ...(plugins ?? [])]}
></SimpleTable>
{instance.current && (
<PageControl
count={instance.current.pageCount}
index={instance.current.state.pageIndex}
size={instance.current.state.pageSize}
total={instance.current.rows.length}
goto={instance.current.gotoPage}
></PageControl>
)}
</>
);
}

@ -1,77 +1,37 @@
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { LoadingProvider } from "@/contexts";
import { ScrollToTop } from "@/utilities";
import { useEffect } from "react";
import { PluginHook, TableOptions, useTable } from "react-table";
import { LoadingIndicator } from "..";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins";
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
plugins?: PluginHook<T>[];
query: UsePaginationQueryResult<T>;
};
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
query: UsePaginationQueryResult<T>;
};
export default function QueryPageTable<T extends object>(props: Props<T>) {
const { plugins, query, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const { query, ...remain } = props;
const {
data,
isLoading,
paginationStatus: {
page,
pageCount,
totalCount,
canPrevious,
canNext,
pageSize,
},
controls: { previousPage, nextPage, gotoPage },
data = { data: [], total: 0 },
paginationStatus: { page, pageCount, totalCount, pageSize, isPageLoading },
controls: { gotoPage },
} = query;
const instance = useTable(
{
...options,
data: data?.data ?? [],
},
useDefaultSettings,
...(plugins ?? [])
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
instance;
useEffect(() => {
ScrollToTop();
}, [page]);
if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<>
<BaseTable
{...style}
headers={headerGroups}
rows={rows}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
<LoadingProvider value={isPageLoading}>
<SimpleTable {...remain} data={data.data}></SimpleTable>
<PageControl
count={pageCount}
index={page}
size={pageSize}
total={totalCount}
canPrevious={canPrevious}
canNext={canNext}
previous={previousPage}
next={nextPage}
goto={gotoPage}
></PageControl>
</>
</LoadingProvider>
);
}

@ -1,29 +1,23 @@
import { PluginHook, TableOptions, useTable } from "react-table";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
import BaseTable, { TableStyleProps } from "./BaseTable";
import { useDefaultSettings } from "./plugins";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
plugins?: PluginHook<T>[];
};
export type SimpleTableProps<T extends object> = TableOptions<T> & {
plugins?: PluginHook<T>[];
instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
tableStyles?: TableStyleProps<T>;
};
export default function SimpleTable<T extends object>(props: Props<T>) {
const { plugins, ...other } = props;
const { style, options } = useStyleAndOptions(other);
export default function SimpleTable<T extends object>(
props: SimpleTableProps<T>
) {
const { plugins, instanceRef, tableStyles, ...options } = props;
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
instance;
if (instanceRef) {
instanceRef.current = instance;
}
return (
<BaseTable
{...style}
headers={headerGroups}
rows={rows}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
);
return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
}

@ -1,5 +1,5 @@
import { Checkbox as MantineCheckbox } from "@mantine/core";
import { forwardRef, useEffect, useRef } from "react";
import { Form } from "react-bootstrap";
import {
CellProps,
Column,
@ -41,13 +41,12 @@ const Checkbox = forwardRef<
}, [resolvedRef, indeterminate, checked, disabled]);
return (
<Form.Check
custom
<MantineCheckbox
key={idIn}
disabled={disabled}
id={idIn}
ref={resolvedRef}
{...rest}
></Form.Check>
></MantineCheckbox>
);
});

@ -1 +0,0 @@
export type PageControlAction = "prev" | "next" | number;

@ -0,0 +1,65 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonProps, Text } from "@mantine/core";
import {
FunctionComponent,
PropsWithChildren,
useCallback,
useState,
} from "react";
type ToolboxButtonProps = Omit<
ButtonProps<"button">,
"color" | "variant" | "leftIcon"
> & {
icon: IconDefinition;
children: string;
};
const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
icon,
children,
...props
}) => {
return (
<Button
color="dark"
variant="subtle"
leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
{...props}
>
<Text size="xs">{children}</Text>
</Button>
);
};
type ToolboxMutateButtonProps<R, T extends () => Promise<R>> = {
promise: T;
onSuccess?: (item: R) => void;
} & Omit<ToolboxButtonProps, "onClick" | "loading">;
export function ToolboxMutateButton<R, T extends () => Promise<R>>(
props: PropsWithChildren<ToolboxMutateButtonProps<R, T>>
): JSX.Element {
const { promise, onSuccess, ...button } = props;
const [loading, setLoading] = useState(false);
const click = useCallback(() => {
setLoading(true);
promise().then((val) => {
setLoading(false);
onSuccess && onSuccess(val);
});
}, [onSuccess, promise]);
return (
<ToolboxButton
loading={loading}
onClick={click}
{...button}
></ToolboxButton>
);
}
export default ToolboxButton;

@ -0,0 +1,31 @@
import { createStyles, Group } from "@mantine/core";
import { FunctionComponent } from "react";
import ToolboxButton, { ToolboxMutateButton } from "./Button";
const useStyles = createStyles((theme) => ({
group: {
backgroundColor:
theme.colorScheme === "light"
? theme.colors.gray[3]
: theme.colors.dark[5],
},
}));
declare type ToolboxComp = FunctionComponent & {
Button: typeof ToolboxButton;
MutateButton: typeof ToolboxMutateButton;
};
const Toolbox: ToolboxComp = ({ children }) => {
const { classes } = useStyles();
return (
<Group p={12} position="apart" className={classes.group}>
{children}
</Group>
);
};
Toolbox.Button = ToolboxButton;
Toolbox.MutateButton = ToolboxMutateButton;
export default Toolbox;

@ -1,35 +0,0 @@
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column } from "react-table";
import { QueryPageTable } from "..";
interface Props<T extends History.Base> {
name: string;
query: UsePaginationQueryResult<T>;
columns: Column<T>[];
}
function HistoryView<T extends History.Base = History.Base>({
columns,
name,
query,
}: Props<T>) {
return (
<Container fluid>
<Helmet>
<title>{name} History - Bazarr</title>
</Helmet>
<Row>
<QueryPageTable
emptyText={`Nothing Found in ${name} History`}
columns={columns}
query={query}
data={[]}
></QueryPageTable>
</Row>
</Container>
);
}
export default HistoryView;

@ -0,0 +1,9 @@
import { MantineNumberSize } from "@mantine/core";
export const GithubRepoRoot = "https://github.com/morpheus65535/bazarr";
export const Layout = {
NAVBAR_WIDTH: 200,
HEADER_HEIGHT: 64,
MOBILE_BREAKPOINT: "sm" as MantineNumberSize,
};

@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
const LoadingContext = createContext<boolean>(false);
export function useIsLoading() {
const context = useContext(LoadingContext);
return context;
}
export default LoadingContext.Provider;

@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
const NavbarContext = createContext<{
showed: boolean;
show: (showed: boolean) => void;
} | null>(null);
export function useNavbar() {
const context = useContext(NavbarContext);
if (context === null) {
throw new Error("NavbarShowedContext not initialized");
}
return context;
}
export default NavbarContext.Provider;

@ -0,0 +1,28 @@
import { createContext, useContext } from "react";
const OnlineContext = createContext<{
online: boolean;
setOnline: (online: boolean) => void;
} | null>(null);
export function useIsOnline() {
const context = useContext(OnlineContext);
if (context === null) {
throw new Error("useIsOnline must be used within a OnlineProvider");
}
return context.online;
}
export function useSetOnline() {
const context = useContext(OnlineContext);
if (context === null) {
throw new Error("useSetOnline must be used within a OnlineProvider");
}
return context.setOnline;
}
export default OnlineContext.Provider;

@ -0,0 +1,2 @@
export * from "./Loading";
export { default as LoadingProvider } from "./Loading";

@ -1,4 +1,10 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import { Entrance } from ".";
import { Main } from "./main";
ReactDOM.render(<Entrance />, document.getElementById("root"));
ReactDOM.render(
<StrictMode>
<Main />
</StrictMode>,
document.getElementById("root")
);

@ -1,30 +0,0 @@
import queryClient from "@/apis/queries";
import store from "@/modules/redux/store";
import "@/styles/index.scss";
import "@fontsource/roboto/300.css";
import { QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import { Provider } from "react-redux";
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}>
<Router>
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
{/* <StrictMode> */}
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
<RouteApp></RouteApp>
{/* </StrictMode> */}
</Router>
</QueryClientProvider>
</Provider>
);

@ -0,0 +1,35 @@
import queryClient from "@/apis/queries";
import ThemeProvider from "@/App/theme";
import { ModalsProvider } from "@/modules/modals";
import "@fontsource/roboto/300.css";
import { NotificationsProvider } from "@mantine/notifications";
import { QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
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 Main = () => {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ModalsProvider>
<NotificationsProvider limit={5}>
<Router>
{Environment.queryDev && (
<ReactQueryDevtools initialIsOpen={false} />
)}
<RouteApp></RouteApp>
</Router>
</NotificationsProvider>
</ModalsProvider>
</ThemeProvider>
</QueryClientProvider>
);
};

@ -1,14 +0,0 @@
import { createContext, Dispatch, SetStateAction } from "react";
export interface ModalData {
key: string;
closeable: boolean;
size: "sm" | "lg" | "xl" | undefined;
}
export type ModalSetter = {
[P in keyof Omit<ModalData, "key">]: Dispatch<SetStateAction<ModalData[P]>>;
};
export const ModalDataContext = createContext<ModalData | null>(null);
export const ModalSetterContext = createContext<ModalSetter | null>(null);

@ -1,44 +0,0 @@
import clsx from "clsx";
import { FunctionComponent, useCallback, useState } from "react";
import { Modal } from "react-bootstrap";
import { useCurrentLayer, useModalControl, useModalData } from "./hooks";
interface Props {}
export const ModalWrapper: FunctionComponent<Props> = ({ children }) => {
const { size, closeable, key } = useModalData();
const [needExit, setExit] = useState(false);
const { hide: hideModal } = useModalControl();
const layer = useCurrentLayer();
const isShowed = layer !== -1;
const hide = useCallback(() => {
setExit(true);
}, []);
const exit = useCallback(() => {
if (isShowed) {
hideModal(key);
}
setExit(false);
}, [isShowed, hideModal, key]);
return (
<Modal
centered
size={size}
show={isShowed && !needExit}
onHide={hide}
onExited={exit}
backdrop={closeable ? undefined : "static"}
className={clsx(`index-${layer}`)}
backdropClassName={clsx(`index-${layer}`)}
>
{children}
</Modal>
);
};
export default ModalWrapper;

@ -0,0 +1,34 @@
import {
ModalsProvider as MantineModalsProvider,
ModalsProviderProps as MantineModalsProviderProps,
} from "@mantine/modals";
import { FunctionComponent, useMemo } from "react";
import { ModalComponent, StaticModals } from "./WithModal";
const DefaultModalProps: MantineModalsProviderProps["modalProps"] = {
centered: true,
styles: {
modal: {
maxWidth: "100%",
},
},
};
const ModalsProvider: FunctionComponent = ({ children }) => {
const modals = useMemo(
() =>
StaticModals.reduce<Record<string, ModalComponent>>((prev, curr) => {
prev[curr.modalKey] = curr;
return prev;
}, {}),
[]
);
return (
<MantineModalsProvider modalProps={DefaultModalProps} modals={modals}>
{children}
</MantineModalsProvider>
);
};
export default ModalsProvider;

@ -1,52 +1,36 @@
import { FunctionComponent, useMemo, useState } from "react";
import {
ModalData,
ModalDataContext,
ModalSetter,
ModalSetterContext,
} from "./ModalContext";
import ModalWrapper from "./ModalWrapper";
/* eslint-disable @typescript-eslint/ban-types */
export interface ModalProps {}
import { ContextModalProps } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
import { createContext, FunctionComponent } from "react";
export type ModalComponent<P> = FunctionComponent<P> & {
modalKey: string;
};
export type ModalComponent<P extends Record<string, unknown> = {}> =
FunctionComponent<ContextModalProps<P>> & {
modalKey: string;
settings?: ModalSettings;
};
export const StaticModals: ModalComponent[] = [];
export default function withModal<T>(
export const ModalIdContext = createContext<string | null>(null);
export default function withModal<T extends {}>(
Content: FunctionComponent<T>,
key: string
key: string,
defaultSettings?: ModalSettings
) {
const Comp: ModalComponent<T> = (props: ModalProps & T) => {
const [closeable, setCloseable] = useState(true);
const [size, setSize] = useState<ModalData["size"]>(undefined);
const data: ModalData = useMemo(
() => ({
key,
size,
closeable,
}),
[closeable, size]
);
const setter: ModalSetter = useMemo(
() => ({
closeable: setCloseable,
size: setSize,
}),
[]
);
const Comp: ModalComponent<T> = (props) => {
const { id, innerProps } = props;
return (
<ModalDataContext.Provider value={data}>
<ModalSetterContext.Provider value={setter}>
<ModalWrapper>
<Content {...props}></Content>
</ModalWrapper>
</ModalSetterContext.Provider>
</ModalDataContext.Provider>
<ModalIdContext.Provider value={id}>
<Content {...innerProps}></Content>
</ModalIdContext.Provider>
);
};
Comp.modalKey = key;
Comp.settings = defaultSettings;
StaticModals.push(Comp as ModalComponent);
return Comp;
}

@ -1,23 +0,0 @@
import { FunctionComponent, ReactNode } from "react";
import { Modal } from "react-bootstrap";
import { useModalData } from "./hooks";
interface StandardModalProps {
title: string;
footer?: ReactNode;
}
export const StandardModalView: FunctionComponent<StandardModalProps> = ({
children,
footer,
title,
}) => {
const { closeable } = useModalData();
return (
<>
<Modal.Header closeButton={closeable}>{title}</Modal.Header>
<Modal.Body>{children}</Modal.Body>
<Modal.Footer hidden={footer === undefined}>{footer}</Modal.Footer>
</>
);
};

@ -1,90 +1,46 @@
import {
hideModalAction,
showModalAction,
} from "@/modules/redux/actions/modal";
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { StandardModalView } from "./components";
import {
ModalData,
ModalDataContext,
ModalSetterContext,
} from "./ModalContext";
import { ModalComponent } from "./WithModal";
type ModalProps = Partial<Omit<ModalData, "key">> & {
onMounted?: () => void;
};
export function useModal(props?: ModalProps): typeof StandardModalView {
const setter = useContext(ModalSetterContext);
useEffect(() => {
if (setter && props) {
setter.closeable(props.closeable ?? true);
setter.size(props.size);
}
}, [props, setter]);
const ref = useRef<ModalProps["onMounted"]>(props?.onMounted);
ref.current = props?.onMounted;
const layer = useCurrentLayer();
useEffect(() => {
if (layer !== -1 && ref.current) {
ref.current();
}
}, [layer]);
return StandardModalView;
}
export function useModalControl() {
const showAction = useReduxAction(showModalAction);
const show = useCallback(
<P>(comp: ModalComponent<P>, payload?: unknown) => {
showAction({ key: comp.modalKey, payload });
/* eslint-disable @typescript-eslint/ban-types */
import { useModals as useMantineModals } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
import { useCallback, useContext, useMemo } from "react";
import { ModalComponent, ModalIdContext } from "./WithModal";
export function useModals() {
const { openContextModal: openMantineContextModal, ...rest } =
useMantineModals();
const openContextModal = useCallback(
<ARGS extends {}>(
modal: ModalComponent<ARGS>,
props: ARGS,
settings?: ModalSettings
) => {
openMantineContextModal(modal.modalKey, {
...modal.settings,
...settings,
innerProps: props,
});
},
[showAction]
[openMantineContextModal]
);
const hideAction = useReduxAction(hideModalAction);
const hide = useCallback(
(key?: string) => {
hideAction(key);
const closeContextModal = useCallback(
(modal: ModalComponent) => {
rest.closeModal(modal.modalKey);
},
[hideAction]
[rest]
);
return { show, hide };
}
export function useModalData(): ModalData {
const data = useContext(ModalDataContext);
if (data === null) {
throw new Error("useModalData should be used inside Modal");
}
const id = useContext(ModalIdContext);
return data;
}
export function usePayload<T>(): T | null {
const { key } = useModalData();
const stack = useReduxStore((s) => s.modal.stack);
const closeSelf = useCallback(() => {
if (id) {
rest.closeModal(id);
}
}, [id, rest]);
// TODO: Performance
return useMemo(
() => (stack.find((m) => m.key === key)?.payload as T) ?? null,
[stack, key]
() => ({ openContextModal, closeContextModal, closeSelf, ...rest }),
[closeContextModal, closeSelf, openContextModal, rest]
);
}
export function useCurrentLayer() {
const { key } = useModalData();
const stack = useReduxStore((s) => s.modal.stack);
return useMemo(() => stack.findIndex((m) => m.key === key), [stack, key]);
}

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

Loading…
Cancel
Save