mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-23 22:27:17 -04:00
Replace Bootstrap with Mantine (#1795)
This commit is contained in:
parent
6515c42f26
commit
2cecb4c5b5
217 changed files with 10481 additions and 12477 deletions
|
@ -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}}
|
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
@ -2,5 +2,6 @@ node_modules
|
|||
dist
|
||||
*.local
|
||||
build
|
||||
coverage
|
||||
|
||||
*.tsbuildinfo
|
||||
|
|
|
@ -2,7 +2,6 @@ import { dependencies } from "../package.json";
|
|||
|
||||
const vendors = [
|
||||
"react",
|
||||
"react-redux",
|
||||
"react-router-dom",
|
||||
"react-dom",
|
||||
"react-query",
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
|
||||
try {
|
||||
window.Bazarr = JSON.parse(`{{BAZARR_SERVER_INJECT | tojson | safe}}`);
|
||||
} catch (error) {}
|
||||
</script>
|
||||
<script type="module" src="./src/dom.tsx"></script>
|
||||
</body>
|
||||
|
|
5782
frontend/package-lock.json
generated
5782
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -13,12 +13,12 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mantine/core": "^4",
|
||||
"@mantine/hooks": "^4",
|
||||
"axios": "^0.26",
|
||||
"react": "^17",
|
||||
"react-bootstrap": "^1",
|
||||
"react-dom": "^17",
|
||||
"react-query": "^3.34",
|
||||
"react-redux": "^7.2",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"socket.io-client": "^4"
|
||||
},
|
||||
|
@ -29,37 +29,32 @@
|
|||
"@fortawesome/free-regular-svg-icons": "^6",
|
||||
"@fortawesome/free-solid-svg-icons": "^6",
|
||||
"@fortawesome/react-fontawesome": "^0.1",
|
||||
"@reduxjs/toolkit": "^1",
|
||||
"@mantine/dropzone": "^4",
|
||||
"@mantine/modals": "^4",
|
||||
"@mantine/notifications": "^4",
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "12",
|
||||
"@testing-library/react-hooks": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@types/bootstrap": "^4",
|
||||
"@types/lodash": "^4",
|
||||
"@types/node": "^17",
|
||||
"@types/react": "^17",
|
||||
"@types/react-dom": "^17",
|
||||
"@types/react-helmet": "^6.1",
|
||||
"@types/react-table": "^7",
|
||||
"@vitejs/plugin-react": "^1.3",
|
||||
"bootstrap": "^4",
|
||||
"clsx": "^1.1.1",
|
||||
"clsx": "^1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"eslint-config-react-app": "^7",
|
||||
"eslint-plugin-react-hooks": "^4",
|
||||
"husky": "^7",
|
||||
"husky": "^8",
|
||||
"jsdom": "latest",
|
||||
"lodash": "^4",
|
||||
"moment": "^2.29.1",
|
||||
"prettier": "^2",
|
||||
"prettier-plugin-organize-imports": "^2",
|
||||
"pretty-quick": "^3.1",
|
||||
"rc-slider": "^9.7",
|
||||
"react-helmet": "^6.1",
|
||||
"react-select": "^5.0.1",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"rooks": "^5",
|
||||
"sass": "^1",
|
||||
"typescript": "^4",
|
||||
"vite": "latest",
|
||||
|
|
|
@ -1,132 +1,118 @@
|
|||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||
import { ActionButton, SearchBar } from "@/components";
|
||||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useIsOffline } from "@/modules/redux/hooks";
|
||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
||||
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
|
||||
import { Action, Search } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useIsOnline } from "@/contexts/Online";
|
||||
import { Environment, useGotoHomepage } from "@/utilities";
|
||||
import {
|
||||
faBars,
|
||||
faHeart,
|
||||
faNetworkWired,
|
||||
faUser,
|
||||
faArrowRotateLeft,
|
||||
faGear,
|
||||
faPowerOff,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Dropdown,
|
||||
Image,
|
||||
Navbar,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import NotificationCenter from "./Notification";
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Burger,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Header,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const Header: FunctionComponent = () => {
|
||||
const useStyles = createStyles((theme) => {
|
||||
const headerBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
|
||||
return {
|
||||
header: {
|
||||
backgroundColor: headerBackgroundColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const AppHeader: FunctionComponent = () => {
|
||||
const { data: settings } = useSystemSettings();
|
||||
const hasLogout = settings?.auth.type === "form";
|
||||
|
||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
||||
const { show, showed } = useNavbar();
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const offline = useIsOffline();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const online = useIsOnline();
|
||||
const offline = !online;
|
||||
|
||||
const { shutdown, restart, logout } = useSystem();
|
||||
|
||||
const serverActions = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="hide-arrow" as={Button}>
|
||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
restart();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
shutdown();
|
||||
}}
|
||||
>
|
||||
Shutdown
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider hidden={!hasLogout}></Dropdown.Divider>
|
||||
<Dropdown.Item
|
||||
hidden={!hasLogout}
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
),
|
||||
[hasLogout, logout, restart, shutdown]
|
||||
);
|
||||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
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"
|
||||
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
|
||||
<Group position="apart" noWrap>
|
||||
<Group noWrap>
|
||||
<MediaQuery
|
||||
smallerThan={Layout.MOBILE_BREAKPOINT}
|
||||
styles={{ display: "none" }}
|
||||
>
|
||||
<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" }}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
|
||||
onClick={() => restart()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
|
||||
</Button>
|
||||
{offline ? (
|
||||
<ActionButton
|
||||
loading
|
||||
alwaysShowText
|
||||
className="ml-2"
|
||||
variant="warning"
|
||||
icon={faNetworkWired}
|
||||
>
|
||||
{isMobile ? "" : "Connecting..."}
|
||||
</ActionButton>
|
||||
) : (
|
||||
serverActions
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Navbar>
|
||||
Restart
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faPowerOff} />}
|
||||
onClick={() => shutdown()}
|
||||
>
|
||||
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;
|
||||
|
|
344
frontend/src/App/Navbar.tsx
Normal file
344
frontend/src/App/Navbar.tsx
Normal file
|
@ -0,0 +1,344 @@
|
|||
import { Action } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
import { BuildKey, pathJoin } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
faHeart,
|
||||
faMoon,
|
||||
faSun,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Collapse,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Navbar as MantineNavbar,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
|
||||
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
select: (path: string | null) => void;
|
||||
}>({
|
||||
selection: null,
|
||||
select: () => {
|
||||
LOG("error", "Selection context not initialized");
|
||||
},
|
||||
});
|
||||
|
||||
function useSelection() {
|
||||
return useContext(Selection);
|
||||
}
|
||||
|
||||
function useBadgeValue(route: Route.Item) {
|
||||
const { badge, children } = route;
|
||||
return useMemo(() => {
|
||||
let value = badge ?? 0;
|
||||
|
||||
if (children === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value +=
|
||||
children.reduce((acc, child: Route.Item) => {
|
||||
if (child.badge && child.hidden !== true) {
|
||||
return acc + (child.badge ?? 0);
|
||||
}
|
||||
return acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return value === 0 ? undefined : value;
|
||||
}, [badge, children]);
|
||||
}
|
||||
|
||||
function useIsActive(parent: string, route: RouteObject) {
|
||||
const { path, children } = route;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const paths = useMemo(
|
||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||
[root, children]
|
||||
);
|
||||
|
||||
const selection = useSelection().selection;
|
||||
return useMemo(
|
||||
() =>
|
||||
selection?.includes(root) ||
|
||||
paths.some((path) => matchPath(path, pathname)),
|
||||
[pathname, paths, root, selection]
|
||||
);
|
||||
}
|
||||
|
||||
const AppNavbar: FunctionComponent = () => {
|
||||
const { showed } = useNavbar();
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
|
||||
const routes = useRouteItems();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
select(null);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<MantineNavbar
|
||||
p="xs"
|
||||
hiddenBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||
hidden={!showed}
|
||||
width={{ [Layout.MOBILE_BREAKPOINT]: Layout.NAVBAR_WIDTH }}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.gray[2]
|
||||
: theme.colors.dark[6],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<MantineNavbar.Section grow>
|
||||
<Stack spacing={0}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
parent="/"
|
||||
route={route}
|
||||
></RouteItem>
|
||||
))}
|
||||
</Stack>
|
||||
</MantineNavbar.Section>
|
||||
<Divider></Divider>
|
||||
<MantineNavbar.Section mt="xs">
|
||||
<Group spacing="xs">
|
||||
<Action
|
||||
color={dark ? "yellow" : "indigo"}
|
||||
variant="hover"
|
||||
onClick={() => toggleColorScheme()}
|
||||
icon={dark ? faSun : faMoon}
|
||||
></Action>
|
||||
<Anchor
|
||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||
target="_blank"
|
||||
>
|
||||
<Action icon={faHeart} variant="hover" color="red"></Action>
|
||||
</Anchor>
|
||||
</Group>
|
||||
</MantineNavbar.Section>
|
||||
</Selection.Provider>
|
||||
</MantineNavbar>
|
||||
);
|
||||
};
|
||||
|
||||
const RouteItem: FunctionComponent<{
|
||||
route: CustomRouteObject;
|
||||
parent: string;
|
||||
}> = ({ route, parent }) => {
|
||||
const { children, name, path, icon, hidden, element } = route;
|
||||
|
||||
const { select } = useSelection();
|
||||
|
||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const badge = useBadgeValue(route);
|
||||
|
||||
const isOpen = useIsActive(parent, route);
|
||||
|
||||
// Ignore path if it is using match
|
||||
if (hidden === true || path === undefined || path.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
const elements = (
|
||||
<Stack spacing={0}>
|
||||
{children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
key={BuildKey(link, "nav", idx)}
|
||||
route={child}
|
||||
></RouteItem>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<NavbarItem
|
||||
primary
|
||||
name={name}
|
||||
link={link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
onClick={(event) => {
|
||||
LOG("info", "clicked", link);
|
||||
|
||||
const validated =
|
||||
element !== undefined ||
|
||||
children?.find((v) => v.index === true) !== undefined;
|
||||
|
||||
if (!validated) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
select(null);
|
||||
} else {
|
||||
select(link);
|
||||
}
|
||||
}}
|
||||
></NavbarItem>
|
||||
<Collapse hidden={children.length === 0} in={isOpen}>
|
||||
{elements}
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
return elements;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<NavbarItem
|
||||
name={name ?? link}
|
||||
link={link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></NavbarItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
const borderColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[5] : theme.colors.dark[4];
|
||||
|
||||
const activeBorderColor =
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.brand[4]
|
||||
: theme.colors.brand[8];
|
||||
|
||||
const activeBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[1] : theme.colors.dark[8];
|
||||
|
||||
const hoverBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
|
||||
|
||||
return {
|
||||
text: { display: "inline-flex", alignItems: "center", width: "100%" },
|
||||
anchor: {
|
||||
textDecoration: "none",
|
||||
borderLeft: `2px solid ${borderColor}`,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: activeBackgroundColor,
|
||||
borderLeft: `2px solid ${activeBorderColor}`,
|
||||
boxShadow: theme.shadows.xs,
|
||||
},
|
||||
hover: {
|
||||
backgroundColor: hoverBackgroundColor,
|
||||
},
|
||||
icon: { width: "1.4rem", marginRight: theme.spacing.xs },
|
||||
badge: {
|
||||
marginLeft: "auto",
|
||||
textDecoration: "none",
|
||||
boxShadow: theme.shadows.xs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface NavbarItemProps {
|
||||
name: string;
|
||||
link: string;
|
||||
icon?: IconDefinition;
|
||||
badge?: number;
|
||||
primary?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
||||
icon,
|
||||
link,
|
||||
name,
|
||||
badge,
|
||||
onClick,
|
||||
primary = false,
|
||||
}) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { show } = useNavbar();
|
||||
|
||||
const { ref, hovered } = useHover();
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={link}
|
||||
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
if (!event.isDefaultPrevented()) {
|
||||
show(false);
|
||||
}
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
clsx(classes.anchor, {
|
||||
[classes.active]: isActive,
|
||||
[classes.hover]: hovered,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={ref}
|
||||
inline
|
||||
p="xs"
|
||||
size="sm"
|
||||
color="gray"
|
||||
weight={primary ? "bold" : "normal"}
|
||||
className={classes.text}
|
||||
>
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
className={classes.icon}
|
||||
icon={icon}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
{name}
|
||||
<Badge
|
||||
className={classes.badge}
|
||||
color="gray"
|
||||
radius="xs"
|
||||
hidden={badge === undefined || badge === 0}
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
</Text>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppNavbar;
|
|
@ -1,241 +0,0 @@
|
|||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { BuildKey, useIsArrayExtended } from "@/utilities";
|
||||
import {
|
||||
faBug,
|
||||
faCircleNotch,
|
||||
faExclamationTriangle,
|
||||
faInfoCircle,
|
||||
faStream,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Overlay,
|
||||
ProgressBar,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Working,
|
||||
Failed,
|
||||
}
|
||||
|
||||
function useTotalProgress(progress: Site.Progress[]) {
|
||||
return useMemo(() => {
|
||||
const { value, count } = progress.reduce(
|
||||
(prev, { value, count }) => {
|
||||
prev.value += value;
|
||||
prev.count += count;
|
||||
return prev;
|
||||
},
|
||||
{ value: 0, count: 0 }
|
||||
);
|
||||
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return (value + 0.001) / count;
|
||||
}
|
||||
}, [progress]);
|
||||
}
|
||||
|
||||
function useHasErrorNotification(notifications: Server.Notification[]) {
|
||||
return useMemo(
|
||||
() => notifications.find((v) => v.type !== "info") !== undefined,
|
||||
[notifications]
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationCenter: FunctionComponent = () => {
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hasNew, setHasNew] = useState(false);
|
||||
|
||||
const hasNewProgress = useIsArrayExtended(progress);
|
||||
const hasNewNotifications = useIsArrayExtended(notifications);
|
||||
useDidUpdate(() => {
|
||||
if (hasNewNotifications || hasNewProgress) {
|
||||
setHasNew(true);
|
||||
}
|
||||
}, [hasNewProgress, hasNewNotifications]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (progress.length === 0 && notifications.length === 0) {
|
||||
setHasNew(false);
|
||||
}
|
||||
}, [progress.length, notifications.length]);
|
||||
|
||||
const [btnState, setBtnState] = useState(State.Idle);
|
||||
|
||||
const totalProgress = useTotalProgress(progress);
|
||||
const hasError = useHasErrorNotification(notifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
setBtnState(State.Failed);
|
||||
} else if (totalProgress > 0 && totalProgress < 1.0) {
|
||||
setBtnState(State.Working);
|
||||
} else {
|
||||
setBtnState(State.Idle);
|
||||
}
|
||||
}, [totalProgress, hasError]);
|
||||
|
||||
const iconProps = useMemo<FontAwesomeIconProps>(() => {
|
||||
switch (btnState) {
|
||||
case State.Idle:
|
||||
return {
|
||||
icon: faStream,
|
||||
};
|
||||
case State.Working:
|
||||
return {
|
||||
icon: faCircleNotch,
|
||||
spin: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: faExclamationTriangle,
|
||||
};
|
||||
}
|
||||
}, [btnState]);
|
||||
|
||||
const content = useMemo<ReactNode>(() => {
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="notifications-header">
|
||||
{notifications.length > 0 ? "Notifications" : "No Notifications"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...notifications.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
|
||||
<Notification {...v}></Notification>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
|
||||
nodes.push(
|
||||
<Dropdown.Header key="background-task-header">
|
||||
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
|
||||
</Dropdown.Header>
|
||||
);
|
||||
nodes.push(
|
||||
...progress.map((v, idx) => (
|
||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
|
||||
<Progress {...v}></Progress>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [progress, notifications]);
|
||||
|
||||
const onToggleClick = useCallback(() => {
|
||||
setHasNew(false);
|
||||
}, []);
|
||||
|
||||
// Tooltip Controller
|
||||
const [showTooltip, setTooltip] = useState(false);
|
||||
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
|
||||
useDidUpdate(() => {
|
||||
if (notifier.content) {
|
||||
setTooltip(true);
|
||||
}
|
||||
}, [notifier.timestamp]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Dropdown
|
||||
onClick={onToggleClick}
|
||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
||||
ref={dropdownRef}
|
||||
alignRight
|
||||
>
|
||||
<Dropdown.Toggle as={Button} className="hide-arrow">
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
|
||||
{(props) => {
|
||||
return (
|
||||
<Tooltip id="new-notification-tip" {...props}>
|
||||
{notifier.content}
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</Overlay>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const Notification: FunctionComponent<Server.Notification> = ({
|
||||
type,
|
||||
message,
|
||||
}) => {
|
||||
const icon = useMemo<IconDefinition>(() => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return faInfoCircle;
|
||||
case "warning":
|
||||
return faExclamationTriangle;
|
||||
default:
|
||||
return faBug;
|
||||
}
|
||||
}, [type]);
|
||||
return (
|
||||
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
|
||||
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
|
||||
<span className="text-dark small">{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Progress: FunctionComponent<Site.Progress> = ({
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
header,
|
||||
}) => {
|
||||
const isCompleted = value / count > 1;
|
||||
const displayValue = Math.min(count, value + 1);
|
||||
return (
|
||||
<div className="notification-center-progress d-flex flex-column">
|
||||
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
|
||||
{header}
|
||||
</p>
|
||||
<p className="progress-name m-0 small text-secondary">
|
||||
{isCompleted ? "Completed successfully" : name}
|
||||
</p>
|
||||
<ProgressBar
|
||||
className="mt-2"
|
||||
animated={!isCompleted}
|
||||
now={displayValue / count}
|
||||
max={1}
|
||||
label={`${displayValue}/${count}`}
|
||||
></ProgressBar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
|
@ -1,58 +1,67 @@
|
|||
import { LoadingIndicator } from "@/components";
|
||||
import AppNavbar from "@/App/Navbar";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { useNotification } from "@/modules/redux/hooks";
|
||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import SocketIO from "@/modules/socketio";
|
||||
import LaunchError from "@/pages/LaunchError";
|
||||
import Sidebar from "@/Sidebar";
|
||||
import { Layout } from "@/constants";
|
||||
import NavbarProvider from "@/contexts/Navbar";
|
||||
import OnlineProvider from "@/contexts/Online";
|
||||
import { notification } from "@/modules/task";
|
||||
import CriticalError from "@/pages/CriticalError";
|
||||
import { Environment } from "@/utilities";
|
||||
import { FunctionComponent, useEffect } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useEffectOnceWhen } from "rooks";
|
||||
import Header from "./Header";
|
||||
import { AppShell } from "@mantine/core";
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import { FunctionComponent, useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import AppHeader from "./Header";
|
||||
|
||||
const App: FunctionComponent = () => {
|
||||
const { status } = useReduxStore((s) => s.site);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [criticalError, setCriticalError] = useState<string | null>(null);
|
||||
const [navbar, setNavbar] = useState(false);
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useWindowEvent("app-critical-error", ({ detail }) => {
|
||||
setCriticalError(detail.message);
|
||||
});
|
||||
|
||||
useWindowEvent("app-login-required", () => {
|
||||
navigate("/login");
|
||||
});
|
||||
|
||||
useWindowEvent("app-online-status", ({ detail }) => {
|
||||
setOnline(detail.online);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
SocketIO.initialize();
|
||||
if (Environment.hasUpdate) {
|
||||
showNotification(
|
||||
notification.info(
|
||||
"Update available",
|
||||
"A new version of Bazarr is ready, restart is required"
|
||||
)
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const notify = useNotification("has-update", 10 * 1000);
|
||||
|
||||
// Has any update?
|
||||
useEffectOnceWhen(() => {
|
||||
if (Environment.hasUpdate) {
|
||||
notify({
|
||||
type: "info",
|
||||
message: "A new version of Bazarr is ready, restart is required",
|
||||
// TODO: Restart action
|
||||
});
|
||||
}
|
||||
}, status === "initialized");
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return <Navigate to="/login"></Navigate>;
|
||||
} else if (status === "uninitialized") {
|
||||
return (
|
||||
<LoadingIndicator>
|
||||
<span>Please wait</span>
|
||||
</LoadingIndicator>
|
||||
);
|
||||
} else if (status === "error") {
|
||||
return <LaunchError>Cannot Initialize Bazarr</LaunchError>;
|
||||
if (criticalError !== null) {
|
||||
return <CriticalError message={criticalError}></CriticalError>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Row noGutters className="header-container">
|
||||
<Header></Header>
|
||||
</Row>
|
||||
<Row noGutters className="flex-nowrap">
|
||||
<Sidebar></Sidebar>
|
||||
<Outlet></Outlet>
|
||||
</Row>
|
||||
<NavbarProvider value={{ showed: navbar, show: setNavbar }}>
|
||||
<OnlineProvider value={{ online, setOnline }}>
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||
header={<AppHeader></AppHeader>}
|
||||
navbar={<AppNavbar></AppNavbar>}
|
||||
padding={0}
|
||||
fixed
|
||||
>
|
||||
<Outlet></Outlet>
|
||||
</AppShell>
|
||||
</OnlineProvider>
|
||||
</NavbarProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
72
frontend/src/App/theme.tsx
Normal file
72
frontend/src/App/theme.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
ColorScheme,
|
||||
ColorSchemeProvider,
|
||||
MantineProvider,
|
||||
MantineThemeOverride,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from "react";
|
||||
|
||||
const theme: MantineThemeOverride = {
|
||||
fontFamily: [
|
||||
"Roboto",
|
||||
"open sans",
|
||||
"Helvetica Neue",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
colors: {
|
||||
brand: [
|
||||
"#F8F0FC",
|
||||
"#F3D9FA",
|
||||
"#EEBEFA",
|
||||
"#E599F7",
|
||||
"#DA77F2",
|
||||
"#CC5DE8",
|
||||
"#BE4BDB",
|
||||
"#AE3EC9",
|
||||
"#9C36B5",
|
||||
"#862E9C",
|
||||
],
|
||||
},
|
||||
primaryColor: "brand",
|
||||
};
|
||||
|
||||
function useAutoColorScheme() {
|
||||
const preferredColorScheme = useColorScheme();
|
||||
const [colorScheme, setColorScheme] = useState(preferredColorScheme);
|
||||
|
||||
// automatically switch dark/light theme
|
||||
useEffect(() => {
|
||||
setColorScheme(preferredColorScheme);
|
||||
}, [preferredColorScheme]);
|
||||
|
||||
const toggleColorScheme = useCallback((value?: ColorScheme) => {
|
||||
setColorScheme((scheme) => value || (scheme === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { colorScheme, setColorScheme, toggleColorScheme };
|
||||
}
|
||||
|
||||
const ThemeProvider: FunctionComponent = ({ children }) => {
|
||||
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
|
||||
|
||||
return (
|
||||
<ColorSchemeProvider
|
||||
colorScheme={colorScheme}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{ colorScheme, ...theme }}
|
||||
emotionOptions={{ key: "bazarr" }}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
|
@ -1,18 +1,27 @@
|
|||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useSystemSettings } from "@/apis/hooks";
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
import { FunctionComponent, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const Redirector: FunctionComponent = () => {
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
const { data } = useSystemSettings();
|
||||
|
||||
let path = "/settings/general";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "/movies";
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <Navigate to={path}></Navigate>;
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const { use_sonarr, use_radarr } = data.general;
|
||||
if (use_sonarr) {
|
||||
navigate("/series");
|
||||
} else if (use_radarr) {
|
||||
navigate("/movies");
|
||||
} else {
|
||||
navigate("/settings/general");
|
||||
}
|
||||
}
|
||||
}, [data, navigate]);
|
||||
|
||||
return <LoadingOverlay visible></LoadingOverlay>;
|
||||
};
|
||||
|
||||
export default Redirector;
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { useBadges } from "@/apis/hooks";
|
||||
import { useEnabledStatus } from "@/apis/hooks/site";
|
||||
import App from "@/App";
|
||||
import Lazy from "@/components/Lazy";
|
||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import { Lazy } from "@/components/async";
|
||||
import Authentication from "@/pages/Authentication";
|
||||
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
||||
import Episodes from "@/pages/Episodes";
|
||||
|
@ -10,6 +11,7 @@ import SeriesHistoryView from "@/pages/History/Series";
|
|||
import MovieView from "@/pages/Movies";
|
||||
import MovieDetailView from "@/pages/Movies/Details";
|
||||
import MovieMassEditor from "@/pages/Movies/Editor";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import SeriesView from "@/pages/Series";
|
||||
import SeriesMassEditor from "@/pages/Series/Editor";
|
||||
import SettingsGeneralView from "@/pages/Settings/General";
|
||||
|
@ -38,7 +40,7 @@ import {
|
|||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
lazy,
|
||||
|
@ -51,8 +53,6 @@ import { CustomRouteObject } from "./type";
|
|||
|
||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||
const Authentication = lazy(() => import("@/pages/Authentication"));
|
||||
const NotFound = lazy(() => import("@/pages/404"));
|
||||
|
||||
function useRoutes(): CustomRouteObject[] {
|
||||
const { data } = useBadges();
|
||||
|
@ -277,25 +277,17 @@ function useRoutes(): CustomRouteObject[] {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
hidden: true,
|
||||
element: <NotFound></NotFound>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<Authentication></Authentication>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<NotFound></NotFound>
|
||||
</Lazy>
|
||||
),
|
||||
element: <Authentication></Authentication>,
|
||||
},
|
||||
],
|
||||
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
||||
|
|
|
@ -1,256 +0,0 @@
|
|||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
import { BuildKey, Environment, pathJoin } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { useGotoHomepage } from "@/utilities/hooks";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Badge,
|
||||
Collapse,
|
||||
Container,
|
||||
Image,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from "react-bootstrap";
|
||||
import {
|
||||
matchPath,
|
||||
NavLink,
|
||||
RouteObject,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
select: (path: string | null) => void;
|
||||
}>({
|
||||
selection: null,
|
||||
select: () => {
|
||||
LOG("error", "Selection context not initialized");
|
||||
},
|
||||
});
|
||||
|
||||
function useSelection() {
|
||||
return useContext(Selection);
|
||||
}
|
||||
|
||||
function useBadgeValue(route: Route.Item) {
|
||||
const { badge, children } = route;
|
||||
return useMemo(() => {
|
||||
let value = badge ?? 0;
|
||||
|
||||
if (children === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value +=
|
||||
children.reduce((acc, child: Route.Item) => {
|
||||
if (child.badge && child.hidden !== true) {
|
||||
return acc + (child.badge ?? 0);
|
||||
}
|
||||
return acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return value === 0 ? undefined : value;
|
||||
}, [badge, children]);
|
||||
}
|
||||
|
||||
function useIsActive(parent: string, route: RouteObject) {
|
||||
const { path, children } = route;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const paths = useMemo(
|
||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||
[root, children]
|
||||
);
|
||||
|
||||
const selection = useSelection().selection;
|
||||
return useMemo(
|
||||
() =>
|
||||
selection?.includes(root) ||
|
||||
paths.some((path) => matchPath(path, pathname)),
|
||||
[pathname, paths, root, selection]
|
||||
);
|
||||
}
|
||||
|
||||
// Actual sidebar
|
||||
const Sidebar: FunctionComponent = () => {
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
const isShow = useReduxStore((s) => s.site.showSidebar);
|
||||
|
||||
const showSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const routes = useRouteItems();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
select(null);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<nav className={clsx("sidebar-container", { open: isShow })}>
|
||||
<Container className="sidebar-title d-flex align-items-center d-md-none">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
className="cursor-pointer"
|
||||
></Image>
|
||||
</Container>
|
||||
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
parent="/"
|
||||
route={route}
|
||||
></RouteItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
</nav>
|
||||
<div
|
||||
className={clsx("sidebar-overlay", { open: isShow })}
|
||||
onClick={() => showSidebar(false)}
|
||||
></div>
|
||||
</Selection.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const RouteItem: FunctionComponent<{
|
||||
route: CustomRouteObject;
|
||||
parent: string;
|
||||
}> = ({ route, parent }) => {
|
||||
const { children, name, path, icon, hidden, element } = route;
|
||||
|
||||
const isValidated = useMemo(
|
||||
() =>
|
||||
element !== undefined ||
|
||||
children?.find((v) => v.index === true) !== undefined,
|
||||
[element, children]
|
||||
);
|
||||
|
||||
const { select } = useSelection();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const badge = useBadgeValue(route);
|
||||
|
||||
const isOpen = useIsActive(parent, route);
|
||||
|
||||
if (hidden === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ignore path if it is using match
|
||||
if (path === undefined || path.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
const elements = children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
key={BuildKey(link, "nav", idx)}
|
||||
route={child}
|
||||
></RouteItem>
|
||||
));
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
|
||||
<ListGroupItem
|
||||
action
|
||||
className={clsx("button", { active: isOpen })}
|
||||
onClick={() => {
|
||||
LOG("info", "clicked", link);
|
||||
|
||||
if (isValidated) {
|
||||
navigate(link);
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
select(null);
|
||||
} else {
|
||||
select(link);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</ListGroupItem>
|
||||
<Collapse in={isOpen}>
|
||||
<div className="indent">{elements}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <>{elements}</>;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
to={link}
|
||||
className={({ isActive }) =>
|
||||
clsx("list-group-item list-group-item-action button sb-collapse", {
|
||||
active: isActive,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface ItemComponentProps {
|
||||
name: string;
|
||||
icon?: IconDefinition;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
|
||||
icon,
|
||||
name,
|
||||
badge,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
|
||||
<span className="d-flex flex-grow-1 justify-content-between">
|
||||
{name}
|
||||
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
|
||||
{badge}
|
||||
</Badge>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
15
frontend/src/apis/hooks/site.ts
Normal file
15
frontend/src/apis/hooks/site.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { useSystemSettings } from ".";
|
||||
|
||||
export function useEnabledStatus() {
|
||||
const { data } = useSystemSettings();
|
||||
|
||||
return {
|
||||
sonarr: data?.general.use_sonarr ?? false,
|
||||
radarr: data?.general.use_radarr ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useShowOnlyDesired() {
|
||||
const { data } = useSystemSettings();
|
||||
return data?.general.embedded_subs_show_desired ?? false;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Environment } from "@/utilities";
|
||||
import { setLoginRequired } from "@/utilities/event";
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import store from "../../modules/redux/store";
|
||||
import { QueryKeys } from "../queries/keys";
|
||||
import api from "../raw";
|
||||
|
||||
|
@ -173,7 +173,7 @@ export function useSystem() {
|
|||
() => api.system.logout(),
|
||||
{
|
||||
onSuccess: () => {
|
||||
store.dispatch(setUnauthenticated());
|
||||
setLoginRequired();
|
||||
client.clear();
|
||||
},
|
||||
}
|
||||
|
@ -185,7 +185,8 @@ export function useSystem() {
|
|||
api.system.login(param.username, param.password),
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.location.reload();
|
||||
// TODO: Hard-coded value
|
||||
window.location.replace(`/${Environment.baseUrl}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@ -216,7 +217,7 @@ export function useSystem() {
|
|||
shutdown,
|
||||
restart,
|
||||
login,
|
||||
isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
|
||||
isMutating: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
|
||||
}),
|
||||
[
|
||||
isLoggingIn,
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import SocketIO from "@/modules/socketio";
|
||||
import { setLoginRequired } from "@/utilities/event";
|
||||
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import { AppDispatch } from "../../modules/redux/store";
|
||||
import { Environment, isProdEnv } from "../../utilities";
|
||||
import { Environment } from "../../utilities";
|
||||
class BazarrClient {
|
||||
axios!: AxiosInstance;
|
||||
source!: CancelTokenSource;
|
||||
dispatch!: AppDispatch;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = `${Environment.baseUrl}/api/`;
|
||||
this.initialize(baseUrl, Environment.apiKey);
|
||||
SocketIO.initialize();
|
||||
}
|
||||
|
||||
initialize(url: string, apikey?: string) {
|
||||
|
@ -48,16 +48,10 @@ class BazarrClient {
|
|||
);
|
||||
}
|
||||
|
||||
_resetApi(apikey: string) {
|
||||
if (!isProdEnv) {
|
||||
this.axios.defaults.headers.common["X-API-KEY"] = apikey;
|
||||
}
|
||||
}
|
||||
|
||||
handleError(code: number) {
|
||||
switch (code) {
|
||||
case 401:
|
||||
this.dispatch(setUnauthenticated());
|
||||
setLoginRequired();
|
||||
break;
|
||||
case 500:
|
||||
break;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { GetItemId } from "@/utilities";
|
||||
import { GetItemId, useOnValueChange } from "@/utilities";
|
||||
import { usePageSize } from "@/utilities/storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
|
@ -13,17 +13,14 @@ export type UsePaginationQueryResult<T extends object> = UseQueryResult<
|
|||
DataWrapperWithTotal<T>
|
||||
> & {
|
||||
controls: {
|
||||
previousPage: () => void;
|
||||
nextPage: () => void;
|
||||
gotoPage: (index: number) => void;
|
||||
};
|
||||
paginationStatus: {
|
||||
isPageLoading: boolean;
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
page: number;
|
||||
canPrevious: boolean;
|
||||
canNext: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -67,16 +64,6 @@ export function usePaginationQuery<
|
|||
const totalCount = data?.total ?? 0;
|
||||
const pageCount = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const previousPage = useCallback(() => {
|
||||
setIndex((index) => Math.max(0, index - 1));
|
||||
}, []);
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
if (pageCount > 0) {
|
||||
setIndex((index) => Math.min(pageCount - 1, index + 1));
|
||||
}
|
||||
}, [pageCount]);
|
||||
|
||||
const gotoPage = useCallback(
|
||||
(idx: number) => {
|
||||
if (idx >= 0 && idx < pageCount) {
|
||||
|
@ -86,6 +73,20 @@ export function usePaginationQuery<
|
|||
[pageCount]
|
||||
);
|
||||
|
||||
const [isPageLoading, setIsPageLoading] = useState(false);
|
||||
|
||||
useOnValueChange(page, () => {
|
||||
if (results.isFetching) {
|
||||
setIsPageLoading(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!results.isFetching) {
|
||||
setIsPageLoading(false);
|
||||
}
|
||||
}, [results.isFetching]);
|
||||
|
||||
// Reset page index if we out of bound
|
||||
useEffect(() => {
|
||||
if (pageCount === 0) return;
|
||||
|
@ -100,17 +101,14 @@ export function usePaginationQuery<
|
|||
return {
|
||||
...results,
|
||||
paginationStatus: {
|
||||
isPageLoading,
|
||||
totalCount,
|
||||
pageCount,
|
||||
pageSize,
|
||||
page,
|
||||
canPrevious: page > 0,
|
||||
canNext: page < pageCount - 1,
|
||||
},
|
||||
controls: {
|
||||
gotoPage,
|
||||
previousPage,
|
||||
nextPage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,204 +0,0 @@
|
|||
import { BuildKey, isMovie } from "@/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import {
|
||||
faBookmark as farBookmark,
|
||||
faClone as fasClone,
|
||||
faFolder,
|
||||
} from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faBookmark,
|
||||
faLanguage,
|
||||
faMusic,
|
||||
faStream,
|
||||
faTags,
|
||||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Col,
|
||||
Container,
|
||||
Image,
|
||||
OverlayTrigger,
|
||||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import Language from "./bazarr/Language";
|
||||
|
||||
interface Props {
|
||||
item: Item.Base;
|
||||
details?: { icon: IconDefinition; text: string }[];
|
||||
}
|
||||
|
||||
const ItemOverview: FunctionComponent<Props> = (props) => {
|
||||
const { item, details } = props;
|
||||
|
||||
const detailBadges = useMemo(() => {
|
||||
const badges: (JSX.Element | null)[] = [];
|
||||
badges.push(
|
||||
<DetailBadge key="file-path" icon={faFolder} desc="File Path">
|
||||
{item.path}
|
||||
</DetailBadge>
|
||||
);
|
||||
|
||||
badges.push(
|
||||
...(details?.map((val, idx) => (
|
||||
<DetailBadge key={BuildKey(idx, "detail", val.text)} icon={val.icon}>
|
||||
{val.text}
|
||||
</DetailBadge>
|
||||
)) ?? [])
|
||||
);
|
||||
|
||||
if (item.tags.length > 0) {
|
||||
badges.push(
|
||||
<DetailBadge key="tags" icon={faTags} desc="Tags">
|
||||
{item.tags.join("|")}
|
||||
</DetailBadge>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}, [details, item.path, item.tags]);
|
||||
|
||||
const audioBadges = useMemo(
|
||||
() =>
|
||||
item.audio_language.map((v, idx) => (
|
||||
<DetailBadge
|
||||
key={BuildKey(idx, "audio", v.code2)}
|
||||
icon={faMusic}
|
||||
desc="Audio Language"
|
||||
>
|
||||
{v.name}
|
||||
</DetailBadge>
|
||||
)),
|
||||
[item.audio_language]
|
||||
);
|
||||
|
||||
const profile = useLanguageProfileBy(item.profileId);
|
||||
const profileItems = useProfileItemsToLanguages(profile);
|
||||
|
||||
const languageBadges = useMemo(() => {
|
||||
const badges: (JSX.Element | null)[] = [];
|
||||
|
||||
if (profile) {
|
||||
badges.push(
|
||||
<DetailBadge
|
||||
key="language-profile"
|
||||
icon={faStream}
|
||||
desc="Languages Profile"
|
||||
>
|
||||
{profile.name}
|
||||
</DetailBadge>
|
||||
);
|
||||
|
||||
badges.push(
|
||||
...profileItems.map((v, idx) => (
|
||||
<DetailBadge
|
||||
key={BuildKey(idx, "lang", v.code2)}
|
||||
icon={faLanguage}
|
||||
desc="Language"
|
||||
>
|
||||
<Language.Text long value={v}></Language.Text>
|
||||
</DetailBadge>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}, [profile, profileItems]);
|
||||
|
||||
const alternativePopover = useMemo(
|
||||
() => (
|
||||
<Popover id="item-overview-alternative">
|
||||
<Popover.Title>Alternate Titles</Popover.Title>
|
||||
<Popover.Content>
|
||||
{item.alternativeTitles.map((v, idx) => (
|
||||
<li key={idx}>{v}</li>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
),
|
||||
[item.alternativeTitles]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container
|
||||
fluid
|
||||
style={{
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "top center",
|
||||
backgroundImage: `url('${item.fanart}')`,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
className="p-4 pb-4"
|
||||
style={{
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<Col sm="auto">
|
||||
<Image
|
||||
className="d-none d-sm-block my-2"
|
||||
style={{
|
||||
maxHeight: 250,
|
||||
}}
|
||||
src={item.poster}
|
||||
></Image>
|
||||
</Col>
|
||||
<Col>
|
||||
<Container fluid className="text-white">
|
||||
<Row>
|
||||
{isMovie(item) ? (
|
||||
<FontAwesomeIcon
|
||||
className="mx-2 mt-2"
|
||||
title={item.monitored ? "monitored" : "unmonitored"}
|
||||
icon={item.monitored ? faBookmark : farBookmark}
|
||||
size="2x"
|
||||
></FontAwesomeIcon>
|
||||
) : null}
|
||||
<h1>{item.title}</h1>
|
||||
<span hidden={item.alternativeTitles.length === 0}>
|
||||
<OverlayTrigger overlay={alternativePopover}>
|
||||
<FontAwesomeIcon
|
||||
className="mx-2"
|
||||
icon={fasClone}
|
||||
></FontAwesomeIcon>
|
||||
</OverlayTrigger>
|
||||
</span>
|
||||
</Row>
|
||||
<Row>{detailBadges}</Row>
|
||||
<Row>{audioBadges}</Row>
|
||||
<Row>{languageBadges}</Row>
|
||||
<Row>
|
||||
<span>{item.overview}</span>
|
||||
</Row>
|
||||
</Container>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemBadgeProps {
|
||||
icon: IconDefinition;
|
||||
children: string | JSX.Element;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
const DetailBadge: FunctionComponent<ItemBadgeProps> = ({
|
||||
icon,
|
||||
desc,
|
||||
children,
|
||||
}) => (
|
||||
<Badge title={desc} variant="secondary" className="mr-2 my-1 text-truncate">
|
||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||
<span className="ml-1">{children}</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export default ItemOverview;
|
|
@ -1,44 +0,0 @@
|
|||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
options: readonly Language.Info[];
|
||||
}
|
||||
|
||||
type RemovedSelectorProps<M extends boolean> = Omit<
|
||||
SelectorProps<Language.Info, M>,
|
||||
"label"
|
||||
>;
|
||||
|
||||
export type LanguageSelectorProps<M extends boolean> = Override<
|
||||
Props,
|
||||
RemovedSelectorProps<M>
|
||||
>;
|
||||
|
||||
function getLabel(lang: Language.Info) {
|
||||
return lang.name;
|
||||
}
|
||||
|
||||
export function LanguageSelector<M extends boolean = false>(
|
||||
props: LanguageSelectorProps<M>
|
||||
) {
|
||||
const { options, ...selector } = props;
|
||||
|
||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
||||
() =>
|
||||
options.map((v) => ({
|
||||
label: v.name,
|
||||
value: v,
|
||||
})),
|
||||
[options]
|
||||
);
|
||||
|
||||
return (
|
||||
<Selector
|
||||
placeholder="Language..."
|
||||
options={items}
|
||||
label={getLabel}
|
||||
{...selector}
|
||||
></Selector>
|
||||
);
|
||||
}
|
70
frontend/src/components/Search.tsx
Normal file
70
frontend/src/components/Search.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
};
|
209
frontend/src/components/SubtitleToolsMenu.tsx
Normal file
209
frontend/src/components/SubtitleToolsMenu.tsx
Normal file
|
@ -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;
|
30
frontend/src/components/TextPopover.tsx
Normal file
30
frontend/src/components/TextPopover.tsx
Normal file
|
@ -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;
|
48
frontend/src/components/async/MutateAction.tsx
Normal file
48
frontend/src/components/async/MutateAction.tsx
Normal file
|
@ -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;
|
47
frontend/src/components/async/MutateButton.tsx
Normal file
47
frontend/src/components/async/MutateButton.tsx
Normal file
|
@ -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;
|
25
frontend/src/components/async/QueryOverlay.tsx
Normal file
25
frontend/src/components/async/QueryOverlay.tsx
Normal file
|
@ -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;
|
3
frontend/src/components/async/index.ts
Normal file
3
frontend/src/components/async/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as Lazy } from "./Lazy";
|
||||
export { default as MutateAction } from "./MutateAction";
|
||||
export { default as QueryOverlay } from "./QueryOverlay";
|
26
frontend/src/components/bazarr/AudioList.tsx
Normal file
26
frontend/src/components/bazarr/AudioList.tsx
Normal file
|
@ -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;
|
54
frontend/src/components/bazarr/HistoryIcon.tsx
Normal file
54
frontend/src/components/bazarr/HistoryIcon.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
faClock,
|
||||
faCloudUploadAlt,
|
||||
faDownload,
|
||||
faRecycle,
|
||||
faTrash,
|
||||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
enum HistoryAction {
|
||||
Delete = 0,
|
||||
Download,
|
||||
Manual,
|
||||
Upgrade,
|
||||
Upload,
|
||||
Sync,
|
||||
}
|
||||
|
||||
const HistoryIcon: FunctionComponent<{
|
||||
action: number;
|
||||
title?: string;
|
||||
}> = ({ action, title }) => {
|
||||
let icon = null;
|
||||
switch (action) {
|
||||
case HistoryAction.Delete:
|
||||
icon = faTrash;
|
||||
break;
|
||||
case HistoryAction.Download:
|
||||
icon = faDownload;
|
||||
break;
|
||||
case HistoryAction.Manual:
|
||||
icon = faUser;
|
||||
break;
|
||||
case HistoryAction.Sync:
|
||||
icon = faClock;
|
||||
break;
|
||||
case HistoryAction.Upgrade:
|
||||
icon = faRecycle;
|
||||
break;
|
||||
case HistoryAction.Upload:
|
||||
icon = faCloudUploadAlt;
|
||||
break;
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default HistoryIcon;
|
|
@ -1,22 +1,21 @@
|
|||
import { useLanguages } from "@/apis/hooks";
|
||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { BuildKey } from "@/utilities";
|
||||
import { Badge, Group, Text, TextProps } from "@mantine/core";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface TextProps {
|
||||
type LanguageTextProps = TextProps<"div"> & {
|
||||
value: Language.Info;
|
||||
className?: string;
|
||||
long?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
declare type LanguageComponent = {
|
||||
Text: typeof LanguageText;
|
||||
Selector: typeof LanguageSelector;
|
||||
List: typeof LanguageList;
|
||||
};
|
||||
|
||||
const LanguageText: FunctionComponent<TextProps> = ({
|
||||
const LanguageText: FunctionComponent<LanguageTextProps> = ({
|
||||
value,
|
||||
className,
|
||||
long,
|
||||
...props
|
||||
}) => {
|
||||
const result = useMemo(() => {
|
||||
let lang = value.code2;
|
||||
|
@ -38,51 +37,29 @@ const LanguageText: FunctionComponent<TextProps> = ({
|
|||
}, [value, long]);
|
||||
|
||||
return (
|
||||
<span title={value.name} className={className}>
|
||||
<Text inherit {...props}>
|
||||
{result}
|
||||
</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
type LanguageSelectorProps<M extends boolean> = Omit<
|
||||
SelectorProps<Language.Info, M>,
|
||||
"label" | "options"
|
||||
> & {
|
||||
history?: boolean;
|
||||
type LanguageListProps = {
|
||||
value: Language.Info[];
|
||||
};
|
||||
|
||||
function getLabel(lang: Language.Info) {
|
||||
return lang.name;
|
||||
}
|
||||
|
||||
export function LanguageSelector<M extends boolean = false>(
|
||||
props: LanguageSelectorProps<M>
|
||||
) {
|
||||
const { history, ...rest } = props;
|
||||
const { data: options } = useLanguages(history);
|
||||
|
||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
||||
() =>
|
||||
options?.map((v) => ({
|
||||
label: v.name,
|
||||
value: v,
|
||||
})) ?? [],
|
||||
[options]
|
||||
);
|
||||
|
||||
const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => {
|
||||
return (
|
||||
<Selector
|
||||
placeholder="Language..."
|
||||
options={items}
|
||||
label={getLabel}
|
||||
{...rest}
|
||||
></Selector>
|
||||
<Group spacing="xs">
|
||||
{value.map((v) => (
|
||||
<Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Components: LanguageComponent = {
|
||||
Text: LanguageText,
|
||||
Selector: LanguageSelector,
|
||||
List: LanguageList,
|
||||
};
|
||||
|
||||
export default Components;
|
||||
|
|
|
@ -3,13 +3,11 @@ import { FunctionComponent, useMemo } from "react";
|
|||
|
||||
interface Props {
|
||||
index: number | null;
|
||||
className?: string;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
const LanguageProfile: FunctionComponent<Props> = ({
|
||||
const LanguageProfileName: FunctionComponent<Props> = ({
|
||||
index,
|
||||
className,
|
||||
empty = "Unknown Profile",
|
||||
}) => {
|
||||
const { data } = useLanguageProfiles();
|
||||
|
@ -19,7 +17,7 @@ const LanguageProfile: FunctionComponent<Props> = ({
|
|||
[data, empty, index]
|
||||
);
|
||||
|
||||
return <span className={className}>{name}</span>;
|
||||
return <>{name}</>;
|
||||
};
|
||||
|
||||
export default LanguageProfile;
|
||||
export default LanguageProfileName;
|
||||
|
|
4
frontend/src/components/bazarr/index.ts
Normal file
4
frontend/src/components/bazarr/index.ts
Normal file
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
133
frontend/src/components/forms/ColorToolForm.tsx
Normal file
133
frontend/src/components/forms/ColorToolForm.tsx
Normal file
|
@ -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;
|
72
frontend/src/components/forms/FrameRateForm.tsx
Normal file
72
frontend/src/components/forms/FrameRateForm.tsx
Normal file
|
@ -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;
|
109
frontend/src/components/forms/ItemEditForm.tsx
Normal file
109
frontend/src/components/forms/ItemEditForm.tsx
Normal file
|
@ -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;
|
276
frontend/src/components/forms/MovieUploadForm.tsx
Normal file
276
frontend/src/components/forms/MovieUploadForm.tsx
Normal file
|
@ -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;
|
311
frontend/src/components/forms/ProfileEditForm.tsx
Normal file
311
frontend/src/components/forms/ProfileEditForm.tsx
Normal file
|
@ -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;
|
349
frontend/src/components/forms/SeriesUploadForm.tsx
Normal file
349
frontend/src/components/forms/SeriesUploadForm.tsx
Normal file
|
@ -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;
|
97
frontend/src/components/forms/TimeOffsetForm.tsx
Normal file
97
frontend/src/components/forms/TimeOffsetForm.tsx
Normal file
|
@ -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;
|
192
frontend/src/components/forms/TranslationForm.tsx
Normal file
192
frontend/src/components/forms/TranslationForm.tsx
Normal file
|
@ -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";
|
||||
|
|
24
frontend/src/components/inputs/Action.tsx
Normal file
24
frontend/src/components/inputs/Action.tsx
Normal file
|
@ -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;
|
34
frontend/src/components/inputs/ChipInput.tsx
Normal file
34
frontend/src/components/inputs/ChipInput.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
};
|
78
frontend/src/components/inputs/File.tsx
Normal file
78
frontend/src/components/inputs/File.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
faArrowUp,
|
||||
faFileCirclePlus,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Box, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Dropzone,
|
||||
DropzoneProps,
|
||||
DropzoneStatus,
|
||||
FullScreenDropzone,
|
||||
FullScreenDropzoneProps,
|
||||
} from "@mantine/dropzone";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
export type FileProps = Omit<DropzoneProps, "children"> & {
|
||||
inner?: FileInnerComponent;
|
||||
};
|
||||
|
||||
const File: FunctionComponent<FileProps> = ({
|
||||
inner: Inner = FileInner,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Dropzone {...props}>
|
||||
{(status) => <Inner status={status}></Inner>}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileOverlayProps = Omit<FullScreenDropzoneProps, "children"> & {
|
||||
inner?: FileInnerComponent;
|
||||
};
|
||||
|
||||
export const FileOverlay: FunctionComponent<FileOverlayProps> = ({
|
||||
inner: Inner = FileInner,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<FullScreenDropzone {...props}>
|
||||
{(status) => <Inner status={status}></Inner>}
|
||||
</FullScreenDropzone>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileInnerProps = {
|
||||
status: DropzoneStatus;
|
||||
};
|
||||
|
||||
type FileInnerComponent = FunctionComponent<FileInnerProps>;
|
||||
|
||||
const FileInner: FileInnerComponent = ({ status }) => {
|
||||
const { accepted, rejected } = status;
|
||||
const icon = useMemo(() => {
|
||||
if (accepted) {
|
||||
return faArrowUp;
|
||||
} else if (rejected) {
|
||||
return faXmark;
|
||||
} else {
|
||||
return faFileCirclePlus;
|
||||
}
|
||||
}, [accepted, rejected]);
|
||||
|
||||
return (
|
||||
<Stack m="lg" align="center" spacing="xs" style={{ pointerEvents: "none" }}>
|
||||
<Box mb="md">
|
||||
<FontAwesomeIcon size="3x" icon={icon}></FontAwesomeIcon>
|
||||
</Box>
|
||||
<Text size="lg">Upload files here</Text>
|
||||
<Text color="dimmed" size="sm">
|
||||
Drag and drop, or click to select
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
|
@ -1,18 +1,11 @@
|
|||
import { useFileSystem } from "@/apis/hooks";
|
||||
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faReply } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
ChangeEvent,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Dropdown, DropdownProps, Form, Spinner } from "react-bootstrap";
|
||||
import { Autocomplete, AutocompleteProps } from "@mantine/core";
|
||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const backKey = "--back--";
|
||||
// TODO: use fortawesome icons
|
||||
const backKey = "⏎ Back";
|
||||
|
||||
function getLastSeparator(path: string): number {
|
||||
let idx = path.lastIndexOf("/");
|
||||
|
@ -31,134 +24,81 @@ function extractPath(raw: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export interface FileBrowserProps {
|
||||
defaultValue?: string;
|
||||
export type FileBrowserProps = Omit<AutocompleteProps, "data"> & {
|
||||
type: "sonarr" | "radarr" | "bazarr";
|
||||
onChange?: (path: string) => void;
|
||||
drop?: DropdownProps["drop"];
|
||||
}
|
||||
};
|
||||
|
||||
type FileTreeItem = {
|
||||
value: string;
|
||||
item?: FileTree;
|
||||
};
|
||||
|
||||
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
||||
defaultValue,
|
||||
type,
|
||||
onChange,
|
||||
drop,
|
||||
...props
|
||||
}) => {
|
||||
const [show, canShow] = useState(false);
|
||||
const [text, setText] = useState(defaultValue ?? "");
|
||||
const [path, setPath] = useState(() => extractPath(text));
|
||||
const [isShow, setIsShow] = useState(false);
|
||||
const [value, setValue] = useState(defaultValue ?? "");
|
||||
const [path, setPath] = useState(() => extractPath(value));
|
||||
|
||||
const { data: tree, isFetching } = useFileSystem(type, path, show);
|
||||
const { data: tree } = useFileSystem(type, path, isShow);
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const idx = getLastSeparator(text);
|
||||
return text.slice(idx + 1);
|
||||
}, [text]);
|
||||
const data = useMemo<FileTreeItem[]>(
|
||||
() => [
|
||||
{ value: backKey },
|
||||
...(tree?.map((v) => ({
|
||||
value: v.path,
|
||||
item: v,
|
||||
})) ?? []),
|
||||
],
|
||||
[tree]
|
||||
);
|
||||
|
||||
const previous = useMemo(() => {
|
||||
const parent = useMemo(() => {
|
||||
const idx = getLastSeparator(path.slice(0, -1));
|
||||
return path.slice(0, idx + 1);
|
||||
}, [path]);
|
||||
|
||||
const requestItems = () => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Dropdown.Item>
|
||||
<Spinner size="sm" animation="border"></Spinner>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
|
||||
const elements = [];
|
||||
|
||||
if (tree) {
|
||||
elements.push(
|
||||
...tree
|
||||
.filter((v) => v.name.startsWith(filter))
|
||||
.map((v) => (
|
||||
<Dropdown.Item eventKey={v.path} key={v.name}>
|
||||
<FontAwesomeIcon
|
||||
icon={v.children ? faFolder : faFile}
|
||||
className="mr-2"
|
||||
></FontAwesomeIcon>
|
||||
<span>{v.name}</span>
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (elements.length === 0) {
|
||||
elements.push(<Dropdown.Header key="no-files">No Files</Dropdown.Header>);
|
||||
}
|
||||
|
||||
if (previous.length !== 0) {
|
||||
return [
|
||||
<Dropdown.Item eventKey={backKey} key="back">
|
||||
<FontAwesomeIcon icon={faReply} className="mr-2"></FontAwesomeIcon>
|
||||
<span>Back</span>
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Divider key="back-divider"></Dropdown.Divider>,
|
||||
...elements,
|
||||
];
|
||||
} else {
|
||||
return elements;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (text === path) {
|
||||
if (value === path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPath = extractPath(text);
|
||||
const newPath = extractPath(value);
|
||||
if (newPath !== path) {
|
||||
setPath(newPath);
|
||||
onChange && onChange(newPath);
|
||||
}
|
||||
}, [path, text, onChange]);
|
||||
}, [path, value, onChange]);
|
||||
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
show={show}
|
||||
drop={drop}
|
||||
onSelect={(key) => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key !== backKey) {
|
||||
setText(key);
|
||||
<Autocomplete
|
||||
{...props}
|
||||
ref={ref}
|
||||
icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
|
||||
placeholder="Click to start"
|
||||
data={data}
|
||||
value={value}
|
||||
filter={(value, item) => {
|
||||
if (item.value === backKey) {
|
||||
return true;
|
||||
} else {
|
||||
setText(previous);
|
||||
}
|
||||
input.current?.focus();
|
||||
}}
|
||||
onToggle={(open, _, meta) => {
|
||||
if (!open && meta.source !== "select") {
|
||||
canShow(false);
|
||||
} else if (open) {
|
||||
canShow(true);
|
||||
return item.value.includes(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
as={Form.Control}
|
||||
placeholder="Click to start"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.currentTarget.value);
|
||||
}}
|
||||
ref={input}
|
||||
></Dropdown.Toggle>
|
||||
<Dropdown.Menu
|
||||
className="w-100"
|
||||
style={{ maxHeight: 256, overflowY: "auto" }}
|
||||
>
|
||||
{requestItems()}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
onChange={(val) => {
|
||||
if (val !== backKey) {
|
||||
setValue(val);
|
||||
} else {
|
||||
setValue(parent);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsShow(true)}
|
||||
onBlur={() => setIsShow(false)}
|
||||
></Autocomplete>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import {
|
||||
ChangeEvent,
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
|
||||
export interface FileFormProps {
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
emptyText: string;
|
||||
value?: File[];
|
||||
onChange?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export const FileForm: FunctionComponent<FileFormProps> = ({
|
||||
value: files,
|
||||
emptyText,
|
||||
multiple,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => {
|
||||
const [fileList, setFileList] = useState<File[]>([]);
|
||||
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (files) {
|
||||
setFileList(files);
|
||||
|
||||
if (files.length === 0 && input.current) {
|
||||
// Manual reset file input
|
||||
input.current.value = "";
|
||||
}
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (fileList.length === 0) {
|
||||
return emptyText;
|
||||
} else {
|
||||
if (multiple) {
|
||||
return `${fileList.length} Files`;
|
||||
} else {
|
||||
return fileList[0].name;
|
||||
}
|
||||
}
|
||||
}, [fileList, emptyText, multiple]);
|
||||
|
||||
return (
|
||||
<Form.File
|
||||
disabled={disabled}
|
||||
custom
|
||||
label={label}
|
||||
multiple={multiple}
|
||||
ref={input}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = e.target;
|
||||
if (files) {
|
||||
const list: File[] = [];
|
||||
for (const file of files) {
|
||||
list.push(file);
|
||||
}
|
||||
setFileList(list);
|
||||
onChange && onChange(list);
|
||||
}
|
||||
}}
|
||||
></Form.File>
|
||||
);
|
||||
};
|
|
@ -1,141 +1,169 @@
|
|||
import clsx from "clsx";
|
||||
import { FocusEvent, useCallback, useMemo, useRef } from "react";
|
||||
import Select, { GroupBase, OnChangeValue } from "react-select";
|
||||
import { SelectComponents } from "react-select/dist/declarations/src/components";
|
||||
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 SelectorOption<T> = {
|
||||
label: string;
|
||||
value: T;
|
||||
};
|
||||
|
||||
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>;
|
||||
type SelectItemWithPayload<T> = SelectItem & {
|
||||
payload: 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>>>
|
||||
>;
|
||||
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;
|
||||
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">
|
||||
>;
|
||||
|
||||
const labelRef = useRef(label);
|
||||
export function Selector<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
onChange,
|
||||
getkey = DefaultKeyBuilder,
|
||||
...select
|
||||
}: SelectorProps<T>) {
|
||||
const keyRef = useRef(getkey);
|
||||
keyRef.current = getkey;
|
||||
|
||||
const getName = useCallback(
|
||||
(item: T) => {
|
||||
if (labelRef.current) {
|
||||
return labelRef.current(item);
|
||||
}
|
||||
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);
|
||||
},
|
||||
[options]
|
||||
[data, onChange]
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
[multiple, getName]
|
||||
);
|
||||
|
||||
const defaultWrapper = useMemo(
|
||||
() => wrapper(defaultValue),
|
||||
[defaultValue, wrapper]
|
||||
);
|
||||
|
||||
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>);
|
||||
}
|
||||
}
|
||||
}}
|
||||
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 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);
|
||||
},
|
||||
[data, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
value={wrappedValue}
|
||||
defaultValue={wrappedDefaultValue}
|
||||
onChange={wrappedOnChange}
|
||||
{...select}
|
||||
data={data}
|
||||
></MultiSelect>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
import RcSlider from "rc-slider";
|
||||
import "rc-slider/assets/index.css";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
|
||||
type TooltipsOptions = boolean | "Always";
|
||||
|
||||
export interface SliderProps {
|
||||
tooltips?: TooltipsOptions;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
start?: number;
|
||||
defaultValue?: number;
|
||||
onAfterChange?: (value: number) => void;
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
export const Slider: FunctionComponent<SliderProps> = ({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
tooltips,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onAfterChange,
|
||||
}) => {
|
||||
max = max ?? 100;
|
||||
min = min ?? 0;
|
||||
step = step ?? 1;
|
||||
|
||||
const [curr, setValue] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row align-items-center py-2">
|
||||
<span className="text-muted text-nowrap pr-3">{`${min} / ${curr}`}</span>
|
||||
<RcSlider
|
||||
min={min}
|
||||
max={max}
|
||||
className="custom-rc-slider"
|
||||
step={step}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange && onChange(v);
|
||||
}}
|
||||
onAfterChange={onAfterChange}
|
||||
handle={(props) => (
|
||||
<div
|
||||
className="rc-slider-handle"
|
||||
style={{
|
||||
left: `${props.offset}%`,
|
||||
}}
|
||||
>
|
||||
<SliderTooltips
|
||||
tooltips={tooltips}
|
||||
value={props.value}
|
||||
></SliderTooltips>
|
||||
</div>
|
||||
)}
|
||||
></RcSlider>
|
||||
<span className="text-muted pl-3">{max}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SliderTooltips: FunctionComponent<{
|
||||
tooltips?: TooltipsOptions;
|
||||
value: number;
|
||||
}> = ({ tooltips, value }) => {
|
||||
const cls = useMemo(() => {
|
||||
const tipsCls = ["rc-slider-handle-tips"];
|
||||
if (tooltips !== undefined) {
|
||||
if (typeof tooltips === "string") {
|
||||
tipsCls.push("rc-slider-handle-tips-always");
|
||||
} else if (tooltips === false) {
|
||||
tipsCls.push("rc-slider-handle-tips-hidden");
|
||||
}
|
||||
}
|
||||
return tipsCls.join(" ");
|
||||
}, [tooltips]);
|
||||
|
||||
return <span className={cls}>{value}</span>;
|
||||
};
|
|
@ -1,44 +0,0 @@
|
|||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { FunctionComponent } from "react";
|
||||
import { AsyncButton } from "..";
|
||||
|
||||
interface Props {
|
||||
history: History.Base;
|
||||
update?: () => void;
|
||||
promise: (form: FormType.AddBlacklist) => Promise<void>;
|
||||
}
|
||||
|
||||
export const BlacklistButton: FunctionComponent<Props> = ({
|
||||
history,
|
||||
update,
|
||||
promise,
|
||||
}) => {
|
||||
const { provider, subs_id, language, subtitles_path, blacklisted } = history;
|
||||
|
||||
if (subs_id && provider && language) {
|
||||
return (
|
||||
<AsyncButton
|
||||
size="sm"
|
||||
variant="light"
|
||||
noReset
|
||||
disabled={blacklisted}
|
||||
promise={() => {
|
||||
const { code2 } = language;
|
||||
const form: FormType.AddBlacklist = {
|
||||
provider,
|
||||
subs_id,
|
||||
subtitles_path,
|
||||
language: code2,
|
||||
};
|
||||
return promise(form);
|
||||
}}
|
||||
onSuccess={update}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFileExcel}></FontAwesomeIcon>
|
||||
</AsyncButton>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -1,5 +1,3 @@
|
|||
export * from "./Chips";
|
||||
export { default as Action } from "./Action";
|
||||
export * from "./FileBrowser";
|
||||
export * from "./FileForm";
|
||||
export * from "./Selector";
|
||||
export * from "./Slider";
|
||||
|
|
|
@ -4,19 +4,26 @@ import {
|
|||
useMovieAddBlacklist,
|
||||
useMovieHistory,
|
||||
} from "@/apis/hooks";
|
||||
import { useModal, usePayload, withModal } from "@/modules/modals";
|
||||
import { withModal } from "@/modules/modals";
|
||||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Badge, Center, Text } from "@mantine/core";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { HistoryIcon, PageTable, QueryOverlay, TextPopover } from "..";
|
||||
import { PageTable } from "..";
|
||||
import MutateAction from "../async/MutateAction";
|
||||
import QueryOverlay from "../async/QueryOverlay";
|
||||
import { HistoryIcon } from "../bazarr";
|
||||
import Language from "../bazarr/Language";
|
||||
import { BlacklistButton } from "../inputs/blacklist";
|
||||
import TextPopover from "../TextPopover";
|
||||
|
||||
const MovieHistoryView: FunctionComponent = () => {
|
||||
const movie = usePayload<Item.Movie>();
|
||||
interface MovieHistoryViewProps {
|
||||
movie: Item.Movie;
|
||||
}
|
||||
|
||||
const Modal = useModal({ size: "lg" });
|
||||
|
||||
const history = useMovieHistory(movie?.radarrId);
|
||||
const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
|
||||
movie,
|
||||
}) => {
|
||||
const history = useMovieHistory(movie.radarrId);
|
||||
|
||||
const { data } = history;
|
||||
|
||||
|
@ -24,17 +31,22 @@ const MovieHistoryView: FunctionComponent = () => {
|
|||
() => [
|
||||
{
|
||||
accessor: "action",
|
||||
className: "text-center",
|
||||
Cell: (row) => {
|
||||
return <HistoryIcon action={row.value}></HistoryIcon>;
|
||||
},
|
||||
Cell: (row) => (
|
||||
<Center>
|
||||
<HistoryIcon action={row.value}></HistoryIcon>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <Language.Text value={value} long></Language.Text>;
|
||||
return (
|
||||
<Badge>
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -51,144 +63,173 @@ const MovieHistoryView: FunctionComponent = () => {
|
|||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
Cell: (row) => {
|
||||
if (row.value) {
|
||||
return (
|
||||
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
|
||||
<span>{row.value}</span>
|
||||
</TextPopover>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
Cell: ({ value, row }) => {
|
||||
return (
|
||||
<TextPopover text={row.original.parsed_timestamp}>
|
||||
<Text>{value}</Text>
|
||||
</TextPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Actions
|
||||
accessor: "blacklisted",
|
||||
Cell: ({ row }) => {
|
||||
const { radarrId } = row.original;
|
||||
const { mutateAsync } = useMovieAddBlacklist();
|
||||
return (
|
||||
<BlacklistButton
|
||||
update={history.refetch}
|
||||
promise={(form) => mutateAsync({ id: radarrId, form })}
|
||||
history={row.original}
|
||||
></BlacklistButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[history.refetch]
|
||||
);
|
||||
Cell: ({ row, value }) => {
|
||||
const add = useMovieAddBlacklist();
|
||||
const { radarrId, provider, subs_id, language, subtitles_path } =
|
||||
row.original;
|
||||
|
||||
return (
|
||||
<Modal title={`History - ${movie?.title ?? ""}`}>
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
emptyText="No History Found"
|
||||
columns={columns}
|
||||
data={data ?? []}
|
||||
></PageTable>
|
||||
</QueryOverlay>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const MovieHistoryModal = withModal(MovieHistoryView, "movie-history");
|
||||
|
||||
const EpisodeHistoryView: FunctionComponent = () => {
|
||||
const episode = usePayload<Item.Episode>();
|
||||
|
||||
const Modal = useModal({ size: "lg" });
|
||||
|
||||
const history = useEpisodeHistory(episode?.sonarrEpisodeId);
|
||||
|
||||
const { data } = history;
|
||||
|
||||
const columns = useMemo<Column<History.Episode>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "action",
|
||||
className: "text-center",
|
||||
Cell: (row) => {
|
||||
return <HistoryIcon action={row.value}></HistoryIcon>;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return <Language.Text value={value} long></Language.Text>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
},
|
||||
{
|
||||
Header: "Score",
|
||||
accessor: "score",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
Cell: (row) => {
|
||||
if (row.value) {
|
||||
if (subs_id && provider && language) {
|
||||
return (
|
||||
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
|
||||
<span>{row.value}</span>
|
||||
</TextPopover>
|
||||
<MutateAction
|
||||
disabled={value}
|
||||
icon={faFileExcel}
|
||||
mutation={add}
|
||||
args={() => ({
|
||||
id: radarrId,
|
||||
form: {
|
||||
provider,
|
||||
subs_id,
|
||||
subtitles_path,
|
||||
language: language.code2,
|
||||
},
|
||||
})}
|
||||
></MutateAction>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// Actions
|
||||
accessor: "blacklisted",
|
||||
Cell: ({ row }) => {
|
||||
const original = row.original;
|
||||
|
||||
const { sonarrEpisodeId, sonarrSeriesId } = original;
|
||||
const { mutateAsync } = useEpisodeAddBlacklist();
|
||||
return (
|
||||
<BlacklistButton
|
||||
history={original}
|
||||
promise={(form) =>
|
||||
mutateAsync({
|
||||
seriesId: sonarrSeriesId,
|
||||
episodeId: sonarrEpisodeId,
|
||||
form,
|
||||
})
|
||||
}
|
||||
></BlacklistButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title={`History - ${episode?.title ?? ""}`}>
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
emptyText="No History Found"
|
||||
columns={columns}
|
||||
data={data ?? []}
|
||||
></PageTable>
|
||||
</QueryOverlay>
|
||||
</Modal>
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
columns={columns}
|
||||
data={data ?? []}
|
||||
tableStyles={{ emptyText: "No history found" }}
|
||||
></PageTable>
|
||||
</QueryOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export const MovieHistoryModal = withModal(MovieHistoryView, "movie-history", {
|
||||
size: "xl",
|
||||
title: "Movie History",
|
||||
});
|
||||
|
||||
interface EpisodeHistoryViewProps {
|
||||
episode: Item.Episode;
|
||||
}
|
||||
|
||||
const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
|
||||
episode,
|
||||
}) => {
|
||||
const history = useEpisodeHistory(episode.sonarrEpisodeId);
|
||||
|
||||
const { data } = history;
|
||||
|
||||
const columns = useMemo<Column<History.Episode>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: "action",
|
||||
Cell: (row) => (
|
||||
<Center>
|
||||
<HistoryIcon action={row.value}></HistoryIcon>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ value }) => {
|
||||
if (value) {
|
||||
return (
|
||||
<Badge>
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
},
|
||||
{
|
||||
Header: "Score",
|
||||
accessor: "score",
|
||||
},
|
||||
{
|
||||
Header: "Date",
|
||||
accessor: "timestamp",
|
||||
Cell: ({ row, value }) => {
|
||||
return (
|
||||
<TextPopover text={row.original.parsed_timestamp}>
|
||||
<Text>{value}</Text>
|
||||
</TextPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Actions
|
||||
accessor: "blacklisted",
|
||||
Cell: ({ row, value }) => {
|
||||
const {
|
||||
sonarrEpisodeId,
|
||||
sonarrSeriesId,
|
||||
provider,
|
||||
subs_id,
|
||||
language,
|
||||
subtitles_path,
|
||||
} = row.original;
|
||||
const add = useEpisodeAddBlacklist();
|
||||
|
||||
if (subs_id && provider && language) {
|
||||
return (
|
||||
<MutateAction
|
||||
disabled={value}
|
||||
icon={faFileExcel}
|
||||
mutation={add}
|
||||
args={() => ({
|
||||
seriesId: sonarrSeriesId,
|
||||
episodeId: sonarrEpisodeId,
|
||||
form: {
|
||||
provider,
|
||||
subs_id,
|
||||
subtitles_path,
|
||||
language: language.code2,
|
||||
},
|
||||
})}
|
||||
></MutateAction>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryOverlay result={history}>
|
||||
<PageTable
|
||||
tableStyles={{ emptyText: "No history found", placeholder: 5 }}
|
||||
columns={columns}
|
||||
data={data ?? []}
|
||||
></PageTable>
|
||||
</QueryOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
export const EpisodeHistoryModal = withModal(
|
||||
EpisodeHistoryView,
|
||||
"episode-history"
|
||||
"episode-history",
|
||||
{ size: "xl" }
|
||||
);
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
import { useIsAnyActionRunning, useLanguageProfiles } from "@/apis/hooks";
|
||||
import {
|
||||
useModal,
|
||||
useModalControl,
|
||||
usePayload,
|
||||
withModal,
|
||||
} from "@/modules/modals";
|
||||
import { GetItemId } from "@/utilities";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
import { Container, Form } from "react-bootstrap";
|
||||
import { UseMutationResult } from "react-query";
|
||||
import { AsyncButton, Selector, SelectorOption } from "..";
|
||||
|
||||
interface Props {
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
|
||||
}
|
||||
|
||||
const Editor: FunctionComponent<Props> = ({ mutation }) => {
|
||||
const { data: profiles } = useLanguageProfiles();
|
||||
|
||||
const payload = usePayload<Item.Base>();
|
||||
const { mutateAsync, isLoading } = mutation;
|
||||
|
||||
const { hide } = useModalControl();
|
||||
|
||||
const hasTask = useIsAnyActionRunning();
|
||||
|
||||
const profileOptions = useMemo<SelectorOption<number>[]>(
|
||||
() =>
|
||||
profiles?.map((v) => {
|
||||
return { label: v.name, value: v.profileId };
|
||||
}) ?? [],
|
||||
[profiles]
|
||||
);
|
||||
|
||||
const [id, setId] = useState<Nullable<number>>(payload?.profileId ?? null);
|
||||
|
||||
const Modal = useModal({
|
||||
closeable: !isLoading,
|
||||
onMounted: () => {
|
||||
setId(payload?.profileId ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
const footer = (
|
||||
<AsyncButton
|
||||
noReset
|
||||
disabled={hasTask}
|
||||
promise={() => {
|
||||
if (payload) {
|
||||
const itemId = GetItemId(payload);
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mutateAsync({
|
||||
id: [itemId],
|
||||
profileid: [id],
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
onSuccess={() => hide()}
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title={payload?.title ?? "Item Editor"} footer={footer}>
|
||||
<Container fluid>
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Label>Audio</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
disabled
|
||||
defaultValue={payload?.audio_language
|
||||
.map((v) => v.name)
|
||||
.join(", ")}
|
||||
></Form.Control>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Languages Profiles</Form.Label>
|
||||
<Selector
|
||||
clearable
|
||||
disabled={hasTask}
|
||||
options={profileOptions}
|
||||
value={id}
|
||||
onChange={(v) => setId(v === undefined ? null : v)}
|
||||
></Selector>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Container>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(Editor, "edit");
|
|
@ -1,29 +1,35 @@
|
|||
import { useModal, usePayload, withModal } from "@/modules/modals";
|
||||
import { createAndDispatchTask } from "@/modules/task/utilities";
|
||||
import { GetItemId, isMovie } from "@/utilities";
|
||||
import { withModal } from "@/modules/modals";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { BuildKey, GetItemId } from "@/utilities";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faExclamationCircle,
|
||||
faInfoCircle,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import clsx from "clsx";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Col,
|
||||
Collapse,
|
||||
Container,
|
||||
OverlayTrigger,
|
||||
Divider,
|
||||
Group,
|
||||
List,
|
||||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import { UseQueryResult } from "react-query";
|
||||
import { Column } from "react-table";
|
||||
import { LoadingIndicator, PageTable } from "..";
|
||||
import { Action, PageTable } from "..";
|
||||
import Language from "../bazarr/Language";
|
||||
|
||||
type SupportType = Item.Movie | Item.Episode;
|
||||
|
@ -33,12 +39,11 @@ interface Props<T extends SupportType> {
|
|||
query: (
|
||||
id?: number
|
||||
) => UseQueryResult<SearchResultType[] | undefined, unknown>;
|
||||
item: T;
|
||||
}
|
||||
|
||||
function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||
const { download, query: useSearch } = props;
|
||||
|
||||
const item = usePayload<T>();
|
||||
const { download, query: useSearch, item } = props;
|
||||
|
||||
const itemId = useMemo(() => GetItemId(item ?? {}), [item]);
|
||||
|
||||
|
@ -49,17 +54,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
const isStale = results.data === undefined;
|
||||
|
||||
const search = useCallback(() => {
|
||||
if (itemId !== undefined) {
|
||||
setId(itemId);
|
||||
results.refetch();
|
||||
}
|
||||
setId(itemId);
|
||||
results.refetch();
|
||||
}, [itemId, results]);
|
||||
|
||||
const columns = useMemo<Column<SearchResultType>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Score",
|
||||
accessor: (d) => `${d.score}%`,
|
||||
accessor: "score",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value}%</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "language",
|
||||
|
@ -71,7 +78,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
name: "",
|
||||
};
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Badge>
|
||||
<Language.Text value={lang}></Language.Text>
|
||||
</Badge>
|
||||
);
|
||||
|
@ -81,13 +88,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
Cell: (row) => {
|
||||
const { classes } = useTableStyles();
|
||||
const value = row.value;
|
||||
const { url } = row.row.original;
|
||||
if (url) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Anchor
|
||||
className={classes.noWrap}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
return value;
|
||||
|
@ -97,55 +110,44 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
{
|
||||
Header: "Release",
|
||||
accessor: "release_info",
|
||||
className: "text-nowrap",
|
||||
Cell: (row) => {
|
||||
const value = row.value;
|
||||
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
value.slice(1).map((v, idx) => (
|
||||
<span className="release-text hidden-item" key={idx}>
|
||||
{v}
|
||||
</span>
|
||||
)),
|
||||
() => value.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>),
|
||||
[value]
|
||||
);
|
||||
|
||||
if (value.length === 0) {
|
||||
return <span className="text-muted">Cannot get release info</span>;
|
||||
return <Text color="dimmed">Cannot get release info</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"release-container d-flex justify-content-between align-items-center",
|
||||
{ "release-multi": value.length > 1 }
|
||||
)}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div className="text-container">
|
||||
<span className="release-text">{value[0]}</span>
|
||||
<Collapse in={open}>
|
||||
<div>{items}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
{value.length > 1 && (
|
||||
<FontAwesomeIcon
|
||||
className="release-icon"
|
||||
icon={faCaretDown}
|
||||
rotation={open ? 180 : undefined}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
</div>
|
||||
<Stack spacing={0} onClick={() => setOpen((o) => !o)}>
|
||||
<Text className={classes.primary}>
|
||||
{value[0]}
|
||||
{value.length > 1 && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCaretDown}
|
||||
rotation={open ? 180 : undefined}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
</Text>
|
||||
<Collapse in={open}>
|
||||
<>{items}</>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Upload",
|
||||
accessor: (d) => d.uploader ?? "-",
|
||||
accessor: "uploader",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value ?? "-"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "matches",
|
||||
|
@ -159,24 +161,23 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Cell: ({ row }) => {
|
||||
const result = row.original;
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
<Action
|
||||
icon={faDownload}
|
||||
color="brand"
|
||||
variant="light"
|
||||
disabled={item === null}
|
||||
onClick={() => {
|
||||
if (!item) return;
|
||||
|
||||
createAndDispatchTask(
|
||||
task.create(
|
||||
item.title,
|
||||
"download-subtitles",
|
||||
TaskGroup.DownloadSubtitle,
|
||||
download,
|
||||
item,
|
||||
result
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload}></FontAwesomeIcon>
|
||||
</Button>
|
||||
></Action>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
@ -184,141 +185,84 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
[download, item]
|
||||
);
|
||||
|
||||
const content = () => {
|
||||
if (results.isFetching) {
|
||||
return <LoadingIndicator animation="grow"></LoadingIndicator>;
|
||||
} else if (isStale) {
|
||||
return (
|
||||
<div className="px-4 py-5">
|
||||
<p className="mb-3 small">{item?.path ?? ""}</p>
|
||||
<Button variant="primary" block onClick={search}>
|
||||
Start Search
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-3 small">{item?.path ?? ""}</p>
|
||||
<PageTable
|
||||
emptyText="No Result"
|
||||
columns={columns}
|
||||
data={results.data ?? []}
|
||||
></PageTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const title = useMemo(() => {
|
||||
let title = "Unknown";
|
||||
|
||||
if (item) {
|
||||
if (item.sceneName) {
|
||||
title = item.sceneName;
|
||||
} else if (isMovie(item)) {
|
||||
title = item.title;
|
||||
} else {
|
||||
title = item.title;
|
||||
}
|
||||
}
|
||||
return `Search - ${title}`;
|
||||
}, [item]);
|
||||
|
||||
const Modal = useModal({
|
||||
size: "xl",
|
||||
closeable: results.isFetching === false,
|
||||
onMounted: () => {
|
||||
// Cleanup the ID when user switches episode / movie
|
||||
if (itemId !== id) {
|
||||
setId(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const footer = (
|
||||
<Button variant="light" hidden={isStale} onClick={search}>
|
||||
Search Again
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title={title} footer={footer}>
|
||||
{content()}
|
||||
</Modal>
|
||||
<Stack>
|
||||
<Alert
|
||||
title="Resource"
|
||||
color="gray"
|
||||
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
|
||||
>
|
||||
{item?.path}
|
||||
</Alert>
|
||||
<Collapse in={!isStale && !results.isFetching}>
|
||||
<PageTable
|
||||
tableStyles={{ emptyText: "No result", placeholder: 10 }}
|
||||
columns={columns}
|
||||
data={results.data ?? []}
|
||||
></PageTable>
|
||||
</Collapse>
|
||||
<Divider></Divider>
|
||||
<Button loading={results.isFetching} fullWidth onClick={search}>
|
||||
{isStale ? "Search" : "Search Again"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const MovieSearchModal = withModal<Props<Item.Movie>>(
|
||||
ManualSearchView,
|
||||
"movie-manual-search"
|
||||
"movie-manual-search",
|
||||
{ title: "Search Subtitles", size: "xl" }
|
||||
);
|
||||
export const EpisodeSearchModal = withModal<Props<Item.Episode>>(
|
||||
ManualSearchView,
|
||||
"episode-manual-search"
|
||||
"episode-manual-search",
|
||||
{ title: "Search Subtitles", size: "xl" }
|
||||
);
|
||||
|
||||
const StateIcon: FunctionComponent<{ matches: string[]; dont: string[] }> = ({
|
||||
matches,
|
||||
dont,
|
||||
}) => {
|
||||
let icon = faCheck;
|
||||
let color = "var(--success)";
|
||||
if (dont.length > 0) {
|
||||
icon = faInfoCircle;
|
||||
color = "var(--warning)";
|
||||
}
|
||||
const hasIssues = dont.length > 0;
|
||||
|
||||
const matchElements = useMemo(
|
||||
() =>
|
||||
matches.map((v, idx) => (
|
||||
<p key={`match-${idx}`} className="text-nowrap m-0">
|
||||
{v}
|
||||
</p>
|
||||
)),
|
||||
[matches]
|
||||
);
|
||||
const dontElements = useMemo(
|
||||
() =>
|
||||
dont.map((v, idx) => (
|
||||
<p key={`dont-${idx}`} className="text-nowrap m-0">
|
||||
{v}
|
||||
</p>
|
||||
)),
|
||||
[dont]
|
||||
);
|
||||
|
||||
const popover = useMemo(
|
||||
() => (
|
||||
<Popover className="w-100" id="manual-search-matches-info">
|
||||
<Popover.Content>
|
||||
<Container fluid>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<FontAwesomeIcon
|
||||
color="var(--success)"
|
||||
icon={faCheck}
|
||||
></FontAwesomeIcon>
|
||||
{matchElements}
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<FontAwesomeIcon
|
||||
color="var(--danger)"
|
||||
icon={faTimes}
|
||||
></FontAwesomeIcon>
|
||||
{dontElements}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
),
|
||||
[matchElements, dontElements]
|
||||
);
|
||||
const { ref, hovered } = useHover();
|
||||
|
||||
return (
|
||||
<OverlayTrigger overlay={popover} placement={"left"}>
|
||||
<FontAwesomeIcon icon={icon} color={color}></FontAwesomeIcon>
|
||||
</OverlayTrigger>
|
||||
<Popover
|
||||
opened={hovered}
|
||||
placement="center"
|
||||
position="top"
|
||||
target={
|
||||
<Text color={hasIssues ? "yellow" : "green"} ref={ref}>
|
||||
<FontAwesomeIcon
|
||||
icon={hasIssues ? faExclamationCircle : faCheckCircle}
|
||||
></FontAwesomeIcon>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Group align="flex-start" spacing="xl">
|
||||
<Stack align="flex-start" spacing="xs">
|
||||
<Text color="green">
|
||||
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
|
||||
</Text>
|
||||
<List>
|
||||
{matches.map((v, idx) => (
|
||||
<List.Item key={BuildKey(idx, v, "match")}>{v}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
<Stack align="flex-start" spacing="xs">
|
||||
<Text color="yellow">
|
||||
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
|
||||
</Text>
|
||||
<List>
|
||||
{dont.map((v, idx) => (
|
||||
<List.Item key={BuildKey(idx, v, "miss")}>{v}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
import { useMovieSubtitleModification } from "@/apis/hooks";
|
||||
import { usePayload, withModal } from "@/modules/modals";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback } from "react";
|
||||
import SubtitleUploader, {
|
||||
PendingSubtitle,
|
||||
Validator,
|
||||
} from "./SubtitleUploadModal";
|
||||
|
||||
const MovieUploadModal: FunctionComponent = () => {
|
||||
const payload = usePayload<Item.Movie>();
|
||||
|
||||
const profile = useLanguageProfileBy(payload?.profileId);
|
||||
|
||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
||||
|
||||
const update = useCallback(async (list: PendingSubtitle<unknown>[]) => {
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const {
|
||||
upload: { mutateAsync },
|
||||
} = useMovieSubtitleModification();
|
||||
|
||||
const validate = useCallback<Validator<unknown>>(
|
||||
(item) => {
|
||||
if (item.language === null) {
|
||||
return {
|
||||
state: "error",
|
||||
messages: ["Language is not selected"],
|
||||
};
|
||||
} else if (
|
||||
payload?.subtitles.find((v) => v.code2 === item.language?.code2) !==
|
||||
undefined
|
||||
) {
|
||||
return {
|
||||
state: "warning",
|
||||
messages: ["Override existing subtitle"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "valid",
|
||||
messages: [],
|
||||
};
|
||||
},
|
||||
[payload?.subtitles]
|
||||
);
|
||||
|
||||
const upload = useCallback(
|
||||
(items: PendingSubtitle<unknown>[]) => {
|
||||
if (payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { radarrId } = payload;
|
||||
|
||||
const tasks = items
|
||||
.filter((v) => v.language !== null)
|
||||
.map((v) => {
|
||||
const { file, language, forced, hi } = v;
|
||||
|
||||
if (language === null) {
|
||||
throw new Error("Language is not selected");
|
||||
}
|
||||
|
||||
return createTask(file.name, mutateAsync, {
|
||||
radarrId,
|
||||
form: {
|
||||
file,
|
||||
forced,
|
||||
hi,
|
||||
language: language.code2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
dispatchTask(tasks, "upload-subtitles");
|
||||
},
|
||||
[mutateAsync, payload]
|
||||
);
|
||||
|
||||
return (
|
||||
<SubtitleUploader
|
||||
hideAllLanguages
|
||||
initial={{ forced: false }}
|
||||
availableLanguages={availableLanguages}
|
||||
columns={[]}
|
||||
upload={upload}
|
||||
update={update}
|
||||
validate={validate}
|
||||
></SubtitleUploader>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(MovieUploadModal, "movie-upload");
|
|
@ -1,175 +0,0 @@
|
|||
import { useEpisodeSubtitleModification } from "@/apis/hooks";
|
||||
import api from "@/apis/raw";
|
||||
import { usePayload, withModal } from "@/modules/modals";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import { Selector, SelectorOption } from "../inputs";
|
||||
import SubtitleUploader, {
|
||||
PendingSubtitle,
|
||||
useRowMutation,
|
||||
Validator,
|
||||
} from "./SubtitleUploadModal";
|
||||
|
||||
interface Payload {
|
||||
instance: Item.Episode | null;
|
||||
}
|
||||
|
||||
interface SeriesProps {
|
||||
episodes: readonly Item.Episode[];
|
||||
}
|
||||
|
||||
const SeriesUploadModal: FunctionComponent<SeriesProps> = ({ episodes }) => {
|
||||
const payload = usePayload<Item.Series>();
|
||||
|
||||
const profile = useLanguageProfileBy(payload?.profileId);
|
||||
|
||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
||||
|
||||
const {
|
||||
upload: { mutateAsync },
|
||||
} = useEpisodeSubtitleModification();
|
||||
|
||||
const update = useCallback(
|
||||
async (list: PendingSubtitle<Payload>[]) => {
|
||||
const newList = [...list];
|
||||
const names = list.map((v) => v.file.name);
|
||||
|
||||
if (names.length > 0) {
|
||||
const results = await api.subtitles.info(names);
|
||||
|
||||
// TODO: Optimization
|
||||
newList.forEach((v) => {
|
||||
const info = results.find((f) => f.filename === v.file.name);
|
||||
if (info) {
|
||||
v.payload.instance =
|
||||
episodes.find(
|
||||
(e) => e.season === info.season && e.episode === info.episode
|
||||
) ?? null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newList;
|
||||
},
|
||||
[episodes]
|
||||
);
|
||||
|
||||
const validate = useCallback<Validator<Payload>>((item) => {
|
||||
const { language } = item;
|
||||
const { instance } = item.payload;
|
||||
if (language === null || instance === null) {
|
||||
return {
|
||||
state: "error",
|
||||
messages: ["Language or Episode is not selected"],
|
||||
};
|
||||
} else if (
|
||||
instance.subtitles.find((v) => v.code2 === language.code2) !== undefined
|
||||
) {
|
||||
return {
|
||||
state: "warning",
|
||||
messages: ["Override existing subtitle"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "valid",
|
||||
messages: [],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
(items: PendingSubtitle<Payload>[]) => {
|
||||
if (payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sonarrSeriesId: seriesId } = payload;
|
||||
|
||||
const tasks = items
|
||||
.filter((v) => v.payload.instance !== undefined)
|
||||
.map((v) => {
|
||||
const {
|
||||
hi,
|
||||
forced,
|
||||
payload: { instance },
|
||||
language,
|
||||
} = v;
|
||||
|
||||
if (language === null || instance === null) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
|
||||
const { code2 } = language;
|
||||
const { sonarrEpisodeId: episodeId } = instance;
|
||||
|
||||
const form: FormType.UploadSubtitle = {
|
||||
file: v.file,
|
||||
language: code2,
|
||||
hi: hi,
|
||||
forced: forced,
|
||||
};
|
||||
|
||||
return createTask(v.file.name, mutateAsync, {
|
||||
seriesId,
|
||||
episodeId,
|
||||
form,
|
||||
});
|
||||
});
|
||||
|
||||
dispatchTask(tasks, "upload-subtitles");
|
||||
},
|
||||
[mutateAsync, payload]
|
||||
);
|
||||
|
||||
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "instance",
|
||||
Header: "Episode",
|
||||
accessor: "payload",
|
||||
className: "vw-1",
|
||||
Cell: ({ value, row }) => {
|
||||
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
|
||||
label: `(${ep.season}x${ep.episode}) ${ep.title}`,
|
||||
value: ep,
|
||||
}));
|
||||
|
||||
const mutate = useRowMutation();
|
||||
|
||||
return (
|
||||
<Selector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={options}
|
||||
value={value.instance}
|
||||
onChange={(ep: Nullable<Item.Episode>) => {
|
||||
if (ep) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.payload.instance = ep;
|
||||
mutate(row.index, newInfo);
|
||||
}
|
||||
}}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[episodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<SubtitleUploader
|
||||
columns={columns}
|
||||
initial={{ instance: null }}
|
||||
availableLanguages={availableLanguages}
|
||||
upload={upload}
|
||||
update={update}
|
||||
validate={validate}
|
||||
></SubtitleUploader>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(SeriesUploadModal, "series-upload");
|
123
frontend/src/components/modals/SubtitleToolsModal.tsx
Normal file
123
frontend/src/components/modals/SubtitleToolsModal.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
import Language from "@/components/bazarr/Language";
|
||||
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
|
||||
import { SimpleTable } from "@/components/tables";
|
||||
import { useCustomSelection } from "@/components/tables/plugins";
|
||||
import { withModal } from "@/modules/modals";
|
||||
import { isMovie } from "@/utilities";
|
||||
import { Badge, Button, Divider, Group, Stack } from "@mantine/core";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
|
||||
type SupportType = Item.Episode | Item.Movie;
|
||||
|
||||
type TableColumnType = FormType.ModifySubtitle & {
|
||||
raw_language: Language.Info;
|
||||
};
|
||||
|
||||
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
|
||||
if (isMovie(item)) {
|
||||
return [item.radarrId, "movie"];
|
||||
} else {
|
||||
return [item.sonarrEpisodeId, "episode"];
|
||||
}
|
||||
}
|
||||
|
||||
const CanSelectSubtitle = (item: TableColumnType) => {
|
||||
return item.path.endsWith(".srt");
|
||||
};
|
||||
|
||||
interface SubtitleToolViewProps {
|
||||
payload: SupportType[];
|
||||
}
|
||||
|
||||
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
|
||||
payload,
|
||||
}) => {
|
||||
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
||||
|
||||
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "raw_language",
|
||||
Cell: ({ value }) => (
|
||||
<Badge color="secondary">
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
Header: "File",
|
||||
accessor: "path",
|
||||
Cell: ({ value }) => {
|
||||
const path = value;
|
||||
|
||||
let idx = path.lastIndexOf("/");
|
||||
|
||||
if (idx === -1) {
|
||||
idx = path.lastIndexOf("\\");
|
||||
}
|
||||
|
||||
if (idx !== -1) {
|
||||
return path.slice(idx + 1);
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const data = useMemo<TableColumnType[]>(
|
||||
() =>
|
||||
payload.flatMap((item) => {
|
||||
const [id, type] = getIdAndType(item);
|
||||
return item.subtitles.flatMap((v) => {
|
||||
if (v.path) {
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type,
|
||||
language: v.code2,
|
||||
path: v.path,
|
||||
raw_language: v,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}),
|
||||
[payload]
|
||||
);
|
||||
|
||||
const plugins = [useRowSelect, useCustomSelection];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<SimpleTable
|
||||
tableStyles={{ emptyText: "No external subtitles found" }}
|
||||
plugins={plugins}
|
||||
columns={columns}
|
||||
onSelect={setSelections}
|
||||
canSelect={CanSelectSubtitle}
|
||||
data={data}
|
||||
></SimpleTable>
|
||||
<Divider></Divider>
|
||||
<Group>
|
||||
<SubtitleToolsMenu selections={selections}>
|
||||
<Button disabled={selections.length === 0} variant="light">
|
||||
Select Action
|
||||
</Button>
|
||||
</SubtitleToolsMenu>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(SubtitleToolView, "subtitle-tools", {
|
||||
title: "Subtitle Tools",
|
||||
size: "xl",
|
||||
});
|
|
@ -1,362 +0,0 @@
|
|||
import { useModal, useModalControl } from "@/modules/modals";
|
||||
import { BuildKey } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
faCheck,
|
||||
faCircleNotch,
|
||||
faInfoCircle,
|
||||
faTimes,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, Container, Form } from "react-bootstrap";
|
||||
import { Column } from "react-table";
|
||||
import { LanguageSelector, MessageIcon } from "..";
|
||||
import { FileForm } from "../inputs";
|
||||
import { SimpleTable } from "../tables";
|
||||
|
||||
type ModifyFn<T> = (index: number, info?: PendingSubtitle<T>) => void;
|
||||
|
||||
const RowContext = createContext<ModifyFn<unknown>>(() => {
|
||||
LOG("error", "RowContext not initialized");
|
||||
});
|
||||
|
||||
export function useRowMutation() {
|
||||
return useContext(RowContext);
|
||||
}
|
||||
|
||||
export interface PendingSubtitle<P> {
|
||||
file: File;
|
||||
state: "valid" | "fetching" | "warning" | "error";
|
||||
messages: string[];
|
||||
language: Language.Info | null;
|
||||
forced: boolean;
|
||||
hi: boolean;
|
||||
payload: P;
|
||||
}
|
||||
|
||||
export type Validator<T> = (
|
||||
item: PendingSubtitle<T>
|
||||
) => Pick<PendingSubtitle<T>, "state" | "messages">;
|
||||
|
||||
interface Props<T = unknown> {
|
||||
initial: T;
|
||||
availableLanguages: Language.Info[];
|
||||
upload: (items: PendingSubtitle<T>[]) => void;
|
||||
update: (items: PendingSubtitle<T>[]) => Promise<PendingSubtitle<T>[]>;
|
||||
validate: Validator<T>;
|
||||
columns: Column<PendingSubtitle<T>>[];
|
||||
hideAllLanguages?: boolean;
|
||||
}
|
||||
|
||||
function SubtitleUploader<T>(props: Props<T>) {
|
||||
const {
|
||||
initial,
|
||||
columns,
|
||||
upload,
|
||||
update,
|
||||
validate,
|
||||
availableLanguages,
|
||||
hideAllLanguages,
|
||||
} = props;
|
||||
|
||||
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
|
||||
|
||||
const showTable = pending.length > 0;
|
||||
|
||||
const Modal = useModal({
|
||||
size: showTable ? "xl" : "lg",
|
||||
});
|
||||
|
||||
const { hide } = useModalControl();
|
||||
|
||||
const fileList = useMemo(() => pending.map((v) => v.file), [pending]);
|
||||
|
||||
const initialRef = useRef(initial);
|
||||
|
||||
const setFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const initialLanguage =
|
||||
availableLanguages.length > 0 ? availableLanguages[0] : null;
|
||||
let list = files.map<PendingSubtitle<T>>((file) => ({
|
||||
file,
|
||||
state: "fetching",
|
||||
messages: [],
|
||||
language: initialLanguage,
|
||||
forced: false,
|
||||
hi: false,
|
||||
payload: { ...initialRef.current },
|
||||
}));
|
||||
|
||||
if (update) {
|
||||
setPending(list);
|
||||
list = await update(list);
|
||||
} else {
|
||||
list = list.map<PendingSubtitle<T>>((v) => ({
|
||||
...v,
|
||||
state: "valid",
|
||||
}));
|
||||
}
|
||||
|
||||
list = list.map((v) => ({
|
||||
...v,
|
||||
...validate(v),
|
||||
}));
|
||||
|
||||
setPending(list);
|
||||
},
|
||||
[update, validate, availableLanguages]
|
||||
);
|
||||
|
||||
const modify = useCallback(
|
||||
(index: number, info?: PendingSubtitle<T>) => {
|
||||
setPending((pd) => {
|
||||
const newPending = [...pd];
|
||||
if (info) {
|
||||
info = { ...info, ...validate(info) };
|
||||
newPending[index] = info;
|
||||
} else {
|
||||
newPending.splice(index, 1);
|
||||
}
|
||||
return newPending;
|
||||
});
|
||||
},
|
||||
[validate]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPending((pd) => {
|
||||
const newPd = pd.map((v) => {
|
||||
if (v.state !== "fetching") {
|
||||
return { ...v, ...validate(v) };
|
||||
} else {
|
||||
return v;
|
||||
}
|
||||
});
|
||||
|
||||
return newPd;
|
||||
});
|
||||
}, [validate]);
|
||||
|
||||
const columnsWithAction = useMemo<Column<PendingSubtitle<T>>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "icon",
|
||||
accessor: "state",
|
||||
className: "text-center",
|
||||
Cell: ({ value, row }) => {
|
||||
let icon = faCircleNotch;
|
||||
let color: string | undefined = undefined;
|
||||
let spin = false;
|
||||
|
||||
switch (value) {
|
||||
case "fetching":
|
||||
spin = true;
|
||||
break;
|
||||
case "warning":
|
||||
icon = faInfoCircle;
|
||||
color = "var(--warning)";
|
||||
break;
|
||||
case "valid":
|
||||
icon = faCheck;
|
||||
color = "var(--success)";
|
||||
break;
|
||||
default:
|
||||
icon = faTimes;
|
||||
color = "var(--danger)";
|
||||
break;
|
||||
}
|
||||
|
||||
const messages = row.original.messages;
|
||||
|
||||
return (
|
||||
<MessageIcon
|
||||
messages={messages}
|
||||
color={color}
|
||||
icon={icon}
|
||||
spin={spin}
|
||||
></MessageIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "File",
|
||||
accessor: (d) => d.file.name,
|
||||
},
|
||||
{
|
||||
id: "hi",
|
||||
Header: "HI",
|
||||
accessor: "hi",
|
||||
Cell: ({ row, value }) => {
|
||||
const { original, index } = row;
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Form.Check
|
||||
custom
|
||||
disabled={original.state === "fetching"}
|
||||
id={BuildKey(index, original.file.name, "hi")}
|
||||
checked={value}
|
||||
onChange={(v) => {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.hi = v.target.checked;
|
||||
mutate(row.index, newInfo);
|
||||
}}
|
||||
></Form.Check>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "forced",
|
||||
Header: "Forced",
|
||||
accessor: "forced",
|
||||
Cell: ({ row, value }) => {
|
||||
const { original, index } = row;
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Form.Check
|
||||
custom
|
||||
disabled={original.state === "fetching"}
|
||||
id={BuildKey(index, original.file.name, "forced")}
|
||||
checked={value}
|
||||
onChange={(v) => {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.forced = v.target.checked;
|
||||
mutate(row.index, newInfo);
|
||||
}}
|
||||
></Form.Check>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
className: "w-25",
|
||||
Cell: ({ row, value }) => {
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<LanguageSelector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={availableLanguages}
|
||||
value={value}
|
||||
onChange={(lang) => {
|
||||
if (lang) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.language = lang;
|
||||
mutate(row.index, newInfo);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
);
|
||||
},
|
||||
},
|
||||
...columns,
|
||||
{
|
||||
id: "action",
|
||||
accessor: "file",
|
||||
Cell: ({ row }) => {
|
||||
const mutate = useRowMutation();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
disabled={row.original.state === "fetching"}
|
||||
onClick={() => {
|
||||
mutate(row.index);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[columns, availableLanguages]
|
||||
);
|
||||
|
||||
const canUpload = useMemo(
|
||||
() =>
|
||||
pending.length > 0 &&
|
||||
pending.every((v) => v.state === "valid" || v.state === "warning"),
|
||||
[pending]
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
|
||||
<div>
|
||||
<Button
|
||||
hidden={!showTable}
|
||||
variant="outline-secondary"
|
||||
className="mr-2"
|
||||
onClick={() => setFiles([])}
|
||||
>
|
||||
Clean
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canUpload || !showTable}
|
||||
onClick={() => {
|
||||
upload(pending);
|
||||
setFiles([]);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-25" hidden={hideAllLanguages}>
|
||||
<LanguageSelector
|
||||
options={availableLanguages}
|
||||
value={null}
|
||||
disabled={!showTable}
|
||||
onChange={(lang) => {
|
||||
if (lang) {
|
||||
setPending((pd) =>
|
||||
pd
|
||||
.map((v) => ({ ...v, language: lang }))
|
||||
.map((v) => ({ ...v, ...validate(v) }))
|
||||
);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Update Subtitles" footer={footer}>
|
||||
<Container fluid className="flex-column">
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<FileForm
|
||||
disabled={showTable}
|
||||
emptyText="Select..."
|
||||
multiple
|
||||
value={fileList}
|
||||
onChange={setFiles}
|
||||
></FileForm>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<div hidden={!showTable}>
|
||||
<RowContext.Provider value={modify as ModifyFn<unknown>}>
|
||||
<SimpleTable
|
||||
columns={columnsWithAction}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
></SimpleTable>
|
||||
</RowContext.Provider>
|
||||
</div>
|
||||
</Container>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SubtitleUploader;
|
|
@ -1,4 +1,2 @@
|
|||
export * from "./HistoryModal";
|
||||
export { default as ItemEditorModal } from "./ItemEditorModal";
|
||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
||||
export { default as SubtitleToolsModal } from "./SubtitleToolsModal";
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
import { Selector } from "@/components";
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { submodProcessColor } from "@/utilities";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
import { colorOptions } from "./tools";
|
||||
|
||||
const ColorTool: FunctionComponent = () => {
|
||||
const [selection, setSelection] = useState<Nullable<string>>(null);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (selection) {
|
||||
const action = submodProcessColor(selection);
|
||||
process(action);
|
||||
}
|
||||
}, [process, selection]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={selection === null} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Choose Color" footer={footer}>
|
||||
<Selector options={colorOptions} onChange={setSelection}></Selector>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(ColorTool, "color-tool");
|
|
@ -1,65 +0,0 @@
|
|||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
|
||||
function submodProcessFrameRate(from: number, to: number) {
|
||||
return `change_FPS(from=${from},to=${to})`;
|
||||
}
|
||||
|
||||
const FrameRateTool: FunctionComponent = () => {
|
||||
const [from, setFrom] = useState<Nullable<number>>(null);
|
||||
const [to, setTo] = useState<Nullable<number>>(null);
|
||||
|
||||
const canSave = from !== null && to !== null && from !== to;
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (canSave) {
|
||||
const action = submodProcessFrameRate(from, to);
|
||||
process(action);
|
||||
}
|
||||
}, [canSave, from, process, to]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Change Frame Rate" footer={footer}>
|
||||
<InputGroup className="px-2">
|
||||
<Form.Control
|
||||
placeholder="From"
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
setFrom(null);
|
||||
} else {
|
||||
setFrom(value);
|
||||
}
|
||||
}}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
placeholder="To"
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
setTo(null);
|
||||
} else {
|
||||
setTo(value);
|
||||
}
|
||||
}}
|
||||
></Form.Control>
|
||||
</InputGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(FrameRateTool, "frame-rate-tool");
|
|
@ -1,100 +0,0 @@
|
|||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
|
||||
function submodProcessOffset(h: number, m: number, s: number, ms: number) {
|
||||
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
|
||||
}
|
||||
|
||||
const TimeAdjustmentTool: FunctionComponent = () => {
|
||||
const [isPlus, setPlus] = useState(true);
|
||||
const [offset, setOffset] = useState<[number, number, number, number]>([
|
||||
0, 0, 0, 0,
|
||||
]);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const updateOffset = useCallback(
|
||||
(idx: number): ChangeEventHandler<HTMLInputElement> => {
|
||||
return (e) => {
|
||||
let value = parseFloat(e.currentTarget.value);
|
||||
if (isNaN(value)) {
|
||||
value = 0;
|
||||
}
|
||||
const newOffset = [...offset] as [number, number, number, number];
|
||||
newOffset[idx] = value;
|
||||
setOffset(newOffset);
|
||||
};
|
||||
},
|
||||
[offset]
|
||||
);
|
||||
|
||||
const canSave = offset.some((v) => v !== 0);
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (canSave) {
|
||||
const newOffset = offset.map((v) => (isPlus ? v : -v));
|
||||
const action = submodProcessOffset(
|
||||
newOffset[0],
|
||||
newOffset[1],
|
||||
newOffset[2],
|
||||
newOffset[3]
|
||||
);
|
||||
process(action);
|
||||
}
|
||||
}, [canSave, offset, process, isPlus]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!canSave} onClick={submit}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title="Adjust Times" footer={footer}>
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={isPlus ? "Later" : "Earlier"}
|
||||
onClick={() => setPlus(!isPlus)}
|
||||
>
|
||||
<FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
|
||||
</Button>
|
||||
</InputGroup.Prepend>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="hour"
|
||||
onChange={updateOffset(0)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="min"
|
||||
onChange={updateOffset(1)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="sec"
|
||||
onChange={updateOffset(2)}
|
||||
></Form.Control>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="ms"
|
||||
onChange={updateOffset(3)}
|
||||
></Form.Control>
|
||||
</InputGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(TimeAdjustmentTool, "time-adjustment");
|
|
@ -1,14 +0,0 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ProcessSubtitleType = (
|
||||
action: string,
|
||||
override?: Partial<FormType.ModifySubtitle>
|
||||
) => void;
|
||||
|
||||
export const ProcessSubtitleContext = createContext<ProcessSubtitleType>(() => {
|
||||
throw new Error("ProcessSubtitleContext not initialized");
|
||||
});
|
||||
|
||||
export function useProcess() {
|
||||
return useContext(ProcessSubtitleContext);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { LanguageSelector } from "@/components/LanguageSelector";
|
||||
import { useModal, withModal } from "@/modules/modals";
|
||||
import { useEnabledLanguages } from "@/utilities/languages";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { useProcess } from "./ToolContext";
|
||||
import { availableTranslation } from "./tools";
|
||||
|
||||
const TranslationTool: FunctionComponent = () => {
|
||||
const { data: languages } = useEnabledLanguages();
|
||||
|
||||
const available = useMemo(
|
||||
() => languages.filter((v) => v.code2 in availableTranslation),
|
||||
[languages]
|
||||
);
|
||||
|
||||
const Modal = useModal();
|
||||
|
||||
const [selectedLanguage, setLanguage] =
|
||||
useState<Nullable<Language.Info>>(null);
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (selectedLanguage) {
|
||||
process("translate", { language: selectedLanguage.code2 });
|
||||
}
|
||||
}, [process, selectedLanguage]);
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!selectedLanguage} onClick={submit}>
|
||||
Translate
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Modal title="Translation" footer={footer}>
|
||||
<Form.Label>
|
||||
Enabled languages not listed here are unsupported by Google Translate.
|
||||
</Form.Label>
|
||||
<LanguageSelector
|
||||
options={available}
|
||||
onChange={setLanguage}
|
||||
></LanguageSelector>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withModal(TranslationTool, "translation-tool");
|
|
@ -1,230 +0,0 @@
|
|||
import { useSubtitleAction } from "@/apis/hooks";
|
||||
import Language from "@/components/bazarr/Language";
|
||||
import { ActionButton, ActionButtonItem } from "@/components/buttons";
|
||||
import { SimpleTable } from "@/components/tables";
|
||||
import { useCustomSelection } from "@/components/tables/plugins";
|
||||
import {
|
||||
useModal,
|
||||
useModalControl,
|
||||
usePayload,
|
||||
withModal,
|
||||
} from "@/modules/modals";
|
||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
||||
import { isMovie } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { isObject } from "lodash";
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||
import { Badge, ButtonGroup, Dropdown } from "react-bootstrap";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
import {
|
||||
ProcessSubtitleContext,
|
||||
ProcessSubtitleType,
|
||||
useProcess,
|
||||
} from "./ToolContext";
|
||||
import { tools } from "./tools";
|
||||
import { ToolOptions } from "./types";
|
||||
|
||||
type SupportType = Item.Episode | Item.Movie;
|
||||
|
||||
type TableColumnType = FormType.ModifySubtitle & {
|
||||
raw_language: Language.Info;
|
||||
};
|
||||
|
||||
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
|
||||
if (isMovie(item)) {
|
||||
return [item.radarrId, "movie"];
|
||||
} else {
|
||||
return [item.sonarrEpisodeId, "episode"];
|
||||
}
|
||||
}
|
||||
|
||||
const CanSelectSubtitle = (item: TableColumnType) => {
|
||||
return item.path.endsWith(".srt");
|
||||
};
|
||||
|
||||
function isElement(value: unknown): value is JSX.Element {
|
||||
return isObject(value);
|
||||
}
|
||||
|
||||
interface SubtitleToolViewProps {
|
||||
count: number;
|
||||
tools: ToolOptions[];
|
||||
select: (items: TableColumnType[]) => void;
|
||||
}
|
||||
|
||||
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
|
||||
tools,
|
||||
count,
|
||||
select,
|
||||
}) => {
|
||||
const payload = usePayload<SupportType[]>();
|
||||
|
||||
const Modal = useModal({
|
||||
size: "lg",
|
||||
});
|
||||
const { show } = useModalControl();
|
||||
|
||||
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
||||
() => [
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "raw_language",
|
||||
Cell: ({ value }) => (
|
||||
<Badge variant="secondary">
|
||||
<Language.Text value={value} long></Language.Text>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "file",
|
||||
Header: "File",
|
||||
accessor: "path",
|
||||
Cell: ({ value }) => {
|
||||
const path = value;
|
||||
|
||||
let idx = path.lastIndexOf("/");
|
||||
|
||||
if (idx === -1) {
|
||||
idx = path.lastIndexOf("\\");
|
||||
}
|
||||
|
||||
if (idx !== -1) {
|
||||
return path.slice(idx + 1);
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const data = useMemo<TableColumnType[]>(
|
||||
() =>
|
||||
payload?.flatMap((item) => {
|
||||
const [id, type] = getIdAndType(item);
|
||||
return item.subtitles.flatMap((v) => {
|
||||
if (v.path !== null) {
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type,
|
||||
language: v.code2,
|
||||
path: v.path,
|
||||
raw_language: v,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}) ?? [],
|
||||
[payload]
|
||||
);
|
||||
|
||||
const plugins = [useRowSelect, useCustomSelection];
|
||||
|
||||
const process = useProcess();
|
||||
|
||||
const footer = useMemo(() => {
|
||||
const action = tools[0];
|
||||
const others = tools.slice(1);
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
disabled={count === 0}
|
||||
icon={action.icon}
|
||||
onClick={() => process(action.key)}
|
||||
>
|
||||
{action.name}
|
||||
</ActionButton>
|
||||
<Dropdown.Toggle
|
||||
disabled={count === 0}
|
||||
split
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="px-2"
|
||||
></Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{others.map((v) => (
|
||||
<Dropdown.Item
|
||||
key={v.key}
|
||||
eventKey={v.modal ? undefined : v.key}
|
||||
onSelect={() => {
|
||||
if (v.modal) {
|
||||
show(v.modal);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionButtonItem icon={v.icon}>{v.name}</ActionButtonItem>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}, [count, process, show, tools]);
|
||||
|
||||
return (
|
||||
<Modal title="Subtitle Tools" footer={footer}>
|
||||
<SimpleTable
|
||||
emptyText="No External Subtitles Found"
|
||||
plugins={plugins}
|
||||
columns={columns}
|
||||
onSelect={select}
|
||||
canSelect={CanSelectSubtitle}
|
||||
data={data}
|
||||
></SimpleTable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleToolModal = withModal(SubtitleToolView, "subtitle-tools");
|
||||
|
||||
const SubtitleTools: FunctionComponent = () => {
|
||||
const modals = useMemo(
|
||||
() =>
|
||||
tools
|
||||
.map((t) => t.modal && <t.modal key={t.key}></t.modal>)
|
||||
.filter(isElement),
|
||||
[]
|
||||
);
|
||||
|
||||
const { hide } = useModalControl();
|
||||
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
||||
const { mutateAsync } = useSubtitleAction();
|
||||
|
||||
const process = useCallback<ProcessSubtitleType>(
|
||||
(action, override) => {
|
||||
LOG("info", "executing action", action);
|
||||
hide(SubtitleToolModal.modalKey);
|
||||
const tasks = selections.map((s) => {
|
||||
const form: FormType.ModifySubtitle = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
language: s.language,
|
||||
path: s.path,
|
||||
...override,
|
||||
};
|
||||
return createTask(s.path, mutateAsync, { action, form });
|
||||
});
|
||||
|
||||
dispatchTask(tasks, "modify-subtitles");
|
||||
},
|
||||
[hide, selections, mutateAsync]
|
||||
);
|
||||
|
||||
return (
|
||||
<ProcessSubtitleContext.Provider value={process}>
|
||||
<SubtitleToolModal
|
||||
count={selections.length}
|
||||
tools={tools}
|
||||
select={setSelections}
|
||||
></SubtitleToolModal>
|
||||
{modals}
|
||||
</ProcessSubtitleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleTools;
|
|
@ -1,257 +0,0 @@
|
|||
import { SelectorOption } from "@/components";
|
||||
import {
|
||||
faClock,
|
||||
faCode,
|
||||
faDeaf,
|
||||
faExchangeAlt,
|
||||
faFilm,
|
||||
faImage,
|
||||
faLanguage,
|
||||
faMagic,
|
||||
faPaintBrush,
|
||||
faPlay,
|
||||
faTextHeight,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import ColorTool from "./ColorTool";
|
||||
import FrameRateTool from "./FrameRateTool";
|
||||
import TimeTool from "./TimeTool";
|
||||
import Translation from "./Translation";
|
||||
import { ToolOptions } from "./types";
|
||||
|
||||
export const tools: ToolOptions[] = [
|
||||
{
|
||||
key: "sync",
|
||||
icon: faPlay,
|
||||
name: "Sync",
|
||||
},
|
||||
{
|
||||
key: "remove_HI",
|
||||
icon: faDeaf,
|
||||
name: "Remove HI Tags",
|
||||
},
|
||||
{
|
||||
key: "remove_tags",
|
||||
icon: faCode,
|
||||
name: "Remove Style Tags",
|
||||
},
|
||||
{
|
||||
key: "OCR_fixes",
|
||||
icon: faImage,
|
||||
name: "OCR Fixes",
|
||||
},
|
||||
{
|
||||
key: "common",
|
||||
icon: faMagic,
|
||||
name: "Common Fixes",
|
||||
},
|
||||
{
|
||||
key: "fix_uppercase",
|
||||
icon: faTextHeight,
|
||||
name: "Fix Uppercase",
|
||||
},
|
||||
{
|
||||
key: "reverse_rtl",
|
||||
icon: faExchangeAlt,
|
||||
name: "Reverse RTL",
|
||||
},
|
||||
{
|
||||
key: "add_color",
|
||||
icon: faPaintBrush,
|
||||
name: "Add Color",
|
||||
modal: ColorTool,
|
||||
},
|
||||
{
|
||||
key: "change_frame_rate",
|
||||
icon: faFilm,
|
||||
name: "Change Frame Rate",
|
||||
modal: FrameRateTool,
|
||||
},
|
||||
{
|
||||
key: "adjust_time",
|
||||
icon: faClock,
|
||||
name: "Adjust Times",
|
||||
modal: TimeTool,
|
||||
},
|
||||
{
|
||||
key: "translation",
|
||||
icon: faLanguage,
|
||||
name: "Translate",
|
||||
modal: Translation,
|
||||
},
|
||||
];
|
||||
|
||||
export const availableTranslation = {
|
||||
af: "afrikaans",
|
||||
sq: "albanian",
|
||||
am: "amharic",
|
||||
ar: "arabic",
|
||||
hy: "armenian",
|
||||
az: "azerbaijani",
|
||||
eu: "basque",
|
||||
be: "belarusian",
|
||||
bn: "bengali",
|
||||
bs: "bosnian",
|
||||
bg: "bulgarian",
|
||||
ca: "catalan",
|
||||
ceb: "cebuano",
|
||||
ny: "chichewa",
|
||||
zh: "chinese (simplified)",
|
||||
zt: "chinese (traditional)",
|
||||
co: "corsican",
|
||||
hr: "croatian",
|
||||
cs: "czech",
|
||||
da: "danish",
|
||||
nl: "dutch",
|
||||
en: "english",
|
||||
eo: "esperanto",
|
||||
et: "estonian",
|
||||
tl: "filipino",
|
||||
fi: "finnish",
|
||||
fr: "french",
|
||||
fy: "frisian",
|
||||
gl: "galician",
|
||||
ka: "georgian",
|
||||
de: "german",
|
||||
el: "greek",
|
||||
gu: "gujarati",
|
||||
ht: "haitian creole",
|
||||
ha: "hausa",
|
||||
haw: "hawaiian",
|
||||
iw: "hebrew",
|
||||
hi: "hindi",
|
||||
hmn: "hmong",
|
||||
hu: "hungarian",
|
||||
is: "icelandic",
|
||||
ig: "igbo",
|
||||
id: "indonesian",
|
||||
ga: "irish",
|
||||
it: "italian",
|
||||
ja: "japanese",
|
||||
jw: "javanese",
|
||||
kn: "kannada",
|
||||
kk: "kazakh",
|
||||
km: "khmer",
|
||||
ko: "korean",
|
||||
ku: "kurdish (kurmanji)",
|
||||
ky: "kyrgyz",
|
||||
lo: "lao",
|
||||
la: "latin",
|
||||
lv: "latvian",
|
||||
lt: "lithuanian",
|
||||
lb: "luxembourgish",
|
||||
mk: "macedonian",
|
||||
mg: "malagasy",
|
||||
ms: "malay",
|
||||
ml: "malayalam",
|
||||
mt: "maltese",
|
||||
mi: "maori",
|
||||
mr: "marathi",
|
||||
mn: "mongolian",
|
||||
my: "myanmar (burmese)",
|
||||
ne: "nepali",
|
||||
no: "norwegian",
|
||||
ps: "pashto",
|
||||
fa: "persian",
|
||||
pl: "polish",
|
||||
pt: "portuguese",
|
||||
pa: "punjabi",
|
||||
ro: "romanian",
|
||||
ru: "russian",
|
||||
sm: "samoan",
|
||||
gd: "scots gaelic",
|
||||
sr: "serbian",
|
||||
st: "sesotho",
|
||||
sn: "shona",
|
||||
sd: "sindhi",
|
||||
si: "sinhala",
|
||||
sk: "slovak",
|
||||
sl: "slovenian",
|
||||
so: "somali",
|
||||
es: "spanish",
|
||||
su: "sundanese",
|
||||
sw: "swahili",
|
||||
sv: "swedish",
|
||||
tg: "tajik",
|
||||
ta: "tamil",
|
||||
te: "telugu",
|
||||
th: "thai",
|
||||
tr: "turkish",
|
||||
uk: "ukrainian",
|
||||
ur: "urdu",
|
||||
uz: "uzbek",
|
||||
vi: "vietnamese",
|
||||
cy: "welsh",
|
||||
xh: "xhosa",
|
||||
yi: "yiddish",
|
||||
yo: "yoruba",
|
||||
zu: "zulu",
|
||||
fil: "Filipino",
|
||||
he: "Hebrew",
|
||||
};
|
||||
|
||||
export const colorOptions: SelectorOption<string>[] = [
|
||||
{
|
||||
label: "White",
|
||||
value: "white",
|
||||
},
|
||||
{
|
||||
label: "Light Gray",
|
||||
value: "light-gray",
|
||||
},
|
||||
{
|
||||
label: "Red",
|
||||
value: "red",
|
||||
},
|
||||
{
|
||||
label: "Green",
|
||||
value: "green",
|
||||
},
|
||||
{
|
||||
label: "Yellow",
|
||||
value: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
label: "Magenta",
|
||||
value: "magenta",
|
||||
},
|
||||
{
|
||||
label: "Cyan",
|
||||
value: "cyan",
|
||||
},
|
||||
{
|
||||
label: "Black",
|
||||
value: "black",
|
||||
},
|
||||
{
|
||||
label: "Dark Red",
|
||||
value: "dark-red",
|
||||
},
|
||||
{
|
||||
label: "Dark Green",
|
||||
value: "dark-green",
|
||||
},
|
||||
{
|
||||
label: "Dark Yellow",
|
||||
value: "dark-yellow",
|
||||
},
|
||||
{
|
||||
label: "Dark Blue",
|
||||
value: "dark-blue",
|
||||
},
|
||||
{
|
||||
label: "Dark Magenta",
|
||||
value: "dark-magenta",
|
||||
},
|
||||
{
|
||||
label: "Dark Cyan",
|
||||
value: "dark-cyan",
|
||||
},
|
||||
{
|
||||
label: "Dark Grey",
|
||||
value: "dark-grey",
|
||||
},
|
||||
];
|
|
@ -1,9 +0,0 @@
|
|||
import { ModalComponent } from "@/modules/modals/WithModal";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export interface ToolOptions {
|
||||
key: string;
|
||||
icon: IconDefinition;
|
||||
name: string;
|
||||
modal?: ModalComponent<unknown>;
|
||||
}
|
|
@ -1,77 +1,44 @@
|
|||
import { useMemo } from "react";
|
||||
import { Table } from "react-bootstrap";
|
||||
import {
|
||||
HeaderGroup,
|
||||
Row,
|
||||
TableBodyProps,
|
||||
TableOptions,
|
||||
TableProps,
|
||||
} from "react-table";
|
||||
import { useIsLoading } from "@/contexts";
|
||||
import { usePageSize } from "@/utilities/storage";
|
||||
import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { HeaderGroup, Row, TableInstance } from "react-table";
|
||||
|
||||
export interface BaseTableProps<T extends object> extends TableStyleProps<T> {
|
||||
// Table Options
|
||||
headers: HeaderGroup<T>[];
|
||||
rows: Row<T>[];
|
||||
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
|
||||
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
|
||||
prepareRow: (row: Row<T>) => void;
|
||||
tableProps: TableProps;
|
||||
tableBodyProps: TableBodyProps;
|
||||
}
|
||||
export type BaseTableProps<T extends object> = TableInstance<T> & {
|
||||
tableStyles?: TableStyleProps<T>;
|
||||
};
|
||||
|
||||
export interface TableStyleProps<T extends object> {
|
||||
emptyText?: string;
|
||||
responsive?: boolean;
|
||||
hoverable?: boolean;
|
||||
striped?: boolean;
|
||||
borderless?: boolean;
|
||||
small?: boolean;
|
||||
placeholder?: number;
|
||||
hideHeader?: boolean;
|
||||
fixHeader?: boolean;
|
||||
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
|
||||
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
|
||||
}
|
||||
|
||||
interface ExtractResult<T extends object> {
|
||||
style: TableStyleProps<T>;
|
||||
options: TableOptions<T>;
|
||||
}
|
||||
|
||||
export function useStyleAndOptions<T extends object>(
|
||||
props: TableStyleProps<T> & TableOptions<T>
|
||||
): ExtractResult<T> {
|
||||
const {
|
||||
emptyText,
|
||||
responsive,
|
||||
hoverable,
|
||||
striped,
|
||||
borderless,
|
||||
small,
|
||||
hideHeader,
|
||||
headersRenderer,
|
||||
rowRenderer,
|
||||
...options
|
||||
} = props;
|
||||
const useStyles = createStyles((theme) => {
|
||||
return {
|
||||
style: {
|
||||
emptyText,
|
||||
responsive,
|
||||
hoverable,
|
||||
striped,
|
||||
borderless,
|
||||
small,
|
||||
hideHeader,
|
||||
headersRenderer,
|
||||
rowRenderer,
|
||||
container: {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
},
|
||||
options,
|
||||
table: {
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
header: {},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function DefaultHeaderRenderer<T extends object>(
|
||||
headers: HeaderGroup<T>[]
|
||||
): JSX.Element[] {
|
||||
return headers.map((col) => (
|
||||
<th {...col.getHeaderProps()}>{col.render("Header")}</th>
|
||||
<th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}>
|
||||
{col.render("Header")}
|
||||
</th>
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -79,9 +46,7 @@ function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
|
|||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
{row.cells.map((cell) => (
|
||||
<td className={cell.column.className} {...cell.getCellProps()}>
|
||||
{cell.render("Cell")}
|
||||
</td>
|
||||
<td {...cell.getCellProps()}>{cell.render("Cell")}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
@ -89,65 +54,73 @@ function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
|
|||
|
||||
export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
|
||||
const {
|
||||
emptyText,
|
||||
responsive,
|
||||
hoverable,
|
||||
striped,
|
||||
borderless,
|
||||
small,
|
||||
hideHeader,
|
||||
|
||||
headers,
|
||||
headerGroups,
|
||||
rows,
|
||||
headersRenderer,
|
||||
rowRenderer,
|
||||
prepareRow,
|
||||
tableProps,
|
||||
tableBodyProps,
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
tableStyles,
|
||||
} = props;
|
||||
|
||||
const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer;
|
||||
const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer;
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
const colCount = useMemo(() => {
|
||||
return headers.reduce(
|
||||
return headerGroups.reduce(
|
||||
(prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
|
||||
0
|
||||
);
|
||||
}, [headers]);
|
||||
}, [headerGroups]);
|
||||
|
||||
const empty = rows.length === 0;
|
||||
|
||||
const hRenderer = headersRenderer ?? DefaultHeaderRenderer;
|
||||
const rRenderer = rowRenderer ?? DefaultRowRenderer;
|
||||
const [pageSize] = usePageSize();
|
||||
const isLoading = useIsLoading();
|
||||
|
||||
let body: ReactNode;
|
||||
if (isLoading) {
|
||||
body = Array(tableStyles?.placeholder ?? pageSize)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={colCount}>
|
||||
<Skeleton height={24}></Skeleton>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
} else if (empty && tableStyles?.emptyText) {
|
||||
body = (
|
||||
<tr>
|
||||
<td colSpan={colCount}>
|
||||
<Text align="center">{tableStyles.emptyText}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else {
|
||||
body = rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return rowRenderer(row);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
size={small ? "sm" : undefined}
|
||||
striped={striped ?? true}
|
||||
borderless={borderless ?? true}
|
||||
hover={hoverable}
|
||||
responsive={responsive ?? true}
|
||||
{...tableProps}
|
||||
>
|
||||
<thead hidden={hideHeader}>
|
||||
{headers.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{hRenderer(headerGroup.headers)}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody {...tableBodyProps}>
|
||||
{emptyText && empty ? (
|
||||
<tr>
|
||||
<td colSpan={colCount} className="text-center">
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return rRenderer(row);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Box className={classes.container}>
|
||||
<Table
|
||||
className={classes.table}
|
||||
striped={tableStyles?.striped ?? true}
|
||||
{...getTableProps()}
|
||||
>
|
||||
<thead className={classes.header} hidden={tableStyles?.hideHeader}>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headersRenderer(headerGroup.headers)}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>{body}</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import {
|
||||
Cell,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
TableOptions,
|
||||
useExpanded,
|
||||
useGroupBy,
|
||||
useSortBy,
|
||||
} from "react-table";
|
||||
import { TableStyleProps } from "./BaseTable";
|
||||
import SimpleTable from "./SimpleTable";
|
||||
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||
|
||||
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
|
||||
if (cell.isGrouped) {
|
||||
return (
|
||||
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span>
|
||||
<div {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</div>
|
||||
);
|
||||
} else if (row.canExpand || cell.isAggregated) {
|
||||
return null;
|
||||
|
@ -31,22 +30,16 @@ function renderRow<T extends object>(row: Row<T>) {
|
|||
const rotation = row.isExpanded ? 90 : undefined;
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td
|
||||
className="p-0"
|
||||
{...cell.getCellProps()}
|
||||
colSpan={row.cells.length}
|
||||
>
|
||||
<span
|
||||
{...row.getToggleRowExpandedProps()}
|
||||
className="d-flex align-items-center p-2"
|
||||
>
|
||||
<td {...cell.getCellProps()} colSpan={row.cells.length}>
|
||||
<Text {...row.getToggleRowExpandedProps()} p={2}>
|
||||
{cell.render("Cell")}
|
||||
<FontAwesomeIcon
|
||||
className="mx-2"
|
||||
icon={faChevronCircleRight}
|
||||
rotation={rotation}
|
||||
></FontAwesomeIcon>
|
||||
</span>
|
||||
<Box component="span" mx={12}>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronCircleRight}
|
||||
rotation={rotation}
|
||||
></FontAwesomeIcon>
|
||||
</Box>
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
@ -59,9 +52,7 @@ function renderRow<T extends object>(row: Row<T>) {
|
|||
{row.cells
|
||||
.filter((cell) => !cell.isPlaceholder)
|
||||
.map((cell) => (
|
||||
<td className={cell.column.className} {...cell.getCellProps()}>
|
||||
{renderCell(cell, row)}
|
||||
</td>
|
||||
<td {...cell.getCellProps()}>{renderCell(cell, row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
@ -76,16 +67,19 @@ function renderHeaders<T extends object>(
|
|||
.map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>);
|
||||
}
|
||||
|
||||
type Props<T extends object> = TableOptions<T> & TableStyleProps<T>;
|
||||
type Props<T extends object> = Omit<
|
||||
SimpleTableProps<T>,
|
||||
"plugins" | "headersRenderer" | "rowRenderer"
|
||||
>;
|
||||
|
||||
const plugins = [useGroupBy, useSortBy, useExpanded];
|
||||
|
||||
function GroupTable<T extends object = object>(props: Props<T>) {
|
||||
const plugins = [useGroupBy, useSortBy, useExpanded];
|
||||
return (
|
||||
<SimpleTable
|
||||
{...props}
|
||||
plugins={plugins}
|
||||
headersRenderer={renderHeaders}
|
||||
rowRenderer={renderRow}
|
||||
tableStyles={{ headersRenderer: renderHeaders, rowRenderer: renderRow }}
|
||||
></SimpleTable>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,17 +1,12 @@
|
|||
import { FunctionComponent, useMemo } from "react";
|
||||
import { Col, Container, Pagination, Row } from "react-bootstrap";
|
||||
import { PageControlAction } from "./types";
|
||||
import { useIsLoading } from "@/contexts";
|
||||
import { Group, Pagination, Text } from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
interface Props {
|
||||
count: number;
|
||||
index: number;
|
||||
size: number;
|
||||
total: number;
|
||||
canPrevious: boolean;
|
||||
previous: () => void;
|
||||
canNext: boolean;
|
||||
next: () => void;
|
||||
goto: (idx: number) => void;
|
||||
loadState?: PageControlAction;
|
||||
}
|
||||
|
||||
const PageControl: FunctionComponent<Props> = ({
|
||||
|
@ -19,77 +14,28 @@ const PageControl: FunctionComponent<Props> = ({
|
|||
index,
|
||||
size,
|
||||
total,
|
||||
canPrevious,
|
||||
previous,
|
||||
canNext,
|
||||
next,
|
||||
goto,
|
||||
loadState,
|
||||
}) => {
|
||||
const empty = total === 0;
|
||||
const start = empty ? 0 : size * index + 1;
|
||||
const end = Math.min(size * (index + 1), total);
|
||||
|
||||
const loading = loadState !== undefined;
|
||||
|
||||
const pageButtons = useMemo(
|
||||
() =>
|
||||
[...Array(count).keys()]
|
||||
.map((idx) => {
|
||||
if (Math.abs(idx - index) >= 4 && idx !== 0 && idx !== count - 1) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<Pagination.Item
|
||||
key={idx}
|
||||
disabled={loading}
|
||||
active={index === idx}
|
||||
onClick={() => goto(idx)}
|
||||
>
|
||||
{idx + 1}
|
||||
</Pagination.Item>
|
||||
);
|
||||
}
|
||||
})
|
||||
.flatMap((item, idx, arr) => {
|
||||
if (item === null) {
|
||||
if (arr[idx + 1] === null) {
|
||||
return [];
|
||||
} else {
|
||||
return (
|
||||
<Pagination.Ellipsis key={idx} disabled></Pagination.Ellipsis>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return [item];
|
||||
}
|
||||
}),
|
||||
[count, index, goto, loading]
|
||||
);
|
||||
const isLoading = useIsLoading();
|
||||
|
||||
return (
|
||||
<Container fluid className="mb-3">
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center justify-content-start">
|
||||
<span>
|
||||
Show {start} to {end} of {total} entries
|
||||
</span>
|
||||
</Col>
|
||||
<Col className="d-flex justify-content-end">
|
||||
<Pagination className="m-0" hidden={count <= 1}>
|
||||
<Pagination.Prev
|
||||
onClick={previous}
|
||||
disabled={!canPrevious || loading}
|
||||
></Pagination.Prev>
|
||||
{pageButtons}
|
||||
<Pagination.Next
|
||||
onClick={next}
|
||||
disabled={!canNext || loading}
|
||||
></Pagination.Next>
|
||||
</Pagination>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
<Group p={16} position="apart">
|
||||
<Text size="sm">
|
||||
Show {start} to {end} of {total} entries
|
||||
</Text>
|
||||
<Pagination
|
||||
size="sm"
|
||||
color={isLoading ? "gray" : "primary"}
|
||||
page={index + 1}
|
||||
onChange={(page) => goto(page - 1)}
|
||||
hidden={count <= 1}
|
||||
total={count}
|
||||
></Pagination>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,74 +1,44 @@
|
|||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { TableInstance, usePagination } from "react-table";
|
||||
import PageControl from "./PageControl";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
autoScroll?: boolean;
|
||||
plugins?: PluginHook<T>[];
|
||||
};
|
||||
type Props<T extends object> = SimpleTableProps<T> & {
|
||||
autoScroll?: boolean;
|
||||
};
|
||||
|
||||
const tablePlugins = [useDefaultSettings, usePagination];
|
||||
|
||||
export default function PageTable<T extends object>(props: Props<T>) {
|
||||
const { autoScroll, plugins, ...remain } = props;
|
||||
const { style, options } = useStyleAndOptions(remain);
|
||||
|
||||
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
|
||||
|
||||
if (plugins) {
|
||||
allPlugins.push(...plugins);
|
||||
}
|
||||
|
||||
const instance = useTable(options, ...allPlugins);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
|
||||
// page
|
||||
page,
|
||||
canNextPage,
|
||||
canPreviousPage,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
state: { pageIndex, pageSize },
|
||||
} = instance;
|
||||
const instance = useRef<TableInstance<T> | null>(null);
|
||||
|
||||
// Scroll to top when page is changed
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
ScrollToTop();
|
||||
}
|
||||
}, [pageIndex, autoScroll]);
|
||||
}, [instance.current?.state.pageIndex, autoScroll]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={page}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
<PageControl
|
||||
count={pageCount}
|
||||
index={pageIndex}
|
||||
size={pageSize}
|
||||
total={rows.length}
|
||||
canPrevious={canPreviousPage}
|
||||
canNext={canNextPage}
|
||||
previous={previousPage}
|
||||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
<SimpleTable
|
||||
{...remain}
|
||||
instanceRef={instance}
|
||||
plugins={[...tablePlugins, ...(plugins ?? [])]}
|
||||
></SimpleTable>
|
||||
{instance.current && (
|
||||
<PageControl
|
||||
count={instance.current.pageCount}
|
||||
index={instance.current.state.pageIndex}
|
||||
size={instance.current.state.pageSize}
|
||||
total={instance.current.rows.length}
|
||||
goto={instance.current.gotoPage}
|
||||
></PageControl>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,77 +1,37 @@
|
|||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||
import { LoadingProvider } from "@/contexts";
|
||||
import { ScrollToTop } from "@/utilities";
|
||||
import { useEffect } from "react";
|
||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
||||
import { LoadingIndicator } from "..";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import PageControl from "./PageControl";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
query: UsePaginationQueryResult<T>;
|
||||
};
|
||||
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
|
||||
query: UsePaginationQueryResult<T>;
|
||||
};
|
||||
|
||||
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
||||
const { plugins, query, ...remain } = props;
|
||||
const { style, options } = useStyleAndOptions(remain);
|
||||
const { query, ...remain } = props;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationStatus: {
|
||||
page,
|
||||
pageCount,
|
||||
totalCount,
|
||||
canPrevious,
|
||||
canNext,
|
||||
pageSize,
|
||||
},
|
||||
controls: { previousPage, nextPage, gotoPage },
|
||||
data = { data: [], total: 0 },
|
||||
paginationStatus: { page, pageCount, totalCount, pageSize, isPageLoading },
|
||||
controls: { gotoPage },
|
||||
} = query;
|
||||
|
||||
const instance = useTable(
|
||||
{
|
||||
...options,
|
||||
data: data?.data ?? [],
|
||||
},
|
||||
useDefaultSettings,
|
||||
...(plugins ?? [])
|
||||
);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
instance;
|
||||
|
||||
useEffect(() => {
|
||||
ScrollToTop();
|
||||
}, [page]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator></LoadingIndicator>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={rows}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
<LoadingProvider value={isPageLoading}>
|
||||
<SimpleTable {...remain} data={data.data}></SimpleTable>
|
||||
<PageControl
|
||||
count={pageCount}
|
||||
index={page}
|
||||
size={pageSize}
|
||||
total={totalCount}
|
||||
canPrevious={canPrevious}
|
||||
canNext={canNext}
|
||||
previous={previousPage}
|
||||
next={nextPage}
|
||||
goto={gotoPage}
|
||||
></PageControl>
|
||||
</>
|
||||
</LoadingProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,29 +1,23 @@
|
|||
import { PluginHook, TableOptions, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
||||
import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
|
||||
import BaseTable, { TableStyleProps } from "./BaseTable";
|
||||
import { useDefaultSettings } from "./plugins";
|
||||
|
||||
type Props<T extends object> = TableOptions<T> &
|
||||
TableStyleProps<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
};
|
||||
export type SimpleTableProps<T extends object> = TableOptions<T> & {
|
||||
plugins?: PluginHook<T>[];
|
||||
instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
|
||||
tableStyles?: TableStyleProps<T>;
|
||||
};
|
||||
|
||||
export default function SimpleTable<T extends object>(props: Props<T>) {
|
||||
const { plugins, ...other } = props;
|
||||
const { style, options } = useStyleAndOptions(other);
|
||||
export default function SimpleTable<T extends object>(
|
||||
props: SimpleTableProps<T>
|
||||
) {
|
||||
const { plugins, instanceRef, tableStyles, ...options } = props;
|
||||
|
||||
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||
instance;
|
||||
if (instanceRef) {
|
||||
instanceRef.current = instance;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
{...style}
|
||||
headers={headerGroups}
|
||||
rows={rows}
|
||||
prepareRow={prepareRow}
|
||||
tableProps={getTableProps()}
|
||||
tableBodyProps={getTableBodyProps()}
|
||||
></BaseTable>
|
||||
);
|
||||
return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Checkbox as MantineCheckbox } from "@mantine/core";
|
||||
import { forwardRef, useEffect, useRef } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import {
|
||||
CellProps,
|
||||
Column,
|
||||
|
@ -41,13 +41,12 @@ const Checkbox = forwardRef<
|
|||
}, [resolvedRef, indeterminate, checked, disabled]);
|
||||
|
||||
return (
|
||||
<Form.Check
|
||||
custom
|
||||
<MantineCheckbox
|
||||
key={idIn}
|
||||
disabled={disabled}
|
||||
id={idIn}
|
||||
ref={resolvedRef}
|
||||
{...rest}
|
||||
></Form.Check>
|
||||
></MantineCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
1
frontend/src/components/tables/types.d.ts
vendored
1
frontend/src/components/tables/types.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
export type PageControlAction = "prev" | "next" | number;
|
65
frontend/src/components/toolbox/Button.tsx
Normal file
65
frontend/src/components/toolbox/Button.tsx
Normal file
|
@ -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;
|
31
frontend/src/components/toolbox/index.tsx
Normal file
31
frontend/src/components/toolbox/index.tsx
Normal file
|
@ -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;
|
9
frontend/src/constants.ts
Normal file
9
frontend/src/constants.ts
Normal file
|
@ -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,
|
||||
};
|
11
frontend/src/contexts/Loading.ts
Normal file
11
frontend/src/contexts/Loading.ts
Normal file
|
@ -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;
|
18
frontend/src/contexts/Navbar.ts
Normal file
18
frontend/src/contexts/Navbar.ts
Normal file
|
@ -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;
|
28
frontend/src/contexts/Online.ts
Normal file
28
frontend/src/contexts/Online.ts
Normal file
|
@ -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;
|
2
frontend/src/contexts/index.ts
Normal file
2
frontend/src/contexts/index.ts
Normal file
|
@ -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>
|
||||
);
|
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
|
@ -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;
|
34
frontend/src/modules/modals/ModalsProvider.tsx
Normal file
34
frontend/src/modules/modals/ModalsProvider.tsx
Normal file
|
@ -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 default function withModal<T>(
|
||||
export const StaticModals: ModalComponent[] = [];
|
||||
|
||||
export const ModalIdContext = createContext<string | null>(null);
|
||||
|
||||
export default function withModal<T extends {}>(
|
||||
Content: FunctionComponent<T>,
|
||||
key: string
|
||||
key: string,
|
||||
defaultSettings?: ModalSettings
|
||||
) {
|
||||
const Comp: ModalComponent<T> = (props: ModalProps & T) => {
|
||||
const [closeable, setCloseable] = useState(true);
|
||||
const [size, setSize] = useState<ModalData["size"]>(undefined);
|
||||
const data: ModalData = useMemo(
|
||||
() => ({
|
||||
key,
|
||||
size,
|
||||
closeable,
|
||||
}),
|
||||
[closeable, size]
|
||||
);
|
||||
|
||||
const setter: ModalSetter = useMemo(
|
||||
() => ({
|
||||
closeable: setCloseable,
|
||||
size: setSize,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const Comp: ModalComponent<T> = (props) => {
|
||||
const { id, innerProps } = props;
|
||||
|
||||
return (
|
||||
<ModalDataContext.Provider value={data}>
|
||||
<ModalSetterContext.Provider value={setter}>
|
||||
<ModalWrapper>
|
||||
<Content {...props}></Content>
|
||||
</ModalWrapper>
|
||||
</ModalSetterContext.Provider>
|
||||
</ModalDataContext.Provider>
|
||||
<ModalIdContext.Provider value={id}>
|
||||
<Content {...innerProps}></Content>
|
||||
</ModalIdContext.Provider>
|
||||
);
|
||||
};
|
||||
Comp.modalKey = key;
|
||||
Comp.settings = defaultSettings;
|
||||
|
||||
StaticModals.push(Comp as ModalComponent);
|
||||
return Comp;
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { FunctionComponent, ReactNode } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { useModalData } from "./hooks";
|
||||
|
||||
interface StandardModalProps {
|
||||
title: string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export const StandardModalView: FunctionComponent<StandardModalProps> = ({
|
||||
children,
|
||||
footer,
|
||||
title,
|
||||
}) => {
|
||||
const { closeable } = useModalData();
|
||||
return (
|
||||
<>
|
||||
<Modal.Header closeButton={closeable}>{title}</Modal.Header>
|
||||
<Modal.Body>{children}</Modal.Body>
|
||||
<Modal.Footer hidden={footer === undefined}>{footer}</Modal.Footer>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,90 +1,46 @@
|
|||
import {
|
||||
hideModalAction,
|
||||
showModalAction,
|
||||
} from "@/modules/redux/actions/modal";
|
||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { StandardModalView } from "./components";
|
||||
import {
|
||||
ModalData,
|
||||
ModalDataContext,
|
||||
ModalSetterContext,
|
||||
} from "./ModalContext";
|
||||
import { ModalComponent } from "./WithModal";
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import { useModals as useMantineModals } from "@mantine/modals";
|
||||
import { ModalSettings } from "@mantine/modals/lib/context";
|
||||
import { useCallback, useContext, useMemo } from "react";
|
||||
import { ModalComponent, ModalIdContext } from "./WithModal";
|
||||
|
||||
type ModalProps = Partial<Omit<ModalData, "key">> & {
|
||||
onMounted?: () => void;
|
||||
};
|
||||
export function useModals() {
|
||||
const { openContextModal: openMantineContextModal, ...rest } =
|
||||
useMantineModals();
|
||||
|
||||
export function useModal(props?: ModalProps): typeof StandardModalView {
|
||||
const setter = useContext(ModalSetterContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (setter && props) {
|
||||
setter.closeable(props.closeable ?? true);
|
||||
setter.size(props.size);
|
||||
}
|
||||
}, [props, setter]);
|
||||
|
||||
const ref = useRef<ModalProps["onMounted"]>(props?.onMounted);
|
||||
ref.current = props?.onMounted;
|
||||
|
||||
const layer = useCurrentLayer();
|
||||
|
||||
useEffect(() => {
|
||||
if (layer !== -1 && ref.current) {
|
||||
ref.current();
|
||||
}
|
||||
}, [layer]);
|
||||
|
||||
return StandardModalView;
|
||||
}
|
||||
|
||||
export function useModalControl() {
|
||||
const showAction = useReduxAction(showModalAction);
|
||||
|
||||
const show = useCallback(
|
||||
<P>(comp: ModalComponent<P>, payload?: unknown) => {
|
||||
showAction({ key: comp.modalKey, payload });
|
||||
const openContextModal = useCallback(
|
||||
<ARGS extends {}>(
|
||||
modal: ModalComponent<ARGS>,
|
||||
props: ARGS,
|
||||
settings?: ModalSettings
|
||||
) => {
|
||||
openMantineContextModal(modal.modalKey, {
|
||||
...modal.settings,
|
||||
...settings,
|
||||
innerProps: props,
|
||||
});
|
||||
},
|
||||
[showAction]
|
||||
[openMantineContextModal]
|
||||
);
|
||||
|
||||
const hideAction = useReduxAction(hideModalAction);
|
||||
|
||||
const hide = useCallback(
|
||||
(key?: string) => {
|
||||
hideAction(key);
|
||||
const closeContextModal = useCallback(
|
||||
(modal: ModalComponent) => {
|
||||
rest.closeModal(modal.modalKey);
|
||||
},
|
||||
[hideAction]
|
||||
[rest]
|
||||
);
|
||||
|
||||
return { show, hide };
|
||||
}
|
||||
const id = useContext(ModalIdContext);
|
||||
|
||||
export function useModalData(): ModalData {
|
||||
const data = useContext(ModalDataContext);
|
||||
|
||||
if (data === null) {
|
||||
throw new Error("useModalData should be used inside Modal");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function usePayload<T>(): T | null {
|
||||
const { key } = useModalData();
|
||||
const stack = useReduxStore((s) => s.modal.stack);
|
||||
const closeSelf = useCallback(() => {
|
||||
if (id) {
|
||||
rest.closeModal(id);
|
||||
}
|
||||
}, [id, rest]);
|
||||
|
||||
// TODO: Performance
|
||||
return useMemo(
|
||||
() => (stack.find((m) => m.key === key)?.payload as T) ?? null,
|
||||
[stack, key]
|
||||
() => ({ openContextModal, closeContextModal, closeSelf, ...rest }),
|
||||
[closeContextModal, closeSelf, openContextModal, rest]
|
||||
);
|
||||
}
|
||||
|
||||
export function useCurrentLayer() {
|
||||
const { key } = useModalData();
|
||||
const stack = useReduxStore((s) => s.modal.stack);
|
||||
|
||||
return useMemo(() => stack.findIndex((m) => m.key === key), [stack, key]);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue