Replace Bootstrap with Mantine (#1795)
parent
6515c42f26
commit
2cecb4c5b5
@ -1,12 +1,15 @@
|
|||||||
From newest to oldest:
|
From newest to oldest:
|
||||||
{{#each releases}}
|
{{#each releases}}
|
||||||
{{#each merges}}
|
{{#each merges}}
|
||||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each fixes}}
|
{{#each fixes}}
|
||||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each commits}}
|
{{#each commits}}
|
||||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
-
|
||||||
|
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/each}}
|
{{/each}}
|
@ -1,12 +1,15 @@
|
|||||||
From newest to oldest:
|
From newest to oldest:
|
||||||
{{#each releases}}
|
{{#each releases}}
|
||||||
{{#each merges}}
|
{{#each merges}}
|
||||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each fixes}}
|
{{#each fixes}}
|
||||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each commits}}
|
{{#each commits}}
|
||||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
-
|
||||||
|
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/each}}
|
{{/each}}
|
File diff suppressed because it is too large
Load Diff
@ -1,132 +1,118 @@
|
|||||||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||||
import { ActionButton, SearchBar } from "@/components";
|
import { Action, Search } from "@/components";
|
||||||
import { setSidebar } from "@/modules/redux/actions";
|
import { Layout } from "@/constants";
|
||||||
import { useIsOffline } from "@/modules/redux/hooks";
|
import { useNavbar } from "@/contexts/Navbar";
|
||||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
import { useIsOnline } from "@/contexts/Online";
|
||||||
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
|
import { Environment, useGotoHomepage } from "@/utilities";
|
||||||
import {
|
import {
|
||||||
faBars,
|
faArrowRotateLeft,
|
||||||
faHeart,
|
faGear,
|
||||||
faNetworkWired,
|
faPowerOff,
|
||||||
faUser,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { FunctionComponent, useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Anchor,
|
||||||
Col,
|
Avatar,
|
||||||
Container,
|
Badge,
|
||||||
Dropdown,
|
Burger,
|
||||||
Image,
|
createStyles,
|
||||||
Navbar,
|
Divider,
|
||||||
Row,
|
Group,
|
||||||
} from "react-bootstrap";
|
Header,
|
||||||
import { Helmet } from "react-helmet";
|
MediaQuery,
|
||||||
import NotificationCenter from "./Notification";
|
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 { 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(
|
return (
|
||||||
() => (
|
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
|
||||||
<Dropdown alignRight>
|
<Group position="apart" noWrap>
|
||||||
<Dropdown.Toggle className="hide-arrow" as={Button}>
|
<Group noWrap>
|
||||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
<MediaQuery
|
||||||
</Dropdown.Toggle>
|
smallerThan={Layout.MOBILE_BREAKPOINT}
|
||||||
<Dropdown.Menu>
|
styles={{ display: "none" }}
|
||||||
<Dropdown.Item
|
|
||||||
onClick={() => {
|
|
||||||
restart();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Restart
|
<Anchor onClick={goHome}>
|
||||||
</Dropdown.Item>
|
<Avatar
|
||||||
<Dropdown.Item
|
alt="brand"
|
||||||
onClick={() => {
|
size={32}
|
||||||
shutdown();
|
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||||
}}
|
></Avatar>
|
||||||
|
</Anchor>
|
||||||
|
</MediaQuery>
|
||||||
|
<MediaQuery
|
||||||
|
largerThan={Layout.MOBILE_BREAKPOINT}
|
||||||
|
styles={{ display: "none" }}
|
||||||
>
|
>
|
||||||
Shutdown
|
<Burger
|
||||||
</Dropdown.Item>
|
opened={showed}
|
||||||
<Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
|
onClick={() => show(!showed)}
|
||||||
<Dropdown.Item
|
size="sm"
|
||||||
hidden={!hasLogout}
|
></Burger>
|
||||||
onClick={() => {
|
</MediaQuery>
|
||||||
logout();
|
<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
|
<Menu.Item
|
||||||
</Dropdown.Item>
|
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
|
||||||
</Dropdown.Menu>
|
onClick={() => restart()}
|
||||||
</Dropdown>
|
>
|
||||||
),
|
Restart
|
||||||
[hasLogout, logout, restart, shutdown]
|
</Menu.Item>
|
||||||
);
|
<Menu.Item
|
||||||
|
icon={<FontAwesomeIcon icon={faPowerOff} />}
|
||||||
const goHome = useGotoHomepage();
|
onClick={() => shutdown()}
|
||||||
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
|
Shutdown
|
||||||
</Button>
|
</Menu.Item>
|
||||||
{offline ? (
|
<Divider hidden={!hasLogout}></Divider>
|
||||||
<ActionButton
|
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
|
||||||
loading
|
Logout
|
||||||
alwaysShowText
|
</Menu.Item>
|
||||||
className="ml-2"
|
</Menu>
|
||||||
variant="warning"
|
</Group>
|
||||||
icon={faNetworkWired}
|
</Group>
|
||||||
>
|
</Header>
|
||||||
{isMobile ? "" : "Connecting..."}
|
|
||||||
</ActionButton>
|
|
||||||
) : (
|
|
||||||
serverActions
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
</Navbar>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
|
@ -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 { useSystemSettings } from "@/apis/hooks";
|
||||||
import { FunctionComponent } from "react";
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
import { Navigate } from "react-router-dom";
|
import { FunctionComponent, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const Redirector: FunctionComponent = () => {
|
const Redirector: FunctionComponent = () => {
|
||||||
const { sonarr, radarr } = useEnabledStatus();
|
const { data } = useSystemSettings();
|
||||||
|
|
||||||
let path = "/settings/general";
|
const navigate = useNavigate();
|
||||||
if (sonarr) {
|
|
||||||
path = "/series";
|
|
||||||
} else if (radarr) {
|
|
||||||
path = "/movies";
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
export default Redirector;
|
||||||
|
@ -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,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 { FunctionComponent, Suspense } from "react";
|
||||||
import { LoadingIndicator } from ".";
|
|
||||||
|
|
||||||
const Lazy: FunctionComponent = ({ children }) => {
|
const Lazy: FunctionComponent = ({ children }) => {
|
||||||
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
|
return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Lazy;
|
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;
|
@ -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 "./inputs";
|
||||||
export * from "./LanguageSelector";
|
export { default as Search } from "./Search";
|
||||||
export * from "./SearchBar";
|
|
||||||
export * from "./tables";
|
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,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 { LOG } from "@/utilities/console";
|
||||||
import { FocusEvent, useCallback, useMemo, useRef } from "react";
|
import {
|
||||||
import Select, { GroupBase, OnChangeValue } from "react-select";
|
MultiSelect,
|
||||||
import { SelectComponents } from "react-select/dist/declarations/src/components";
|
MultiSelectProps,
|
||||||
|
Select,
|
||||||
export type SelectorOption<T> = {
|
SelectItem,
|
||||||
label: string;
|
SelectProps,
|
||||||
value: T;
|
} from "@mantine/core";
|
||||||
};
|
import { isNull, isUndefined } from "lodash";
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
export type SelectorComponents<T, M extends boolean> = SelectComponents<
|
export type SelectorOption<T> = Override<
|
||||||
SelectorOption<T>,
|
{
|
||||||
M,
|
value: T;
|
||||||
GroupBase<SelectorOption<T>>
|
label: string;
|
||||||
|
},
|
||||||
|
SelectItem
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type SelectorValueType<T, M extends boolean> = M extends true
|
type SelectItemWithPayload<T> = SelectItem & {
|
||||||
? ReadonlyArray<T>
|
payload: T;
|
||||||
: Nullable<T>;
|
};
|
||||||
|
|
||||||
export interface SelectorProps<T, M extends boolean> {
|
function DefaultKeyBuilder<T>(value: T) {
|
||||||
className?: string;
|
if (typeof value === "string") {
|
||||||
placeholder?: string;
|
return value;
|
||||||
options: readonly SelectorOption<T>[];
|
} else if (typeof value === "number") {
|
||||||
disabled?: boolean;
|
return value.toString();
|
||||||
clearable?: boolean;
|
} else {
|
||||||
loading?: boolean;
|
LOG("error", "Unknown value type", value);
|
||||||
multiple?: M;
|
throw new Error(
|
||||||
onChange?: (k: SelectorValueType<T, M>) => void;
|
`Invalid type (${typeof value}) in the SelectorOption, please provide a label builder`
|
||||||
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>>>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Selector<T = string, M extends boolean = false>(
|
export type SelectorProps<T> = Override<
|
||||||
props: SelectorProps<T, M>
|
{
|
||||||
) {
|
value?: T | null;
|
||||||
const {
|
defaultValue?: T | null;
|
||||||
className,
|
options: SelectorOption<T>[];
|
||||||
placeholder,
|
onChange?: (value: T | null) => void;
|
||||||
label,
|
getkey?: (value: T) => string;
|
||||||
disabled,
|
},
|
||||||
clearable,
|
Omit<SelectProps, "data">
|
||||||
loading,
|
>;
|
||||||
options,
|
|
||||||
multiple,
|
export function Selector<T>({
|
||||||
onChange,
|
value,
|
||||||
onFocus,
|
defaultValue,
|
||||||
defaultValue,
|
options,
|
||||||
components,
|
onChange,
|
||||||
value,
|
getkey = DefaultKeyBuilder,
|
||||||
} = props;
|
...select
|
||||||
|
}: SelectorProps<T>) {
|
||||||
const labelRef = useRef(label);
|
const keyRef = useRef(getkey);
|
||||||
|
keyRef.current = getkey;
|
||||||
const getName = useCallback(
|
|
||||||
(item: T) => {
|
const data = useMemo(
|
||||||
if (labelRef.current) {
|
() =>
|
||||||
return labelRef.current(item);
|
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]
|
[options]
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapper = useCallback(
|
const wrappedValue = useMemo(
|
||||||
(
|
() => value && value.map(labelRef.current),
|
||||||
value: SelectorValueType<T, M> | undefined | null
|
[value]
|
||||||
):
|
);
|
||||||
| SelectorOption<T>
|
const wrappedDefaultValue = useMemo(
|
||||||
| ReadonlyArray<SelectorOption<T>>
|
() => defaultValue && defaultValue.map(labelRef.current),
|
||||||
| null
|
[defaultValue]
|
||||||
| undefined => {
|
);
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return value as null | undefined;
|
const wrappedOnChange = useCallback(
|
||||||
} else {
|
(values: string[]) => {
|
||||||
if (multiple === true) {
|
const payloads: T[] = [];
|
||||||
return (value as SelectorValueType<T, true>).map((v) => ({
|
for (const value of values) {
|
||||||
label: getName(v),
|
const payload = data.find((v) => v.value === value)?.payload;
|
||||||
value: v,
|
if (payload) {
|
||||||
}));
|
payloads.push(payload);
|
||||||
} else {
|
|
||||||
const v = value as T;
|
|
||||||
return {
|
|
||||||
label: getName(v),
|
|
||||||
value: v,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onChange?.(payloads);
|
||||||
},
|
},
|
||||||
[multiple, getName]
|
[data, onChange]
|
||||||
);
|
|
||||||
|
|
||||||
const defaultWrapper = useMemo(
|
|
||||||
() => wrapper(defaultValue),
|
|
||||||
[defaultValue, wrapper]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const valueWrapper = useMemo(() => wrapper(value), [wrapper, value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<MultiSelect
|
||||||
isLoading={loading}
|
value={wrappedValue}
|
||||||
placeholder={placeholder}
|
defaultValue={wrappedDefaultValue}
|
||||||
isSearchable={options.length >= 10}
|
onChange={wrappedOnChange}
|
||||||
isMulti={multiple}
|
{...select}
|
||||||
closeMenuOnSelect={!multiple}
|
data={data}
|
||||||
defaultValue={defaultWrapper}
|
></MultiSelect>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,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,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 * from "./HistoryModal";
|
||||||
export { default as ItemEditorModal } from "./ItemEditorModal";
|
export { default as SubtitleToolsModal } from "./SubtitleToolsModal";
|
||||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
|
||||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
|
||||||
|
@ -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,74 +1,44 @@
|
|||||||
import { ScrollToTop } from "@/utilities";
|
import { ScrollToTop } from "@/utilities";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
|
import { TableInstance, usePagination } from "react-table";
|
||||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
|
||||||
import PageControl from "./PageControl";
|
import PageControl from "./PageControl";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import { useDefaultSettings } from "./plugins";
|
||||||
|
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
type Props<T extends object> = SimpleTableProps<T> & {
|
||||||
TableStyleProps<T> & {
|
autoScroll?: boolean;
|
||||||
autoScroll?: boolean;
|
};
|
||||||
plugins?: PluginHook<T>[];
|
|
||||||
};
|
const tablePlugins = [useDefaultSettings, usePagination];
|
||||||
|
|
||||||
export default function PageTable<T extends object>(props: Props<T>) {
|
export default function PageTable<T extends object>(props: Props<T>) {
|
||||||
const { autoScroll, plugins, ...remain } = props;
|
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
|
const instance = useRef<TableInstance<T> | null>(null);
|
||||||
page,
|
|
||||||
canNextPage,
|
|
||||||
canPreviousPage,
|
|
||||||
pageCount,
|
|
||||||
gotoPage,
|
|
||||||
nextPage,
|
|
||||||
previousPage,
|
|
||||||
state: { pageIndex, pageSize },
|
|
||||||
} = instance;
|
|
||||||
|
|
||||||
// Scroll to top when page is changed
|
// Scroll to top when page is changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScroll) {
|
if (autoScroll) {
|
||||||
ScrollToTop();
|
ScrollToTop();
|
||||||
}
|
}
|
||||||
}, [pageIndex, autoScroll]);
|
}, [instance.current?.state.pageIndex, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseTable
|
<SimpleTable
|
||||||
{...style}
|
{...remain}
|
||||||
headers={headerGroups}
|
instanceRef={instance}
|
||||||
rows={page}
|
plugins={[...tablePlugins, ...(plugins ?? [])]}
|
||||||
prepareRow={prepareRow}
|
></SimpleTable>
|
||||||
tableProps={getTableProps()}
|
{instance.current && (
|
||||||
tableBodyProps={getTableBodyProps()}
|
<PageControl
|
||||||
></BaseTable>
|
count={instance.current.pageCount}
|
||||||
<PageControl
|
index={instance.current.state.pageIndex}
|
||||||
count={pageCount}
|
size={instance.current.state.pageSize}
|
||||||
index={pageIndex}
|
total={instance.current.rows.length}
|
||||||
size={pageSize}
|
goto={instance.current.gotoPage}
|
||||||
total={rows.length}
|
></PageControl>
|
||||||
canPrevious={canPreviousPage}
|
)}
|
||||||
canNext={canNextPage}
|
|
||||||
previous={previousPage}
|
|
||||||
next={nextPage}
|
|
||||||
goto={gotoPage}
|
|
||||||
></PageControl>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,77 +1,37 @@
|
|||||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||||
|
import { LoadingProvider } from "@/contexts";
|
||||||
import { ScrollToTop } from "@/utilities";
|
import { ScrollToTop } from "@/utilities";
|
||||||
import { useEffect } from "react";
|
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 PageControl from "./PageControl";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
|
||||||
TableStyleProps<T> & {
|
query: UsePaginationQueryResult<T>;
|
||||||
plugins?: PluginHook<T>[];
|
};
|
||||||
query: UsePaginationQueryResult<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
||||||
const { plugins, query, ...remain } = props;
|
const { query, ...remain } = props;
|
||||||
const { style, options } = useStyleAndOptions(remain);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data = { data: [], total: 0 },
|
||||||
isLoading,
|
paginationStatus: { page, pageCount, totalCount, pageSize, isPageLoading },
|
||||||
paginationStatus: {
|
controls: { gotoPage },
|
||||||
page,
|
|
||||||
pageCount,
|
|
||||||
totalCount,
|
|
||||||
canPrevious,
|
|
||||||
canNext,
|
|
||||||
pageSize,
|
|
||||||
},
|
|
||||||
controls: { previousPage, nextPage, gotoPage },
|
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const instance = useTable(
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
data: data?.data ?? [],
|
|
||||||
},
|
|
||||||
useDefaultSettings,
|
|
||||||
...(plugins ?? [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
|
||||||
instance;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ScrollToTop();
|
ScrollToTop();
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingIndicator></LoadingIndicator>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<LoadingProvider value={isPageLoading}>
|
||||||
<BaseTable
|
<SimpleTable {...remain} data={data.data}></SimpleTable>
|
||||||
{...style}
|
|
||||||
headers={headerGroups}
|
|
||||||
rows={rows}
|
|
||||||
prepareRow={prepareRow}
|
|
||||||
tableProps={getTableProps()}
|
|
||||||
tableBodyProps={getTableBodyProps()}
|
|
||||||
></BaseTable>
|
|
||||||
<PageControl
|
<PageControl
|
||||||
count={pageCount}
|
count={pageCount}
|
||||||
index={page}
|
index={page}
|
||||||
size={pageSize}
|
size={pageSize}
|
||||||
total={totalCount}
|
total={totalCount}
|
||||||
canPrevious={canPrevious}
|
|
||||||
canNext={canNext}
|
|
||||||
previous={previousPage}
|
|
||||||
next={nextPage}
|
|
||||||
goto={gotoPage}
|
goto={gotoPage}
|
||||||
></PageControl>
|
></PageControl>
|
||||||
</>
|
</LoadingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,23 @@
|
|||||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
|
||||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
import BaseTable, { TableStyleProps } from "./BaseTable";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import { useDefaultSettings } from "./plugins";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
export type SimpleTableProps<T extends object> = TableOptions<T> & {
|
||||||
TableStyleProps<T> & {
|
plugins?: PluginHook<T>[];
|
||||||
plugins?: PluginHook<T>[];
|
instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
|
||||||
};
|
tableStyles?: TableStyleProps<T>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function SimpleTable<T extends object>(props: Props<T>) {
|
export default function SimpleTable<T extends object>(
|
||||||
const { plugins, ...other } = props;
|
props: SimpleTableProps<T>
|
||||||
const { style, options } = useStyleAndOptions(other);
|
) {
|
||||||
|
const { plugins, instanceRef, tableStyles, ...options } = props;
|
||||||
|
|
||||||
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
if (instanceRef) {
|
||||||
instance;
|
instanceRef.current = instance;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
|
||||||
<BaseTable
|
|
||||||
{...style}
|
|
||||||
headers={headerGroups}
|
|
||||||
rows={rows}
|
|
||||||
prepareRow={prepareRow}
|
|
||||||
tableProps={getTableProps()}
|
|
||||||
tableBodyProps={getTableBodyProps()}
|
|
||||||
></BaseTable>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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 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";
|
/* eslint-disable @typescript-eslint/ban-types */
|
||||||
import {
|
|
||||||
ModalData,
|
|
||||||
ModalDataContext,
|
|
||||||
ModalSetter,
|
|
||||||
ModalSetterContext,
|
|
||||||
} from "./ModalContext";
|
|
||||||
import ModalWrapper from "./ModalWrapper";
|
|
||||||
|
|
||||||
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> & {
|
export type ModalComponent<P extends Record<string, unknown> = {}> =
|
||||||
modalKey: string;
|
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>,
|
Content: FunctionComponent<T>,
|
||||||
key: string
|
key: string,
|
||||||
|
defaultSettings?: ModalSettings
|
||||||
) {
|
) {
|
||||||
const Comp: ModalComponent<T> = (props: ModalProps & T) => {
|
const Comp: ModalComponent<T> = (props) => {
|
||||||
const [closeable, setCloseable] = useState(true);
|
const { id, innerProps } = props;
|
||||||
const [size, setSize] = useState<ModalData["size"]>(undefined);
|
|
||||||
const data: ModalData = useMemo(
|
|
||||||
() => ({
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
closeable,
|
|
||||||
}),
|
|
||||||
[closeable, size]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setter: ModalSetter = useMemo(
|
|
||||||
() => ({
|
|
||||||
closeable: setCloseable,
|
|
||||||
size: setSize,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDataContext.Provider value={data}>
|
<ModalIdContext.Provider value={id}>
|
||||||
<ModalSetterContext.Provider value={setter}>
|
<Content {...innerProps}></Content>
|
||||||
<ModalWrapper>
|
</ModalIdContext.Provider>
|
||||||
<Content {...props}></Content>
|
|
||||||
</ModalWrapper>
|
|
||||||
</ModalSetterContext.Provider>
|
|
||||||
</ModalDataContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Comp.modalKey = key;
|
Comp.modalKey = key;
|
||||||
|
Comp.settings = defaultSettings;
|
||||||
|
|
||||||
|
StaticModals.push(Comp as ModalComponent);
|
||||||
return Comp;
|
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue