Replace Bootstrap with Mantine (#1795)
parent
6515c42f26
commit
2cecb4c5b5
@ -1,12 +1,15 @@
|
||||
From newest to oldest:
|
||||
{{#each releases}}
|
||||
{{#each merges}}
|
||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
-
|
||||
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each fixes}}
|
||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
-
|
||||
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each commits}}
|
||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
-
|
||||
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{/each}}
|
||||
{{/each}}
|
@ -1,12 +1,15 @@
|
||||
From newest to oldest:
|
||||
{{#each releases}}
|
||||
{{#each merges}}
|
||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
-
|
||||
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each fixes}}
|
||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
-
|
||||
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each commits}}
|
||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
-
|
||||
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{/each}}
|
||||
{{/each}}
|
File diff suppressed because it is too large
Load Diff
@ -1,132 +1,118 @@
|
||||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||
import { ActionButton, SearchBar } from "@/components";
|
||||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useIsOffline } from "@/modules/redux/hooks";
|
||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
||||
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
|
||||
import { Action, Search } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useIsOnline } from "@/contexts/Online";
|
||||
import { Environment, useGotoHomepage } from "@/utilities";
|
||||
import {
|
||||
faBars,
|
||||
faHeart,
|
||||
faNetworkWired,
|
||||
faUser,
|
||||
faArrowRotateLeft,
|
||||
faGear,
|
||||
faPowerOff,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Dropdown,
|
||||
Image,
|
||||
Navbar,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import NotificationCenter from "./Notification";
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Burger,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Header,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const Header: FunctionComponent = () => {
|
||||
const useStyles = createStyles((theme) => {
|
||||
const headerBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
|
||||
return {
|
||||
header: {
|
||||
backgroundColor: headerBackgroundColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const AppHeader: FunctionComponent = () => {
|
||||
const { data: settings } = useSystemSettings();
|
||||
const hasLogout = settings?.auth.type === "form";
|
||||
|
||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
||||
const { show, showed } = useNavbar();
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
const online = useIsOnline();
|
||||
const offline = !online;
|
||||
|
||||
const offline = useIsOffline();
|
||||
const { shutdown, restart, logout } = useSystem();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const { shutdown, restart, logout } = useSystem();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const serverActions = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="hide-arrow" as={Button}>
|
||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
restart();
|
||||
}}
|
||||
return (
|
||||
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
|
||||
<Group position="apart" noWrap>
|
||||
<Group noWrap>
|
||||
<MediaQuery
|
||||
smallerThan={Layout.MOBILE_BREAKPOINT}
|
||||
styles={{ display: "none" }}
|
||||
>
|
||||
Restart
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
shutdown();
|
||||
}}
|
||||
<Anchor onClick={goHome}>
|
||||
<Avatar
|
||||
alt="brand"
|
||||
size={32}
|
||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||
></Avatar>
|
||||
</Anchor>
|
||||
</MediaQuery>
|
||||
<MediaQuery
|
||||
largerThan={Layout.MOBILE_BREAKPOINT}
|
||||
styles={{ display: "none" }}
|
||||
>
|
||||
Shutdown
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
|
||||
<Dropdown.Item
|
||||
hidden={!hasLogout}
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
<Burger
|
||||
opened={showed}
|
||||
onClick={() => show(!showed)}
|
||||
size="sm"
|
||||
></Burger>
|
||||
</MediaQuery>
|
||||
<Badge size="lg" radius="sm">
|
||||
Bazarr
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group spacing="xs" position="right" noWrap>
|
||||
<Search></Search>
|
||||
<Menu
|
||||
control={
|
||||
<Action
|
||||
loading={offline}
|
||||
color={offline ? "yellow" : undefined}
|
||||
icon={faGear}
|
||||
size="lg"
|
||||
variant="light"
|
||||
></Action>
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
),
|
||||
[hasLogout, logout, restart, shutdown]
|
||||
);
|
||||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
return (
|
||||
<Navbar bg="primary" className="flex-grow-1 px-0">
|
||||
<Helmet>
|
||||
<meta name="theme-color" content="#911f93" />
|
||||
</Helmet>
|
||||
<div className="header-icon px-3 m-0 d-none d-md-block">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
role="button"
|
||||
></Image>
|
||||
</div>
|
||||
<Button
|
||||
className="mx-2 m-0 d-md-none"
|
||||
onClick={() => changeSidebar(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
|
||||
</Button>
|
||||
<Container fluid>
|
||||
<Row noGutters className="flex-grow-1">
|
||||
<Col xs={4} sm={6} className="d-flex align-items-center">
|
||||
<SearchBar></SearchBar>
|
||||
</Col>
|
||||
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
|
||||
<NotificationCenter></NotificationCenter>
|
||||
<Button
|
||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||
target="_blank"
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
|
||||
onClick={() => restart()}
|
||||
>
|
||||
Restart
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faPowerOff} />}
|
||||
onClick={() => shutdown()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
|
||||
</Button>
|
||||
{offline ? (
|
||||
<ActionButton
|
||||
loading
|
||||
alwaysShowText
|
||||
className="ml-2"
|
||||
variant="warning"
|
||||
icon={faNetworkWired}
|
||||
>
|
||||
{isMobile ? "" : "Connecting..."}
|
||||
</ActionButton>
|
||||
) : (
|
||||
serverActions
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Navbar>
|
||||
Shutdown
|
||||
</Menu.Item>
|
||||
<Divider hidden={!hasLogout}></Divider>
|
||||
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default AppHeader;
|
||||
|
@ -0,0 +1,344 @@
|
||||
import { Action } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
import { BuildKey, pathJoin } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
faHeart,
|
||||
faMoon,
|
||||
faSun,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Collapse,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Navbar as MantineNavbar,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
|
||||
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
select: (path: string | null) => void;
|
||||
}>({
|
||||
selection: null,
|
||||
select: () => {
|
||||
LOG("error", "Selection context not initialized");
|
||||
},
|
||||
});
|
||||
|
||||
function useSelection() {
|
||||
return useContext(Selection);
|
||||
}
|
||||
|
||||
function useBadgeValue(route: Route.Item) {
|
||||
const { badge, children } = route;
|
||||
return useMemo(() => {
|
||||
let value = badge ?? 0;
|
||||
|
||||
if (children === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value +=
|
||||
children.reduce((acc, child: Route.Item) => {
|
||||
if (child.badge && child.hidden !== true) {
|
||||
return acc + (child.badge ?? 0);
|
||||
}
|
||||
return acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return value === 0 ? undefined : value;
|
||||
}, [badge, children]);
|
||||
}
|
||||
|
||||
function useIsActive(parent: string, route: RouteObject) {
|
||||
const { path, children } = route;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const paths = useMemo(
|
||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||
[root, children]
|
||||
);
|
||||
|
||||
const selection = useSelection().selection;
|
||||
return useMemo(
|
||||
() =>
|
||||
selection?.includes(root) ||
|
||||
paths.some((path) => matchPath(path, pathname)),
|
||||
[pathname, paths, root, selection]
|
||||
);
|
||||
}
|
||||
|
||||
const AppNavbar: FunctionComponent = () => {
|
||||
const { showed } = useNavbar();
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const routes = useRouteItems();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
select(null);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<MantineNavbar
|
||||
p="xs"
|
||||
hiddenBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||
hidden={!showed}
|
||||
width={{ [Layout.MOBILE_BREAKPOINT]: Layout.NAVBAR_WIDTH }}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.gray[2]
|
||||
: theme.colors.dark[6],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<MantineNavbar.Section grow>
|
||||
<Stack spacing={0}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
parent="/"
|
||||
route={route}
|
||||
></RouteItem>
|
||||
))}
|
||||
</Stack>
|
||||
</MantineNavbar.Section>
|
||||
<Divider></Divider>
|
||||
<MantineNavbar.Section mt="xs">
|
||||
<Group spacing="xs">
|
||||
<Action
|
||||
color={dark ? "yellow" : "indigo"}
|
||||
variant="hover"
|
||||
onClick={() => toggleColorScheme()}
|
||||
icon={dark ? faSun : faMoon}
|
||||
></Action>
|
||||
<Anchor
|
||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||
target="_blank"
|
||||
>
|
||||
<Action icon={faHeart} variant="hover" color="red"></Action>
|
||||
</Anchor>
|
||||
</Group>
|
||||
</MantineNavbar.Section>
|
||||
</Selection.Provider>
|
||||
</MantineNavbar>
|
||||
);
|
||||
};
|
||||
|
||||
const RouteItem: FunctionComponent<{
|
||||
route: CustomRouteObject;
|
||||
parent: string;
|
||||
}> = ({ route, parent }) => {
|
||||
const { children, name, path, icon, hidden, element } = route;
|
||||
|
||||
const { select } = useSelection();
|
||||
|
||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const badge = useBadgeValue(route);
|
||||
|
||||
const isOpen = useIsActive(parent, route);
|
||||
|
||||
// Ignore path if it is using match
|
||||
if (hidden === true || path === undefined || path.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
const elements = (
|
||||
<Stack spacing={0}>
|
||||
{children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
key={BuildKey(link, "nav", idx)}
|
||||
route={child}
|
||||
></RouteItem>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<NavbarItem
|
||||
primary
|
||||
name={name}
|
||||
link={link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
onClick={(event) => {
|
||||
LOG("info", "clicked", link);
|
||||
|
||||
const validated =
|
||||
element !== undefined ||
|
||||
children?.find((v) => v.index === true) !== undefined;
|
||||
|
||||
if (!validated) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
select(null);
|
||||
} else {
|
||||
select(link);
|
||||
}
|
||||
}}
|
||||
></NavbarItem>
|
||||
<Collapse hidden={children.length === 0} in={isOpen}>
|
||||
{elements}
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
return elements;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<NavbarItem
|
||||
name={name ?? link}
|
||||
link={link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></NavbarItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
const borderColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[5] : theme.colors.dark[4];
|
||||
|
||||
const activeBorderColor =
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.brand[4]
|
||||
: theme.colors.brand[8];
|
||||
|
||||
const activeBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[1] : theme.colors.dark[8];
|
||||
|
||||
const hoverBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
|
||||
|
||||
return {
|
||||
text: { display: "inline-flex", alignItems: "center", width: "100%" },
|
||||
anchor: {
|
||||
textDecoration: "none",
|
||||
borderLeft: `2px solid ${borderColor}`,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: activeBackgroundColor,
|
||||
borderLeft: `2px solid ${activeBorderColor}`,
|
||||
boxShadow: theme.shadows.xs,
|
||||
},
|
||||
hover: {
|
||||
backgroundColor: hoverBackgroundColor,
|
||||
},
|
||||
icon: { width: "1.4rem", marginRight: theme.spacing.xs },
|
||||
badge: {
|
||||
marginLeft: "auto",
|
||||
textDecoration: "none",
|
||||
boxShadow: theme.shadows.xs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface NavbarItemProps {
|
||||
name: string;
|
||||
link: string;
|
||||
icon?: IconDefinition;
|
||||
badge?: number;
|
||||
primary?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
||||
icon,
|
||||
link,
|
||||
name,
|
||||
badge,
|
||||
onClick,
|
||||
primary = false,
|
||||
}) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { show } = useNavbar();
|
||||
|
||||
const { ref, hovered } = useHover();
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={link}
|
||||
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.isDefaultPrevented()) {
|
||||
show(false);
|
||||
}
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
clsx(classes.anchor, {
|
||||
[classes.active]: isActive,
|
||||
[classes.hover]: hovered,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={ref}
|
||||
inline
|
||||
p="xs"
|
||||
size="sm"
|
||||
color="gray"
|
||||
weight={primary ? "bold" : "normal"}
|
||||
className={classes.text}
|
||||
>
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
className={classes.icon}
|
||||
icon={icon}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
{name}
|
||||
<Badge
|
||||
className={classes.badge}
|
||||
color="gray"
|
||||
radius="xs"
|
||||
hidden={badge === undefined || badge === 0}
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
</Text>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppNavbar;
|
@ -1,241 +0,0 @@
|
||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { BuildKey, useIsArrayExtended } from "@/utilities";
|
||||
import {
|
||||
faBug,
|
||||
faCircleNotch,
|
||||
faExclamationTriangle,
|
||||
faInfoCircle,
|
||||
faStream,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Overlay,
|
||||
ProgressBar,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Working,
|
||||
Failed,
|
||||
}
|
||||
|
||||
function useTotalProgress(progress: Site.Progress[]) {
|
||||
return useMemo(() => {
|
||||
const { value, count } = progress.reduce(
|
||||
(prev, { value, count }) => {
|
||||
prev.value += value;
|
||||
prev.count += count;
|
||||
return prev;
|
||||
},
|
||||
{ value: 0, count: 0 }
|
||||
);
|
||||
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return (value + 0.001) / count;
|
||||
}
|
||||
}, [progress]);
|
||||
}
|
||||
|
||||
function useHasErrorNotification(notifications: Server.Notification[]) {
|
||||
return useMemo(
|
||||
() => notifications.find((v) => v.type !== "info") !== undefined,
|
||||
[notifications]
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationCenter: FunctionComponent = () => {
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hasNew, setHasNew] = useState(false);
|
||||
|
||||
const hasNewProgress = useIsArrayExtended(progress);
|
||||
const hasNewNotifications = useIsArrayExtended(notifications);
|
||||
useDidUpdate(() => {
|
||||
if (hasNewNotifications || hasNewProgress) {
|
||||
setHasNew(true);
|
||||
}
|
||||
}, [hasNewProgress, hasNewNotifications]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (progress.length === 0 && notifications.length === 0) {
|
||||
setHasNew(false);
|
||||
}
|
||||
}, [progress.length, notifications.length]);
|
||||
|
||||
const [btnState, setBtnState] = useState(State.Idle);
|
||||
|
||||
const totalProgress = useTotalProgress(progress);
|
||||
const hasError = useHasErrorNotification(notifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
setBtnState(State.Failed);
|
||||
} else if (totalProgress > 0 && totalProgress < 1.0) {
|
||||
setBtnState(State.Working);
|
||||
} else {
|
||||
setBtnState(State.Idle);
|
||||
}
|
||||
}, [totalProgress, hasError]);
|
||||
|
||||
const iconProps = useMemo<FontAwesomeIconProps>(() => {
|
||||
switch (btnState) {
|
||||
case State.Idle:
|
||||
return {
|
||||
icon: faStream,
|
||||
};
|
||||
case State.Working:
|
||||
return {
|
||||
icon: faCircleNotch,
|
||||
spin: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: faExclamationTriangle,
|
||||
};
|
||||
}
|
||||
}, [btnState]);
|
||||
|
||||
const content = useMemo<ReactNode>(() => {
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="notifications-header">
|
||||
{notifications.length > 0 ? "Notifications" : "No Notifications"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...notifications.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
|
||||
<Notification {...v}></Notification>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="background-task-header">
|
||||
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...progress.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
|
||||
<Progress {...v}></Progress>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [progress, notifications]);
|
||||
|
||||
const onToggleClick = useCallback(() => {
|
||||
setHasNew(false);
|
||||
}, []);
|
||||
|
||||
// Tooltip Controller
|
||||
const [showTooltip, setTooltip] = useState(false);
|
||||
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
|
||||
useDidUpdate(() => {
|
||||
if (notifier.content) {
|
||||
setTooltip(true);
|
||||
}
|
||||
}, [notifier.timestamp]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown
|
||||
onClick={onToggleClick}
|
||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
||||
ref={dropdownRef}
|
||||
alignRight
|
||||
>
|
||||
<Dropdown.Toggle as={Button} className="hide-arrow">
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
|
||||
{(props) => {
|
||||
return (
|
||||
<Tooltip id="new-notification-tip" {...props}>
|
||||
{notifier.content}
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</Overlay>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const Notification: FunctionComponent<Server.Notification> = ({
|
||||
type,
|
||||
message,
|
||||
}) => {
|
||||
const icon = useMemo<IconDefinition>(() => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return faInfoCircle;
|
||||
case "warning":
|
||||
return faExclamationTriangle;
|
||||
default:
|
||||
return faBug;
|
||||
}
|
||||
}, [type]);
|
||||
return (
|
||||
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
|
||||
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
|
||||
<span className="text-dark small">{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Progress: FunctionComponent<Site.Progress> = ({
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
header,
|
||||
}) => {
|
||||
const isCompleted = value / count > 1;
|
||||
const displayValue = Math.min(count, value + 1);
|
||||
return (
|
||||
<div className="notification-center-progress d-flex flex-column">
|
||||
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
|
||||
{header}
|
||||
</p>
|
||||
<p className="progress-name m-0 small text-secondary">
|
||||
{isCompleted ? "Completed successfully" : name}
|
||||
</p>
|
||||
<ProgressBar
|
||||
className="mt-2"
|
||||
animated={!isCompleted}
|
||||
now={displayValue / count}
|
||||
max={1}
|
||||
label={`${displayValue}/${count}`}
|
||||
></ProgressBar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
@ -0,0 +1,72 @@
|
||||
import {
|
||||
ColorScheme,
|
||||
ColorSchemeProvider,
|
||||
MantineProvider,
|
||||
MantineThemeOverride,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from "react";
|
||||
|
||||
const theme: MantineThemeOverride = {
|
||||
fontFamily: [
|
||||
"Roboto",
|
||||
"open sans",
|
||||
"Helvetica Neue",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
colors: {
|
||||
brand: [
|
||||
"#F8F0FC",
|
||||
"#F3D9FA",
|
||||
"#EEBEFA",
|
||||
"#E599F7",
|
||||
"#DA77F2",
|
||||
"#CC5DE8",
|
||||
"#BE4BDB",
|
||||
"#AE3EC9",
|
||||
"#9C36B5",
|
||||
"#862E9C",
|
||||
],
|
||||
},
|
||||
primaryColor: "brand",
|
||||
};
|
||||
|
||||
function useAutoColorScheme() {
|
||||
const preferredColorScheme = useColorScheme();
|
||||
const [colorScheme, setColorScheme] = useState(preferredColorScheme);
|
||||
|
||||
// automatically switch dark/light theme
|
||||
useEffect(() => {
|
||||
setColorScheme(preferredColorScheme);
|
||||
}, [preferredColorScheme]);
|
||||
|
||||
const toggleColorScheme = useCallback((value?: ColorScheme) => {
|
||||
setColorScheme((scheme) => value || (scheme === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { colorScheme, setColorScheme, toggleColorScheme };
|
||||
}
|
||||
|
||||
const ThemeProvider: FunctionComponent = ({ children }) => {
|
||||
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
|
||||
|
||||
return (
|
||||
<ColorSchemeProvider
|
||||
colorScheme={colorScheme}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{ colorScheme, ...theme }}
|
||||
emotionOptions={{ key: "bazarr" }}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
@ -1,18 +1,27 @@
|
||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useSystemSettings } from "@/apis/hooks";
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
import { FunctionComponent, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const Redirector: FunctionComponent = () => {
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
const { data } = useSystemSettings();
|
||||
|
||||
let path = "/settings/general";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "/movies";
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <Navigate to={path}></Navigate>;
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const { use_sonarr, use_radarr } = data.general;
|
||||
if (use_sonarr) {
|
||||
navigate("/series");
|
||||
} else if (use_radarr) {
|
||||
navigate("/movies");
|
||||
} else {
|
||||
navigate("/settings/general");
|
||||
}
|
||||
}
|
||||
}, [data, navigate]);
|
||||
|
||||
return <LoadingOverlay visible></LoadingOverlay>;
|
||||
};
|
||||
|
||||
export default Redirector;
|
||||
|
@ -1,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 { LoadingIndicator } from ".";
|
||||
|
||||
const Lazy: FunctionComponent = ({ children }) => {
|
||||
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
|
||||
return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>;
|
||||
};
|
||||
|
||||
export default Lazy;
|
@ -0,0 +1,48 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { UseMutationResult } from "react-query";
|
||||
import { Action } from "../inputs";
|
||||
import { ActionProps } from "../inputs/Action";
|
||||
|
||||
type MutateActionProps<DATA, VAR> = Omit<
|
||||
ActionProps,
|
||||
"onClick" | "loading" | "color"
|
||||
> & {
|
||||
mutation: UseMutationResult<DATA, unknown, VAR>;
|
||||
args: () => VAR | null;
|
||||
onSuccess?: (args: DATA) => void;
|
||||
onError?: () => void;
|
||||
noReset?: boolean;
|
||||
};
|
||||
|
||||
function MutateAction<DATA, VAR>({
|
||||
mutation,
|
||||
noReset,
|
||||
onSuccess,
|
||||
onError,
|
||||
args,
|
||||
...props
|
||||
}: MutateActionProps<DATA, VAR>) {
|
||||
const { mutateAsync } = mutation;
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const argument = args();
|
||||
if (argument !== null) {
|
||||
const data = await mutateAsync(argument);
|
||||
onSuccess?.(data);
|
||||
} else {
|
||||
onError?.();
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.();
|
||||
}
|
||||
setLoading(false);
|
||||
}, [args, mutateAsync, onError, onSuccess]);
|
||||
|
||||
return <Action {...props} loading={isLoading} onClick={onClick}></Action>;
|
||||
}
|
||||
|
||||
export default MutateAction;
|
@ -0,0 +1,47 @@
|
||||
import { Button, ButtonProps } from "@mantine/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { UseMutationResult } from "react-query";
|
||||
|
||||
type MutateButtonProps<DATA, VAR> = Omit<
|
||||
ButtonProps<"button">,
|
||||
"onClick" | "loading" | "color"
|
||||
> & {
|
||||
mutation: UseMutationResult<DATA, unknown, VAR>;
|
||||
args: () => VAR | null;
|
||||
onSuccess?: (args: DATA) => void;
|
||||
onError?: () => void;
|
||||
noReset?: boolean;
|
||||
};
|
||||
|
||||
function MutateButton<DATA, VAR>({
|
||||
mutation,
|
||||
noReset,
|
||||
onSuccess,
|
||||
onError,
|
||||
args,
|
||||
...props
|
||||
}: MutateButtonProps<DATA, VAR>) {
|
||||
const { mutateAsync } = mutation;
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const argument = args();
|
||||
if (argument !== null) {
|
||||
const data = await mutateAsync(argument);
|
||||
onSuccess?.(data);
|
||||
} else {
|
||||
onError?.();
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.();
|
||||
}
|
||||
setLoading(false);
|
||||
}, [args, mutateAsync, onError, onSuccess]);
|
||||
|
||||
return <Button {...props} loading={isLoading} onClick={onClick}></Button>;
|
||||
}
|
||||
|
||||
export default MutateButton;
|
@ -0,0 +1,25 @@
|
||||
import { LoadingProvider } from "@/contexts";
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
import { FunctionComponent, ReactNode } from "react";
|
||||
import { UseQueryResult } from "react-query";
|
||||
|
||||
interface QueryOverlayProps {
|
||||
result: UseQueryResult<unknown, unknown>;
|
||||
global?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
|
||||
children,
|
||||
global = false,
|
||||
result: { isLoading, isError, error },
|
||||
}) => {
|
||||
return (
|
||||
<LoadingProvider value={isLoading}>
|
||||
<LoadingOverlay visible={global && isLoading}></LoadingOverlay>
|
||||
{children}
|
||||
</LoadingProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryOverlay;
|
@ -0,0 +1,3 @@
|
||||
export { default as Lazy } from "./Lazy";
|
||||
export { default as MutateAction } from "./MutateAction";
|
||||
export { default as QueryOverlay } from "./QueryOverlay";
|
@ -0,0 +1,26 @@
|
||||
import { BuildKey } from "@/utilities";
|
||||
import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
export type AudioListProps = GroupProps & {
|
||||
audios: Language.Info[];
|
||||
badgeProps?: BadgeProps<"div">;
|
||||
};
|
||||
|
||||
const AudioList: FunctionComponent<AudioListProps> = ({
|
||||
audios,
|
||||
badgeProps,
|
||||
...group
|
||||
}) => {
|
||||
return (
|
||||
<Group spacing="xs" {...group}>
|
||||
{audios.map((audio, idx) => (
|
||||
<Badge color="teal" key={BuildKey(idx, audio.code2)} {...badgeProps}>
|
||||
{audio.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioList;
|
@ -0,0 +1,54 @@
|
||||
import {
|
||||
faClock,
|
||||
faCloudUploadAlt,
|
||||
faDownload,
|
||||
faRecycle,
|
||||
faTrash,
|
||||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
enum HistoryAction {
|
||||
Delete = 0,
|
||||
Download,
|
||||
Manual,
|
||||
Upgrade,
|
||||
Upload,
|
||||
Sync,
|
||||
}
|
||||
|
||||
const HistoryIcon: FunctionComponent<{
|
||||
action: number;
|
||||
title?: string;
|
||||
}> = ({ action, title }) => {
|
||||
let icon = null;
|
||||
switch (action) {
|
||||
case HistoryAction.Delete:
|
||||
icon = faTrash;
|
||||
break;
|
||||
case HistoryAction.Download:
|
||||
icon = faDownload;
|
||||
break;
|
||||
case HistoryAction.Manual:
|
||||
icon = faUser;
|
||||
break;
|
||||
case HistoryAction.Sync:
|
||||
icon = faClock;
|
||||
break;
|
||||
case HistoryAction.Upgrade:
|
||||
icon = faRecycle;
|
||||
break;
|
||||
case HistoryAction.Upload:
|
||||
icon = faCloudUploadAlt;
|
||||
break;
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default HistoryIcon;
|
@ -0,0 +1,4 @@
|
||||
export { default as AudioList } from "./AudioList";
|
||||
export { default as HistoryIcon } from "./HistoryIcon";
|
||||
export { default as Language } from "./Language";
|
||||
export { default as LanguageProfile } from "./LanguageProfile";
|
@ -1,80 +0,0 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent, MouseEvent } from "react";
|
||||
import { Badge, Button, ButtonProps } from "react-bootstrap";
|
||||
|
||||
export const ActionBadge: FunctionComponent<{
|
||||
icon: IconDefinition;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
}> = ({ icon, onClick }) => {
|
||||
return (
|
||||
<Button
|
||||
as={Badge}
|
||||
className="mx-1 p-1"
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ActionButtonProps extends ActionButtonItemProps {
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
variant?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
className?: string;
|
||||
size?: ButtonProps["size"];
|
||||
}
|
||||
|
||||
export const ActionButton: FunctionComponent<ActionButtonProps> = ({
|
||||
onClick,
|
||||
destructive,
|
||||
disabled,
|
||||
variant,
|
||||
className,
|
||||
size,
|
||||
...other
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
disabled={other.loading || disabled}
|
||||
size={size ?? "sm"}
|
||||
variant={variant ?? "light"}
|
||||
className={`text-nowrap ${className ?? ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ActionButtonItem {...other}></ActionButtonItem>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ActionButtonItemProps {
|
||||
loading?: boolean;
|
||||
alwaysShowText?: boolean;
|
||||
icon: IconDefinition;
|
||||
children?: string;
|
||||
}
|
||||
|
||||
export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
||||
icon,
|
||||
children,
|
||||
loading,
|
||||
alwaysShowText,
|
||||
}) => {
|
||||
const showText = alwaysShowText === true || loading !== true;
|
||||
return (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
style={{ width: "1rem" }}
|
||||
icon={loading ? faCircleNotch : icon}
|
||||
spin={loading}
|
||||
></FontAwesomeIcon>
|
||||
{children && showText ? (
|
||||
<span className="ml-2 font-weight-bold">{children}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,133 @@
|
||||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import { Selector, SelectorOption } from "@/components";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task } from "@/modules/task";
|
||||
import { Button, Divider, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const TaskName = "Changing Color";
|
||||
|
||||
function convertToAction(color: string) {
|
||||
return `color(name=${color})`;
|
||||
}
|
||||
|
||||
export const colorOptions: SelectorOption<string>[] = [
|
||||
{
|
||||
label: "White",
|
||||
value: "white",
|
||||
},
|
||||
{
|
||||
label: "Light Gray",
|
||||
value: "light-gray",
|
||||
},
|
||||
{
|
||||
label: "Red",
|
||||
value: "red",
|
||||
},
|
||||
{
|
||||
label: "Green",
|
||||
value: "green",
|
||||
},
|
||||
{
|
||||
label: "Yellow",
|
||||
value: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
label: "Magenta",
|
||||
value: "magenta",
|
||||
},
|
||||
{
|
||||
label: "Cyan",
|
||||
value: "cyan",
|
||||
},
|
||||
{
|
||||
label: "Black",
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
label: "Dark Red",
|
||||
value: "dark-red",
|
||||
},
|
||||
{
|
||||
label: "Dark Green",
|
||||
value: "dark-green",
|
||||
},
|
||||
{
|
||||
label: "Dark Yellow",
|
||||
value: "dark-yellow",
|
||||
},
|
||||
{
|
||||
label: "Dark Blue",
|
||||
value: "dark-blue",
|
||||
},
|
||||
{
|
||||
label: "Dark Magenta",
|
||||
value: "dark-magenta",
|
||||
},
|
||||
{
|
||||
label: "Dark Cyan",
|
||||
value: "dark-cyan",
|
||||
},
|
||||
{
|
||||
label: "Dark Grey",
|
||||
value: "dark-grey",
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
selections: FormType.ModifySubtitle[];
|
||||
onSubmit?: VoidFunction;
|
||||
}
|
||||
|
||||
const ColorToolForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
const modals = useModals();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
color: "",
|
||||
},
|
||||
validationRules: {
|
||||
color: (c) => colorOptions.find((op) => op.value === c) !== undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ color }) => {
|
||||
const action = convertToAction(color);
|
||||
|
||||
selections.forEach((s) =>
|
||||
task.create(s.path, TaskName, mutateAsync, {
|
||||
action,
|
||||
form: s,
|
||||
})
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Selector
|
||||
required
|
||||
options={colorOptions}
|
||||
{...form.getInputProps("color")}
|
||||
></Selector>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Start</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorToolModal = withModal(ColorToolForm, "color-tool", {
|
||||
title: "Change Color",
|
||||
});
|
||||
|
||||
export default ColorToolForm;
|
@ -0,0 +1,72 @@
|
||||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task } from "@/modules/task";
|
||||
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const TaskName = "Changing Frame Rate";
|
||||
|
||||
function convertToAction(from: number, to: number) {
|
||||
return `change_FPS(from=${from},to=${to})`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selections: FormType.ModifySubtitle[];
|
||||
onSubmit?: VoidFunction;
|
||||
}
|
||||
|
||||
const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
const modals = useModals();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
validationRules: {
|
||||
from: (v) => v > 0,
|
||||
to: (v) => v > 0,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ from, to }) => {
|
||||
const action = convertToAction(from, to);
|
||||
|
||||
selections.forEach((s) =>
|
||||
task.create(s.path, TaskName, mutateAsync, {
|
||||
action,
|
||||
form: s,
|
||||
})
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="xs" grow>
|
||||
<NumberInput
|
||||
placeholder="From"
|
||||
{...form.getInputProps("from")}
|
||||
></NumberInput>
|
||||
<NumberInput
|
||||
placeholder="To"
|
||||
{...form.getInputProps("to")}
|
||||
></NumberInput>
|
||||
</Group>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Start</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const FrameRateModal = withModal(FrameRateForm, "frame-rate-tool", {
|
||||
title: "Change Frame Rate",
|
||||
});
|
||||
|
||||
export default FrameRateForm;
|
@ -0,0 +1,109 @@
|
||||
import { useLanguageProfiles } from "@/apis/hooks";
|
||||
import { MultiSelector, Selector } from "@/components/inputs";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { GetItemId, useSelectorOptions } from "@/utilities";
|
||||
import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { UseMutationResult } from "react-query";
|
||||
|
||||
interface Props {
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
|
||||
item: Item.Base | null;
|
||||
onComplete?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const ItemEditForm: FunctionComponent<Props> = ({
|
||||
mutation,
|
||||
item,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { data, isFetching } = useLanguageProfiles();
|
||||
const { isLoading, mutate } = mutation;
|
||||
const modals = useModals();
|
||||
|
||||
const profileOptions = useSelectorOptions(
|
||||
data ?? [],
|
||||
(v) => v.name ?? "Unknown",
|
||||
(v) => v.profileId.toString() ?? "-1"
|
||||
);
|
||||
|
||||
const profile = useMemo(
|
||||
() => data?.find((v) => v.profileId === item?.profileId) ?? null,
|
||||
[data, item?.profileId]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
profile: profile ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const options = useSelectorOptions(
|
||||
item?.audio_language ?? [],
|
||||
(v) => v.name,
|
||||
(v) => v.code2
|
||||
);
|
||||
|
||||
const isOverlayVisible = isLoading || isFetching || item === null;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ profile }) => {
|
||||
if (item) {
|
||||
const itemId = GetItemId(item);
|
||||
if (itemId) {
|
||||
mutate({ id: [itemId], profileid: [profile?.profileId ?? null] });
|
||||
onComplete?.();
|
||||
modals.closeSelf();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
form.setErrors({ profile: "Invalid profile" });
|
||||
})}
|
||||
>
|
||||
<LoadingOverlay visible={isOverlayVisible}></LoadingOverlay>
|
||||
<Stack>
|
||||
<MultiSelector
|
||||
label="Audio Languages"
|
||||
disabled
|
||||
{...options}
|
||||
value={item?.audio_language ?? []}
|
||||
></MultiSelector>
|
||||
<Selector
|
||||
{...profileOptions}
|
||||
{...form.getInputProps("profile")}
|
||||
clearable
|
||||
label="Languages Profiles"
|
||||
></Selector>
|
||||
<Divider></Divider>
|
||||
<Group position="right">
|
||||
<Button
|
||||
disabled={isOverlayVisible}
|
||||
onClick={() => {
|
||||
onCancel?.();
|
||||
modals.closeSelf();
|
||||
}}
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isOverlayVisible} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemEditModal = withModal(ItemEditForm, "item-editor", {
|
||||
title: "Editor",
|
||||
size: "md",
|
||||
});
|
||||
|
||||
export default ItemEditForm;
|
@ -0,0 +1,276 @@
|
||||
import { useMovieSubtitleModification } from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleNotch,
|
||||
faInfoCircle,
|
||||
faTimes,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { isString } from "lodash";
|
||||
import { FunctionComponent, useEffect, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { Action, Selector } from "../inputs";
|
||||
import { SimpleTable } from "../tables";
|
||||
import TextPopover from "../TextPopover";
|
||||
|
||||
type SubtitleFile = {
|
||||
file: File;
|
||||
language: Language.Info | null;
|
||||
forced: boolean;
|
||||
hi: boolean;
|
||||
validateResult?: SubtitleValidateResult;
|
||||
};
|
||||
|
||||
type SubtitleValidateResult = {
|
||||
state: "valid" | "warning" | "error";
|
||||
messages?: string;
|
||||
};
|
||||
|
||||
const validator = (
|
||||
movie: Item.Movie,
|
||||
file: SubtitleFile
|
||||
): SubtitleValidateResult => {
|
||||
if (file.language === null) {
|
||||
return {
|
||||
state: "error",
|
||||
messages: "Language is not selected",
|
||||
};
|
||||
} else {
|
||||
const { subtitles } = movie;
|
||||
const existing = subtitles.find(
|
||||
(v) => v.code2 === file.language?.code2 && isString(v.path)
|
||||
);
|
||||
if (existing !== undefined) {
|
||||
return {
|
||||
state: "warning",
|
||||
messages: "Override existing subtitle",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
files: File[];
|
||||
movie: Item.Movie;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const MovieUploadForm: FunctionComponent<Props> = ({
|
||||
files,
|
||||
movie,
|
||||
onComplete,
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const profile = useLanguageProfileBy(movie.profileId);
|
||||
|
||||
const languages = useProfileItemsToLanguages(profile);
|
||||
const languageOptions = useSelectorOptions(
|
||||
languages,
|
||||
(v) => v.name,
|
||||
(v) => v.code2
|
||||
);
|
||||
|
||||
const defaultLanguage = useMemo(
|
||||
() => (languages.length > 0 ? languages[0] : null),
|
||||
[languages]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
files: files
|
||||
.map<SubtitleFile>((file) => ({
|
||||
file,
|
||||
language: defaultLanguage,
|
||||
forced: defaultLanguage?.forced ?? false,
|
||||
hi: defaultLanguage?.hi ?? false,
|
||||
}))
|
||||
.map<SubtitleFile>((v) => ({
|
||||
...v,
|
||||
validateResult: validator(movie, v),
|
||||
})),
|
||||
},
|
||||
validationRules: {
|
||||
files: (values) => {
|
||||
return (
|
||||
values.find(
|
||||
(v) =>
|
||||
v.language === null ||
|
||||
v.validateResult === undefined ||
|
||||
v.validateResult.state === "error"
|
||||
) === undefined
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (form.values.files.length <= 0) {
|
||||
modals.closeSelf();
|
||||
}
|
||||
}, [form.values.files.length, modals]);
|
||||
|
||||
const action = useArrayAction<SubtitleFile>((fn) => {
|
||||
form.setValues(({ files, ...rest }) => {
|
||||
const newFiles = fn(files);
|
||||
newFiles.forEach((v) => {
|
||||
v.validateResult = validator(movie, v);
|
||||
});
|
||||
return { ...rest, files: newFiles };
|
||||
});
|
||||
});
|
||||
|
||||
const columns = useMemo<Column<SubtitleFile>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "validateResult",
|
||||
Cell: ({ cell: { value } }) => {
|
||||
const icon = useMemo(() => {
|
||||
switch (value?.state) {
|
||||
case "valid":
|
||||
return faCheck;
|
||||
case "warning":
|
||||
return faInfoCircle;
|
||||
case "error":
|
||||
return faTimes;
|
||||
default:
|
||||
return faCircleNotch;
|
||||
}
|
||||
}, [value?.state]);
|
||||
|
||||
return (
|
||||
<TextPopover text={value?.messages}>
|
||||
{/* TODO: Color */}
|
||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||
</TextPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "File",
|
||||
id: "filename",
|
||||
accessor: "file",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
return <Text className={classes.primary}>{value.name}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Forced",
|
||||
accessor: "forced",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, { ...original, forced: checked });
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "HI",
|
||||
accessor: "hi",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, { ...original, hi: checked });
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(index, { ...original, language: item });
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessor: "file",
|
||||
Cell: ({ row: { index } }) => {
|
||||
return (
|
||||
<Action
|
||||
icon={faXmark}
|
||||
color="red"
|
||||
onClick={() => action.remove(index)}
|
||||
></Action>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[action, languageOptions]
|
||||
);
|
||||
|
||||
const { upload } = useMovieSubtitleModification();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ files }) => {
|
||||
const { radarrId } = movie;
|
||||
|
||||
files.forEach(({ file, language, hi, forced }) => {
|
||||
if (language === null) {
|
||||
throw new Error("Language is not selected");
|
||||
}
|
||||
|
||||
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
|
||||
radarrId,
|
||||
form: { file, language: language.code2, hi, forced },
|
||||
});
|
||||
});
|
||||
|
||||
onComplete?.();
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Upload</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const MovieUploadModal = withModal(
|
||||
MovieUploadForm,
|
||||
"upload-movie-subtitle",
|
||||
{
|
||||
title: "Upload Subtitles",
|
||||
size: "xl",
|
||||
}
|
||||
);
|
||||
|
||||
export default MovieUploadForm;
|
@ -0,0 +1,311 @@
|
||||
import { Action, Selector, SelectorOption, SimpleTable } from "@/components";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import ChipInput from "../inputs/ChipInput";
|
||||
|
||||
export const anyCutoff = 65535;
|
||||
|
||||
const defaultCutoffOptions: SelectorOption<Language.ProfileItem>[] = [
|
||||
{
|
||||
label: "Any",
|
||||
value: {
|
||||
id: anyCutoff,
|
||||
audio_exclude: "False",
|
||||
forced: "False",
|
||||
hi: "False",
|
||||
language: "any",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onComplete?: (profile: Language.Profile) => void;
|
||||
languages: readonly Language.Info[];
|
||||
profile: Language.Profile;
|
||||
}
|
||||
|
||||
const ProfileEditForm: FunctionComponent<Props> = ({
|
||||
onComplete,
|
||||
languages,
|
||||
profile,
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: profile,
|
||||
validationRules: {
|
||||
name: (value) => value.length > 0,
|
||||
items: (value) => value.length > 0,
|
||||
},
|
||||
errorMessages: {
|
||||
items: (
|
||||
<Alert color="yellow" variant="outline">
|
||||
Must contain at lease 1 language
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const languageOptions = useSelectorOptions(languages, (l) => l.name);
|
||||
|
||||
const itemCutoffOptions = useSelectorOptions(
|
||||
form.values.items,
|
||||
(v) => v.language
|
||||
);
|
||||
|
||||
const cutoffOptions = useMemo(
|
||||
() => ({
|
||||
...itemCutoffOptions,
|
||||
options: [...itemCutoffOptions.options, ...defaultCutoffOptions],
|
||||
}),
|
||||
[itemCutoffOptions]
|
||||
);
|
||||
|
||||
const mustContainOptions = useSelectorOptions(
|
||||
form.values.mustContain,
|
||||
(v) => v
|
||||
);
|
||||
|
||||
const mustNotContainOptions = useSelectorOptions(
|
||||
form.values.mustNotContain,
|
||||
(v) => v
|
||||
);
|
||||
|
||||
const action = useArrayAction<Language.ProfileItem>((fn) => {
|
||||
form.setValues((values) => ({ ...values, items: fn(values.items) }));
|
||||
});
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
const id =
|
||||
1 +
|
||||
form.values.items.reduce<number>(
|
||||
(val, item) => Math.max(item.id, val),
|
||||
0
|
||||
);
|
||||
|
||||
if (languages.length > 0) {
|
||||
const language = languages[0].code2;
|
||||
|
||||
const item: Language.ProfileItem = {
|
||||
id,
|
||||
language,
|
||||
audio_exclude: "False",
|
||||
hi: "False",
|
||||
forced: "False",
|
||||
};
|
||||
|
||||
const list = [...form.values.items, item];
|
||||
form.setValues((values) => ({ ...values, items: list }));
|
||||
}
|
||||
}, [form, languages]);
|
||||
|
||||
const columns = useMemo<Column<Language.ProfileItem>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "ID",
|
||||
accessor: "id",
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value: code, row: { original: item, index } }) => {
|
||||
const language = useMemo(
|
||||
() =>
|
||||
languageOptions.options.find((l) => l.value.code2 === code)
|
||||
?.value ?? null,
|
||||
[code]
|
||||
);
|
||||
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
value={language}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
item.language = value.code2;
|
||||
action.mutate(index, { ...item, language: value.code2 });
|
||||
}
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Forced",
|
||||
accessor: "forced",
|
||||
Cell: ({ row: { original: item, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value === "True"}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, {
|
||||
...item,
|
||||
forced: checked ? "True" : "False",
|
||||
hi: checked ? "False" : item.hi,
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "HI",
|
||||
accessor: "hi",
|
||||
Cell: ({ row: { original: item, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value === "True"}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, {
|
||||
...item,
|
||||
hi: checked ? "True" : "False",
|
||||
forced: checked ? "False" : item.forced,
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Exclude Audio",
|
||||
accessor: "audio_exclude",
|
||||
Cell: ({ row: { original: item, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value === "True"}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, {
|
||||
...item,
|
||||
audio_exclude: checked ? "True" : "False",
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessor: "id",
|
||||
Cell: ({ row }) => {
|
||||
return (
|
||||
<Action
|
||||
icon={faXmark}
|
||||
color="red"
|
||||
onClick={() => action.remove(row.index)}
|
||||
></Action>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[action, languageOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((value) => {
|
||||
onComplete?.(value);
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label="Name" {...form.getInputProps("name")}></TextInput>
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
multiple
|
||||
iconPosition="right"
|
||||
initialItem={0}
|
||||
styles={(theme) => ({
|
||||
contentInner: {
|
||||
[theme.fn.smallerThan("md")]: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label="Languages">
|
||||
<Stack>
|
||||
{form.errors.items}
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={form.values.items}
|
||||
></SimpleTable>
|
||||
<Button fullWidth color="light" onClick={addItem}>
|
||||
Add Language
|
||||
</Button>
|
||||
<Selector
|
||||
clearable
|
||||
label="Cutoff"
|
||||
{...cutoffOptions}
|
||||
{...form.getInputProps("cutoff")}
|
||||
></Selector>
|
||||
</Stack>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item label="Release Info">
|
||||
<Stack>
|
||||
<ChipInput
|
||||
label="Must contain"
|
||||
{...mustContainOptions}
|
||||
{...form.getInputProps("mustContain")}
|
||||
></ChipInput>
|
||||
<Text size="sm">
|
||||
Subtitles release info must include one of those words or they
|
||||
will be excluded from search results (regex supported).
|
||||
</Text>
|
||||
<ChipInput
|
||||
label="Must not contain"
|
||||
{...mustNotContainOptions}
|
||||
{...form.getInputProps("mustNotContain")}
|
||||
></ChipInput>
|
||||
<Text size="sm">
|
||||
Subtitles release info including one of those words (case
|
||||
insensitive) will be excluded from search results (regex
|
||||
supported).
|
||||
</Text>
|
||||
</Stack>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item label="Subtitles">
|
||||
<Stack my="xs">
|
||||
<Switch
|
||||
label="Use Original Format"
|
||||
{...form.getInputProps("originalFormat")}
|
||||
></Switch>
|
||||
<Text size="sm">
|
||||
Download subtitle file without format conversion
|
||||
</Text>
|
||||
</Stack>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Save</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProfileEditModal = withModal(
|
||||
ProfileEditForm,
|
||||
"languages-profile-editor",
|
||||
{
|
||||
title: "Edit Languages Profile",
|
||||
size: "lg",
|
||||
}
|
||||
);
|
||||
|
||||
export default ProfileEditForm;
|
@ -0,0 +1,349 @@
|
||||
import {
|
||||
useEpisodesBySeriesId,
|
||||
useEpisodeSubtitleModification,
|
||||
useSubtitleInfos,
|
||||
} from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleNotch,
|
||||
faInfoCircle,
|
||||
faTimes,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { isString } from "lodash";
|
||||
import { FunctionComponent, useEffect, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { Action, Selector } from "../inputs";
|
||||
import { SimpleTable } from "../tables";
|
||||
import TextPopover from "../TextPopover";
|
||||
|
||||
type SubtitleFile = {
|
||||
file: File;
|
||||
language: Language.Info | null;
|
||||
forced: boolean;
|
||||
hi: boolean;
|
||||
episode: Item.Episode | null;
|
||||
validateResult?: SubtitleValidateResult;
|
||||
};
|
||||
|
||||
type SubtitleValidateResult = {
|
||||
state: "valid" | "warning" | "error";
|
||||
messages?: string;
|
||||
};
|
||||
|
||||
const validator = (file: SubtitleFile): SubtitleValidateResult => {
|
||||
if (file.language === null) {
|
||||
return {
|
||||
state: "error",
|
||||
messages: "Language is not selected",
|
||||
};
|
||||
} else if (file.episode === null) {
|
||||
return {
|
||||
state: "error",
|
||||
messages: "Episode is not selected",
|
||||
};
|
||||
} else {
|
||||
const { subtitles } = file.episode;
|
||||
const existing = subtitles.find(
|
||||
(v) => v.code2 === file.language?.code2 && isString(v.path)
|
||||
);
|
||||
if (existing !== undefined) {
|
||||
return {
|
||||
state: "warning",
|
||||
messages: "Override existing subtitle",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
};
|
||||
};
|
||||
|
||||
interface Props {
|
||||
files: File[];
|
||||
series: Item.Series;
|
||||
onComplete?: VoidFunction;
|
||||
}
|
||||
|
||||
const SeriesUploadForm: FunctionComponent<Props> = ({
|
||||
series,
|
||||
files,
|
||||
onComplete,
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const episodes = useEpisodesBySeriesId(series.sonarrSeriesId);
|
||||
const episodeOptions = useSelectorOptions(
|
||||
episodes.data ?? [],
|
||||
(v) => `(${v.season}x${v.episode}) ${v.title}`,
|
||||
(v) => v.sonarrEpisodeId.toString()
|
||||
);
|
||||
|
||||
const profile = useLanguageProfileBy(series.profileId);
|
||||
const languages = useProfileItemsToLanguages(profile);
|
||||
const languageOptions = useSelectorOptions(
|
||||
languages,
|
||||
(v) => v.name,
|
||||
(v) => v.code2
|
||||
);
|
||||
|
||||
const defaultLanguage = useMemo(
|
||||
() => (languages.length > 0 ? languages[0] : null),
|
||||
[languages]
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
files: files
|
||||
.map<SubtitleFile>((file) => ({
|
||||
file,
|
||||
language: defaultLanguage,
|
||||
forced: defaultLanguage?.forced ?? false,
|
||||
hi: defaultLanguage?.hi ?? false,
|
||||
episode: null,
|
||||
}))
|
||||
.map<SubtitleFile>((file) => ({
|
||||
...file,
|
||||
validateResult: validator(file),
|
||||
})),
|
||||
},
|
||||
validationRules: {
|
||||
files: (values) =>
|
||||
values.find(
|
||||
(v) =>
|
||||
v.language === null ||
|
||||
v.episode === null ||
|
||||
v.validateResult === undefined ||
|
||||
v.validateResult.state === "error"
|
||||
) === undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const action = useArrayAction<SubtitleFile>((fn) => {
|
||||
form.setValues(({ files, ...rest }) => {
|
||||
const newFiles = fn(files);
|
||||
newFiles.forEach((v) => {
|
||||
v.validateResult = validator(v);
|
||||
});
|
||||
return { ...rest, files: newFiles };
|
||||
});
|
||||
});
|
||||
|
||||
const names = useMemo(() => files.map((v) => v.name), [files]);
|
||||
const infos = useSubtitleInfos(names);
|
||||
|
||||
// Auto assign episode if available
|
||||
useEffect(() => {
|
||||
if (infos.data !== undefined) {
|
||||
action.update((item) => {
|
||||
const info = infos.data.find((v) => v.filename === item.file.name);
|
||||
if (info) {
|
||||
item.episode =
|
||||
episodes.data?.find(
|
||||
(v) => v.season === info.season && v.episode === info.episode
|
||||
) ?? item.episode;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}, [action, episodes.data, infos.data]);
|
||||
|
||||
const columns = useMemo<Column<SubtitleFile>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "validateResult",
|
||||
Cell: ({ cell: { value } }) => {
|
||||
const icon = useMemo(() => {
|
||||
switch (value?.state) {
|
||||
case "valid":
|
||||
return faCheck;
|
||||
case "warning":
|
||||
return faInfoCircle;
|
||||
case "error":
|
||||
return faTimes;
|
||||
default:
|
||||
return faCircleNotch;
|
||||
}
|
||||
}, [value?.state]);
|
||||
|
||||
return (
|
||||
<TextPopover text={value?.messages}>
|
||||
{/* TODO: Color */}
|
||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||
</TextPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "File",
|
||||
id: "filename",
|
||||
accessor: "file",
|
||||
Cell: ({ value: { name } }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.primary}>{name}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Forced",
|
||||
accessor: "forced",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, {
|
||||
...original,
|
||||
forced: checked,
|
||||
hi: checked ? false : original.hi,
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "HI",
|
||||
accessor: "hi",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onChange={({ currentTarget: { checked } }) => {
|
||||
action.mutate(index, {
|
||||
...original,
|
||||
hi: checked,
|
||||
forced: checked ? false : original.forced,
|
||||
});
|
||||
}}
|
||||
></Checkbox>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
value={null}
|
||||
placeholder="Language"
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
action.update((item) => {
|
||||
item.language = value;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}}
|
||||
></Selector>
|
||||
),
|
||||
accessor: "language",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(index, { ...original, language: item });
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "episode",
|
||||
Header: "Episode",
|
||||
accessor: "episode",
|
||||
Cell: ({ value, row }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...episodeOptions}
|
||||
className={classes.select}
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(row.index, { ...row.original, episode: item });
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessor: "file",
|
||||
Cell: ({ row: { index } }) => {
|
||||
return (
|
||||
<Action
|
||||
icon={faXmark}
|
||||
color="red"
|
||||
onClick={() => action.remove(index)}
|
||||
></Action>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[action, episodeOptions, languageOptions]
|
||||
);
|
||||
|
||||
const { upload } = useEpisodeSubtitleModification();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ files }) => {
|
||||
const { sonarrSeriesId: seriesId } = series;
|
||||
|
||||
files.forEach((value) => {
|
||||
const { file, hi, forced, language, episode } = value;
|
||||
|
||||
if (language === null || episode === null) {
|
||||
throw new Error(
|
||||
"Invalid language or episode. This shouldn't happen, please report this bug."
|
||||
);
|
||||
}
|
||||
|
||||
const { code2 } = language;
|
||||
const { sonarrEpisodeId: episodeId } = episode;
|
||||
|
||||
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
|
||||
seriesId,
|
||||
episodeId,
|
||||
form: {
|
||||
file,
|
||||
language: code2,
|
||||
hi,
|
||||
forced,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onComplete?.();
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Upload</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const SeriesUploadModal = withModal(
|
||||
SeriesUploadForm,
|
||||
"upload-series-subtitles",
|
||||
{ title: "Upload Subtitles", size: "xl" }
|
||||
);
|
||||
|
||||
export default SeriesUploadForm;
|
@ -0,0 +1,97 @@
|
||||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task } from "@/modules/task";
|
||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const TaskName = "Changing Time";
|
||||
|
||||
function convertToAction(h: number, m: number, s: number, ms: number) {
|
||||
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selections: FormType.ModifySubtitle[];
|
||||
onSubmit?: VoidFunction;
|
||||
}
|
||||
|
||||
const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
const modals = useModals();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
positive: true,
|
||||
hour: 0,
|
||||
min: 0,
|
||||
sec: 0,
|
||||
ms: 0,
|
||||
},
|
||||
validationRules: {
|
||||
hour: (v) => v >= 0,
|
||||
min: (v) => v >= 0,
|
||||
sec: (v) => v >= 0,
|
||||
ms: (v) => v >= 0,
|
||||
},
|
||||
});
|
||||
|
||||
const enabled =
|
||||
form.values.hour > 0 ||
|
||||
form.values.min > 0 ||
|
||||
form.values.sec > 0 ||
|
||||
form.values.ms > 0;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ positive, hour, min, sec, ms }) => {
|
||||
const action = convertToAction(hour, min, sec, ms);
|
||||
|
||||
selections.forEach((s) =>
|
||||
task.create(s.path, TaskName, mutateAsync, {
|
||||
action,
|
||||
form: s,
|
||||
})
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Group align="end" spacing="xs" noWrap>
|
||||
<Button
|
||||
color="gray"
|
||||
variant="filled"
|
||||
onClick={() =>
|
||||
form.setValues((f) => ({ ...f, positive: !f.positive }))
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={form.values.positive ? faPlus : faMinus}
|
||||
></FontAwesomeIcon>
|
||||
</Button>
|
||||
<NumberInput
|
||||
label="hour"
|
||||
{...form.getInputProps("hour")}
|
||||
></NumberInput>
|
||||
<NumberInput label="min" {...form.getInputProps("min")}></NumberInput>
|
||||
<NumberInput label="sec" {...form.getInputProps("sec")}></NumberInput>
|
||||
<NumberInput label="ms" {...form.getInputProps("ms")}></NumberInput>
|
||||
</Group>
|
||||
<Divider></Divider>
|
||||
<Button disabled={!enabled} type="submit">
|
||||
Start
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimeOffsetModal = withModal(TimeOffsetForm, "time-offset", {
|
||||
title: "Change Time",
|
||||
});
|
||||
|
||||
export default TimeOffsetForm;
|
@ -0,0 +1,192 @@
|
||||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task } from "@/modules/task";
|
||||
import { useSelectorOptions } from "@/utilities";
|
||||
import { useEnabledLanguages } from "@/utilities/languages";
|
||||
import { Alert, Button, Divider, Stack } from "@mantine/core";
|
||||
import { useForm } from "@mantine/hooks";
|
||||
import { isObject } from "lodash";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { Selector } from "../inputs";
|
||||
|
||||
const TaskName = "Translating Subtitles";
|
||||
|
||||
const translations = {
|
||||
af: "afrikaans",
|
||||
sq: "albanian",
|
||||
am: "amharic",
|
||||
ar: "arabic",
|
||||
hy: "armenian",
|
||||
az: "azerbaijani",
|
||||
eu: "basque",
|
||||
be: "belarusian",
|
||||
bn: "bengali",
|
||||
bs: "bosnian",
|
||||
bg: "bulgarian",
|
||||
ca: "catalan",
|
||||
ceb: "cebuano",
|
||||
ny: "chichewa",
|
||||
zh: "chinese (simplified)",
|
||||
zt: "chinese (traditional)",
|
||||
co: "corsican",
|
||||
hr: "croatian",
|
||||
cs: "czech",
|
||||
da: "danish",
|
||||
nl: "dutch",
|
||||
en: "english",
|
||||
eo: "esperanto",
|
||||
et: "estonian",
|
||||
tl: "filipino",
|
||||
fi: "finnish",
|
||||
fr: "french",
|
||||
fy: "frisian",
|
||||
gl: "galician",
|
||||
ka: "georgian",
|
||||
de: "german",
|
||||
el: "greek",
|
||||
gu: "gujarati",
|
||||
ht: "haitian creole",
|
||||
ha: "hausa",
|
||||
haw: "hawaiian",
|
||||
iw: "hebrew",
|
||||
hi: "hindi",
|
||||
hmn: "hmong",
|
||||
hu: "hungarian",
|
||||
is: "icelandic",
|
||||
ig: "igbo",
|
||||
id: "indonesian",
|
||||
ga: "irish",
|
||||
it: "italian",
|
||||
ja: "japanese",
|
||||
jw: "javanese",
|
||||
kn: "kannada",
|
||||
kk: "kazakh",
|
||||
km: "khmer",
|
||||
ko: "korean",
|
||||
ku: "kurdish (kurmanji)",
|
||||
ky: "kyrgyz",
|
||||
lo: "lao",
|
||||
la: "latin",
|
||||
lv: "latvian",
|
||||
lt: "lithuanian",
|
||||
lb: "luxembourgish",
|
||||
mk: "macedonian",
|
||||
mg: "malagasy",
|
||||
ms: "malay",
|
||||
ml: "malayalam",
|
||||
mt: "maltese",
|
||||
mi: "maori",
|
||||
mr: "marathi",
|
||||
mn: "mongolian",
|
||||
my: "myanmar (burmese)",
|
||||
ne: "nepali",
|
||||
no: "norwegian",
|
||||
ps: "pashto",
|
||||
fa: "persian",
|
||||
pl: "polish",
|
||||
pt: "portuguese",
|
||||
pa: "punjabi",
|
||||
ro: "romanian",
|
||||
ru: "russian",
|
||||
sm: "samoan",
|
||||
gd: "scots gaelic",
|
||||
sr: "serbian",
|
||||
st: "sesotho",
|
||||
sn: "shona",
|
||||
sd: "sindhi",
|
||||
si: "sinhala",
|
||||
sk: "slovak",
|
||||
sl: "slovenian",
|
||||
so: "somali",
|
||||
es: "spanish",
|
||||
su: "sundanese",
|
||||
sw: "swahili",
|
||||
sv: "swedish",
|
||||
tg: "tajik",
|
||||
ta: "tamil",
|
||||
te: "telugu",
|
||||
th: "thai",
|
||||
tr: "turkish",
|
||||
uk: "ukrainian",
|
||||
ur: "urdu",
|
||||
uz: "uzbek",
|
||||
vi: "vietnamese",
|
||||
cy: "welsh",
|
||||
xh: "xhosa",
|
||||
yi: "yiddish",
|
||||
yo: "yoruba",
|
||||
zu: "zulu",
|
||||
fil: "Filipino",
|
||||
he: "Hebrew",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
selections: FormType.ModifySubtitle[];
|
||||
onSubmit?: VoidFunction;
|
||||
}
|
||||
|
||||
const TranslationForm: FunctionComponent<Props> = ({
|
||||
selections,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
const modals = useModals();
|
||||
|
||||
const { data: languages } = useEnabledLanguages();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
language: null as Language.Info | null,
|
||||
},
|
||||
validationRules: {
|
||||
language: isObject,
|
||||
},
|
||||
});
|
||||
|
||||
const available = useMemo(
|
||||
() => languages.filter((v) => v.code2 in translations),
|
||||
[languages]
|
||||
);
|
||||
|
||||
const options = useSelectorOptions(
|
||||
available,
|
||||
(v) => v.name,
|
||||
(v) => v.code2
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(({ language }) => {
|
||||
if (language) {
|
||||
selections.forEach((s) =>
|
||||
task.create(s.path, TaskName, mutateAsync, {
|
||||
action: "translate",
|
||||
form: {
|
||||
...s,
|
||||
language: language.code2,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
modals.closeSelf();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Alert variant="outline">
|
||||
Enabled languages not listed here are unsupported by Google Translate.
|
||||
</Alert>
|
||||
<Selector {...options} {...form.getInputProps("language")}></Selector>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Start</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const TranslationModal = withModal(TranslationForm, "translation-tool", {
|
||||
title: "Translate Subtitle(s)",
|
||||
});
|
||||
|
||||
export default TranslationForm;
|
@ -1,78 +0,0 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
FunctionComponent,
|
||||
MouseEvent,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
interface CHButtonProps {
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
icon: IconDefinition;
|
||||
updating?: boolean;
|
||||
updatingIcon?: IconDefinition;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
|
||||
const { children, icon, disabled, updating, updatingIcon, onClick } = props;
|
||||
|
||||
let displayIcon = icon;
|
||||
if (updating) {
|
||||
displayIcon = updatingIcon ? updatingIcon : faSpinner;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="dark"
|
||||
className="d-flex flex-column text-nowrap py-1"
|
||||
disabled={disabled || updating}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="mx-auto my-1"
|
||||
icon={displayIcon}
|
||||
spin={updating}
|
||||
></FontAwesomeIcon>
|
||||
<span className="align-bottom text-themecolor small text-center">
|
||||
{children}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
|
||||
promise: T;
|
||||
onSuccess?: (item: R) => void;
|
||||
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
|
||||
|
||||
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
|
||||
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
|
||||
): JSX.Element {
|
||||
const { promise, onSuccess, ...button } = props;
|
||||
|
||||
const [updating, setUpdate] = useState(false);
|
||||
|
||||
const click = useCallback(() => {
|
||||
setUpdate(true);
|
||||
promise().then((val) => {
|
||||
setUpdate(false);
|
||||
onSuccess && onSuccess(val);
|
||||
});
|
||||
}, [onSuccess, promise]);
|
||||
|
||||
return (
|
||||
<ContentHeaderButton
|
||||
updating={updating}
|
||||
onClick={click}
|
||||
{...button}
|
||||
></ContentHeaderButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContentHeaderButton;
|
@ -1,15 +0,0 @@
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
type GroupPosition = "start" | "end";
|
||||
interface GroupProps {
|
||||
pos: GroupPosition;
|
||||
}
|
||||
|
||||
const ContentHeaderGroup: FunctionComponent<GroupProps> = (props) => {
|
||||
const { children, pos } = props;
|
||||
|
||||
const className = `d-flex flex-grow-1 align-items-center justify-content-${pos}`;
|
||||
return <div className={className}>{children}</div>;
|
||||
};
|
||||
|
||||
export default ContentHeaderGroup;
|
@ -1,47 +0,0 @@
|
||||
import { FunctionComponent, ReactNode, useMemo } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
|
||||
import ContentHeaderGroup from "./Group";
|
||||
|
||||
interface Props {
|
||||
scroll?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
declare type Header = FunctionComponent<Props> & {
|
||||
Button: typeof ContentHeaderButton;
|
||||
AsyncButton: typeof ContentHeaderAsyncButton;
|
||||
Group: typeof ContentHeaderGroup;
|
||||
};
|
||||
|
||||
export const ContentHeader: Header = ({ children, scroll, className }) => {
|
||||
const cls = useMemo(() => {
|
||||
const rowCls = ["content-header", "bg-dark", "p-2"];
|
||||
|
||||
if (className !== undefined) {
|
||||
rowCls.push(className);
|
||||
}
|
||||
|
||||
if (scroll !== false) {
|
||||
rowCls.push("scroll");
|
||||
}
|
||||
return rowCls.join(" ");
|
||||
}, [scroll, className]);
|
||||
|
||||
let childItem: ReactNode;
|
||||
|
||||
if (scroll !== false) {
|
||||
childItem = (
|
||||
<div className="d-flex flex-nowrap flex-grow-1">{children}</div>
|
||||
);
|
||||
} else {
|
||||
childItem = children;
|
||||
}
|
||||
return <Row className={cls}>{childItem}</Row>;
|
||||
};
|
||||
|
||||
ContentHeader.Button = ContentHeaderButton;
|
||||
ContentHeader.Group = ContentHeaderGroup;
|
||||
ContentHeader.AsyncButton = ContentHeaderAsyncButton;
|
||||
|
||||
export default ContentHeader;
|
@ -1,135 +1,4 @@
|
||||
import {
|
||||
faClock,
|
||||
faCloudUploadAlt,
|
||||
faDownload,
|
||||
faRecycle,
|
||||
faTrash,
|
||||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
import { FunctionComponent, ReactElement } from "react";
|
||||
import {
|
||||
OverlayTrigger,
|
||||
OverlayTriggerProps,
|
||||
Popover,
|
||||
Spinner,
|
||||
SpinnerProps,
|
||||
} from "react-bootstrap";
|
||||
|
||||
enum HistoryAction {
|
||||
Delete = 0,
|
||||
Download,
|
||||
Manual,
|
||||
Upgrade,
|
||||
Upload,
|
||||
Sync,
|
||||
}
|
||||
|
||||
export const HistoryIcon: FunctionComponent<{
|
||||
action: number;
|
||||
title?: string;
|
||||
}> = (props) => {
|
||||
const { action, title } = props;
|
||||
let icon = null;
|
||||
switch (action) {
|
||||
case HistoryAction.Delete:
|
||||
icon = faTrash;
|
||||
break;
|
||||
case HistoryAction.Download:
|
||||
icon = faDownload;
|
||||
break;
|
||||
case HistoryAction.Manual:
|
||||
icon = faUser;
|
||||
break;
|
||||
case HistoryAction.Sync:
|
||||
icon = faClock;
|
||||
break;
|
||||
case HistoryAction.Upgrade:
|
||||
icon = faRecycle;
|
||||
break;
|
||||
case HistoryAction.Upload:
|
||||
icon = faCloudUploadAlt;
|
||||
break;
|
||||
}
|
||||
if (icon) {
|
||||
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface MessageIconProps extends FontAwesomeIconProps {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export const MessageIcon: FunctionComponent<MessageIconProps> = (props) => {
|
||||
const { messages, ...iconProps } = props;
|
||||
|
||||
const popover = (
|
||||
<Popover hidden={messages.length === 0} id="overlay-icon">
|
||||
<Popover.Content>
|
||||
{messages.map((m) => (
|
||||
<li key={m}>{m}</li>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger overlay={popover}>
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingIndicator: FunctionComponent<{
|
||||
animation?: SpinnerProps["animation"];
|
||||
}> = ({ children, animation: style }) => {
|
||||
return (
|
||||
<div className="d-flex flex-column flex-grow-1 align-items-center py-5">
|
||||
<Spinner animation={style ?? "border"} className="mb-2"></Spinner>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextPopoverProps {
|
||||
children: ReactElement;
|
||||
text: string | undefined | null;
|
||||
placement?: OverlayTriggerProps["placement"];
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const TextPopover: FunctionComponent<TextPopoverProps> = ({
|
||||
children,
|
||||
text,
|
||||
placement,
|
||||
delay,
|
||||
}) => {
|
||||
if (isNull(text) || isUndefined(text)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover className="mw-100 py-1" id={text}>
|
||||
<span className="mx-2">{text}</span>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<OverlayTrigger delay={delay} overlay={popover} placement={placement}>
|
||||
{children}
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export * from "./async";
|
||||
export * from "./buttons";
|
||||
export * from "./header";
|
||||
export * from "./inputs";
|
||||
export * from "./LanguageSelector";
|
||||
export * from "./SearchBar";
|
||||
export { default as Search } from "./Search";
|
||||
export * from "./tables";
|
||||
export { default as Toolbox } from "./toolbox";
|
||||
|
@ -0,0 +1,24 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { ActionIcon, ActionIconProps } from "@mantine/core";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export type ActionProps = ActionIconProps<"button"> & {
|
||||
icon: IconDefinition;
|
||||
iconProps?: Omit<FontAwesomeIconProps, "icon">;
|
||||
};
|
||||
|
||||
const Action = forwardRef<HTMLButtonElement, ActionProps>(
|
||||
({ icon, iconProps, ...props }, ref) => {
|
||||
return (
|
||||
<ActionIcon {...props} ref={ref}>
|
||||
<FontAwesomeIcon icon={icon} {...iconProps}></FontAwesomeIcon>
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Action;
|
@ -0,0 +1,34 @@
|
||||
import { useSelectorOptions } from "@/utilities";
|
||||
import { FunctionComponent } from "react";
|
||||
import { MultiSelector, MultiSelectorProps } from "./Selector";
|
||||
|
||||
export type ChipInputProps = Omit<
|
||||
MultiSelectorProps<string>,
|
||||
| "searchable"
|
||||
| "creatable"
|
||||
| "getCreateLabel"
|
||||
| "onCreate"
|
||||
| "options"
|
||||
| "getkey"
|
||||
>;
|
||||
|
||||
const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const options = useSelectorOptions(value ?? [], (v) => v);
|
||||
|
||||
return (
|
||||
<MultiSelector
|
||||
{...props}
|
||||
{...options}
|
||||
creatable
|
||||
searchable
|
||||
getCreateLabel={(query) => `Add "${query}"`}
|
||||
onCreate={(query) => {
|
||||
onChange?.([...(value ?? []), query]);
|
||||
}}
|
||||
></MultiSelector>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChipInput;
|
@ -1,147 +0,0 @@
|
||||
import {
|
||||
FocusEvent,
|
||||
FunctionComponent,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];
|
||||
|
||||
export interface ChipsProps {
|
||||
disabled?: boolean;
|
||||
defaultValue?: readonly string[];
|
||||
value?: readonly string[];
|
||||
onChange?: (v: string[]) => void;
|
||||
}
|
||||
|
||||
export const Chips: FunctionComponent<ChipsProps> = ({
|
||||
defaultValue,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => {
|
||||
const [chips, setChips] = useState<Readonly<string[]>>(() => {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setChips(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addChip = useCallback(
|
||||
(value: string) => {
|
||||
setChips((cp) => {
|
||||
const newChips = [...cp, value];
|
||||
onChange && onChange(newChips);
|
||||
return newChips;
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const removeChip = useCallback(
|
||||
(idx?: number) => {
|
||||
setChips((cp) => {
|
||||
const index = idx ?? cp.length - 1;
|
||||
if (index !== -1) {
|
||||
const newChips = [...cp];
|
||||
newChips.splice(index, 1);
|
||||
onChange && onChange(newChips);
|
||||
return newChips;
|
||||
} else {
|
||||
return cp;
|
||||
}
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
if (input.current) {
|
||||
input.current.value = "";
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const pressed = event.key;
|
||||
const value = event.currentTarget.value;
|
||||
if (SplitKeys.includes(pressed) && value.length !== 0) {
|
||||
event.preventDefault();
|
||||
addChip(value);
|
||||
clearInput();
|
||||
} else if (pressed === "Backspace" && value.length === 0) {
|
||||
event.preventDefault();
|
||||
removeChip();
|
||||
}
|
||||
},
|
||||
[addChip, removeChip, clearInput]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const pressed = event.key;
|
||||
const value = event.currentTarget.value;
|
||||
if (SplitKeys.includes(pressed) && value.length !== 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
if (value.length !== 0) {
|
||||
event.preventDefault();
|
||||
addChip(value);
|
||||
clearInput();
|
||||
}
|
||||
},
|
||||
[addChip, clearInput]
|
||||
);
|
||||
|
||||
const chipElements = useMemo(
|
||||
() =>
|
||||
chips.map((v, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
title={v}
|
||||
className={`custom-chip ${disabled ? "" : "active"}`}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
removeChip(idx);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
)),
|
||||
[chips, removeChip, disabled]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="form-control custom-chip-input d-flex">
|
||||
<div className="chip-container">{chipElements}</div>
|
||||
<input
|
||||
disabled={disabled}
|
||||
className="main-input p-0"
|
||||
ref={input}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
></input>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,78 @@
|
||||
import {
|
||||
faArrowUp,
|
||||
faFileCirclePlus,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Box, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Dropzone,
|
||||
DropzoneProps,
|
||||
DropzoneStatus,
|
||||
FullScreenDropzone,
|
||||
FullScreenDropzoneProps,
|
||||
} from "@mantine/dropzone";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
export type FileProps = Omit<DropzoneProps, "children"> & {
|
||||
inner?: FileInnerComponent;
|
||||
};
|
||||
|
||||
const File: FunctionComponent<FileProps> = ({
|
||||
inner: Inner = FileInner,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Dropzone {...props}>
|
||||
{(status) => <Inner status={status}></Inner>}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileOverlayProps = Omit<FullScreenDropzoneProps, "children"> & {
|
||||
inner?: FileInnerComponent;
|
||||
};
|
||||
|
||||
export const FileOverlay: FunctionComponent<FileOverlayProps> = ({
|
||||
inner: Inner = FileInner,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<FullScreenDropzone {...props}>
|
||||
{(status) => <Inner status={status}></Inner>}
|
||||
</FullScreenDropzone>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileInnerProps = {
|
||||
status: DropzoneStatus;
|
||||
};
|
||||
|
||||
type FileInnerComponent = FunctionComponent<FileInnerProps>;
|
||||
|
||||
const FileInner: FileInnerComponent = ({ status }) => {
|
||||
const { accepted, rejected } = status;
|
||||
const icon = useMemo(() => {
|
||||
if (accepted) {
|
||||
return faArrowUp;
|
||||
} else if (rejected) {
|
||||
return faXmark;
|
||||
} else {
|
||||
return faFileCirclePlus;
|
||||
}
|
||||
}, [accepted, rejected]);
|
||||
|
||||
return (
|
||||
<Stack m="lg" align="center" spacing="xs" style={{ pointerEvents: "none" }}>
|
||||
<Box mb="md">
|
||||
<FontAwesomeIcon size="3x" icon={icon}></FontAwesomeIcon>
|
||||
</Box>
|
||||
<Text size="lg">Upload files here</Text>
|
||||
<Text color="dimmed" size="sm">
|
||||
Drag and drop, or click to select
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
@ -1,73 +0,0 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
|
||||
export interface FileFormProps {
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
emptyText: string;
|
||||
value?: File[];
|
||||
onChange?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export const FileForm: FunctionComponent<FileFormProps> = ({
|
||||
value: files,
|
||||
emptyText,
|
||||
multiple,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => {
|
||||
const [fileList, setFileList] = useState<File[]>([]);
|
||||
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files) {
|
||||
setFileList(files);
|
||||
|
||||
if (files.length === 0 && input.current) {
|
||||
// Manual reset file input
|
||||
input.current.value = "";
|
||||
}
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (fileList.length === 0) {
|
||||
return emptyText;
|
||||
} else {
|
||||
if (multiple) {
|
||||
return `${fileList.length} Files`;
|
||||
} else {
|
||||
return fileList[0].name;
|
||||
}
|
||||
}
|
||||
}, [fileList, emptyText, multiple]);
|
||||
|
||||
return (
|
||||
<Form.File
|
||||
disabled={disabled}
|
||||
custom
|
||||
label={label}
|
||||
multiple={multiple}
|
||||
ref={input}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = e.target;
|
||||
if (files) {
|
||||
const list: File[] = [];
|
||||
for (const file of files) {
|
||||
list.push(file);
|
||||
}
|
||||
setFileList(list);
|
||||
onChange && onChange(list);
|
||||
}
|
||||
}}
|
||||
></Form.File>
|
||||
);
|
||||
};
|
@ -1,141 +1,169 @@
|
||||
import clsx from "clsx";
|
||||
import { FocusEvent, useCallback, useMemo, useRef } from "react";
|
||||
import Select, { GroupBase, OnChangeValue } from "react-select";
|
||||
import { SelectComponents } from "react-select/dist/declarations/src/components";
|
||||
|
||||
export type SelectorOption<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
};
|
||||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectProps,
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectProps,
|
||||
} from "@mantine/core";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
export type SelectorComponents<T, M extends boolean> = SelectComponents<
|
||||
SelectorOption<T>,
|
||||
M,
|
||||
GroupBase<SelectorOption<T>>
|
||||
export type SelectorOption<T> = Override<
|
||||
{
|
||||
value: T;
|
||||
label: string;
|
||||
},
|
||||
SelectItem
|
||||
>;
|
||||
|
||||
export type SelectorValueType<T, M extends boolean> = M extends true
|
||||
? ReadonlyArray<T>
|
||||
: Nullable<T>;
|
||||
|
||||
export interface SelectorProps<T, M extends boolean> {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
options: readonly SelectorOption<T>[];
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
loading?: boolean;
|
||||
multiple?: M;
|
||||
onChange?: (k: SelectorValueType<T, M>) => void;
|
||||
onFocus?: (e: FocusEvent<HTMLElement>) => void;
|
||||
label?: (item: T) => string;
|
||||
defaultValue?: SelectorValueType<T, M>;
|
||||
value?: SelectorValueType<T, M>;
|
||||
components?: Partial<
|
||||
SelectComponents<SelectorOption<T>, M, GroupBase<SelectorOption<T>>>
|
||||
>;
|
||||
type SelectItemWithPayload<T> = SelectItem & {
|
||||
payload: T;
|
||||
};
|
||||
|
||||
function DefaultKeyBuilder<T>(value: T) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
} else if (typeof value === "number") {
|
||||
return value.toString();
|
||||
} else {
|
||||
LOG("error", "Unknown value type", value);
|
||||
throw new Error(
|
||||
`Invalid type (${typeof value}) in the SelectorOption, please provide a label builder`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function Selector<T = string, M extends boolean = false>(
|
||||
props: SelectorProps<T, M>
|
||||
) {
|
||||
const {
|
||||
className,
|
||||
placeholder,
|
||||
label,
|
||||
disabled,
|
||||
clearable,
|
||||
loading,
|
||||
options,
|
||||
multiple,
|
||||
onChange,
|
||||
onFocus,
|
||||
defaultValue,
|
||||
components,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
const labelRef = useRef(label);
|
||||
|
||||
const getName = useCallback(
|
||||
(item: T) => {
|
||||
if (labelRef.current) {
|
||||
return labelRef.current(item);
|
||||
}
|
||||
export type SelectorProps<T> = Override<
|
||||
{
|
||||
value?: T | null;
|
||||
defaultValue?: T | null;
|
||||
options: SelectorOption<T>[];
|
||||
onChange?: (value: T | null) => void;
|
||||
getkey?: (value: T) => string;
|
||||
},
|
||||
Omit<SelectProps, "data">
|
||||
>;
|
||||
|
||||
export function Selector<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
onChange,
|
||||
getkey = DefaultKeyBuilder,
|
||||
...select
|
||||
}: SelectorProps<T>) {
|
||||
const keyRef = useRef(getkey);
|
||||
keyRef.current = getkey;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
options.map<SelectItemWithPayload<T>>(({ value, label, ...option }) => ({
|
||||
label,
|
||||
value: keyRef.current(value),
|
||||
payload: value,
|
||||
...option,
|
||||
})),
|
||||
[keyRef, options]
|
||||
);
|
||||
|
||||
return options.find((v) => v.value === item)?.label ?? "Unknown";
|
||||
const wrappedValue = useMemo(() => {
|
||||
if (isNull(value) || isUndefined(value)) {
|
||||
return value;
|
||||
} else {
|
||||
return keyRef.current(value);
|
||||
}
|
||||
}, [keyRef, value]);
|
||||
|
||||
const wrappedDefaultValue = useMemo(() => {
|
||||
if (isNull(defaultValue) || isUndefined(defaultValue)) {
|
||||
return defaultValue;
|
||||
} else {
|
||||
return keyRef.current(defaultValue);
|
||||
}
|
||||
}, [defaultValue, keyRef]);
|
||||
|
||||
const wrappedOnChange = useCallback(
|
||||
(value: string) => {
|
||||
const payload = data.find((v) => v.value === value)?.payload ?? null;
|
||||
onChange?.(payload);
|
||||
},
|
||||
[data, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={data}
|
||||
defaultValue={wrappedDefaultValue}
|
||||
value={wrappedValue}
|
||||
onChange={wrappedOnChange}
|
||||
{...select}
|
||||
></Select>
|
||||
);
|
||||
}
|
||||
|
||||
export type MultiSelectorProps<T> = Override<
|
||||
{
|
||||
value?: readonly T[];
|
||||
defaultValue?: readonly T[];
|
||||
options: readonly SelectorOption<T>[];
|
||||
onChange?: (value: T[]) => void;
|
||||
getkey?: (value: T) => string;
|
||||
},
|
||||
Omit<MultiSelectProps, "data">
|
||||
>;
|
||||
|
||||
export function MultiSelector<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
onChange,
|
||||
getkey = DefaultKeyBuilder,
|
||||
...select
|
||||
}: MultiSelectorProps<T>) {
|
||||
const labelRef = useRef(getkey);
|
||||
labelRef.current = getkey;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
options.map<SelectItemWithPayload<T>>(({ value, ...option }) => ({
|
||||
value: labelRef.current(value),
|
||||
payload: value,
|
||||
...option,
|
||||
})),
|
||||
[options]
|
||||
);
|
||||
|
||||
const wrapper = useCallback(
|
||||
(
|
||||
value: SelectorValueType<T, M> | undefined | null
|
||||
):
|
||||
| SelectorOption<T>
|
||||
| ReadonlyArray<SelectorOption<T>>
|
||||
| null
|
||||
| undefined => {
|
||||
if (value === null || value === undefined) {
|
||||
return value as null | undefined;
|
||||
} else {
|
||||
if (multiple === true) {
|
||||
return (value as SelectorValueType<T, true>).map((v) => ({
|
||||
label: getName(v),
|
||||
value: v,
|
||||
}));
|
||||
} else {
|
||||
const v = value as T;
|
||||
return {
|
||||
label: getName(v),
|
||||
value: v,
|
||||
};
|
||||
const wrappedValue = useMemo(
|
||||
() => value && value.map(labelRef.current),
|
||||
[value]
|
||||
);
|
||||
const wrappedDefaultValue = useMemo(
|
||||
() => defaultValue && defaultValue.map(labelRef.current),
|
||||
[defaultValue]
|
||||
);
|
||||
|
||||
const wrappedOnChange = useCallback(
|
||||
(values: string[]) => {
|
||||
const payloads: T[] = [];
|
||||
for (const value of values) {
|
||||
const payload = data.find((v) => v.value === value)?.payload;
|
||||
if (payload) {
|
||||
payloads.push(payload);
|
||||
}
|
||||
}
|
||||
onChange?.(payloads);
|
||||
},
|
||||
[multiple, getName]
|
||||
);
|
||||
|
||||
const defaultWrapper = useMemo(
|
||||
() => wrapper(defaultValue),
|
||||
[defaultValue, wrapper]
|
||||
[data, onChange]
|
||||
);
|
||||
|
||||
const valueWrapper = useMemo(() => wrapper(value), [wrapper, value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
isLoading={loading}
|
||||
placeholder={placeholder}
|
||||
isSearchable={options.length >= 10}
|
||||
isMulti={multiple}
|
||||
closeMenuOnSelect={!multiple}
|
||||
defaultValue={defaultWrapper}
|
||||
value={valueWrapper}
|
||||
isClearable={clearable}
|
||||
isDisabled={disabled}
|
||||
options={options}
|
||||
components={components}
|
||||
className={clsx("custom-selector w-100", className)}
|
||||
classNamePrefix="selector"
|
||||
onFocus={onFocus}
|
||||
onChange={(newValue) => {
|
||||
if (onChange) {
|
||||
if (multiple === true) {
|
||||
const values = (
|
||||
newValue as OnChangeValue<SelectorOption<T>, true>
|
||||
).map((v) => v.value) as ReadonlyArray<T>;
|
||||
|
||||
onChange(values as SelectorValueType<T, M>);
|
||||
} else {
|
||||
const value =
|
||||
(newValue as OnChangeValue<SelectorOption<T>, false>)?.value ??
|
||||
null;
|
||||
|
||||
onChange(value as SelectorValueType<T, M>);
|
||||
}
|
||||
}
|
||||
}}
|
||||
></Select>
|
||||
<MultiSelect
|
||||
value={wrappedValue}
|
||||
defaultValue={wrappedDefaultValue}
|
||||
onChange={wrappedOnChange}
|
||||
{...select}
|
||||
data={data}
|
||||
></MultiSelect>
|
||||
);
|
||||
}
|
||||
|
@ -1,83 +0,0 @@
|
||||
import RcSlider from "rc-slider";
|
||||
import "rc-slider/assets/index.css";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
|
||||
type TooltipsOptions = boolean | "Always";
|
||||
|
||||
export interface SliderProps {
|
||||
tooltips?: TooltipsOptions;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
start?: number;
|
||||
defaultValue?: number;
|
||||
onAfterChange?: (value: number) => void;
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
export const Slider: FunctionComponent<SliderProps> = ({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
tooltips,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onAfterChange,
|
||||
}) => {
|
||||
max = max ?? 100;
|
||||
min = min ?? 0;
|
||||
step = step ?? 1;
|
||||
|
||||
const [curr, setValue] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row align-items-center py-2">
|
||||
<span className="text-muted text-nowrap pr-3">{`${min} / ${curr}`}</span>
|
||||
<RcSlider
|
||||
min={min}
|
||||
max={max}
|
||||
className="custom-rc-slider"
|
||||
step={step}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange && onChange(v);
|
||||
}}
|
||||
onAfterChange={onAfterChange}
|
||||
handle={(props) => (
|
||||
<div
|
||||
className="rc-slider-handle"
|
||||
style={{
|
||||
left: `${props.offset}%`,
|
||||
}}
|
||||
>
|
||||
<SliderTooltips
|
||||
tooltips={tooltips}
|
||||
value={props.value}
|
||||
></SliderTooltips>
|
||||
</div>
|
||||
)}
|
||||
></RcSlider>
|
||||
<span className="text-muted pl-3">{max}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SliderTooltips: FunctionComponent<{
|
||||
tooltips?: TooltipsOptions;
|
||||
value: number;
|
||||
}> = ({ tooltips, value }) => {
|
||||
const cls = useMemo(() => {
|
||||
const tipsCls = ["rc-slider-handle-tips"];
|
||||
if (tooltips !== undefined) {
|
||||
if (typeof tooltips === "string") {
|
||||
tipsCls.push("rc-slider-handle-tips-always");
|
||||
} else if (tooltips === false) {
|
||||
tipsCls.push("rc-slider-handle-tips-hidden");
|
||||
}
|
||||
}
|
||||
return tipsCls.join(" ");
|
||||
}, [tooltips]);
|
||||
|
||||
return <span className={cls}>{value}</span>;
|
||||
};
|
@ -1,44 +0,0 @@
|
||||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent } from "react";
|
||||
import { AsyncButton } from "..";
|
||||
|
||||
interface Props {
|
||||
history: History.Base;
|
||||
update?: () => void;
|
||||
promise: (form: FormType.AddBlacklist) => Promise<void>;
|
||||
}
|
||||
|
||||
export const BlacklistButton: FunctionComponent<Props> = ({
|
||||
history,
|
||||
update,
|
||||
promise,
|
||||
}) => {
|
||||
const { provider, subs_id, language, subtitles_path, blacklisted } = history;
|
||||
|
||||
if (subs_id && provider && language) {
|
||||
return (
|
||||
<AsyncButton
|
||||
size="sm"
|
||||
variant="light"
|
||||
noReset
|
||||
disabled={blacklisted}
|
||||
promise={() => {
|
||||
const { code2 } = language;
|
||||
const form: FormType.AddBlacklist = {
|
||||
provider,
|
||||
subs_id,
|
||||
subtitles_path,
|
||||
language: code2,
|
||||
};
|
||||
return promise(form);
|
||||
}}
|
||||
onSuccess={update}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFileExcel}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
@ -1,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 { default as ItemEditorModal } from "./ItemEditorModal";
|
||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
||||
export { default as SubtitleToolsModal } from "./SubtitleToolsModal";
|
||||
|
@ -1,36 +0,0 @@
|
||||
import { Selector } from "@/components";
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { submodProcessColor } from "@/utilities";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
import { colorOptions } from "./tools";
|
||||
|
||||
const ColorTool: FunctionComponent = () => {
|
||||
const [selection, setSelection] = useState<Nullable<string>>(null);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (selection) {
|
||||
const action = submodProcessColor(selection);
|
||||
process(action);
|
||||
}
|
||||
}, [process, selection]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={selection === null} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Choose Color" footer={footer}>
|
||||
<Selector options={colorOptions} onChange={setSelection}></Selector>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(ColorTool, "color-tool");
|
@ -1,65 +0,0 @@
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
|
||||
function submodProcessFrameRate(from: number, to: number) {
|
||||
return `change_FPS(from=${from},to=${to})`;
|
||||
}
|
||||
|
||||
const FrameRateTool: FunctionComponent = () => {
|
||||
const [from, setFrom] = useState<Nullable<number>>(null);
|
||||
const [to, setTo] = useState<Nullable<number>>(null);
|
||||
|
||||
const canSave = from !== null && to !== null && from !== to;
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (canSave) {
|
||||
const action = submodProcessFrameRate(from, to);
|
||||
process(action);
|
||||
}
|
||||
}, [canSave, from, process, to]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Change Frame Rate" footer={footer}>
|
||||
<InputGroup className="px-2">
|
||||
<Form.Control
|
||||
placeholder="From"
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
setFrom(null);
|
||||
} else {
|
||||
setFrom(value);
|
||||
}
|
||||
}}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
placeholder="To"
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
setTo(null);
|
||||
} else {
|
||||
setTo(value);
|
||||
}
|
||||
}}
|
||||
></Form.Control>
|
||||
</InputGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(FrameRateTool, "frame-rate-tool");
|
@ -1,100 +0,0 @@
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
|
||||
function submodProcessOffset(h: number, m: number, s: number, ms: number) {
|
||||
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
|
||||
}
|
||||
|
||||
const TimeAdjustmentTool: FunctionComponent = () => {
|
||||
const [isPlus, setPlus] = useState(true);
|
||||
const [offset, setOffset] = useState<[number, number, number, number]>([
|
||||
0, 0, 0, 0,
|
||||
]);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const updateOffset = useCallback(
|
||||
(idx: number): ChangeEventHandler<HTMLInputElement> => {
|
||||
return (e) => {
|
||||
let value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
const newOffset = [...offset] as [number, number, number, number];
|
||||
newOffset[idx] = value;
|
||||
setOffset(newOffset);
|
||||
};
|
||||
},
|
||||
[offset]
|
||||
);
|
||||
|
||||
const canSave = offset.some((v) => v !== 0);
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (canSave) {
|
||||
const newOffset = offset.map((v) => (isPlus ? v : -v));
|
||||
const action = submodProcessOffset(
|
||||
newOffset[0],
|
||||
newOffset[1],
|
||||
newOffset[2],
|
||||
newOffset[3]
|
||||
);
|
||||
process(action);
|
||||
}
|
||||
}, [canSave, offset, process, isPlus]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Adjust Times" footer={footer}>
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={isPlus ? "Later" : "Earlier"}
|
||||
onClick={() => setPlus(!isPlus)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
|
||||
</Button>
|
||||
</InputGroup.Prepend>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="hour"
|
||||
onChange={updateOffset(0)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="min"
|
||||
onChange={updateOffset(1)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="sec"
|
||||
onChange={updateOffset(2)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="ms"
|
||||
onChange={updateOffset(3)}
|
||||
></Form.Control>
|
||||
</InputGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(TimeAdjustmentTool, "time-adjustment");
|
@ -1,14 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ProcessSubtitleType = (
|
||||
action: string,
|
||||
override?: Partial<FormType.ModifySubtitle>
|
||||
) => void;
|
||||
|
||||
export const ProcessSubtitleContext = createContext<ProcessSubtitleType>(() => {
|
||||
throw new Error("ProcessSubtitleContext not initialized");
|
||||
});
|
||||
|
||||
export function useProcess() {
|
||||
return useContext(ProcessSubtitleContext);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { LanguageSelector } from "@/components/LanguageSelector";
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { useEnabledLanguages } from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
import { availableTranslation } from "./tools";
|
||||
|
||||
const TranslationTool: FunctionComponent = () => {
|
||||
const { data: languages } = useEnabledLanguages();
|
||||
|
||||
const available = useMemo(
|
||||
() => languages.filter((v) => v.code2 in availableTranslation),
|
||||
[languages]
|
||||
);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const [selectedLanguage, setLanguage] =
|
||||
useState<Nullable<Language.Info>>(null);
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (selectedLanguage) {
|
||||
process("translate", { language: selectedLanguage.code2 });
|
||||
}
|
||||
}, [process, selectedLanguage]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!selectedLanguage} onClick={submit}>
|
||||
Translate
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Modal title="Translation" footer={footer}>
|
||||
<Form.Label>
|
||||
Enabled languages not listed here are unsupported by Google Translate.
|
||||
</Form.Label>
|
||||
<LanguageSelector
|
||||
options={available}
|
||||
onChange={setLanguage}
|
||||
></LanguageSelector>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(TranslationTool, "translation-tool");
|
@ -1,230 +0,0 @@
|
||||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import Language from "@/components/bazarr/Language";
|
||||
import { ActionButton, ActionButtonItem } from "@/components/buttons";
|
||||
import { SimpleTable } from "@/components/tables";
|
||||
import { useCustomSelection } from "@/components/tables/plugins";
|
||||
import {
|
||||
useModal,
|
||||
useModalControl,
|
||||
usePayload,
|
||||
withModal,
|
||||
} from "@/modules/modals";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import { isMovie } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { isObject } from "lodash";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import { Badge, ButtonGroup, Dropdown } from "react-bootstrap";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
import {
|
||||
ProcessSubtitleContext,
|
||||
ProcessSubtitleType,
|
||||
useProcess,
|
||||
} from "./ToolContext";
|
||||
import { tools } from "./tools";
|
||||
import { ToolOptions } from "./types";
|
||||
|
||||
type SupportType = Item.Episode | Item.Movie;
|
||||
|
||||
type TableColumnType = FormType.ModifySubtitle & {
|
||||
raw_language: Language.Info;
|
||||
};
|
||||
|
||||
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
|
||||
if (isMovie(item)) {
|
||||
return [item.radarrId, "movie"];
|
||||
} else {
|
||||
return [item.sonarrEpisodeId, "episode"];
|
||||
}
|
||||
}
|
||||
|
||||
const CanSelectSubtitle = (item: TableColumnType) => {
|
||||
return item.path.endsWith(".srt");
|
||||
};
|
||||
|
||||
function isElement(value: unknown): value is JSX.Element {
|
||||
return isObject(value);
|
||||
}
|
||||
|
||||
interface SubtitleToolViewProps {
|
||||
count: number;
|
||||
tools: ToolOptions[];
|
||||
select: (items: TableColumnType[]) => void;
|
||||
}
|
||||
|
||||
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
|
||||
tools,
|
||||
count,
|
||||
select,
|
||||
}) => {
|
||||
const payload = usePayload<SupportType[]>();
|
||||
|
||||
const Modal = useModal({
|
||||
size: "lg",
|
||||
});
|
||||
const { show } = useModalControl();
|
||||
|
||||
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "raw_language",
|
||||
Cell: ({ value }) => (
|
||||
<Badge variant="secondary">
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
Header: "File",
|
||||
accessor: "path",
|
||||
Cell: ({ value }) => {
|
||||
const path = value;
|
||||
|
||||
let idx = path.lastIndexOf("/");
|
||||
|
||||
if (idx === -1) {
|
||||
idx = path.lastIndexOf("\\");
|
||||
}
|
||||
|
||||
if (idx !== -1) {
|
||||
return path.slice(idx + 1);
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const data = useMemo<TableColumnType[]>(
|
||||
() =>
|
||||
payload?.flatMap((item) => {
|
||||
const [id, type] = getIdAndType(item);
|
||||
return item.subtitles.flatMap((v) => {
|
||||
if (v.path !== null) {
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type,
|
||||
language: v.code2,
|
||||
path: v.path,
|
||||
raw_language: v,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}) ?? [],
|
||||
[payload]
|
||||
);
|
||||
|
||||
const plugins = [useRowSelect, useCustomSelection];
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const footer = useMemo(() => {
|
||||
const action = tools[0];
|
||||
const others = tools.slice(1);
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
disabled={count === 0}
|
||||
icon={action.icon}
|
||||
onClick={() => process(action.key)}
|
||||
>
|
||||
{action.name}
|
||||
</ActionButton>
|
||||
<Dropdown.Toggle
|
||||
disabled={count === 0}
|
||||
split
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="px-2"
|
||||
></Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{others.map((v) => (
|
||||
<Dropdown.Item
|
||||
key={v.key}
|
||||
eventKey={v.modal ? undefined : v.key}
|
||||
onSelect={() => {
|
||||
if (v.modal) {
|
||||
show(v.modal);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionButtonItem icon={v.icon}>{v.name}</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}, [count, process, show, tools]);
|
||||
|
||||
return (
|
||||
<Modal title="Subtitle Tools" footer={footer}>
|
||||
<SimpleTable
|
||||
emptyText="No External Subtitles Found"
|
||||
plugins={plugins}
|
||||
columns={columns}
|
||||
onSelect={select}
|
||||
canSelect={CanSelectSubtitle}
|
||||
data={data}
|
||||
></SimpleTable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleToolModal = withModal(SubtitleToolView, "subtitle-tools");
|
||||
|
||||
const SubtitleTools: FunctionComponent = () => {
|
||||
const modals = useMemo(
|
||||
() =>
|
||||
tools
|
||||
.map((t) => t.modal && <t.modal key={t.key}></t.modal>)
|
||||
.filter(isElement),
|
||||
[]
|
||||
);
|
||||
|
||||
const { hide } = useModalControl();
|
||||
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
|
||||
const process = useCallback<ProcessSubtitleType>(
|
||||
(action, override) => {
|
||||
LOG("info", "executing action", action);
|
||||
hide(SubtitleToolModal.modalKey);
|
||||
const tasks = selections.map((s) => {
|
||||
const form: FormType.ModifySubtitle = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
language: s.language,
|
||||
path: s.path,
|
||||
...override,
|
||||
};
|
||||
return createTask(s.path, mutateAsync, { action, form });
|
||||
});
|
||||
|
||||
dispatchTask(tasks, "modify-subtitles");
|
||||
},
|
||||
[hide, selections, mutateAsync]
|
||||
);
|
||||
|
||||
return (
|
||||
<ProcessSubtitleContext.Provider value={process}>
|
||||
<SubtitleToolModal
|
||||
count={selections.length}
|
||||
tools={tools}
|
||||
select={setSelections}
|
||||
></SubtitleToolModal>
|
||||
{modals}
|
||||
</ProcessSubtitleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleTools;
|
@ -1,257 +0,0 @@
|
||||
import { SelectorOption } from "@/components";
|
||||
import {
|
||||
faClock,
|
||||
faCode,
|
||||
faDeaf,
|
||||
faExchangeAlt,
|
||||
faFilm,
|
||||
faImage,
|
||||
faLanguage,
|
||||
faMagic,
|
||||
faPaintBrush,
|
||||
faPlay,
|
||||
faTextHeight,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import ColorTool from "./ColorTool";
|
||||
import FrameRateTool from "./FrameRateTool";
|
||||
import TimeTool from "./TimeTool";
|
||||
import Translation from "./Translation";
|
||||
import { ToolOptions } from "./types";
|
||||
|
||||
export const tools: ToolOptions[] = [
|
||||
{
|
||||
key: "sync",
|
||||
icon: faPlay,
|
||||
name: "Sync",
|
||||
},
|
||||
{
|
||||
key: "remove_HI",
|
||||
icon: faDeaf,
|
||||
name: "Remove HI Tags",
|
||||
},
|
||||
{
|
||||
key: "remove_tags",
|
||||
icon: faCode,
|
||||
name: "Remove Style Tags",
|
||||
},
|
||||
{
|
||||
key: "OCR_fixes",
|
||||
icon: faImage,
|
||||
name: "OCR Fixes",
|
||||
},
|
||||
{
|
||||
key: "common",
|
||||
icon: faMagic,
|
||||
name: "Common Fixes",
|
||||
},
|
||||
{
|
||||
key: "fix_uppercase",
|
||||
icon: faTextHeight,
|
||||
name: "Fix Uppercase",
|
||||
},
|
||||
{
|
||||
key: "reverse_rtl",
|
||||
icon: faExchangeAlt,
|
||||
name: "Reverse RTL",
|
||||
},
|
||||
{
|
||||
key: "add_color",
|
||||
icon: faPaintBrush,
|
||||
name: "Add Color",
|
||||
modal: ColorTool,
|
||||
},
|
||||
{
|
||||
key: "change_frame_rate",
|
||||
icon: faFilm,
|
||||
name: "Change Frame Rate",
|
||||
modal: FrameRateTool,
|
||||
},
|
||||
{
|
||||
key: "adjust_time",
|
||||
icon: faClock,
|
||||
name: "Adjust Times",
|
||||
modal: TimeTool,
|
||||
},
|
||||
{
|
||||
key: "translation",
|
||||
icon: faLanguage,
|
||||
name: "Translate",
|
||||
modal: Translation,
|
||||
},
|
||||
];
|
||||
|
||||
export const availableTranslation = {
|
||||
af: "afrikaans",
|
||||
sq: "albanian",
|
||||
am: "amharic",
|
||||
ar: "arabic",
|
||||
hy: "armenian",
|
||||
az: "azerbaijani",
|
||||
eu: "basque",
|
||||
be: "belarusian",
|
||||
bn: "bengali",
|
||||
bs: "bosnian",
|
||||
bg: "bulgarian",
|
||||
ca: "catalan",
|
||||
ceb: "cebuano",
|
||||
ny: "chichewa",
|
||||
zh: "chinese (simplified)",
|
||||
zt: "chinese (traditional)",
|
||||
co: "corsican",
|
||||
hr: "croatian",
|
||||
cs: "czech",
|
||||
da: "danish",
|
||||
nl: "dutch",
|
||||
en: "english",
|
||||
eo: "esperanto",
|
||||
et: "estonian",
|
||||
tl: "filipino",
|
||||
fi: "finnish",
|
||||
fr: "french",
|
||||
fy: "frisian",
|
||||
gl: "galician",
|
||||
ka: "georgian",
|
||||
de: "german",
|
||||
el: "greek",
|
||||
gu: "gujarati",
|
||||
ht: "haitian creole",
|
||||
ha: "hausa",
|
||||
haw: "hawaiian",
|
||||
iw: "hebrew",
|
||||
hi: "hindi",
|
||||
hmn: "hmong",
|
||||
hu: "hungarian",
|
||||
is: "icelandic",
|
||||
ig: "igbo",
|
||||
id: "indonesian",
|
||||
ga: "irish",
|
||||
it: "italian",
|
||||
ja: "japanese",
|
||||
jw: "javanese",
|
||||
kn: "kannada",
|
||||
kk: "kazakh",
|
||||
km: "khmer",
|
||||
ko: "korean",
|
||||
ku: "kurdish (kurmanji)",
|
||||
ky: "kyrgyz",
|
||||
lo: "lao",
|
||||
la: "latin",
|
||||
lv: "latvian",
|
||||
lt: "lithuanian",
|
||||
lb: "luxembourgish",
|
||||
mk: "macedonian",
|
||||
mg: "malagasy",
|
||||
ms: "malay",
|
||||
ml: "malayalam",
|
||||
mt: "maltese",
|
||||
mi: "maori",
|
||||
mr: "marathi",
|
||||
mn: "mongolian",
|
||||
my: "myanmar (burmese)",
|
||||
ne: "nepali",
|
||||
no: "norwegian",
|
||||
ps: "pashto",
|
||||
fa: "persian",
|
||||
pl: "polish",
|
||||
pt: "portuguese",
|
||||
pa: "punjabi",
|
||||
ro: "romanian",
|
||||
ru: "russian",
|
||||
sm: "samoan",
|
||||
gd: "scots gaelic",
|
||||
sr: "serbian",
|
||||
st: "sesotho",
|
||||
sn: "shona",
|
||||
sd: "sindhi",
|
||||
si: "sinhala",
|
||||
sk: "slovak",
|
||||
sl: "slovenian",
|
||||
so: "somali",
|
||||
es: "spanish",
|
||||
su: "sundanese",
|
||||
sw: "swahili",
|
||||
sv: "swedish",
|
||||
tg: "tajik",
|
||||
ta: "tamil",
|
||||
te: "telugu",
|
||||
th: "thai",
|
||||
tr: "turkish",
|
||||
uk: "ukrainian",
|
||||
ur: "urdu",
|
||||
uz: "uzbek",
|
||||
vi: "vietnamese",
|
||||
cy: "welsh",
|
||||
xh: "xhosa",
|
||||
yi: "yiddish",
|
||||
yo: "yoruba",
|
||||
zu: "zulu",
|
||||
fil: "Filipino",
|
||||
he: "Hebrew",
|
||||
};
|
||||
|
||||
export const colorOptions: SelectorOption<string>[] = [
|
||||
{
|
||||
label: "White",
|
||||
value: "white",
|
||||
},
|
||||
{
|
||||
label: "Light Gray",
|
||||
value: "light-gray",
|
||||
},
|
||||
{
|
||||
label: "Red",
|
||||
value: "red",
|
||||
},
|
||||
{
|
||||
label: "Green",
|
||||
value: "green",
|
||||
},
|
||||
{
|
||||
label: "Yellow",
|
||||
value: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
label: "Magenta",
|
||||
value: "magenta",
|
||||
},
|
||||
{
|
||||
label: "Cyan",
|
||||
value: "cyan",
|
||||
},
|
||||
{
|
||||
label: "Black",
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
label: "Dark Red",
|
||||
value: "dark-red",
|
||||
},
|
||||
{
|
||||
label: "Dark Green",
|
||||
value: "dark-green",
|
||||
},
|
||||
{
|
||||
label: "Dark Yellow",
|
||||
value: "dark-yellow",
|
||||
},
|
||||
{
|
||||
label: "Dark Blue",
|
||||
value: "dark-blue",
|
||||
},
|
||||
{
|
||||
label: "Dark Magenta",
|
||||
value: "dark-magenta",
|
||||
},
|
||||
{
|
||||
label: "Dark Cyan",
|
||||
value: "dark-cyan",
|
||||
},
|
||||
{
|
||||
label: "Dark Grey",
|
||||
value: "dark-grey",
|
||||
},
|
||||
];
|
@ -1,9 +0,0 @@
|
||||
import { ModalComponent } from "@/modules/modals/WithModal";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export interface ToolOptions {
|
||||
key: string;
|
||||
icon: IconDefinition;
|
||||
name: string;
|
||||
modal?: ModalComponent<unknown>;
|
||||
}
|
@ -1,74 +1,44 @@
|
||||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { TableInstance, usePagination } from "react-table";
|
||||
import PageControl from "./PageControl";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
autoScroll?: boolean;
|
||||
plugins?: PluginHook<T>[];
|
||||
};
|
||||
type Props<T extends object> = SimpleTableProps<T> & {
|
||||
autoScroll?: boolean;
|
||||
};
|
||||
|
||||
const tablePlugins = [useDefaultSettings, usePagination];
|
||||
|
||||
export default function PageTable<T extends object>(props: Props<T>) {
|
||||
const { autoScroll, plugins, ...remain } = props;
|
||||
const { style, options } = useStyleAndOptions(remain);
|
||||
|
||||
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
|
||||
|
||||
if (plugins) {
|
||||
allPlugins.push(...plugins);
|
||||
}
|
||||
|
||||
const instance = useTable(options, ...allPlugins);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
|
||||
// page
|
||||
page,
|
||||
canNextPage,
|
||||
canPreviousPage,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
state: { pageIndex, pageSize },
|
||||
} = instance;
|
||||
const instance = useRef<TableInstance<T> | null>(null);
|
||||
|
||||
// Scroll to top when page is changed
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
ScrollToTop();
|
||||
}
|
||||
}, [pageIndex, autoScroll]);
|
||||
}, [instance.current?.state.pageIndex, autoScroll]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={page}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
<PageControl
|
||||
count={pageCount}
|
||||
index={pageIndex}
|
||||
size={pageSize}
|
||||
total={rows.length}
|
||||
canPrevious={canPreviousPage}
|
||||
canNext={canNextPage}
|
||||
previous={previousPage}
|
||||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
<SimpleTable
|
||||
{...remain}
|
||||
instanceRef={instance}
|
||||
plugins={[...tablePlugins, ...(plugins ?? [])]}
|
||||
></SimpleTable>
|
||||
{instance.current && (
|
||||
<PageControl
|
||||
count={instance.current.pageCount}
|
||||
index={instance.current.state.pageIndex}
|
||||
size={instance.current.state.pageSize}
|
||||
total={instance.current.rows.length}
|
||||
goto={instance.current.gotoPage}
|
||||
></PageControl>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,77 +1,37 @@
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { LoadingProvider } from "@/contexts";
|
||||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
||||
import { LoadingIndicator } from "..";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import PageControl from "./PageControl";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
query: UsePaginationQueryResult<T>;
|
||||
};
|
||||
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
|
||||
query: UsePaginationQueryResult<T>;
|
||||
};
|
||||
|
||||
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
||||
const { plugins, query, ...remain } = props;
|
||||
const { style, options } = useStyleAndOptions(remain);
|
||||
const { query, ...remain } = props;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationStatus: {
|
||||
page,
|
||||
pageCount,
|
||||
totalCount,
|
||||
canPrevious,
|
||||
canNext,
|
||||
pageSize,
|
||||
},
|
||||
controls: { previousPage, nextPage, gotoPage },
|
||||
data = { data: [], total: 0 },
|
||||
paginationStatus: { page, pageCount, totalCount, pageSize, isPageLoading },
|
||||
controls: { gotoPage },
|
||||
} = query;
|
||||
|
||||
const instance = useTable(
|
||||
{
|
||||
...options,
|
||||
data: data?.data ?? [],
|
||||
},
|
||||
useDefaultSettings,
|
||||
...(plugins ?? [])
|
||||
);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
instance;
|
||||
|
||||
useEffect(() => {
|
||||
ScrollToTop();
|
||||
}, [page]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={rows}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
<LoadingProvider value={isPageLoading}>
|
||||
<SimpleTable {...remain} data={data.data}></SimpleTable>
|
||||
<PageControl
|
||||
count={pageCount}
|
||||
index={page}
|
||||
size={pageSize}
|
||||
total={totalCount}
|
||||
canPrevious={canPrevious}
|
||||
canNext={canNext}
|
||||
previous={previousPage}
|
||||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
</>
|
||||
</LoadingProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,29 +1,23 @@
|
||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps } from "./BaseTable";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
};
|
||||
export type SimpleTableProps<T extends object> = TableOptions<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
|
||||
tableStyles?: TableStyleProps<T>;
|
||||
};
|
||||
|
||||
export default function SimpleTable<T extends object>(props: Props<T>) {
|
||||
const { plugins, ...other } = props;
|
||||
const { style, options } = useStyleAndOptions(other);
|
||||
export default function SimpleTable<T extends object>(
|
||||
props: SimpleTableProps<T>
|
||||
) {
|
||||
const { plugins, instanceRef, tableStyles, ...options } = props;
|
||||
|
||||
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
instance;
|
||||
if (instanceRef) {
|
||||
instanceRef.current = instance;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={rows}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
);
|
||||
return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export type PageControlAction = "prev" | "next" | number;
|
@ -0,0 +1,65 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, ButtonProps, Text } from "@mantine/core";
|
||||
import {
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ToolboxButtonProps = Omit<
|
||||
ButtonProps<"button">,
|
||||
"color" | "variant" | "leftIcon"
|
||||
> & {
|
||||
icon: IconDefinition;
|
||||
children: string;
|
||||
};
|
||||
|
||||
const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
color="dark"
|
||||
variant="subtle"
|
||||
leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
|
||||
{...props}
|
||||
>
|
||||
<Text size="xs">{children}</Text>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolboxMutateButtonProps<R, T extends () => Promise<R>> = {
|
||||
promise: T;
|
||||
onSuccess?: (item: R) => void;
|
||||
} & Omit<ToolboxButtonProps, "onClick" | "loading">;
|
||||
|
||||
export function ToolboxMutateButton<R, T extends () => Promise<R>>(
|
||||
props: PropsWithChildren<ToolboxMutateButtonProps<R, T>>
|
||||
): JSX.Element {
|
||||
const { promise, onSuccess, ...button } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const click = useCallback(() => {
|
||||
setLoading(true);
|
||||
promise().then((val) => {
|
||||
setLoading(false);
|
||||
onSuccess && onSuccess(val);
|
||||
});
|
||||
}, [onSuccess, promise]);
|
||||
|
||||
return (
|
||||
<ToolboxButton
|
||||
loading={loading}
|
||||
onClick={click}
|
||||
{...button}
|
||||
></ToolboxButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolboxButton;
|
@ -0,0 +1,31 @@
|
||||
import { createStyles, Group } from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
import ToolboxButton, { ToolboxMutateButton } from "./Button";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
group: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.gray[3]
|
||||
: theme.colors.dark[5],
|
||||
},
|
||||
}));
|
||||
|
||||
declare type ToolboxComp = FunctionComponent & {
|
||||
Button: typeof ToolboxButton;
|
||||
MutateButton: typeof ToolboxMutateButton;
|
||||
};
|
||||
|
||||
const Toolbox: ToolboxComp = ({ children }) => {
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<Group p={12} position="apart" className={classes.group}>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
Toolbox.Button = ToolboxButton;
|
||||
Toolbox.MutateButton = ToolboxMutateButton;
|
||||
|
||||
export default Toolbox;
|
@ -1,35 +0,0 @@
|
||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Column } from "react-table";
|
||||
import { QueryPageTable } from "..";
|
||||
|
||||
interface Props<T extends History.Base> {
|
||||
name: string;
|
||||
query: UsePaginationQueryResult<T>;
|
||||
columns: Column<T>[];
|
||||
}
|
||||
|
||||
function HistoryView<T extends History.Base = History.Base>({
|
||||
columns,
|
||||
name,
|
||||
query,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>{name} History - Bazarr</title>
|
||||
</Helmet>
|
||||
<Row>
|
||||
<QueryPageTable
|
||||
emptyText={`Nothing Found in ${name} History`}
|
||||
columns={columns}
|
||||
query={query}
|
||||
data={[]}
|
||||
></QueryPageTable>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryView;
|
@ -0,0 +1,9 @@
|
||||
import { MantineNumberSize } from "@mantine/core";
|
||||
|
||||
export const GithubRepoRoot = "https://github.com/morpheus65535/bazarr";
|
||||
|
||||
export const Layout = {
|
||||
NAVBAR_WIDTH: 200,
|
||||
HEADER_HEIGHT: 64,
|
||||
MOBILE_BREAKPOINT: "sm" as MantineNumberSize,
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const LoadingContext = createContext<boolean>(false);
|
||||
|
||||
export function useIsLoading() {
|
||||
const context = useContext(LoadingContext);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default LoadingContext.Provider;
|
@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const NavbarContext = createContext<{
|
||||
showed: boolean;
|
||||
show: (showed: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
export function useNavbar() {
|
||||
const context = useContext(NavbarContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("NavbarShowedContext not initialized");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default NavbarContext.Provider;
|
@ -0,0 +1,28 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const OnlineContext = createContext<{
|
||||
online: boolean;
|
||||
setOnline: (online: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
export function useIsOnline() {
|
||||
const context = useContext(OnlineContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("useIsOnline must be used within a OnlineProvider");
|
||||
}
|
||||
|
||||
return context.online;
|
||||
}
|
||||
|
||||
export function useSetOnline() {
|
||||
const context = useContext(OnlineContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("useSetOnline must be used within a OnlineProvider");
|
||||
}
|
||||
|
||||
return context.setOnline;
|
||||
}
|
||||
|
||||
export default OnlineContext.Provider;
|
@ -0,0 +1,2 @@
|
||||
export * from "./Loading";
|
||||
export { default as LoadingProvider } from "./Loading";
|
@ -1,4 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Entrance } from ".";
|
||||
import { Main } from "./main";
|
||||
|
||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
||||
ReactDOM.render(
|
||||
<StrictMode>
|
||||
<Main />
|
||||
</StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
|
@ -1,30 +0,0 @@
|
||||
import queryClient from "@/apis/queries";
|
||||
import store from "@/modules/redux/store";
|
||||
import "@/styles/index.scss";
|
||||
import "@fontsource/roboto/300.css";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import { Provider } from "react-redux";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { Router, useRouteItems } from "./Router";
|
||||
import { Environment } from "./utilities";
|
||||
|
||||
const RouteApp = () => {
|
||||
const items = useRouteItems();
|
||||
|
||||
return useRoutes(items);
|
||||
};
|
||||
|
||||
export const Entrance = () => (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
||||
{/* <StrictMode> */}
|
||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<RouteApp></RouteApp>
|
||||
{/* </StrictMode> */}
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
@ -0,0 +1,35 @@
|
||||
import queryClient from "@/apis/queries";
|
||||
import ThemeProvider from "@/App/theme";
|
||||
import { ModalsProvider } from "@/modules/modals";
|
||||
import "@fontsource/roboto/300.css";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { ReactQueryDevtools } from "react-query/devtools";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { Router, useRouteItems } from "./Router";
|
||||
import { Environment } from "./utilities";
|
||||
|
||||
const RouteApp = () => {
|
||||
const items = useRouteItems();
|
||||
|
||||
return useRoutes(items);
|
||||
};
|
||||
|
||||
export const Main = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<ModalsProvider>
|
||||
<NotificationsProvider limit={5}>
|
||||
<Router>
|
||||
{Environment.queryDev && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
<RouteApp></RouteApp>
|
||||
</Router>
|
||||
</NotificationsProvider>
|
||||
</ModalsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
@ -1,14 +0,0 @@
|
||||
import { createContext, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface ModalData {
|
||||
key: string;
|
||||
closeable: boolean;
|
||||
size: "sm" | "lg" | "xl" | undefined;
|
||||
}
|
||||
|
||||
export type ModalSetter = {
|
||||
[P in keyof Omit<ModalData, "key">]: Dispatch<SetStateAction<ModalData[P]>>;
|
||||
};
|
||||
|
||||
export const ModalDataContext = createContext<ModalData | null>(null);
|
||||
export const ModalSetterContext = createContext<ModalSetter | null>(null);
|
@ -1,44 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { useCurrentLayer, useModalControl, useModalData } from "./hooks";
|
||||
|
||||
interface Props {}
|
||||
|
||||
export const ModalWrapper: FunctionComponent<Props> = ({ children }) => {
|
||||
const { size, closeable, key } = useModalData();
|
||||
const [needExit, setExit] = useState(false);
|
||||
|
||||
const { hide: hideModal } = useModalControl();
|
||||
|
||||
const layer = useCurrentLayer();
|
||||
const isShowed = layer !== -1;
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setExit(true);
|
||||
}, []);
|
||||
|
||||
const exit = useCallback(() => {
|
||||
if (isShowed) {
|
||||
hideModal(key);
|
||||
}
|
||||
setExit(false);
|
||||
}, [isShowed, hideModal, key]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
size={size}
|
||||
show={isShowed && !needExit}
|
||||
onHide={hide}
|
||||
onExited={exit}
|
||||
backdrop={closeable ? undefined : "static"}
|
||||
className={clsx(`index-${layer}`)}
|
||||
backdropClassName={clsx(`index-${layer}`)}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalWrapper;
|
@ -0,0 +1,34 @@
|
||||
import {
|
||||
ModalsProvider as MantineModalsProvider,
|
||||
ModalsProviderProps as MantineModalsProviderProps,
|
||||
} from "@mantine/modals";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { ModalComponent, StaticModals } from "./WithModal";
|
||||
|
||||
const DefaultModalProps: MantineModalsProviderProps["modalProps"] = {
|
||||
centered: true,
|
||||
styles: {
|
||||
modal: {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ModalsProvider: FunctionComponent = ({ children }) => {
|
||||
const modals = useMemo(
|
||||
() =>
|
||||
StaticModals.reduce<Record<string, ModalComponent>>((prev, curr) => {
|
||||
prev[curr.modalKey] = curr;
|
||||
return prev;
|
||||
}, {}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineModalsProvider modalProps={DefaultModalProps} modals={modals}>
|
||||
{children}
|
||||
</MantineModalsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalsProvider;
|
@ -1,52 +1,36 @@
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
import {
|
||||
ModalData,
|
||||
ModalDataContext,
|
||||
ModalSetter,
|
||||
ModalSetterContext,
|
||||
} from "./ModalContext";
|
||||
import ModalWrapper from "./ModalWrapper";
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
|
||||
export interface ModalProps {}
|
||||
import { ContextModalProps } from "@mantine/modals";
|
||||
import { ModalSettings } from "@mantine/modals/lib/context";
|
||||
import { createContext, FunctionComponent } from "react";
|
||||
|
||||
export type ModalComponent<P> = FunctionComponent<P> & {
|
||||
modalKey: string;
|
||||
};
|
||||
export type ModalComponent<P extends Record<string, unknown> = {}> =
|
||||
FunctionComponent<ContextModalProps<P>> & {
|
||||
modalKey: string;
|
||||
settings?: ModalSettings;
|
||||
};
|
||||
|
||||
export const StaticModals: ModalComponent[] = [];
|
||||
|
||||
export default function withModal<T>(
|
||||
export const ModalIdContext = createContext<string | null>(null);
|
||||
|
||||
export default function withModal<T extends {}>(
|
||||
Content: FunctionComponent<T>,
|
||||
key: string
|
||||
key: string,
|
||||
defaultSettings?: ModalSettings
|
||||
) {
|
||||
const Comp: ModalComponent<T> = (props: ModalProps & T) => {
|
||||
const [closeable, setCloseable] = useState(true);
|
||||
const [size, setSize] = useState<ModalData["size"]>(undefined);
|
||||
const data: ModalData = useMemo(
|
||||
() => ({
|
||||
key,
|
||||
size,
|
||||
closeable,
|
||||
}),
|
||||
[closeable, size]
|
||||
);
|
||||
|
||||
const setter: ModalSetter = useMemo(
|
||||
() => ({
|
||||
closeable: setCloseable,
|
||||
size: setSize,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const Comp: ModalComponent<T> = (props) => {
|
||||
const { id, innerProps } = props;
|
||||
|
||||
return (
|
||||
<ModalDataContext.Provider value={data}>
|
||||
<ModalSetterContext.Provider value={setter}>
|
||||
<ModalWrapper>
|
||||
<Content {...props}></Content>
|
||||
</ModalWrapper>
|
||||
</ModalSetterContext.Provider>
|
||||
</ModalDataContext.Provider>
|
||||
<ModalIdContext.Provider value={id}>
|
||||
<Content {...innerProps}></Content>
|
||||
</ModalIdContext.Provider>
|
||||
);
|
||||
};
|
||||
Comp.modalKey = key;
|
||||
Comp.settings = defaultSettings;
|
||||
|
||||
StaticModals.push(Comp as ModalComponent);
|
||||
return Comp;
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { FunctionComponent, ReactNode } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { useModalData } from "./hooks";
|
||||
|
||||
interface StandardModalProps {
|
||||
title: string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export const StandardModalView: FunctionComponent<StandardModalProps> = ({
|
||||
children,
|
||||
footer,
|
||||
title,
|
||||
}) => {
|
||||
const { closeable } = useModalData();
|
||||
return (
|
||||
<>
|
||||
<Modal.Header closeButton={closeable}>{title}</Modal.Header>
|
||||
<Modal.Body>{children}</Modal.Body>
|
||||
<Modal.Footer hidden={footer === undefined}>{footer}</Modal.Footer>
|
||||
</>
|
||||
);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue