mirror of https://github.com/morpheus65535/bazarr
Replace Bootstrap with Mantine (#1795)
This commit is contained in:
parent
6515c42f26
commit
2cecb4c5b5
|
@ -1,12 +1,15 @@
|
||||||
From newest to oldest:
|
From newest to oldest:
|
||||||
{{#each releases}}
|
{{#each releases}}
|
||||||
{{#each merges}}
|
{{#each merges}}
|
||||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each fixes}}
|
{{#each fixes}}
|
||||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each commits}}
|
{{#each commits}}
|
||||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
-
|
||||||
|
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/each}}
|
{{/each}}
|
|
@ -1,12 +1,15 @@
|
||||||
From newest to oldest:
|
From newest to oldest:
|
||||||
{{#each releases}}
|
{{#each releases}}
|
||||||
{{#each merges}}
|
{{#each merges}}
|
||||||
- {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each fixes}}
|
{{#each fixes}}
|
||||||
- {{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
-
|
||||||
|
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{#each commits}}
|
{{#each commits}}
|
||||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
-
|
||||||
|
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/each}}
|
{{/each}}
|
|
@ -2,5 +2,6 @@ node_modules
|
||||||
dist
|
dist
|
||||||
*.local
|
*.local
|
||||||
build
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { dependencies } from "../package.json";
|
||||||
|
|
||||||
const vendors = [
|
const vendors = [
|
||||||
"react",
|
"react",
|
||||||
"react-redux",
|
|
||||||
"react-router-dom",
|
"react-router-dom",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react-query",
|
"react-query",
|
||||||
|
|
|
@ -18,7 +18,9 @@
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script>
|
<script>
|
||||||
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
|
try {
|
||||||
|
window.Bazarr = JSON.parse(`{{BAZARR_SERVER_INJECT | tojson | safe}}`);
|
||||||
|
} catch (error) {}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="./src/dom.tsx"></script>
|
<script type="module" src="./src/dom.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -13,12 +13,12 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mantine/core": "^4",
|
||||||
|
"@mantine/hooks": "^4",
|
||||||
"axios": "^0.26",
|
"axios": "^0.26",
|
||||||
"react": "^17",
|
"react": "^17",
|
||||||
"react-bootstrap": "^1",
|
|
||||||
"react-dom": "^17",
|
"react-dom": "^17",
|
||||||
"react-query": "^3.34",
|
"react-query": "^3.34",
|
||||||
"react-redux": "^7.2",
|
|
||||||
"react-router-dom": "^6.2.1",
|
"react-router-dom": "^6.2.1",
|
||||||
"socket.io-client": "^4"
|
"socket.io-client": "^4"
|
||||||
},
|
},
|
||||||
|
@ -29,37 +29,32 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6",
|
"@fortawesome/free-regular-svg-icons": "^6",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6",
|
"@fortawesome/free-solid-svg-icons": "^6",
|
||||||
"@fortawesome/react-fontawesome": "^0.1",
|
"@fortawesome/react-fontawesome": "^0.1",
|
||||||
"@reduxjs/toolkit": "^1",
|
"@mantine/dropzone": "^4",
|
||||||
|
"@mantine/modals": "^4",
|
||||||
|
"@mantine/notifications": "^4",
|
||||||
"@testing-library/jest-dom": "latest",
|
"@testing-library/jest-dom": "latest",
|
||||||
"@testing-library/react": "12",
|
"@testing-library/react": "12",
|
||||||
"@testing-library/react-hooks": "latest",
|
"@testing-library/react-hooks": "latest",
|
||||||
"@testing-library/user-event": "latest",
|
"@testing-library/user-event": "latest",
|
||||||
"@types/bootstrap": "^4",
|
|
||||||
"@types/lodash": "^4",
|
"@types/lodash": "^4",
|
||||||
"@types/node": "^17",
|
"@types/node": "^17",
|
||||||
"@types/react": "^17",
|
"@types/react": "^17",
|
||||||
"@types/react-dom": "^17",
|
"@types/react-dom": "^17",
|
||||||
"@types/react-helmet": "^6.1",
|
|
||||||
"@types/react-table": "^7",
|
"@types/react-table": "^7",
|
||||||
"@vitejs/plugin-react": "^1.3",
|
"@vitejs/plugin-react": "^1.3",
|
||||||
"bootstrap": "^4",
|
"clsx": "^1",
|
||||||
"clsx": "^1.1.1",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-react-app": "^7.0.0",
|
"eslint-config-react-app": "^7",
|
||||||
"eslint-plugin-react-hooks": "^4",
|
"eslint-plugin-react-hooks": "^4",
|
||||||
"husky": "^7",
|
"husky": "^8",
|
||||||
"jsdom": "latest",
|
"jsdom": "latest",
|
||||||
"lodash": "^4",
|
"lodash": "^4",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"prettier": "^2",
|
"prettier": "^2",
|
||||||
"prettier-plugin-organize-imports": "^2",
|
"prettier-plugin-organize-imports": "^2",
|
||||||
"pretty-quick": "^3.1",
|
"pretty-quick": "^3.1",
|
||||||
"rc-slider": "^9.7",
|
|
||||||
"react-helmet": "^6.1",
|
|
||||||
"react-select": "^5.0.1",
|
|
||||||
"react-table": "^7",
|
"react-table": "^7",
|
||||||
"recharts": "^2.0.8",
|
"recharts": "^2.0.8",
|
||||||
"rooks": "^5",
|
|
||||||
"sass": "^1",
|
"sass": "^1",
|
||||||
"typescript": "^4",
|
"typescript": "^4",
|
||||||
"vite": "latest",
|
"vite": "latest",
|
||||||
|
|
|
@ -1,132 +1,118 @@
|
||||||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||||
import { ActionButton, SearchBar } from "@/components";
|
import { Action, Search } from "@/components";
|
||||||
import { setSidebar } from "@/modules/redux/actions";
|
import { Layout } from "@/constants";
|
||||||
import { useIsOffline } from "@/modules/redux/hooks";
|
import { useNavbar } from "@/contexts/Navbar";
|
||||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
import { useIsOnline } from "@/contexts/Online";
|
||||||
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
|
import { Environment, useGotoHomepage } from "@/utilities";
|
||||||
import {
|
import {
|
||||||
faBars,
|
faArrowRotateLeft,
|
||||||
faHeart,
|
faGear,
|
||||||
faNetworkWired,
|
faPowerOff,
|
||||||
faUser,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { FunctionComponent, useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Anchor,
|
||||||
Col,
|
Avatar,
|
||||||
Container,
|
Badge,
|
||||||
Dropdown,
|
Burger,
|
||||||
Image,
|
createStyles,
|
||||||
Navbar,
|
Divider,
|
||||||
Row,
|
Group,
|
||||||
} from "react-bootstrap";
|
Header,
|
||||||
import { Helmet } from "react-helmet";
|
MediaQuery,
|
||||||
import NotificationCenter from "./Notification";
|
Menu,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
const Header: FunctionComponent = () => {
|
const useStyles = createStyles((theme) => {
|
||||||
|
const headerBackgroundColor =
|
||||||
|
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
backgroundColor: headerBackgroundColor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const AppHeader: FunctionComponent = () => {
|
||||||
const { data: settings } = useSystemSettings();
|
const { data: settings } = useSystemSettings();
|
||||||
|
const hasLogout = settings?.auth.type === "form";
|
||||||
|
|
||||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
const { show, showed } = useNavbar();
|
||||||
|
|
||||||
const changeSidebar = useReduxAction(setSidebar);
|
const online = useIsOnline();
|
||||||
|
const offline = !online;
|
||||||
const offline = useIsOffline();
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const { shutdown, restart, logout } = useSystem();
|
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 goHome = useGotoHomepage();
|
||||||
|
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar bg="primary" className="flex-grow-1 px-0">
|
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
|
||||||
<Helmet>
|
<Group position="apart" noWrap>
|
||||||
<meta name="theme-color" content="#911f93" />
|
<Group noWrap>
|
||||||
</Helmet>
|
<MediaQuery
|
||||||
<div className="header-icon px-3 m-0 d-none d-md-block">
|
smallerThan={Layout.MOBILE_BREAKPOINT}
|
||||||
<Image
|
styles={{ display: "none" }}
|
||||||
alt="brand"
|
>
|
||||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
<Anchor onClick={goHome}>
|
||||||
width="32"
|
<Avatar
|
||||||
height="32"
|
alt="brand"
|
||||||
onClick={goHome}
|
size={32}
|
||||||
role="button"
|
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||||
></Image>
|
></Avatar>
|
||||||
</div>
|
</Anchor>
|
||||||
<Button
|
</MediaQuery>
|
||||||
className="mx-2 m-0 d-md-none"
|
<MediaQuery
|
||||||
onClick={() => changeSidebar(true)}
|
largerThan={Layout.MOBILE_BREAKPOINT}
|
||||||
>
|
styles={{ display: "none" }}
|
||||||
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
|
>
|
||||||
</Button>
|
<Burger
|
||||||
<Container fluid>
|
opened={showed}
|
||||||
<Row noGutters className="flex-grow-1">
|
onClick={() => show(!showed)}
|
||||||
<Col xs={4} sm={6} className="d-flex align-items-center">
|
size="sm"
|
||||||
<SearchBar></SearchBar>
|
></Burger>
|
||||||
</Col>
|
</MediaQuery>
|
||||||
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
|
<Badge size="lg" radius="sm">
|
||||||
<NotificationCenter></NotificationCenter>
|
Bazarr
|
||||||
<Button
|
</Badge>
|
||||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
</Group>
|
||||||
target="_blank"
|
<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>
|
Restart
|
||||||
</Button>
|
</Menu.Item>
|
||||||
{offline ? (
|
<Menu.Item
|
||||||
<ActionButton
|
icon={<FontAwesomeIcon icon={faPowerOff} />}
|
||||||
loading
|
onClick={() => shutdown()}
|
||||||
alwaysShowText
|
>
|
||||||
className="ml-2"
|
Shutdown
|
||||||
variant="warning"
|
</Menu.Item>
|
||||||
icon={faNetworkWired}
|
<Divider hidden={!hasLogout}></Divider>
|
||||||
>
|
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
|
||||||
{isMobile ? "" : "Connecting..."}
|
Logout
|
||||||
</ActionButton>
|
</Menu.Item>
|
||||||
) : (
|
</Menu>
|
||||||
serverActions
|
</Group>
|
||||||
)}
|
</Group>
|
||||||
</Col>
|
</Header>
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
</Navbar>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default AppHeader;
|
||||||
|
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { Action } from "@/components";
|
||||||
|
import { Layout } from "@/constants";
|
||||||
|
import { useNavbar } from "@/contexts/Navbar";
|
||||||
|
import { useRouteItems } from "@/Router";
|
||||||
|
import { CustomRouteObject, Route } from "@/Router/type";
|
||||||
|
import { BuildKey, pathJoin } from "@/utilities";
|
||||||
|
import { LOG } from "@/utilities/console";
|
||||||
|
import {
|
||||||
|
faHeart,
|
||||||
|
faMoon,
|
||||||
|
faSun,
|
||||||
|
IconDefinition,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Collapse,
|
||||||
|
createStyles,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Navbar as MantineNavbar,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useHover } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
FunctionComponent,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
const Selection = createContext<{
|
||||||
|
selection: string | null;
|
||||||
|
select: (path: string | null) => void;
|
||||||
|
}>({
|
||||||
|
selection: null,
|
||||||
|
select: () => {
|
||||||
|
LOG("error", "Selection context not initialized");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useSelection() {
|
||||||
|
return useContext(Selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBadgeValue(route: Route.Item) {
|
||||||
|
const { badge, children } = route;
|
||||||
|
return useMemo(() => {
|
||||||
|
let value = badge ?? 0;
|
||||||
|
|
||||||
|
if (children === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
value +=
|
||||||
|
children.reduce((acc, child: Route.Item) => {
|
||||||
|
if (child.badge && child.hidden !== true) {
|
||||||
|
return acc + (child.badge ?? 0);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0) ?? 0;
|
||||||
|
|
||||||
|
return value === 0 ? undefined : value;
|
||||||
|
}, [badge, children]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIsActive(parent: string, route: RouteObject) {
|
||||||
|
const { path, children } = route;
|
||||||
|
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||||
|
|
||||||
|
const paths = useMemo(
|
||||||
|
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||||
|
[root, children]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selection = useSelection().selection;
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
selection?.includes(root) ||
|
||||||
|
paths.some((path) => matchPath(path, pathname)),
|
||||||
|
[pathname, paths, root, selection]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppNavbar: FunctionComponent = () => {
|
||||||
|
const { showed } = useNavbar();
|
||||||
|
const [selection, select] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const routes = useRouteItems();
|
||||||
|
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
select(null);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineNavbar
|
||||||
|
p="xs"
|
||||||
|
hiddenBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||||
|
hidden={!showed}
|
||||||
|
width={{ [Layout.MOBILE_BREAKPOINT]: Layout.NAVBAR_WIDTH }}
|
||||||
|
styles={(theme) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "light"
|
||||||
|
? theme.colors.gray[2]
|
||||||
|
: theme.colors.dark[6],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Selection.Provider value={{ selection, select }}>
|
||||||
|
<MantineNavbar.Section grow>
|
||||||
|
<Stack spacing={0}>
|
||||||
|
{routes.map((route, idx) => (
|
||||||
|
<RouteItem
|
||||||
|
key={BuildKey("nav", idx)}
|
||||||
|
parent="/"
|
||||||
|
route={route}
|
||||||
|
></RouteItem>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</MantineNavbar.Section>
|
||||||
|
<Divider></Divider>
|
||||||
|
<MantineNavbar.Section mt="xs">
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Action
|
||||||
|
color={dark ? "yellow" : "indigo"}
|
||||||
|
variant="hover"
|
||||||
|
onClick={() => toggleColorScheme()}
|
||||||
|
icon={dark ? faSun : faMoon}
|
||||||
|
></Action>
|
||||||
|
<Anchor
|
||||||
|
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Action icon={faHeart} variant="hover" color="red"></Action>
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
</MantineNavbar.Section>
|
||||||
|
</Selection.Provider>
|
||||||
|
</MantineNavbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RouteItem: FunctionComponent<{
|
||||||
|
route: CustomRouteObject;
|
||||||
|
parent: string;
|
||||||
|
}> = ({ route, parent }) => {
|
||||||
|
const { children, name, path, icon, hidden, element } = route;
|
||||||
|
|
||||||
|
const { select } = useSelection();
|
||||||
|
|
||||||
|
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||||
|
|
||||||
|
const badge = useBadgeValue(route);
|
||||||
|
|
||||||
|
const isOpen = useIsActive(parent, route);
|
||||||
|
|
||||||
|
// Ignore path if it is using match
|
||||||
|
if (hidden === true || path === undefined || path.includes(":")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children !== undefined) {
|
||||||
|
const elements = (
|
||||||
|
<Stack spacing={0}>
|
||||||
|
{children.map((child, idx) => (
|
||||||
|
<RouteItem
|
||||||
|
parent={link}
|
||||||
|
key={BuildKey(link, "nav", idx)}
|
||||||
|
route={child}
|
||||||
|
></RouteItem>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={0}>
|
||||||
|
<NavbarItem
|
||||||
|
primary
|
||||||
|
name={name}
|
||||||
|
link={link}
|
||||||
|
icon={icon}
|
||||||
|
badge={badge}
|
||||||
|
onClick={(event) => {
|
||||||
|
LOG("info", "clicked", link);
|
||||||
|
|
||||||
|
const validated =
|
||||||
|
element !== undefined ||
|
||||||
|
children?.find((v) => v.index === true) !== undefined;
|
||||||
|
|
||||||
|
if (!validated) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
select(null);
|
||||||
|
} else {
|
||||||
|
select(link);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></NavbarItem>
|
||||||
|
<Collapse hidden={children.length === 0} in={isOpen}>
|
||||||
|
{elements}
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<NavbarItem
|
||||||
|
name={name ?? link}
|
||||||
|
link={link}
|
||||||
|
icon={icon}
|
||||||
|
badge={badge}
|
||||||
|
></NavbarItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => {
|
||||||
|
const borderColor =
|
||||||
|
theme.colorScheme === "light" ? theme.colors.gray[5] : theme.colors.dark[4];
|
||||||
|
|
||||||
|
const activeBorderColor =
|
||||||
|
theme.colorScheme === "light"
|
||||||
|
? theme.colors.brand[4]
|
||||||
|
: theme.colors.brand[8];
|
||||||
|
|
||||||
|
const activeBackgroundColor =
|
||||||
|
theme.colorScheme === "light" ? theme.colors.gray[1] : theme.colors.dark[8];
|
||||||
|
|
||||||
|
const hoverBackgroundColor =
|
||||||
|
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: { display: "inline-flex", alignItems: "center", width: "100%" },
|
||||||
|
anchor: {
|
||||||
|
textDecoration: "none",
|
||||||
|
borderLeft: `2px solid ${borderColor}`,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
backgroundColor: activeBackgroundColor,
|
||||||
|
borderLeft: `2px solid ${activeBorderColor}`,
|
||||||
|
boxShadow: theme.shadows.xs,
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
backgroundColor: hoverBackgroundColor,
|
||||||
|
},
|
||||||
|
icon: { width: "1.4rem", marginRight: theme.spacing.xs },
|
||||||
|
badge: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
textDecoration: "none",
|
||||||
|
boxShadow: theme.shadows.xs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NavbarItemProps {
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
icon?: IconDefinition;
|
||||||
|
badge?: number;
|
||||||
|
primary?: boolean;
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
||||||
|
icon,
|
||||||
|
link,
|
||||||
|
name,
|
||||||
|
badge,
|
||||||
|
onClick,
|
||||||
|
primary = false,
|
||||||
|
}) => {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
const { show } = useNavbar();
|
||||||
|
|
||||||
|
const { ref, hovered } = useHover();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={link}
|
||||||
|
onClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
onClick?.(event);
|
||||||
|
if (!event.isDefaultPrevented()) {
|
||||||
|
show(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
clsx(classes.anchor, {
|
||||||
|
[classes.active]: isActive,
|
||||||
|
[classes.hover]: hovered,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ref={ref}
|
||||||
|
inline
|
||||||
|
p="xs"
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
weight={primary ? "bold" : "normal"}
|
||||||
|
className={classes.text}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={classes.icon}
|
||||||
|
icon={icon}
|
||||||
|
></FontAwesomeIcon>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
<Badge
|
||||||
|
className={classes.badge}
|
||||||
|
color="gray"
|
||||||
|
radius="xs"
|
||||||
|
hidden={badge === undefined || badge === 0}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
</Text>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppNavbar;
|
|
@ -1,241 +0,0 @@
|
||||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
|
||||||
import { BuildKey, useIsArrayExtended } from "@/utilities";
|
|
||||||
import {
|
|
||||||
faBug,
|
|
||||||
faCircleNotch,
|
|
||||||
faExclamationTriangle,
|
|
||||||
faInfoCircle,
|
|
||||||
faStream,
|
|
||||||
IconDefinition,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {
|
|
||||||
FontAwesomeIcon,
|
|
||||||
FontAwesomeIconProps,
|
|
||||||
} from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
Fragment,
|
|
||||||
FunctionComponent,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dropdown,
|
|
||||||
Overlay,
|
|
||||||
ProgressBar,
|
|
||||||
Tooltip,
|
|
||||||
} from "react-bootstrap";
|
|
||||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
|
||||||
|
|
||||||
enum State {
|
|
||||||
Idle,
|
|
||||||
Working,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
function useTotalProgress(progress: Site.Progress[]) {
|
|
||||||
return useMemo(() => {
|
|
||||||
const { value, count } = progress.reduce(
|
|
||||||
(prev, { value, count }) => {
|
|
||||||
prev.value += value;
|
|
||||||
prev.count += count;
|
|
||||||
return prev;
|
|
||||||
},
|
|
||||||
{ value: 0, count: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
return 0;
|
|
||||||
} else {
|
|
||||||
return (value + 0.001) / count;
|
|
||||||
}
|
|
||||||
}, [progress]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useHasErrorNotification(notifications: Server.Notification[]) {
|
|
||||||
return useMemo(
|
|
||||||
() => notifications.find((v) => v.type !== "info") !== undefined,
|
|
||||||
[notifications]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotificationCenter: FunctionComponent = () => {
|
|
||||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
|
||||||
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [hasNew, setHasNew] = useState(false);
|
|
||||||
|
|
||||||
const hasNewProgress = useIsArrayExtended(progress);
|
|
||||||
const hasNewNotifications = useIsArrayExtended(notifications);
|
|
||||||
useDidUpdate(() => {
|
|
||||||
if (hasNewNotifications || hasNewProgress) {
|
|
||||||
setHasNew(true);
|
|
||||||
}
|
|
||||||
}, [hasNewProgress, hasNewNotifications]);
|
|
||||||
|
|
||||||
useDidUpdate(() => {
|
|
||||||
if (progress.length === 0 && notifications.length === 0) {
|
|
||||||
setHasNew(false);
|
|
||||||
}
|
|
||||||
}, [progress.length, notifications.length]);
|
|
||||||
|
|
||||||
const [btnState, setBtnState] = useState(State.Idle);
|
|
||||||
|
|
||||||
const totalProgress = useTotalProgress(progress);
|
|
||||||
const hasError = useHasErrorNotification(notifications);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasError) {
|
|
||||||
setBtnState(State.Failed);
|
|
||||||
} else if (totalProgress > 0 && totalProgress < 1.0) {
|
|
||||||
setBtnState(State.Working);
|
|
||||||
} else {
|
|
||||||
setBtnState(State.Idle);
|
|
||||||
}
|
|
||||||
}, [totalProgress, hasError]);
|
|
||||||
|
|
||||||
const iconProps = useMemo<FontAwesomeIconProps>(() => {
|
|
||||||
switch (btnState) {
|
|
||||||
case State.Idle:
|
|
||||||
return {
|
|
||||||
icon: faStream,
|
|
||||||
};
|
|
||||||
case State.Working:
|
|
||||||
return {
|
|
||||||
icon: faCircleNotch,
|
|
||||||
spin: true,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: faExclamationTriangle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [btnState]);
|
|
||||||
|
|
||||||
const content = useMemo<ReactNode>(() => {
|
|
||||||
const nodes: JSX.Element[] = [];
|
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
<Dropdown.Header key="notifications-header">
|
|
||||||
{notifications.length > 0 ? "Notifications" : "No Notifications"}
|
|
||||||
</Dropdown.Header>
|
|
||||||
);
|
|
||||||
nodes.push(
|
|
||||||
...notifications.map((v, idx) => (
|
|
||||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
|
|
||||||
<Notification {...v}></Notification>
|
|
||||||
</Dropdown.Item>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
<Dropdown.Header key="background-task-header">
|
|
||||||
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
|
|
||||||
</Dropdown.Header>
|
|
||||||
);
|
|
||||||
nodes.push(
|
|
||||||
...progress.map((v, idx) => (
|
|
||||||
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
|
|
||||||
<Progress {...v}></Progress>
|
|
||||||
</Dropdown.Item>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
return nodes;
|
|
||||||
}, [progress, notifications]);
|
|
||||||
|
|
||||||
const onToggleClick = useCallback(() => {
|
|
||||||
setHasNew(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Tooltip Controller
|
|
||||||
const [showTooltip, setTooltip] = useState(false);
|
|
||||||
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
|
|
||||||
useDidUpdate(() => {
|
|
||||||
if (notifier.content) {
|
|
||||||
setTooltip(true);
|
|
||||||
}
|
|
||||||
}, [notifier.timestamp]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<Dropdown
|
|
||||||
onClick={onToggleClick}
|
|
||||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
|
||||||
ref={dropdownRef}
|
|
||||||
alignRight
|
|
||||||
>
|
|
||||||
<Dropdown.Toggle as={Button} className="hide-arrow">
|
|
||||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
|
||||||
</Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
|
|
||||||
{(props) => {
|
|
||||||
return (
|
|
||||||
<Tooltip id="new-notification-tip" {...props}>
|
|
||||||
{notifier.content}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Overlay>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Notification: FunctionComponent<Server.Notification> = ({
|
|
||||||
type,
|
|
||||||
message,
|
|
||||||
}) => {
|
|
||||||
const icon = useMemo<IconDefinition>(() => {
|
|
||||||
switch (type) {
|
|
||||||
case "info":
|
|
||||||
return faInfoCircle;
|
|
||||||
case "warning":
|
|
||||||
return faExclamationTriangle;
|
|
||||||
default:
|
|
||||||
return faBug;
|
|
||||||
}
|
|
||||||
}, [type]);
|
|
||||||
return (
|
|
||||||
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
|
|
||||||
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
|
|
||||||
<span className="text-dark small">{message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Progress: FunctionComponent<Site.Progress> = ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
count,
|
|
||||||
header,
|
|
||||||
}) => {
|
|
||||||
const isCompleted = value / count > 1;
|
|
||||||
const displayValue = Math.min(count, value + 1);
|
|
||||||
return (
|
|
||||||
<div className="notification-center-progress d-flex flex-column">
|
|
||||||
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
|
|
||||||
{header}
|
|
||||||
</p>
|
|
||||||
<p className="progress-name m-0 small text-secondary">
|
|
||||||
{isCompleted ? "Completed successfully" : name}
|
|
||||||
</p>
|
|
||||||
<ProgressBar
|
|
||||||
className="mt-2"
|
|
||||||
animated={!isCompleted}
|
|
||||||
now={displayValue / count}
|
|
||||||
max={1}
|
|
||||||
label={`${displayValue}/${count}`}
|
|
||||||
></ProgressBar>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotificationCenter;
|
|
|
@ -1,58 +1,67 @@
|
||||||
import { LoadingIndicator } from "@/components";
|
import AppNavbar from "@/App/Navbar";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { useNotification } from "@/modules/redux/hooks";
|
import { Layout } from "@/constants";
|
||||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
import NavbarProvider from "@/contexts/Navbar";
|
||||||
import SocketIO from "@/modules/socketio";
|
import OnlineProvider from "@/contexts/Online";
|
||||||
import LaunchError from "@/pages/LaunchError";
|
import { notification } from "@/modules/task";
|
||||||
import Sidebar from "@/Sidebar";
|
import CriticalError from "@/pages/CriticalError";
|
||||||
import { Environment } from "@/utilities";
|
import { Environment } from "@/utilities";
|
||||||
import { FunctionComponent, useEffect } from "react";
|
import { AppShell } from "@mantine/core";
|
||||||
import { Row } from "react-bootstrap";
|
import { useWindowEvent } from "@mantine/hooks";
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import { useEffectOnceWhen } from "rooks";
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
import Header from "./Header";
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import AppHeader from "./Header";
|
||||||
|
|
||||||
const App: FunctionComponent = () => {
|
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(() => {
|
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);
|
if (criticalError !== null) {
|
||||||
|
return <CriticalError message={criticalError}></CriticalError>;
|
||||||
// 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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Row noGutters className="header-container">
|
<NavbarProvider value={{ showed: navbar, show: setNavbar }}>
|
||||||
<Header></Header>
|
<OnlineProvider value={{ online, setOnline }}>
|
||||||
</Row>
|
<AppShell
|
||||||
<Row noGutters className="flex-nowrap">
|
navbarOffsetBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||||
<Sidebar></Sidebar>
|
header={<AppHeader></AppHeader>}
|
||||||
<Outlet></Outlet>
|
navbar={<AppNavbar></AppNavbar>}
|
||||||
</Row>
|
padding={0}
|
||||||
|
fixed
|
||||||
|
>
|
||||||
|
<Outlet></Outlet>
|
||||||
|
</AppShell>
|
||||||
|
</OnlineProvider>
|
||||||
|
</NavbarProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
import {
|
||||||
|
ColorScheme,
|
||||||
|
ColorSchemeProvider,
|
||||||
|
MantineProvider,
|
||||||
|
MantineThemeOverride,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useColorScheme } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const theme: MantineThemeOverride = {
|
||||||
|
fontFamily: [
|
||||||
|
"Roboto",
|
||||||
|
"open sans",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"Helvetica",
|
||||||
|
"Arial",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
brand: [
|
||||||
|
"#F8F0FC",
|
||||||
|
"#F3D9FA",
|
||||||
|
"#EEBEFA",
|
||||||
|
"#E599F7",
|
||||||
|
"#DA77F2",
|
||||||
|
"#CC5DE8",
|
||||||
|
"#BE4BDB",
|
||||||
|
"#AE3EC9",
|
||||||
|
"#9C36B5",
|
||||||
|
"#862E9C",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
primaryColor: "brand",
|
||||||
|
};
|
||||||
|
|
||||||
|
function useAutoColorScheme() {
|
||||||
|
const preferredColorScheme = useColorScheme();
|
||||||
|
const [colorScheme, setColorScheme] = useState(preferredColorScheme);
|
||||||
|
|
||||||
|
// automatically switch dark/light theme
|
||||||
|
useEffect(() => {
|
||||||
|
setColorScheme(preferredColorScheme);
|
||||||
|
}, [preferredColorScheme]);
|
||||||
|
|
||||||
|
const toggleColorScheme = useCallback((value?: ColorScheme) => {
|
||||||
|
setColorScheme((scheme) => value || (scheme === "dark" ? "light" : "dark"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { colorScheme, setColorScheme, toggleColorScheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProvider: FunctionComponent = ({ children }) => {
|
||||||
|
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColorSchemeProvider
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
toggleColorScheme={toggleColorScheme}
|
||||||
|
>
|
||||||
|
<MantineProvider
|
||||||
|
withGlobalStyles
|
||||||
|
withNormalizeCSS
|
||||||
|
theme={{ colorScheme, ...theme }}
|
||||||
|
emotionOptions={{ key: "bazarr" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</ColorSchemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeProvider;
|
|
@ -1,18 +1,27 @@
|
||||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
import { useSystemSettings } from "@/apis/hooks";
|
||||||
import { FunctionComponent } from "react";
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
import { Navigate } from "react-router-dom";
|
import { FunctionComponent, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const Redirector: FunctionComponent = () => {
|
const Redirector: FunctionComponent = () => {
|
||||||
const { sonarr, radarr } = useEnabledStatus();
|
const { data } = useSystemSettings();
|
||||||
|
|
||||||
let path = "/settings/general";
|
const navigate = useNavigate();
|
||||||
if (sonarr) {
|
|
||||||
path = "/series";
|
|
||||||
} else if (radarr) {
|
|
||||||
path = "/movies";
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Navigate to={path}></Navigate>;
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const { use_sonarr, use_radarr } = data.general;
|
||||||
|
if (use_sonarr) {
|
||||||
|
navigate("/series");
|
||||||
|
} else if (use_radarr) {
|
||||||
|
navigate("/movies");
|
||||||
|
} else {
|
||||||
|
navigate("/settings/general");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, navigate]);
|
||||||
|
|
||||||
|
return <LoadingOverlay visible></LoadingOverlay>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Redirector;
|
export default Redirector;
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { useBadges } from "@/apis/hooks";
|
import { useBadges } from "@/apis/hooks";
|
||||||
|
import { useEnabledStatus } from "@/apis/hooks/site";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
import Lazy from "@/components/Lazy";
|
import { Lazy } from "@/components/async";
|
||||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
import Authentication from "@/pages/Authentication";
|
||||||
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
||||||
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
||||||
import Episodes from "@/pages/Episodes";
|
import Episodes from "@/pages/Episodes";
|
||||||
|
@ -10,6 +11,7 @@ import SeriesHistoryView from "@/pages/History/Series";
|
||||||
import MovieView from "@/pages/Movies";
|
import MovieView from "@/pages/Movies";
|
||||||
import MovieDetailView from "@/pages/Movies/Details";
|
import MovieDetailView from "@/pages/Movies/Details";
|
||||||
import MovieMassEditor from "@/pages/Movies/Editor";
|
import MovieMassEditor from "@/pages/Movies/Editor";
|
||||||
|
import NotFound from "@/pages/NotFound";
|
||||||
import SeriesView from "@/pages/Series";
|
import SeriesView from "@/pages/Series";
|
||||||
import SeriesMassEditor from "@/pages/Series/Editor";
|
import SeriesMassEditor from "@/pages/Series/Editor";
|
||||||
import SettingsGeneralView from "@/pages/Settings/General";
|
import SettingsGeneralView from "@/pages/Settings/General";
|
||||||
|
@ -38,7 +40,7 @@ import {
|
||||||
faLaptop,
|
faLaptop,
|
||||||
faPlay,
|
faPlay,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import React, {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
FunctionComponent,
|
FunctionComponent,
|
||||||
lazy,
|
lazy,
|
||||||
|
@ -51,8 +53,6 @@ import { CustomRouteObject } from "./type";
|
||||||
|
|
||||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||||
const Authentication = lazy(() => import("@/pages/Authentication"));
|
|
||||||
const NotFound = lazy(() => import("@/pages/404"));
|
|
||||||
|
|
||||||
function useRoutes(): CustomRouteObject[] {
|
function useRoutes(): CustomRouteObject[] {
|
||||||
const { data } = useBadges();
|
const { data } = useBadges();
|
||||||
|
@ -277,25 +277,17 @@ function useRoutes(): CustomRouteObject[] {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
hidden: true,
|
||||||
|
element: <NotFound></NotFound>,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
element: (
|
element: <Authentication></Authentication>,
|
||||||
<Lazy>
|
|
||||||
<Authentication></Authentication>
|
|
||||||
</Lazy>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "*",
|
|
||||||
hidden: true,
|
|
||||||
element: (
|
|
||||||
<Lazy>
|
|
||||||
<NotFound></NotFound>
|
|
||||||
</Lazy>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
||||||
|
|
|
@ -1,256 +0,0 @@
|
||||||
import { setSidebar } from "@/modules/redux/actions";
|
|
||||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
|
||||||
import { useRouteItems } from "@/Router";
|
|
||||||
import { CustomRouteObject, Route } from "@/Router/type";
|
|
||||||
import { BuildKey, Environment, pathJoin } from "@/utilities";
|
|
||||||
import { LOG } from "@/utilities/console";
|
|
||||||
import { useGotoHomepage } from "@/utilities/hooks";
|
|
||||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
FunctionComponent,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Collapse,
|
|
||||||
Container,
|
|
||||||
Image,
|
|
||||||
ListGroup,
|
|
||||||
ListGroupItem,
|
|
||||||
} from "react-bootstrap";
|
|
||||||
import {
|
|
||||||
matchPath,
|
|
||||||
NavLink,
|
|
||||||
RouteObject,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
} from "react-router-dom";
|
|
||||||
|
|
||||||
const Selection = createContext<{
|
|
||||||
selection: string | null;
|
|
||||||
select: (path: string | null) => void;
|
|
||||||
}>({
|
|
||||||
selection: null,
|
|
||||||
select: () => {
|
|
||||||
LOG("error", "Selection context not initialized");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function useSelection() {
|
|
||||||
return useContext(Selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useBadgeValue(route: Route.Item) {
|
|
||||||
const { badge, children } = route;
|
|
||||||
return useMemo(() => {
|
|
||||||
let value = badge ?? 0;
|
|
||||||
|
|
||||||
if (children === undefined) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
value +=
|
|
||||||
children.reduce((acc, child: Route.Item) => {
|
|
||||||
if (child.badge && child.hidden !== true) {
|
|
||||||
return acc + (child.badge ?? 0);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, 0) ?? 0;
|
|
||||||
|
|
||||||
return value === 0 ? undefined : value;
|
|
||||||
}, [badge, children]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useIsActive(parent: string, route: RouteObject) {
|
|
||||||
const { path, children } = route;
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
|
||||||
|
|
||||||
const paths = useMemo(
|
|
||||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
|
||||||
[root, children]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selection = useSelection().selection;
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
selection?.includes(root) ||
|
|
||||||
paths.some((path) => matchPath(path, pathname)),
|
|
||||||
[pathname, paths, root, selection]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actual sidebar
|
|
||||||
const Sidebar: FunctionComponent = () => {
|
|
||||||
const [selection, select] = useState<string | null>(null);
|
|
||||||
const isShow = useReduxStore((s) => s.site.showSidebar);
|
|
||||||
|
|
||||||
const showSidebar = useReduxAction(setSidebar);
|
|
||||||
|
|
||||||
const goHome = useGotoHomepage();
|
|
||||||
|
|
||||||
const routes = useRouteItems();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
select(null);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Selection.Provider value={{ selection, select }}>
|
|
||||||
<nav className={clsx("sidebar-container", { open: isShow })}>
|
|
||||||
<Container className="sidebar-title d-flex align-items-center d-md-none">
|
|
||||||
<Image
|
|
||||||
alt="brand"
|
|
||||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
onClick={goHome}
|
|
||||||
className="cursor-pointer"
|
|
||||||
></Image>
|
|
||||||
</Container>
|
|
||||||
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
|
|
||||||
{routes.map((route, idx) => (
|
|
||||||
<RouteItem
|
|
||||||
key={BuildKey("nav", idx)}
|
|
||||||
parent="/"
|
|
||||||
route={route}
|
|
||||||
></RouteItem>
|
|
||||||
))}
|
|
||||||
</ListGroup>
|
|
||||||
</nav>
|
|
||||||
<div
|
|
||||||
className={clsx("sidebar-overlay", { open: isShow })}
|
|
||||||
onClick={() => showSidebar(false)}
|
|
||||||
></div>
|
|
||||||
</Selection.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RouteItem: FunctionComponent<{
|
|
||||||
route: CustomRouteObject;
|
|
||||||
parent: string;
|
|
||||||
}> = ({ route, parent }) => {
|
|
||||||
const { children, name, path, icon, hidden, element } = route;
|
|
||||||
|
|
||||||
const isValidated = useMemo(
|
|
||||||
() =>
|
|
||||||
element !== undefined ||
|
|
||||||
children?.find((v) => v.index === true) !== undefined,
|
|
||||||
[element, children]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { select } = useSelection();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
|
||||||
|
|
||||||
const badge = useBadgeValue(route);
|
|
||||||
|
|
||||||
const isOpen = useIsActive(parent, route);
|
|
||||||
|
|
||||||
if (hidden === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore path if it is using match
|
|
||||||
if (path === undefined || path.includes(":")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (children !== undefined) {
|
|
||||||
const elements = children.map((child, idx) => (
|
|
||||||
<RouteItem
|
|
||||||
parent={link}
|
|
||||||
key={BuildKey(link, "nav", idx)}
|
|
||||||
route={child}
|
|
||||||
></RouteItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
return (
|
|
||||||
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
|
|
||||||
<ListGroupItem
|
|
||||||
action
|
|
||||||
className={clsx("button", { active: isOpen })}
|
|
||||||
onClick={() => {
|
|
||||||
LOG("info", "clicked", link);
|
|
||||||
|
|
||||||
if (isValidated) {
|
|
||||||
navigate(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
select(null);
|
|
||||||
} else {
|
|
||||||
select(link);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RouteItemContent
|
|
||||||
name={name ?? link}
|
|
||||||
icon={icon}
|
|
||||||
badge={badge}
|
|
||||||
></RouteItemContent>
|
|
||||||
</ListGroupItem>
|
|
||||||
<Collapse in={isOpen}>
|
|
||||||
<div className="indent">{elements}</div>
|
|
||||||
</Collapse>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <>{elements}</>;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
to={link}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
clsx("list-group-item list-group-item-action button sb-collapse", {
|
|
||||||
active: isActive,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RouteItemContent
|
|
||||||
name={name ?? link}
|
|
||||||
icon={icon}
|
|
||||||
badge={badge}
|
|
||||||
></RouteItemContent>
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ItemComponentProps {
|
|
||||||
name: string;
|
|
||||||
icon?: IconDefinition;
|
|
||||||
badge?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
|
|
||||||
icon,
|
|
||||||
name,
|
|
||||||
badge,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
|
|
||||||
<span className="d-flex flex-grow-1 justify-content-between">
|
|
||||||
{name}
|
|
||||||
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
|
|
||||||
{badge}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Sidebar;
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useSystemSettings } from ".";
|
||||||
|
|
||||||
|
export function useEnabledStatus() {
|
||||||
|
const { data } = useSystemSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sonarr: data?.general.use_sonarr ?? false,
|
||||||
|
radarr: data?.general.use_radarr ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShowOnlyDesired() {
|
||||||
|
const { data } = useSystemSettings();
|
||||||
|
return data?.general.embedded_subs_show_desired ?? false;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { Environment } from "@/utilities";
|
||||||
|
import { setLoginRequired } from "@/utilities/event";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
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 { QueryKeys } from "../queries/keys";
|
||||||
import api from "../raw";
|
import api from "../raw";
|
||||||
|
|
||||||
|
@ -173,7 +173,7 @@ export function useSystem() {
|
||||||
() => api.system.logout(),
|
() => api.system.logout(),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
store.dispatch(setUnauthenticated());
|
setLoginRequired();
|
||||||
client.clear();
|
client.clear();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -185,7 +185,8 @@ export function useSystem() {
|
||||||
api.system.login(param.username, param.password),
|
api.system.login(param.username, param.password),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.location.reload();
|
// TODO: Hard-coded value
|
||||||
|
window.location.replace(`/${Environment.baseUrl}`);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -216,7 +217,7 @@ export function useSystem() {
|
||||||
shutdown,
|
shutdown,
|
||||||
restart,
|
restart,
|
||||||
login,
|
login,
|
||||||
isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
|
isMutating: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isLoggingIn,
|
isLoggingIn,
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
|
import SocketIO from "@/modules/socketio";
|
||||||
|
import { setLoginRequired } from "@/utilities/event";
|
||||||
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
||||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
import { Environment } from "../../utilities";
|
||||||
import { AppDispatch } from "../../modules/redux/store";
|
|
||||||
import { Environment, isProdEnv } from "../../utilities";
|
|
||||||
class BazarrClient {
|
class BazarrClient {
|
||||||
axios!: AxiosInstance;
|
axios!: AxiosInstance;
|
||||||
source!: CancelTokenSource;
|
source!: CancelTokenSource;
|
||||||
dispatch!: AppDispatch;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const baseUrl = `${Environment.baseUrl}/api/`;
|
const baseUrl = `${Environment.baseUrl}/api/`;
|
||||||
this.initialize(baseUrl, Environment.apiKey);
|
this.initialize(baseUrl, Environment.apiKey);
|
||||||
|
SocketIO.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(url: string, apikey?: string) {
|
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) {
|
handleError(code: number) {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 401:
|
case 401:
|
||||||
this.dispatch(setUnauthenticated());
|
setLoginRequired();
|
||||||
break;
|
break;
|
||||||
case 500:
|
case 500:
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { GetItemId } from "@/utilities";
|
import { GetItemId, useOnValueChange } from "@/utilities";
|
||||||
import { usePageSize } from "@/utilities/storage";
|
import { usePageSize } from "@/utilities/storage";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
@ -13,17 +13,14 @@ export type UsePaginationQueryResult<T extends object> = UseQueryResult<
|
||||||
DataWrapperWithTotal<T>
|
DataWrapperWithTotal<T>
|
||||||
> & {
|
> & {
|
||||||
controls: {
|
controls: {
|
||||||
previousPage: () => void;
|
|
||||||
nextPage: () => void;
|
|
||||||
gotoPage: (index: number) => void;
|
gotoPage: (index: number) => void;
|
||||||
};
|
};
|
||||||
paginationStatus: {
|
paginationStatus: {
|
||||||
|
isPageLoading: boolean;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
page: number;
|
page: number;
|
||||||
canPrevious: boolean;
|
|
||||||
canNext: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -67,16 +64,6 @@ export function usePaginationQuery<
|
||||||
const totalCount = data?.total ?? 0;
|
const totalCount = data?.total ?? 0;
|
||||||
const pageCount = Math.ceil(totalCount / pageSize);
|
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(
|
const gotoPage = useCallback(
|
||||||
(idx: number) => {
|
(idx: number) => {
|
||||||
if (idx >= 0 && idx < pageCount) {
|
if (idx >= 0 && idx < pageCount) {
|
||||||
|
@ -86,6 +73,20 @@ export function usePaginationQuery<
|
||||||
[pageCount]
|
[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
|
// Reset page index if we out of bound
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageCount === 0) return;
|
if (pageCount === 0) return;
|
||||||
|
@ -100,17 +101,14 @@ export function usePaginationQuery<
|
||||||
return {
|
return {
|
||||||
...results,
|
...results,
|
||||||
paginationStatus: {
|
paginationStatus: {
|
||||||
|
isPageLoading,
|
||||||
totalCount,
|
totalCount,
|
||||||
pageCount,
|
pageCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
canPrevious: page > 0,
|
|
||||||
canNext: page < pageCount - 1,
|
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
gotoPage,
|
gotoPage,
|
||||||
previousPage,
|
|
||||||
nextPage,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,204 +0,0 @@
|
||||||
import { BuildKey, isMovie } from "@/utilities";
|
|
||||||
import {
|
|
||||||
useLanguageProfileBy,
|
|
||||||
useProfileItemsToLanguages,
|
|
||||||
} from "@/utilities/languages";
|
|
||||||
import {
|
|
||||||
faBookmark as farBookmark,
|
|
||||||
faClone as fasClone,
|
|
||||||
faFolder,
|
|
||||||
} from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import {
|
|
||||||
faBookmark,
|
|
||||||
faLanguage,
|
|
||||||
faMusic,
|
|
||||||
faStream,
|
|
||||||
faTags,
|
|
||||||
IconDefinition,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { FunctionComponent, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Col,
|
|
||||||
Container,
|
|
||||||
Image,
|
|
||||||
OverlayTrigger,
|
|
||||||
Popover,
|
|
||||||
Row,
|
|
||||||
} from "react-bootstrap";
|
|
||||||
import Language from "./bazarr/Language";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: Item.Base;
|
|
||||||
details?: { icon: IconDefinition; text: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ItemOverview: FunctionComponent<Props> = (props) => {
|
|
||||||
const { item, details } = props;
|
|
||||||
|
|
||||||
const detailBadges = useMemo(() => {
|
|
||||||
const badges: (JSX.Element | null)[] = [];
|
|
||||||
badges.push(
|
|
||||||
<DetailBadge key="file-path" icon={faFolder} desc="File Path">
|
|
||||||
{item.path}
|
|
||||||
</DetailBadge>
|
|
||||||
);
|
|
||||||
|
|
||||||
badges.push(
|
|
||||||
...(details?.map((val, idx) => (
|
|
||||||
<DetailBadge key={BuildKey(idx, "detail", val.text)} icon={val.icon}>
|
|
||||||
{val.text}
|
|
||||||
</DetailBadge>
|
|
||||||
)) ?? [])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (item.tags.length > 0) {
|
|
||||||
badges.push(
|
|
||||||
<DetailBadge key="tags" icon={faTags} desc="Tags">
|
|
||||||
{item.tags.join("|")}
|
|
||||||
</DetailBadge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return badges;
|
|
||||||
}, [details, item.path, item.tags]);
|
|
||||||
|
|
||||||
const audioBadges = useMemo(
|
|
||||||
() =>
|
|
||||||
item.audio_language.map((v, idx) => (
|
|
||||||
<DetailBadge
|
|
||||||
key={BuildKey(idx, "audio", v.code2)}
|
|
||||||
icon={faMusic}
|
|
||||||
desc="Audio Language"
|
|
||||||
>
|
|
||||||
{v.name}
|
|
||||||
</DetailBadge>
|
|
||||||
)),
|
|
||||||
[item.audio_language]
|
|
||||||
);
|
|
||||||
|
|
||||||
const profile = useLanguageProfileBy(item.profileId);
|
|
||||||
const profileItems = useProfileItemsToLanguages(profile);
|
|
||||||
|
|
||||||
const languageBadges = useMemo(() => {
|
|
||||||
const badges: (JSX.Element | null)[] = [];
|
|
||||||
|
|
||||||
if (profile) {
|
|
||||||
badges.push(
|
|
||||||
<DetailBadge
|
|
||||||
key="language-profile"
|
|
||||||
icon={faStream}
|
|
||||||
desc="Languages Profile"
|
|
||||||
>
|
|
||||||
{profile.name}
|
|
||||||
</DetailBadge>
|
|
||||||
);
|
|
||||||
|
|
||||||
badges.push(
|
|
||||||
...profileItems.map((v, idx) => (
|
|
||||||
<DetailBadge
|
|
||||||
key={BuildKey(idx, "lang", v.code2)}
|
|
||||||
icon={faLanguage}
|
|
||||||
desc="Language"
|
|
||||||
>
|
|
||||||
<Language.Text long value={v}></Language.Text>
|
|
||||||
</DetailBadge>
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return badges;
|
|
||||||
}, [profile, profileItems]);
|
|
||||||
|
|
||||||
const alternativePopover = useMemo(
|
|
||||||
() => (
|
|
||||||
<Popover id="item-overview-alternative">
|
|
||||||
<Popover.Title>Alternate Titles</Popover.Title>
|
|
||||||
<Popover.Content>
|
|
||||||
{item.alternativeTitles.map((v, idx) => (
|
|
||||||
<li key={idx}>{v}</li>
|
|
||||||
))}
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover>
|
|
||||||
),
|
|
||||||
[item.alternativeTitles]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
fluid
|
|
||||||
style={{
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "top center",
|
|
||||||
backgroundImage: `url('${item.fanart}')`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Row
|
|
||||||
className="p-4 pb-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgba(0,0,0,0.7)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Col sm="auto">
|
|
||||||
<Image
|
|
||||||
className="d-none d-sm-block my-2"
|
|
||||||
style={{
|
|
||||||
maxHeight: 250,
|
|
||||||
}}
|
|
||||||
src={item.poster}
|
|
||||||
></Image>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<Container fluid className="text-white">
|
|
||||||
<Row>
|
|
||||||
{isMovie(item) ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
className="mx-2 mt-2"
|
|
||||||
title={item.monitored ? "monitored" : "unmonitored"}
|
|
||||||
icon={item.monitored ? faBookmark : farBookmark}
|
|
||||||
size="2x"
|
|
||||||
></FontAwesomeIcon>
|
|
||||||
) : null}
|
|
||||||
<h1>{item.title}</h1>
|
|
||||||
<span hidden={item.alternativeTitles.length === 0}>
|
|
||||||
<OverlayTrigger overlay={alternativePopover}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
className="mx-2"
|
|
||||||
icon={fasClone}
|
|
||||||
></FontAwesomeIcon>
|
|
||||||
</OverlayTrigger>
|
|
||||||
</span>
|
|
||||||
</Row>
|
|
||||||
<Row>{detailBadges}</Row>
|
|
||||||
<Row>{audioBadges}</Row>
|
|
||||||
<Row>{languageBadges}</Row>
|
|
||||||
<Row>
|
|
||||||
<span>{item.overview}</span>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ItemBadgeProps {
|
|
||||||
icon: IconDefinition;
|
|
||||||
children: string | JSX.Element;
|
|
||||||
desc?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DetailBadge: FunctionComponent<ItemBadgeProps> = ({
|
|
||||||
icon,
|
|
||||||
desc,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<Badge title={desc} variant="secondary" className="mr-2 my-1 text-truncate">
|
|
||||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
|
||||||
<span className="ml-1">{children}</span>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ItemOverview;
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
options: readonly Language.Info[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type RemovedSelectorProps<M extends boolean> = Omit<
|
|
||||||
SelectorProps<Language.Info, M>,
|
|
||||||
"label"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type LanguageSelectorProps<M extends boolean> = Override<
|
|
||||||
Props,
|
|
||||||
RemovedSelectorProps<M>
|
|
||||||
>;
|
|
||||||
|
|
||||||
function getLabel(lang: Language.Info) {
|
|
||||||
return lang.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LanguageSelector<M extends boolean = false>(
|
|
||||||
props: LanguageSelectorProps<M>
|
|
||||||
) {
|
|
||||||
const { options, ...selector } = props;
|
|
||||||
|
|
||||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
|
||||||
() =>
|
|
||||||
options.map((v) => ({
|
|
||||||
label: v.name,
|
|
||||||
value: v,
|
|
||||||
})),
|
|
||||||
[options]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Selector
|
|
||||||
placeholder="Language..."
|
|
||||||
options={items}
|
|
||||||
label={getLabel}
|
|
||||||
{...selector}
|
|
||||||
></Selector>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useServerSearch } from "@/apis/hooks";
|
||||||
|
import { useDebouncedValue } from "@/utilities";
|
||||||
|
import { faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Anchor, Autocomplete, SelectItemProps } from "@mantine/core";
|
||||||
|
import { forwardRef, FunctionComponent, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
type SearchResultItem = {
|
||||||
|
value: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useSearch(query: string) {
|
||||||
|
const debouncedQuery = useDebouncedValue(query, 500);
|
||||||
|
const { data } = useServerSearch(debouncedQuery, debouncedQuery.length > 0);
|
||||||
|
|
||||||
|
return useMemo<SearchResultItem[]>(
|
||||||
|
() =>
|
||||||
|
data?.map((v) => {
|
||||||
|
let link: string;
|
||||||
|
if (v.sonarrSeriesId) {
|
||||||
|
link = `/series/${v.sonarrSeriesId}`;
|
||||||
|
} else if (v.radarrId) {
|
||||||
|
link = `/movies/${v.radarrId}`;
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown search result");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: `${v.title} (${v.year})`,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
}) ?? [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultCompProps = SelectItemProps & SearchResultItem;
|
||||||
|
|
||||||
|
const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>(
|
||||||
|
({ link, value }, ref) => {
|
||||||
|
return (
|
||||||
|
<Anchor component={Link} to={link} underline={false} color="gray" p="sm">
|
||||||
|
{value}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Search: FunctionComponent = () => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const results = useSearch(query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
icon={<FontAwesomeIcon icon={faSearch} />}
|
||||||
|
itemComponent={ResultComponent}
|
||||||
|
placeholder="Search"
|
||||||
|
size="sm"
|
||||||
|
data={results}
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
onBlur={() => setQuery("")}
|
||||||
|
></Autocomplete>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
|
@ -1,119 +0,0 @@
|
||||||
import { useServerSearch } from "@/apis/hooks";
|
|
||||||
import { uniqueId } from "lodash";
|
|
||||||
import {
|
|
||||||
FunctionComponent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Dropdown, Form } from "react-bootstrap";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useThrottle } from "rooks";
|
|
||||||
|
|
||||||
function useSearch(query: string) {
|
|
||||||
const { data } = useServerSearch(query, query.length > 0);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
data?.map((v) => {
|
|
||||||
let link: string;
|
|
||||||
let id: string;
|
|
||||||
if (v.sonarrSeriesId) {
|
|
||||||
link = `/series/${v.sonarrSeriesId}`;
|
|
||||||
id = `series-${v.sonarrSeriesId}`;
|
|
||||||
} else if (v.radarrId) {
|
|
||||||
link = `/movies/${v.radarrId}`;
|
|
||||||
id = `movie-${v.radarrId}`;
|
|
||||||
} else {
|
|
||||||
link = "";
|
|
||||||
id = uniqueId("unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `${v.title} (${v.year})`,
|
|
||||||
link,
|
|
||||||
id,
|
|
||||||
};
|
|
||||||
}) ?? [],
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export interface SearchResult {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
onFocus?: () => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchBar: FunctionComponent<Props> = ({
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const [display, setDisplay] = useState("");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const [debounce] = useThrottle(setQuery, 500);
|
|
||||||
useEffect(() => {
|
|
||||||
debounce(display);
|
|
||||||
}, [debounce, display]);
|
|
||||||
|
|
||||||
const results = useSearch(query);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
|
||||||
setDisplay("");
|
|
||||||
setQuery("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const its = results.map((v) => (
|
|
||||||
<Dropdown.Item
|
|
||||||
key={v.id}
|
|
||||||
eventKey={v.link}
|
|
||||||
disabled={v.link === undefined}
|
|
||||||
>
|
|
||||||
<span>{v.name}</span>
|
|
||||||
</Dropdown.Item>
|
|
||||||
));
|
|
||||||
|
|
||||||
if (its.length === 0) {
|
|
||||||
its.push(<Dropdown.Header key="notify">No Found</Dropdown.Header>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return its;
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
show={query.length !== 0}
|
|
||||||
className={className}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onSelect={(link) => {
|
|
||||||
if (link) {
|
|
||||||
clear();
|
|
||||||
navigate(link);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Control
|
|
||||||
type="text"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Search..."
|
|
||||||
value={display}
|
|
||||||
onChange={(e) => setDisplay(e.currentTarget.value)}
|
|
||||||
></Form.Control>
|
|
||||||
<Dropdown.Menu style={{ maxHeight: 256, overflowY: "auto" }}>
|
|
||||||
{items}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { useSubtitleAction } from "@/apis/hooks";
|
||||||
|
import { ColorToolModal } from "@/components/forms/ColorToolForm";
|
||||||
|
import { FrameRateModal } from "@/components/forms/FrameRateForm";
|
||||||
|
import { TimeOffsetModal } from "@/components/forms/TimeOffsetForm";
|
||||||
|
import { TranslationModal } from "@/components/forms/TranslationForm";
|
||||||
|
import { useModals } from "@/modules/modals";
|
||||||
|
import { ModalComponent } from "@/modules/modals/WithModal";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import {
|
||||||
|
faClock,
|
||||||
|
faCode,
|
||||||
|
faDeaf,
|
||||||
|
faExchangeAlt,
|
||||||
|
faFilm,
|
||||||
|
faImage,
|
||||||
|
faLanguage,
|
||||||
|
faMagic,
|
||||||
|
faPaintBrush,
|
||||||
|
faPlay,
|
||||||
|
faSearch,
|
||||||
|
faTextHeight,
|
||||||
|
faTrash,
|
||||||
|
IconDefinition,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
|
||||||
|
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
export interface ToolOptions {
|
||||||
|
key: string;
|
||||||
|
icon: IconDefinition;
|
||||||
|
name: string;
|
||||||
|
modal?: ModalComponent<{
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTools() {
|
||||||
|
return useMemo<ToolOptions[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "sync",
|
||||||
|
icon: faPlay,
|
||||||
|
name: "Sync",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "remove_HI",
|
||||||
|
icon: faDeaf,
|
||||||
|
name: "Remove HI Tags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "remove_tags",
|
||||||
|
icon: faCode,
|
||||||
|
name: "Remove Style Tags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "OCR_fixes",
|
||||||
|
icon: faImage,
|
||||||
|
name: "OCR Fixes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "common",
|
||||||
|
icon: faMagic,
|
||||||
|
name: "Common Fixes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fix_uppercase",
|
||||||
|
icon: faTextHeight,
|
||||||
|
name: "Fix Uppercase",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reverse_rtl",
|
||||||
|
icon: faExchangeAlt,
|
||||||
|
name: "Reverse RTL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "add_color",
|
||||||
|
icon: faPaintBrush,
|
||||||
|
name: "Add Color...",
|
||||||
|
modal: ColorToolModal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "change_frame_rate",
|
||||||
|
icon: faFilm,
|
||||||
|
name: "Change Frame Rate...",
|
||||||
|
modal: FrameRateModal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "adjust_time",
|
||||||
|
icon: faClock,
|
||||||
|
name: "Adjust Times...",
|
||||||
|
modal: TimeOffsetModal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "translation",
|
||||||
|
icon: faLanguage,
|
||||||
|
name: "Translate...",
|
||||||
|
modal: TranslationModal,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
children?: ReactElement;
|
||||||
|
menu?: Omit<MenuProps, "control" | "children">;
|
||||||
|
onAction?: (action: "delete" | "search") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubtitleToolsMenu: FunctionComponent<Props> = ({
|
||||||
|
selections,
|
||||||
|
children,
|
||||||
|
menu,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
|
||||||
|
const process = useCallback(
|
||||||
|
(action: string, name: string) => {
|
||||||
|
selections.forEach((s) => {
|
||||||
|
const form: FormType.ModifySubtitle = {
|
||||||
|
id: s.id,
|
||||||
|
type: s.type,
|
||||||
|
language: s.language,
|
||||||
|
path: s.path,
|
||||||
|
};
|
||||||
|
task.create(s.path, name, mutateAsync, { action, form });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutateAsync, selections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = useTools();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const disabledTools = selections.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
control={children}
|
||||||
|
withArrow
|
||||||
|
placement="end"
|
||||||
|
position="left"
|
||||||
|
{...menu}
|
||||||
|
>
|
||||||
|
<Menu.Label>Tools</Menu.Label>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={tool.key}
|
||||||
|
disabled={disabledTools}
|
||||||
|
icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
|
||||||
|
onClick={() => {
|
||||||
|
if (tool.modal) {
|
||||||
|
modals.openContextModal(tool.modal, { selections });
|
||||||
|
} else {
|
||||||
|
process(tool.key, tool.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tool.name}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
<Divider></Divider>
|
||||||
|
<Menu.Label>Actions</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
disabled={selections.length !== 0 || onAction === undefined}
|
||||||
|
icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
|
||||||
|
onClick={() => {
|
||||||
|
onAction?.("search");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
disabled={selections.length === 0 || onAction === undefined}
|
||||||
|
color="red"
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
|
||||||
|
onClick={() => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: "The following subtitles will be deleted",
|
||||||
|
size: "lg",
|
||||||
|
children: (
|
||||||
|
<ScrollArea style={{ maxHeight: "20rem" }}>
|
||||||
|
<List>
|
||||||
|
{selections.map((s) => (
|
||||||
|
<List.Item my="md" key={s.path}>
|
||||||
|
{s.path}
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</ScrollArea>
|
||||||
|
),
|
||||||
|
onConfirm: () => {
|
||||||
|
onAction?.("delete");
|
||||||
|
},
|
||||||
|
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete...
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubtitleToolsMenu;
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Tooltip, TooltipProps } from "@mantine/core";
|
||||||
|
import { useHover } from "@mantine/hooks";
|
||||||
|
import { isNull, isUndefined } from "lodash";
|
||||||
|
import { FunctionComponent, ReactElement } from "react";
|
||||||
|
|
||||||
|
interface TextPopoverProps {
|
||||||
|
children: ReactElement;
|
||||||
|
text: string | undefined | null;
|
||||||
|
tooltip?: Omit<TooltipProps, "opened" | "label" | "children">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextPopover: FunctionComponent<TextPopoverProps> = ({
|
||||||
|
children,
|
||||||
|
text,
|
||||||
|
tooltip,
|
||||||
|
}) => {
|
||||||
|
const { hovered, ref } = useHover();
|
||||||
|
|
||||||
|
if (isNull(text) || isUndefined(text)) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip opened={hovered} label={text} {...tooltip}>
|
||||||
|
<div ref={ref}>{children}</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextPopover;
|
|
@ -1,161 +0,0 @@
|
||||||
import {
|
|
||||||
faCheck,
|
|
||||||
faCircleNotch,
|
|
||||||
faTimes,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
FunctionComponent,
|
|
||||||
PropsWithChildren,
|
|
||||||
ReactElement,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Button, ButtonProps } from "react-bootstrap";
|
|
||||||
import { UseQueryResult } from "react-query";
|
|
||||||
import { useTimeoutWhen } from "rooks";
|
|
||||||
import { LoadingIndicator } from ".";
|
|
||||||
|
|
||||||
interface QueryOverlayProps {
|
|
||||||
result: UseQueryResult<unknown, unknown>;
|
|
||||||
children: ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
|
|
||||||
children,
|
|
||||||
result: { isLoading, isError, error },
|
|
||||||
}) => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingIndicator></LoadingIndicator>;
|
|
||||||
} else if (isError) {
|
|
||||||
return <p>{error as string}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PromiseProps<T> {
|
|
||||||
promise: () => Promise<T>;
|
|
||||||
children: FunctionComponent<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
|
|
||||||
const [item, setItem] = useState<T | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
promise().then(setItem);
|
|
||||||
}, [promise]);
|
|
||||||
|
|
||||||
if (item === null) {
|
|
||||||
return <LoadingIndicator></LoadingIndicator>;
|
|
||||||
} else {
|
|
||||||
return children(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AsyncButtonProps<T> {
|
|
||||||
as?: ButtonProps["as"];
|
|
||||||
variant?: ButtonProps["variant"];
|
|
||||||
size?: ButtonProps["size"];
|
|
||||||
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange?: (v: boolean) => void;
|
|
||||||
|
|
||||||
noReset?: boolean;
|
|
||||||
animation?: boolean;
|
|
||||||
|
|
||||||
promise: () => Promise<T> | null;
|
|
||||||
onSuccess?: (result: T) => void;
|
|
||||||
error?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RequestState {
|
|
||||||
Success,
|
|
||||||
Error,
|
|
||||||
Invalid,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AsyncButton<T>(
|
|
||||||
props: PropsWithChildren<AsyncButtonProps<T>>
|
|
||||||
): JSX.Element {
|
|
||||||
const {
|
|
||||||
children: propChildren,
|
|
||||||
className,
|
|
||||||
promise,
|
|
||||||
onSuccess,
|
|
||||||
noReset,
|
|
||||||
animation,
|
|
||||||
error,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
...button
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const [state, setState] = useState(RequestState.Invalid);
|
|
||||||
|
|
||||||
const needFire = state !== RequestState.Invalid && !noReset;
|
|
||||||
|
|
||||||
useTimeoutWhen(
|
|
||||||
() => {
|
|
||||||
setState(RequestState.Invalid);
|
|
||||||
},
|
|
||||||
2 * 1000,
|
|
||||||
needFire
|
|
||||||
);
|
|
||||||
|
|
||||||
const click = useCallback(() => {
|
|
||||||
if (state !== RequestState.Invalid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = promise();
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
setLoading(true);
|
|
||||||
onChange && onChange(true);
|
|
||||||
result
|
|
||||||
.then((res) => {
|
|
||||||
setState(RequestState.Success);
|
|
||||||
onSuccess && onSuccess(res);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setState(RequestState.Error);
|
|
||||||
error && error();
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
onChange && onChange(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [error, onChange, promise, onSuccess, state]);
|
|
||||||
|
|
||||||
const showAnimation = animation ?? true;
|
|
||||||
|
|
||||||
let children = propChildren;
|
|
||||||
if (showAnimation) {
|
|
||||||
if (loading) {
|
|
||||||
children = <FontAwesomeIcon icon={faCircleNotch} spin></FontAwesomeIcon>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === RequestState.Success) {
|
|
||||||
children = <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>;
|
|
||||||
} else if (state === RequestState.Error) {
|
|
||||||
children = <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={className}
|
|
||||||
disabled={loading || disabled || state !== RequestState.Invalid}
|
|
||||||
{...button}
|
|
||||||
onClick={click}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
import { FunctionComponent, Suspense } from "react";
|
import { FunctionComponent, Suspense } from "react";
|
||||||
import { LoadingIndicator } from ".";
|
|
||||||
|
|
||||||
const Lazy: FunctionComponent = ({ children }) => {
|
const Lazy: FunctionComponent = ({ children }) => {
|
||||||
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
|
return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Lazy;
|
export default Lazy;
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { UseMutationResult } from "react-query";
|
||||||
|
import { Action } from "../inputs";
|
||||||
|
import { ActionProps } from "../inputs/Action";
|
||||||
|
|
||||||
|
type MutateActionProps<DATA, VAR> = Omit<
|
||||||
|
ActionProps,
|
||||||
|
"onClick" | "loading" | "color"
|
||||||
|
> & {
|
||||||
|
mutation: UseMutationResult<DATA, unknown, VAR>;
|
||||||
|
args: () => VAR | null;
|
||||||
|
onSuccess?: (args: DATA) => void;
|
||||||
|
onError?: () => void;
|
||||||
|
noReset?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MutateAction<DATA, VAR>({
|
||||||
|
mutation,
|
||||||
|
noReset,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
args,
|
||||||
|
...props
|
||||||
|
}: MutateActionProps<DATA, VAR>) {
|
||||||
|
const { mutateAsync } = mutation;
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onClick = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const argument = args();
|
||||||
|
if (argument !== null) {
|
||||||
|
const data = await mutateAsync(argument);
|
||||||
|
onSuccess?.(data);
|
||||||
|
} else {
|
||||||
|
onError?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onError?.();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [args, mutateAsync, onError, onSuccess]);
|
||||||
|
|
||||||
|
return <Action {...props} loading={isLoading} onClick={onClick}></Action>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MutateAction;
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Button, ButtonProps } from "@mantine/core";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { UseMutationResult } from "react-query";
|
||||||
|
|
||||||
|
type MutateButtonProps<DATA, VAR> = Omit<
|
||||||
|
ButtonProps<"button">,
|
||||||
|
"onClick" | "loading" | "color"
|
||||||
|
> & {
|
||||||
|
mutation: UseMutationResult<DATA, unknown, VAR>;
|
||||||
|
args: () => VAR | null;
|
||||||
|
onSuccess?: (args: DATA) => void;
|
||||||
|
onError?: () => void;
|
||||||
|
noReset?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MutateButton<DATA, VAR>({
|
||||||
|
mutation,
|
||||||
|
noReset,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
args,
|
||||||
|
...props
|
||||||
|
}: MutateButtonProps<DATA, VAR>) {
|
||||||
|
const { mutateAsync } = mutation;
|
||||||
|
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onClick = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const argument = args();
|
||||||
|
if (argument !== null) {
|
||||||
|
const data = await mutateAsync(argument);
|
||||||
|
onSuccess?.(data);
|
||||||
|
} else {
|
||||||
|
onError?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onError?.();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [args, mutateAsync, onError, onSuccess]);
|
||||||
|
|
||||||
|
return <Button {...props} loading={isLoading} onClick={onClick}></Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MutateButton;
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { LoadingProvider } from "@/contexts";
|
||||||
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
|
import { FunctionComponent, ReactNode } from "react";
|
||||||
|
import { UseQueryResult } from "react-query";
|
||||||
|
|
||||||
|
interface QueryOverlayProps {
|
||||||
|
result: UseQueryResult<unknown, unknown>;
|
||||||
|
global?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
|
||||||
|
children,
|
||||||
|
global = false,
|
||||||
|
result: { isLoading, isError, error },
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<LoadingProvider value={isLoading}>
|
||||||
|
<LoadingOverlay visible={global && isLoading}></LoadingOverlay>
|
||||||
|
{children}
|
||||||
|
</LoadingProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QueryOverlay;
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as Lazy } from "./Lazy";
|
||||||
|
export { default as MutateAction } from "./MutateAction";
|
||||||
|
export { default as QueryOverlay } from "./QueryOverlay";
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { BuildKey } from "@/utilities";
|
||||||
|
import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
export type AudioListProps = GroupProps & {
|
||||||
|
audios: Language.Info[];
|
||||||
|
badgeProps?: BadgeProps<"div">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioList: FunctionComponent<AudioListProps> = ({
|
||||||
|
audios,
|
||||||
|
badgeProps,
|
||||||
|
...group
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Group spacing="xs" {...group}>
|
||||||
|
{audios.map((audio, idx) => (
|
||||||
|
<Badge color="teal" key={BuildKey(idx, audio.code2)} {...badgeProps}>
|
||||||
|
{audio.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioList;
|
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
faClock,
|
||||||
|
faCloudUploadAlt,
|
||||||
|
faDownload,
|
||||||
|
faRecycle,
|
||||||
|
faTrash,
|
||||||
|
faUser,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
enum HistoryAction {
|
||||||
|
Delete = 0,
|
||||||
|
Download,
|
||||||
|
Manual,
|
||||||
|
Upgrade,
|
||||||
|
Upload,
|
||||||
|
Sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
const HistoryIcon: FunctionComponent<{
|
||||||
|
action: number;
|
||||||
|
title?: string;
|
||||||
|
}> = ({ action, title }) => {
|
||||||
|
let icon = null;
|
||||||
|
switch (action) {
|
||||||
|
case HistoryAction.Delete:
|
||||||
|
icon = faTrash;
|
||||||
|
break;
|
||||||
|
case HistoryAction.Download:
|
||||||
|
icon = faDownload;
|
||||||
|
break;
|
||||||
|
case HistoryAction.Manual:
|
||||||
|
icon = faUser;
|
||||||
|
break;
|
||||||
|
case HistoryAction.Sync:
|
||||||
|
icon = faClock;
|
||||||
|
break;
|
||||||
|
case HistoryAction.Upgrade:
|
||||||
|
icon = faRecycle;
|
||||||
|
break;
|
||||||
|
case HistoryAction.Upload:
|
||||||
|
icon = faCloudUploadAlt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HistoryIcon;
|
|
@ -1,22 +1,21 @@
|
||||||
import { useLanguages } from "@/apis/hooks";
|
import { BuildKey } from "@/utilities";
|
||||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
import { Badge, Group, Text, TextProps } from "@mantine/core";
|
||||||
import { FunctionComponent, useMemo } from "react";
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
|
||||||
interface TextProps {
|
type LanguageTextProps = TextProps<"div"> & {
|
||||||
value: Language.Info;
|
value: Language.Info;
|
||||||
className?: string;
|
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
declare type LanguageComponent = {
|
declare type LanguageComponent = {
|
||||||
Text: typeof LanguageText;
|
Text: typeof LanguageText;
|
||||||
Selector: typeof LanguageSelector;
|
List: typeof LanguageList;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LanguageText: FunctionComponent<TextProps> = ({
|
const LanguageText: FunctionComponent<LanguageTextProps> = ({
|
||||||
value,
|
value,
|
||||||
className,
|
|
||||||
long,
|
long,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const result = useMemo(() => {
|
const result = useMemo(() => {
|
||||||
let lang = value.code2;
|
let lang = value.code2;
|
||||||
|
@ -38,51 +37,29 @@ const LanguageText: FunctionComponent<TextProps> = ({
|
||||||
}, [value, long]);
|
}, [value, long]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span title={value.name} className={className}>
|
<Text inherit {...props}>
|
||||||
{result}
|
{result}
|
||||||
</span>
|
</Text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type LanguageSelectorProps<M extends boolean> = Omit<
|
type LanguageListProps = {
|
||||||
SelectorProps<Language.Info, M>,
|
value: Language.Info[];
|
||||||
"label" | "options"
|
|
||||||
> & {
|
|
||||||
history?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getLabel(lang: Language.Info) {
|
const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => {
|
||||||
return lang.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LanguageSelector<M extends boolean = false>(
|
|
||||||
props: LanguageSelectorProps<M>
|
|
||||||
) {
|
|
||||||
const { history, ...rest } = props;
|
|
||||||
const { data: options } = useLanguages(history);
|
|
||||||
|
|
||||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
|
||||||
() =>
|
|
||||||
options?.map((v) => ({
|
|
||||||
label: v.name,
|
|
||||||
value: v,
|
|
||||||
})) ?? [],
|
|
||||||
[options]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Selector
|
<Group spacing="xs">
|
||||||
placeholder="Language..."
|
{value.map((v) => (
|
||||||
options={items}
|
<Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge>
|
||||||
label={getLabel}
|
))}
|
||||||
{...rest}
|
</Group>
|
||||||
></Selector>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const Components: LanguageComponent = {
|
const Components: LanguageComponent = {
|
||||||
Text: LanguageText,
|
Text: LanguageText,
|
||||||
Selector: LanguageSelector,
|
List: LanguageList,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Components;
|
export default Components;
|
||||||
|
|
|
@ -3,13 +3,11 @@ import { FunctionComponent, useMemo } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number | null;
|
index: number | null;
|
||||||
className?: string;
|
|
||||||
empty?: string;
|
empty?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LanguageProfile: FunctionComponent<Props> = ({
|
const LanguageProfileName: FunctionComponent<Props> = ({
|
||||||
index,
|
index,
|
||||||
className,
|
|
||||||
empty = "Unknown Profile",
|
empty = "Unknown Profile",
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = useLanguageProfiles();
|
const { data } = useLanguageProfiles();
|
||||||
|
@ -19,7 +17,7 @@ const LanguageProfile: FunctionComponent<Props> = ({
|
||||||
[data, empty, index]
|
[data, empty, index]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <span className={className}>{name}</span>;
|
return <>{name}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageProfile;
|
export default LanguageProfileName;
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as AudioList } from "./AudioList";
|
||||||
|
export { default as HistoryIcon } from "./HistoryIcon";
|
||||||
|
export { default as Language } from "./Language";
|
||||||
|
export { default as LanguageProfile } from "./LanguageProfile";
|
|
@ -1,80 +0,0 @@
|
||||||
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
|
||||||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { FunctionComponent, MouseEvent } from "react";
|
|
||||||
import { Badge, Button, ButtonProps } from "react-bootstrap";
|
|
||||||
|
|
||||||
export const ActionBadge: FunctionComponent<{
|
|
||||||
icon: IconDefinition;
|
|
||||||
onClick?: (e: MouseEvent) => void;
|
|
||||||
}> = ({ icon, onClick }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
as={Badge}
|
|
||||||
className="mx-1 p-1"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ActionButtonProps extends ActionButtonItemProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
destructive?: boolean;
|
|
||||||
variant?: string;
|
|
||||||
onClick?: (e: MouseEvent) => void;
|
|
||||||
className?: string;
|
|
||||||
size?: ButtonProps["size"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActionButton: FunctionComponent<ActionButtonProps> = ({
|
|
||||||
onClick,
|
|
||||||
destructive,
|
|
||||||
disabled,
|
|
||||||
variant,
|
|
||||||
className,
|
|
||||||
size,
|
|
||||||
...other
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
disabled={other.loading || disabled}
|
|
||||||
size={size ?? "sm"}
|
|
||||||
variant={variant ?? "light"}
|
|
||||||
className={`text-nowrap ${className ?? ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<ActionButtonItem {...other}></ActionButtonItem>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ActionButtonItemProps {
|
|
||||||
loading?: boolean;
|
|
||||||
alwaysShowText?: boolean;
|
|
||||||
icon: IconDefinition;
|
|
||||||
children?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
loading,
|
|
||||||
alwaysShowText,
|
|
||||||
}) => {
|
|
||||||
const showText = alwaysShowText === true || loading !== true;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
style={{ width: "1rem" }}
|
|
||||||
icon={loading ? faCircleNotch : icon}
|
|
||||||
spin={loading}
|
|
||||||
></FontAwesomeIcon>
|
|
||||||
{children && showText ? (
|
|
||||||
<span className="ml-2 font-weight-bold">{children}</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useSubtitleAction } from "@/apis/hooks";
|
||||||
|
import { Selector, SelectorOption } from "@/components";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import { Button, Divider, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
const TaskName = "Changing Color";
|
||||||
|
|
||||||
|
function convertToAction(color: string) {
|
||||||
|
return `color(name=${color})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const colorOptions: SelectorOption<string>[] = [
|
||||||
|
{
|
||||||
|
label: "White",
|
||||||
|
value: "white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Light Gray",
|
||||||
|
value: "light-gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Red",
|
||||||
|
value: "red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Green",
|
||||||
|
value: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Yellow",
|
||||||
|
value: "yellow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Blue",
|
||||||
|
value: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Magenta",
|
||||||
|
value: "magenta",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cyan",
|
||||||
|
value: "cyan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Black",
|
||||||
|
value: "black",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Red",
|
||||||
|
value: "dark-red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Green",
|
||||||
|
value: "dark-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Yellow",
|
||||||
|
value: "dark-yellow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Blue",
|
||||||
|
value: "dark-blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Magenta",
|
||||||
|
value: "dark-magenta",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Cyan",
|
||||||
|
value: "dark-cyan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark Grey",
|
||||||
|
value: "dark-grey",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
onSubmit?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColorToolForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
color: "",
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
color: (c) => colorOptions.find((op) => op.value === c) !== undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ color }) => {
|
||||||
|
const action = convertToAction(color);
|
||||||
|
|
||||||
|
selections.forEach((s) =>
|
||||||
|
task.create(s.path, TaskName, mutateAsync, {
|
||||||
|
action,
|
||||||
|
form: s,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Selector
|
||||||
|
required
|
||||||
|
options={colorOptions}
|
||||||
|
{...form.getInputProps("color")}
|
||||||
|
></Selector>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Start</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorToolModal = withModal(ColorToolForm, "color-tool", {
|
||||||
|
title: "Change Color",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ColorToolForm;
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useSubtitleAction } from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
const TaskName = "Changing Frame Rate";
|
||||||
|
|
||||||
|
function convertToAction(from: number, to: number) {
|
||||||
|
return `change_FPS(from=${from},to=${to})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
onSubmit?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
from: (v) => v > 0,
|
||||||
|
to: (v) => v > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ from, to }) => {
|
||||||
|
const action = convertToAction(from, to);
|
||||||
|
|
||||||
|
selections.forEach((s) =>
|
||||||
|
task.create(s.path, TaskName, mutateAsync, {
|
||||||
|
action,
|
||||||
|
form: s,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Group spacing="xs" grow>
|
||||||
|
<NumberInput
|
||||||
|
placeholder="From"
|
||||||
|
{...form.getInputProps("from")}
|
||||||
|
></NumberInput>
|
||||||
|
<NumberInput
|
||||||
|
placeholder="To"
|
||||||
|
{...form.getInputProps("to")}
|
||||||
|
></NumberInput>
|
||||||
|
</Group>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Start</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FrameRateModal = withModal(FrameRateForm, "frame-rate-tool", {
|
||||||
|
title: "Change Frame Rate",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FrameRateForm;
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useLanguageProfiles } from "@/apis/hooks";
|
||||||
|
import { MultiSelector, Selector } from "@/components/inputs";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { GetItemId, useSelectorOptions } from "@/utilities";
|
||||||
|
import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
import { UseMutationResult } from "react-query";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
|
||||||
|
item: Item.Base | null;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemEditForm: FunctionComponent<Props> = ({
|
||||||
|
mutation,
|
||||||
|
item,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const { data, isFetching } = useLanguageProfiles();
|
||||||
|
const { isLoading, mutate } = mutation;
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const profileOptions = useSelectorOptions(
|
||||||
|
data ?? [],
|
||||||
|
(v) => v.name ?? "Unknown",
|
||||||
|
(v) => v.profileId.toString() ?? "-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = useMemo(
|
||||||
|
() => data?.find((v) => v.profileId === item?.profileId) ?? null,
|
||||||
|
[data, item?.profileId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
profile: profile ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = useSelectorOptions(
|
||||||
|
item?.audio_language ?? [],
|
||||||
|
(v) => v.name,
|
||||||
|
(v) => v.code2
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOverlayVisible = isLoading || isFetching || item === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ profile }) => {
|
||||||
|
if (item) {
|
||||||
|
const itemId = GetItemId(item);
|
||||||
|
if (itemId) {
|
||||||
|
mutate({ id: [itemId], profileid: [profile?.profileId ?? null] });
|
||||||
|
onComplete?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setErrors({ profile: "Invalid profile" });
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LoadingOverlay visible={isOverlayVisible}></LoadingOverlay>
|
||||||
|
<Stack>
|
||||||
|
<MultiSelector
|
||||||
|
label="Audio Languages"
|
||||||
|
disabled
|
||||||
|
{...options}
|
||||||
|
value={item?.audio_language ?? []}
|
||||||
|
></MultiSelector>
|
||||||
|
<Selector
|
||||||
|
{...profileOptions}
|
||||||
|
{...form.getInputProps("profile")}
|
||||||
|
clearable
|
||||||
|
label="Languages Profiles"
|
||||||
|
></Selector>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Group position="right">
|
||||||
|
<Button
|
||||||
|
disabled={isOverlayVisible}
|
||||||
|
onClick={() => {
|
||||||
|
onCancel?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
}}
|
||||||
|
color="gray"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isOverlayVisible} type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemEditModal = withModal(ItemEditForm, "item-editor", {
|
||||||
|
title: "Editor",
|
||||||
|
size: "md",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ItemEditForm;
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useMovieSubtitleModification } from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task, TaskGroup } from "@/modules/task";
|
||||||
|
import { useTableStyles } from "@/styles";
|
||||||
|
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||||
|
import {
|
||||||
|
useLanguageProfileBy,
|
||||||
|
useProfileItemsToLanguages,
|
||||||
|
} from "@/utilities/languages";
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faCircleNotch,
|
||||||
|
faInfoCircle,
|
||||||
|
faTimes,
|
||||||
|
faXmark,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { isString } from "lodash";
|
||||||
|
import { FunctionComponent, useEffect, useMemo } from "react";
|
||||||
|
import { Column } from "react-table";
|
||||||
|
import { Action, Selector } from "../inputs";
|
||||||
|
import { SimpleTable } from "../tables";
|
||||||
|
import TextPopover from "../TextPopover";
|
||||||
|
|
||||||
|
type SubtitleFile = {
|
||||||
|
file: File;
|
||||||
|
language: Language.Info | null;
|
||||||
|
forced: boolean;
|
||||||
|
hi: boolean;
|
||||||
|
validateResult?: SubtitleValidateResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubtitleValidateResult = {
|
||||||
|
state: "valid" | "warning" | "error";
|
||||||
|
messages?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validator = (
|
||||||
|
movie: Item.Movie,
|
||||||
|
file: SubtitleFile
|
||||||
|
): SubtitleValidateResult => {
|
||||||
|
if (file.language === null) {
|
||||||
|
return {
|
||||||
|
state: "error",
|
||||||
|
messages: "Language is not selected",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { subtitles } = movie;
|
||||||
|
const existing = subtitles.find(
|
||||||
|
(v) => v.code2 === file.language?.code2 && isString(v.path)
|
||||||
|
);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return {
|
||||||
|
state: "warning",
|
||||||
|
messages: "Override existing subtitle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "valid",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: File[];
|
||||||
|
movie: Item.Movie;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MovieUploadForm: FunctionComponent<Props> = ({
|
||||||
|
files,
|
||||||
|
movie,
|
||||||
|
onComplete,
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const profile = useLanguageProfileBy(movie.profileId);
|
||||||
|
|
||||||
|
const languages = useProfileItemsToLanguages(profile);
|
||||||
|
const languageOptions = useSelectorOptions(
|
||||||
|
languages,
|
||||||
|
(v) => v.name,
|
||||||
|
(v) => v.code2
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultLanguage = useMemo(
|
||||||
|
() => (languages.length > 0 ? languages[0] : null),
|
||||||
|
[languages]
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
files: files
|
||||||
|
.map<SubtitleFile>((file) => ({
|
||||||
|
file,
|
||||||
|
language: defaultLanguage,
|
||||||
|
forced: defaultLanguage?.forced ?? false,
|
||||||
|
hi: defaultLanguage?.hi ?? false,
|
||||||
|
}))
|
||||||
|
.map<SubtitleFile>((v) => ({
|
||||||
|
...v,
|
||||||
|
validateResult: validator(movie, v),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
files: (values) => {
|
||||||
|
return (
|
||||||
|
values.find(
|
||||||
|
(v) =>
|
||||||
|
v.language === null ||
|
||||||
|
v.validateResult === undefined ||
|
||||||
|
v.validateResult.state === "error"
|
||||||
|
) === undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (form.values.files.length <= 0) {
|
||||||
|
modals.closeSelf();
|
||||||
|
}
|
||||||
|
}, [form.values.files.length, modals]);
|
||||||
|
|
||||||
|
const action = useArrayAction<SubtitleFile>((fn) => {
|
||||||
|
form.setValues(({ files, ...rest }) => {
|
||||||
|
const newFiles = fn(files);
|
||||||
|
newFiles.forEach((v) => {
|
||||||
|
v.validateResult = validator(movie, v);
|
||||||
|
});
|
||||||
|
return { ...rest, files: newFiles };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo<Column<SubtitleFile>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessor: "validateResult",
|
||||||
|
Cell: ({ cell: { value } }) => {
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
switch (value?.state) {
|
||||||
|
case "valid":
|
||||||
|
return faCheck;
|
||||||
|
case "warning":
|
||||||
|
return faInfoCircle;
|
||||||
|
case "error":
|
||||||
|
return faTimes;
|
||||||
|
default:
|
||||||
|
return faCircleNotch;
|
||||||
|
}
|
||||||
|
}, [value?.state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextPopover text={value?.messages}>
|
||||||
|
{/* TODO: Color */}
|
||||||
|
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||||
|
</TextPopover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "File",
|
||||||
|
id: "filename",
|
||||||
|
accessor: "file",
|
||||||
|
Cell: ({ value }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
|
||||||
|
return <Text className={classes.primary}>{value.name}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Forced",
|
||||||
|
accessor: "forced",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, { ...original, forced: checked });
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "HI",
|
||||||
|
accessor: "hi",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, { ...original, hi: checked });
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Language",
|
||||||
|
accessor: "language",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return (
|
||||||
|
<Selector
|
||||||
|
{...languageOptions}
|
||||||
|
className={classes.select}
|
||||||
|
value={value}
|
||||||
|
onChange={(item) => {
|
||||||
|
action.mutate(index, { ...original, language: item });
|
||||||
|
}}
|
||||||
|
></Selector>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
accessor: "file",
|
||||||
|
Cell: ({ row: { index } }) => {
|
||||||
|
return (
|
||||||
|
<Action
|
||||||
|
icon={faXmark}
|
||||||
|
color="red"
|
||||||
|
onClick={() => action.remove(index)}
|
||||||
|
></Action>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[action, languageOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { upload } = useMovieSubtitleModification();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ files }) => {
|
||||||
|
const { radarrId } = movie;
|
||||||
|
|
||||||
|
files.forEach(({ file, language, hi, forced }) => {
|
||||||
|
if (language === null) {
|
||||||
|
throw new Error("Language is not selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
|
||||||
|
radarrId,
|
||||||
|
form: { file, language: language.code2, hi, forced },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onComplete?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Upload</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MovieUploadModal = withModal(
|
||||||
|
MovieUploadForm,
|
||||||
|
"upload-movie-subtitle",
|
||||||
|
{
|
||||||
|
title: "Upload Subtitles",
|
||||||
|
size: "xl",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MovieUploadForm;
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { Action, Selector, SelectorOption, SimpleTable } from "@/components";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { useTableStyles } from "@/styles";
|
||||||
|
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||||
|
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||||
|
import { Column } from "react-table";
|
||||||
|
import ChipInput from "../inputs/ChipInput";
|
||||||
|
|
||||||
|
export const anyCutoff = 65535;
|
||||||
|
|
||||||
|
const defaultCutoffOptions: SelectorOption<Language.ProfileItem>[] = [
|
||||||
|
{
|
||||||
|
label: "Any",
|
||||||
|
value: {
|
||||||
|
id: anyCutoff,
|
||||||
|
audio_exclude: "False",
|
||||||
|
forced: "False",
|
||||||
|
hi: "False",
|
||||||
|
language: "any",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onComplete?: (profile: Language.Profile) => void;
|
||||||
|
languages: readonly Language.Info[];
|
||||||
|
profile: Language.Profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileEditForm: FunctionComponent<Props> = ({
|
||||||
|
onComplete,
|
||||||
|
languages,
|
||||||
|
profile,
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: profile,
|
||||||
|
validationRules: {
|
||||||
|
name: (value) => value.length > 0,
|
||||||
|
items: (value) => value.length > 0,
|
||||||
|
},
|
||||||
|
errorMessages: {
|
||||||
|
items: (
|
||||||
|
<Alert color="yellow" variant="outline">
|
||||||
|
Must contain at lease 1 language
|
||||||
|
</Alert>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const languageOptions = useSelectorOptions(languages, (l) => l.name);
|
||||||
|
|
||||||
|
const itemCutoffOptions = useSelectorOptions(
|
||||||
|
form.values.items,
|
||||||
|
(v) => v.language
|
||||||
|
);
|
||||||
|
|
||||||
|
const cutoffOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
...itemCutoffOptions,
|
||||||
|
options: [...itemCutoffOptions.options, ...defaultCutoffOptions],
|
||||||
|
}),
|
||||||
|
[itemCutoffOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mustContainOptions = useSelectorOptions(
|
||||||
|
form.values.mustContain,
|
||||||
|
(v) => v
|
||||||
|
);
|
||||||
|
|
||||||
|
const mustNotContainOptions = useSelectorOptions(
|
||||||
|
form.values.mustNotContain,
|
||||||
|
(v) => v
|
||||||
|
);
|
||||||
|
|
||||||
|
const action = useArrayAction<Language.ProfileItem>((fn) => {
|
||||||
|
form.setValues((values) => ({ ...values, items: fn(values.items) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const addItem = useCallback(() => {
|
||||||
|
const id =
|
||||||
|
1 +
|
||||||
|
form.values.items.reduce<number>(
|
||||||
|
(val, item) => Math.max(item.id, val),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (languages.length > 0) {
|
||||||
|
const language = languages[0].code2;
|
||||||
|
|
||||||
|
const item: Language.ProfileItem = {
|
||||||
|
id,
|
||||||
|
language,
|
||||||
|
audio_exclude: "False",
|
||||||
|
hi: "False",
|
||||||
|
forced: "False",
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = [...form.values.items, item];
|
||||||
|
form.setValues((values) => ({ ...values, items: list }));
|
||||||
|
}
|
||||||
|
}, [form, languages]);
|
||||||
|
|
||||||
|
const columns = useMemo<Column<Language.ProfileItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: "ID",
|
||||||
|
accessor: "id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Language",
|
||||||
|
accessor: "language",
|
||||||
|
Cell: ({ value: code, row: { original: item, index } }) => {
|
||||||
|
const language = useMemo(
|
||||||
|
() =>
|
||||||
|
languageOptions.options.find((l) => l.value.code2 === code)
|
||||||
|
?.value ?? null,
|
||||||
|
[code]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Selector
|
||||||
|
{...languageOptions}
|
||||||
|
className={classes.select}
|
||||||
|
value={language}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
item.language = value.code2;
|
||||||
|
action.mutate(index, { ...item, language: value.code2 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></Selector>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Forced",
|
||||||
|
accessor: "forced",
|
||||||
|
Cell: ({ row: { original: item, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value === "True"}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, {
|
||||||
|
...item,
|
||||||
|
forced: checked ? "True" : "False",
|
||||||
|
hi: checked ? "False" : item.hi,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "HI",
|
||||||
|
accessor: "hi",
|
||||||
|
Cell: ({ row: { original: item, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value === "True"}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, {
|
||||||
|
...item,
|
||||||
|
hi: checked ? "True" : "False",
|
||||||
|
forced: checked ? "False" : item.forced,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Exclude Audio",
|
||||||
|
accessor: "audio_exclude",
|
||||||
|
Cell: ({ row: { original: item, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value === "True"}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, {
|
||||||
|
...item,
|
||||||
|
audio_exclude: checked ? "True" : "False",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
accessor: "id",
|
||||||
|
Cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Action
|
||||||
|
icon={faXmark}
|
||||||
|
color="red"
|
||||||
|
onClick={() => action.remove(row.index)}
|
||||||
|
></Action>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[action, languageOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((value) => {
|
||||||
|
onComplete?.(value);
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Name" {...form.getInputProps("name")}></TextInput>
|
||||||
|
<Accordion
|
||||||
|
offsetIcon={false}
|
||||||
|
multiple
|
||||||
|
iconPosition="right"
|
||||||
|
initialItem={0}
|
||||||
|
styles={(theme) => ({
|
||||||
|
contentInner: {
|
||||||
|
[theme.fn.smallerThan("md")]: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Accordion.Item label="Languages">
|
||||||
|
<Stack>
|
||||||
|
{form.errors.items}
|
||||||
|
<SimpleTable
|
||||||
|
columns={columns}
|
||||||
|
data={form.values.items}
|
||||||
|
></SimpleTable>
|
||||||
|
<Button fullWidth color="light" onClick={addItem}>
|
||||||
|
Add Language
|
||||||
|
</Button>
|
||||||
|
<Selector
|
||||||
|
clearable
|
||||||
|
label="Cutoff"
|
||||||
|
{...cutoffOptions}
|
||||||
|
{...form.getInputProps("cutoff")}
|
||||||
|
></Selector>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item label="Release Info">
|
||||||
|
<Stack>
|
||||||
|
<ChipInput
|
||||||
|
label="Must contain"
|
||||||
|
{...mustContainOptions}
|
||||||
|
{...form.getInputProps("mustContain")}
|
||||||
|
></ChipInput>
|
||||||
|
<Text size="sm">
|
||||||
|
Subtitles release info must include one of those words or they
|
||||||
|
will be excluded from search results (regex supported).
|
||||||
|
</Text>
|
||||||
|
<ChipInput
|
||||||
|
label="Must not contain"
|
||||||
|
{...mustNotContainOptions}
|
||||||
|
{...form.getInputProps("mustNotContain")}
|
||||||
|
></ChipInput>
|
||||||
|
<Text size="sm">
|
||||||
|
Subtitles release info including one of those words (case
|
||||||
|
insensitive) will be excluded from search results (regex
|
||||||
|
supported).
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Item>
|
||||||
|
<Accordion.Item label="Subtitles">
|
||||||
|
<Stack my="xs">
|
||||||
|
<Switch
|
||||||
|
label="Use Original Format"
|
||||||
|
{...form.getInputProps("originalFormat")}
|
||||||
|
></Switch>
|
||||||
|
<Text size="sm">
|
||||||
|
Download subtitle file without format conversion
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileEditModal = withModal(
|
||||||
|
ProfileEditForm,
|
||||||
|
"languages-profile-editor",
|
||||||
|
{
|
||||||
|
title: "Edit Languages Profile",
|
||||||
|
size: "lg",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ProfileEditForm;
|
|
@ -0,0 +1,349 @@
|
||||||
|
import {
|
||||||
|
useEpisodesBySeriesId,
|
||||||
|
useEpisodeSubtitleModification,
|
||||||
|
useSubtitleInfos,
|
||||||
|
} from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task, TaskGroup } from "@/modules/task";
|
||||||
|
import { useTableStyles } from "@/styles";
|
||||||
|
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||||
|
import {
|
||||||
|
useLanguageProfileBy,
|
||||||
|
useProfileItemsToLanguages,
|
||||||
|
} from "@/utilities/languages";
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faCircleNotch,
|
||||||
|
faInfoCircle,
|
||||||
|
faTimes,
|
||||||
|
faXmark,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { isString } from "lodash";
|
||||||
|
import { FunctionComponent, useEffect, useMemo } from "react";
|
||||||
|
import { Column } from "react-table";
|
||||||
|
import { Action, Selector } from "../inputs";
|
||||||
|
import { SimpleTable } from "../tables";
|
||||||
|
import TextPopover from "../TextPopover";
|
||||||
|
|
||||||
|
type SubtitleFile = {
|
||||||
|
file: File;
|
||||||
|
language: Language.Info | null;
|
||||||
|
forced: boolean;
|
||||||
|
hi: boolean;
|
||||||
|
episode: Item.Episode | null;
|
||||||
|
validateResult?: SubtitleValidateResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubtitleValidateResult = {
|
||||||
|
state: "valid" | "warning" | "error";
|
||||||
|
messages?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validator = (file: SubtitleFile): SubtitleValidateResult => {
|
||||||
|
if (file.language === null) {
|
||||||
|
return {
|
||||||
|
state: "error",
|
||||||
|
messages: "Language is not selected",
|
||||||
|
};
|
||||||
|
} else if (file.episode === null) {
|
||||||
|
return {
|
||||||
|
state: "error",
|
||||||
|
messages: "Episode is not selected",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { subtitles } = file.episode;
|
||||||
|
const existing = subtitles.find(
|
||||||
|
(v) => v.code2 === file.language?.code2 && isString(v.path)
|
||||||
|
);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return {
|
||||||
|
state: "warning",
|
||||||
|
messages: "Override existing subtitle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "valid",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: File[];
|
||||||
|
series: Item.Series;
|
||||||
|
onComplete?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SeriesUploadForm: FunctionComponent<Props> = ({
|
||||||
|
series,
|
||||||
|
files,
|
||||||
|
onComplete,
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
const episodes = useEpisodesBySeriesId(series.sonarrSeriesId);
|
||||||
|
const episodeOptions = useSelectorOptions(
|
||||||
|
episodes.data ?? [],
|
||||||
|
(v) => `(${v.season}x${v.episode}) ${v.title}`,
|
||||||
|
(v) => v.sonarrEpisodeId.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = useLanguageProfileBy(series.profileId);
|
||||||
|
const languages = useProfileItemsToLanguages(profile);
|
||||||
|
const languageOptions = useSelectorOptions(
|
||||||
|
languages,
|
||||||
|
(v) => v.name,
|
||||||
|
(v) => v.code2
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultLanguage = useMemo(
|
||||||
|
() => (languages.length > 0 ? languages[0] : null),
|
||||||
|
[languages]
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
files: files
|
||||||
|
.map<SubtitleFile>((file) => ({
|
||||||
|
file,
|
||||||
|
language: defaultLanguage,
|
||||||
|
forced: defaultLanguage?.forced ?? false,
|
||||||
|
hi: defaultLanguage?.hi ?? false,
|
||||||
|
episode: null,
|
||||||
|
}))
|
||||||
|
.map<SubtitleFile>((file) => ({
|
||||||
|
...file,
|
||||||
|
validateResult: validator(file),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
files: (values) =>
|
||||||
|
values.find(
|
||||||
|
(v) =>
|
||||||
|
v.language === null ||
|
||||||
|
v.episode === null ||
|
||||||
|
v.validateResult === undefined ||
|
||||||
|
v.validateResult.state === "error"
|
||||||
|
) === undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = useArrayAction<SubtitleFile>((fn) => {
|
||||||
|
form.setValues(({ files, ...rest }) => {
|
||||||
|
const newFiles = fn(files);
|
||||||
|
newFiles.forEach((v) => {
|
||||||
|
v.validateResult = validator(v);
|
||||||
|
});
|
||||||
|
return { ...rest, files: newFiles };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = useMemo(() => files.map((v) => v.name), [files]);
|
||||||
|
const infos = useSubtitleInfos(names);
|
||||||
|
|
||||||
|
// Auto assign episode if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (infos.data !== undefined) {
|
||||||
|
action.update((item) => {
|
||||||
|
const info = infos.data.find((v) => v.filename === item.file.name);
|
||||||
|
if (info) {
|
||||||
|
item.episode =
|
||||||
|
episodes.data?.find(
|
||||||
|
(v) => v.season === info.season && v.episode === info.episode
|
||||||
|
) ?? item.episode;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [action, episodes.data, infos.data]);
|
||||||
|
|
||||||
|
const columns = useMemo<Column<SubtitleFile>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessor: "validateResult",
|
||||||
|
Cell: ({ cell: { value } }) => {
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
switch (value?.state) {
|
||||||
|
case "valid":
|
||||||
|
return faCheck;
|
||||||
|
case "warning":
|
||||||
|
return faInfoCircle;
|
||||||
|
case "error":
|
||||||
|
return faTimes;
|
||||||
|
default:
|
||||||
|
return faCircleNotch;
|
||||||
|
}
|
||||||
|
}, [value?.state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextPopover text={value?.messages}>
|
||||||
|
{/* TODO: Color */}
|
||||||
|
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||||
|
</TextPopover>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "File",
|
||||||
|
id: "filename",
|
||||||
|
accessor: "file",
|
||||||
|
Cell: ({ value: { name } }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return <Text className={classes.primary}>{name}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "Forced",
|
||||||
|
accessor: "forced",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, {
|
||||||
|
...original,
|
||||||
|
forced: checked,
|
||||||
|
hi: checked ? false : original.hi,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: "HI",
|
||||||
|
accessor: "hi",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={value}
|
||||||
|
onChange={({ currentTarget: { checked } }) => {
|
||||||
|
action.mutate(index, {
|
||||||
|
...original,
|
||||||
|
hi: checked,
|
||||||
|
forced: checked ? false : original.forced,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: (
|
||||||
|
<Selector
|
||||||
|
{...languageOptions}
|
||||||
|
value={null}
|
||||||
|
placeholder="Language"
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
action.update((item) => {
|
||||||
|
item.language = value;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></Selector>
|
||||||
|
),
|
||||||
|
accessor: "language",
|
||||||
|
Cell: ({ row: { original, index }, value }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return (
|
||||||
|
<Selector
|
||||||
|
{...languageOptions}
|
||||||
|
className={classes.select}
|
||||||
|
value={value}
|
||||||
|
onChange={(item) => {
|
||||||
|
action.mutate(index, { ...original, language: item });
|
||||||
|
}}
|
||||||
|
></Selector>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episode",
|
||||||
|
Header: "Episode",
|
||||||
|
accessor: "episode",
|
||||||
|
Cell: ({ value, row }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return (
|
||||||
|
<Selector
|
||||||
|
{...episodeOptions}
|
||||||
|
className={classes.select}
|
||||||
|
value={value}
|
||||||
|
onChange={(item) => {
|
||||||
|
action.mutate(row.index, { ...row.original, episode: item });
|
||||||
|
}}
|
||||||
|
></Selector>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
accessor: "file",
|
||||||
|
Cell: ({ row: { index } }) => {
|
||||||
|
return (
|
||||||
|
<Action
|
||||||
|
icon={faXmark}
|
||||||
|
color="red"
|
||||||
|
onClick={() => action.remove(index)}
|
||||||
|
></Action>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[action, episodeOptions, languageOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { upload } = useEpisodeSubtitleModification();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ files }) => {
|
||||||
|
const { sonarrSeriesId: seriesId } = series;
|
||||||
|
|
||||||
|
files.forEach((value) => {
|
||||||
|
const { file, hi, forced, language, episode } = value;
|
||||||
|
|
||||||
|
if (language === null || episode === null) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid language or episode. This shouldn't happen, please report this bug."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code2 } = language;
|
||||||
|
const { sonarrEpisodeId: episodeId } = episode;
|
||||||
|
|
||||||
|
task.create(file.name, TaskGroup.UploadSubtitle, upload.mutateAsync, {
|
||||||
|
seriesId,
|
||||||
|
episodeId,
|
||||||
|
form: {
|
||||||
|
file,
|
||||||
|
language: code2,
|
||||||
|
hi,
|
||||||
|
forced,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onComplete?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Upload</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeriesUploadModal = withModal(
|
||||||
|
SeriesUploadForm,
|
||||||
|
"upload-series-subtitles",
|
||||||
|
{ title: "Upload Subtitles", size: "xl" }
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SeriesUploadForm;
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useSubtitleAction } from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
const TaskName = "Changing Time";
|
||||||
|
|
||||||
|
function convertToAction(h: number, m: number, s: number, ms: number) {
|
||||||
|
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
onSubmit?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
positive: true,
|
||||||
|
hour: 0,
|
||||||
|
min: 0,
|
||||||
|
sec: 0,
|
||||||
|
ms: 0,
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
hour: (v) => v >= 0,
|
||||||
|
min: (v) => v >= 0,
|
||||||
|
sec: (v) => v >= 0,
|
||||||
|
ms: (v) => v >= 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
form.values.hour > 0 ||
|
||||||
|
form.values.min > 0 ||
|
||||||
|
form.values.sec > 0 ||
|
||||||
|
form.values.ms > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ positive, hour, min, sec, ms }) => {
|
||||||
|
const action = convertToAction(hour, min, sec, ms);
|
||||||
|
|
||||||
|
selections.forEach((s) =>
|
||||||
|
task.create(s.path, TaskName, mutateAsync, {
|
||||||
|
action,
|
||||||
|
form: s,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Group align="end" spacing="xs" noWrap>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
variant="filled"
|
||||||
|
onClick={() =>
|
||||||
|
form.setValues((f) => ({ ...f, positive: !f.positive }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={form.values.positive ? faPlus : faMinus}
|
||||||
|
></FontAwesomeIcon>
|
||||||
|
</Button>
|
||||||
|
<NumberInput
|
||||||
|
label="hour"
|
||||||
|
{...form.getInputProps("hour")}
|
||||||
|
></NumberInput>
|
||||||
|
<NumberInput label="min" {...form.getInputProps("min")}></NumberInput>
|
||||||
|
<NumberInput label="sec" {...form.getInputProps("sec")}></NumberInput>
|
||||||
|
<NumberInput label="ms" {...form.getInputProps("ms")}></NumberInput>
|
||||||
|
</Group>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button disabled={!enabled} type="submit">
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimeOffsetModal = withModal(TimeOffsetForm, "time-offset", {
|
||||||
|
title: "Change Time",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TimeOffsetForm;
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { useSubtitleAction } from "@/apis/hooks";
|
||||||
|
import { useModals, withModal } from "@/modules/modals";
|
||||||
|
import { task } from "@/modules/task";
|
||||||
|
import { useSelectorOptions } from "@/utilities";
|
||||||
|
import { useEnabledLanguages } from "@/utilities/languages";
|
||||||
|
import { Alert, Button, Divider, Stack } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/hooks";
|
||||||
|
import { isObject } from "lodash";
|
||||||
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
import { Selector } from "../inputs";
|
||||||
|
|
||||||
|
const TaskName = "Translating Subtitles";
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
af: "afrikaans",
|
||||||
|
sq: "albanian",
|
||||||
|
am: "amharic",
|
||||||
|
ar: "arabic",
|
||||||
|
hy: "armenian",
|
||||||
|
az: "azerbaijani",
|
||||||
|
eu: "basque",
|
||||||
|
be: "belarusian",
|
||||||
|
bn: "bengali",
|
||||||
|
bs: "bosnian",
|
||||||
|
bg: "bulgarian",
|
||||||
|
ca: "catalan",
|
||||||
|
ceb: "cebuano",
|
||||||
|
ny: "chichewa",
|
||||||
|
zh: "chinese (simplified)",
|
||||||
|
zt: "chinese (traditional)",
|
||||||
|
co: "corsican",
|
||||||
|
hr: "croatian",
|
||||||
|
cs: "czech",
|
||||||
|
da: "danish",
|
||||||
|
nl: "dutch",
|
||||||
|
en: "english",
|
||||||
|
eo: "esperanto",
|
||||||
|
et: "estonian",
|
||||||
|
tl: "filipino",
|
||||||
|
fi: "finnish",
|
||||||
|
fr: "french",
|
||||||
|
fy: "frisian",
|
||||||
|
gl: "galician",
|
||||||
|
ka: "georgian",
|
||||||
|
de: "german",
|
||||||
|
el: "greek",
|
||||||
|
gu: "gujarati",
|
||||||
|
ht: "haitian creole",
|
||||||
|
ha: "hausa",
|
||||||
|
haw: "hawaiian",
|
||||||
|
iw: "hebrew",
|
||||||
|
hi: "hindi",
|
||||||
|
hmn: "hmong",
|
||||||
|
hu: "hungarian",
|
||||||
|
is: "icelandic",
|
||||||
|
ig: "igbo",
|
||||||
|
id: "indonesian",
|
||||||
|
ga: "irish",
|
||||||
|
it: "italian",
|
||||||
|
ja: "japanese",
|
||||||
|
jw: "javanese",
|
||||||
|
kn: "kannada",
|
||||||
|
kk: "kazakh",
|
||||||
|
km: "khmer",
|
||||||
|
ko: "korean",
|
||||||
|
ku: "kurdish (kurmanji)",
|
||||||
|
ky: "kyrgyz",
|
||||||
|
lo: "lao",
|
||||||
|
la: "latin",
|
||||||
|
lv: "latvian",
|
||||||
|
lt: "lithuanian",
|
||||||
|
lb: "luxembourgish",
|
||||||
|
mk: "macedonian",
|
||||||
|
mg: "malagasy",
|
||||||
|
ms: "malay",
|
||||||
|
ml: "malayalam",
|
||||||
|
mt: "maltese",
|
||||||
|
mi: "maori",
|
||||||
|
mr: "marathi",
|
||||||
|
mn: "mongolian",
|
||||||
|
my: "myanmar (burmese)",
|
||||||
|
ne: "nepali",
|
||||||
|
no: "norwegian",
|
||||||
|
ps: "pashto",
|
||||||
|
fa: "persian",
|
||||||
|
pl: "polish",
|
||||||
|
pt: "portuguese",
|
||||||
|
pa: "punjabi",
|
||||||
|
ro: "romanian",
|
||||||
|
ru: "russian",
|
||||||
|
sm: "samoan",
|
||||||
|
gd: "scots gaelic",
|
||||||
|
sr: "serbian",
|
||||||
|
st: "sesotho",
|
||||||
|
sn: "shona",
|
||||||
|
sd: "sindhi",
|
||||||
|
si: "sinhala",
|
||||||
|
sk: "slovak",
|
||||||
|
sl: "slovenian",
|
||||||
|
so: "somali",
|
||||||
|
es: "spanish",
|
||||||
|
su: "sundanese",
|
||||||
|
sw: "swahili",
|
||||||
|
sv: "swedish",
|
||||||
|
tg: "tajik",
|
||||||
|
ta: "tamil",
|
||||||
|
te: "telugu",
|
||||||
|
th: "thai",
|
||||||
|
tr: "turkish",
|
||||||
|
uk: "ukrainian",
|
||||||
|
ur: "urdu",
|
||||||
|
uz: "uzbek",
|
||||||
|
vi: "vietnamese",
|
||||||
|
cy: "welsh",
|
||||||
|
xh: "xhosa",
|
||||||
|
yi: "yiddish",
|
||||||
|
yo: "yoruba",
|
||||||
|
zu: "zulu",
|
||||||
|
fil: "Filipino",
|
||||||
|
he: "Hebrew",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selections: FormType.ModifySubtitle[];
|
||||||
|
onSubmit?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranslationForm: FunctionComponent<Props> = ({
|
||||||
|
selections,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { mutateAsync } = useSubtitleAction();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const { data: languages } = useEnabledLanguages();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
language: null as Language.Info | null,
|
||||||
|
},
|
||||||
|
validationRules: {
|
||||||
|
language: isObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const available = useMemo(
|
||||||
|
() => languages.filter((v) => v.code2 in translations),
|
||||||
|
[languages]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useSelectorOptions(
|
||||||
|
available,
|
||||||
|
(v) => v.name,
|
||||||
|
(v) => v.code2
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(({ language }) => {
|
||||||
|
if (language) {
|
||||||
|
selections.forEach((s) =>
|
||||||
|
task.create(s.path, TaskName, mutateAsync, {
|
||||||
|
action: "translate",
|
||||||
|
form: {
|
||||||
|
...s,
|
||||||
|
language: language.code2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
modals.closeSelf();
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Alert variant="outline">
|
||||||
|
Enabled languages not listed here are unsupported by Google Translate.
|
||||||
|
</Alert>
|
||||||
|
<Selector {...options} {...form.getInputProps("language")}></Selector>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Button type="submit">Start</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TranslationModal = withModal(TranslationForm, "translation-tool", {
|
||||||
|
title: "Translate Subtitle(s)",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TranslationForm;
|
|
@ -1,78 +0,0 @@
|
||||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
FunctionComponent,
|
|
||||||
MouseEvent,
|
|
||||||
PropsWithChildren,
|
|
||||||
useCallback,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Button } from "react-bootstrap";
|
|
||||||
|
|
||||||
interface CHButtonProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
hidden?: boolean;
|
|
||||||
icon: IconDefinition;
|
|
||||||
updating?: boolean;
|
|
||||||
updatingIcon?: IconDefinition;
|
|
||||||
onClick?: (e: MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
|
|
||||||
const { children, icon, disabled, updating, updatingIcon, onClick } = props;
|
|
||||||
|
|
||||||
let displayIcon = icon;
|
|
||||||
if (updating) {
|
|
||||||
displayIcon = updatingIcon ? updatingIcon : faSpinner;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="dark"
|
|
||||||
className="d-flex flex-column text-nowrap py-1"
|
|
||||||
disabled={disabled || updating}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
className="mx-auto my-1"
|
|
||||||
icon={displayIcon}
|
|
||||||
spin={updating}
|
|
||||||
></FontAwesomeIcon>
|
|
||||||
<span className="align-bottom text-themecolor small text-center">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
|
|
||||||
promise: T;
|
|
||||||
onSuccess?: (item: R) => void;
|
|
||||||
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
|
|
||||||
|
|
||||||
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
|
|
||||||
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
|
|
||||||
): JSX.Element {
|
|
||||||
const { promise, onSuccess, ...button } = props;
|
|
||||||
|
|
||||||
const [updating, setUpdate] = useState(false);
|
|
||||||
|
|
||||||
const click = useCallback(() => {
|
|
||||||
setUpdate(true);
|
|
||||||
promise().then((val) => {
|
|
||||||
setUpdate(false);
|
|
||||||
onSuccess && onSuccess(val);
|
|
||||||
});
|
|
||||||
}, [onSuccess, promise]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContentHeaderButton
|
|
||||||
updating={updating}
|
|
||||||
onClick={click}
|
|
||||||
{...button}
|
|
||||||
></ContentHeaderButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentHeaderButton;
|
|
|
@ -1,15 +0,0 @@
|
||||||
import { FunctionComponent } from "react";
|
|
||||||
|
|
||||||
type GroupPosition = "start" | "end";
|
|
||||||
interface GroupProps {
|
|
||||||
pos: GroupPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentHeaderGroup: FunctionComponent<GroupProps> = (props) => {
|
|
||||||
const { children, pos } = props;
|
|
||||||
|
|
||||||
const className = `d-flex flex-grow-1 align-items-center justify-content-${pos}`;
|
|
||||||
return <div className={className}>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentHeaderGroup;
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { FunctionComponent, ReactNode, useMemo } from "react";
|
|
||||||
import { Row } from "react-bootstrap";
|
|
||||||
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
|
|
||||||
import ContentHeaderGroup from "./Group";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
scroll?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare type Header = FunctionComponent<Props> & {
|
|
||||||
Button: typeof ContentHeaderButton;
|
|
||||||
AsyncButton: typeof ContentHeaderAsyncButton;
|
|
||||||
Group: typeof ContentHeaderGroup;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ContentHeader: Header = ({ children, scroll, className }) => {
|
|
||||||
const cls = useMemo(() => {
|
|
||||||
const rowCls = ["content-header", "bg-dark", "p-2"];
|
|
||||||
|
|
||||||
if (className !== undefined) {
|
|
||||||
rowCls.push(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scroll !== false) {
|
|
||||||
rowCls.push("scroll");
|
|
||||||
}
|
|
||||||
return rowCls.join(" ");
|
|
||||||
}, [scroll, className]);
|
|
||||||
|
|
||||||
let childItem: ReactNode;
|
|
||||||
|
|
||||||
if (scroll !== false) {
|
|
||||||
childItem = (
|
|
||||||
<div className="d-flex flex-nowrap flex-grow-1">{children}</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
childItem = children;
|
|
||||||
}
|
|
||||||
return <Row className={cls}>{childItem}</Row>;
|
|
||||||
};
|
|
||||||
|
|
||||||
ContentHeader.Button = ContentHeaderButton;
|
|
||||||
ContentHeader.Group = ContentHeaderGroup;
|
|
||||||
ContentHeader.AsyncButton = ContentHeaderAsyncButton;
|
|
||||||
|
|
||||||
export default ContentHeader;
|
|
|
@ -1,135 +1,4 @@
|
||||||
import {
|
|
||||||
faClock,
|
|
||||||
faCloudUploadAlt,
|
|
||||||
faDownload,
|
|
||||||
faRecycle,
|
|
||||||
faTrash,
|
|
||||||
faUser,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {
|
|
||||||
FontAwesomeIcon,
|
|
||||||
FontAwesomeIconProps,
|
|
||||||
} from "@fortawesome/react-fontawesome";
|
|
||||||
import { isNull, isUndefined } from "lodash";
|
|
||||||
import { FunctionComponent, ReactElement } from "react";
|
|
||||||
import {
|
|
||||||
OverlayTrigger,
|
|
||||||
OverlayTriggerProps,
|
|
||||||
Popover,
|
|
||||||
Spinner,
|
|
||||||
SpinnerProps,
|
|
||||||
} from "react-bootstrap";
|
|
||||||
|
|
||||||
enum HistoryAction {
|
|
||||||
Delete = 0,
|
|
||||||
Download,
|
|
||||||
Manual,
|
|
||||||
Upgrade,
|
|
||||||
Upload,
|
|
||||||
Sync,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HistoryIcon: FunctionComponent<{
|
|
||||||
action: number;
|
|
||||||
title?: string;
|
|
||||||
}> = (props) => {
|
|
||||||
const { action, title } = props;
|
|
||||||
let icon = null;
|
|
||||||
switch (action) {
|
|
||||||
case HistoryAction.Delete:
|
|
||||||
icon = faTrash;
|
|
||||||
break;
|
|
||||||
case HistoryAction.Download:
|
|
||||||
icon = faDownload;
|
|
||||||
break;
|
|
||||||
case HistoryAction.Manual:
|
|
||||||
icon = faUser;
|
|
||||||
break;
|
|
||||||
case HistoryAction.Sync:
|
|
||||||
icon = faClock;
|
|
||||||
break;
|
|
||||||
case HistoryAction.Upgrade:
|
|
||||||
icon = faRecycle;
|
|
||||||
break;
|
|
||||||
case HistoryAction.Upload:
|
|
||||||
icon = faCloudUploadAlt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (icon) {
|
|
||||||
return <FontAwesomeIcon title={title} icon={icon}></FontAwesomeIcon>;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MessageIconProps extends FontAwesomeIconProps {
|
|
||||||
messages: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageIcon: FunctionComponent<MessageIconProps> = (props) => {
|
|
||||||
const { messages, ...iconProps } = props;
|
|
||||||
|
|
||||||
const popover = (
|
|
||||||
<Popover hidden={messages.length === 0} id="overlay-icon">
|
|
||||||
<Popover.Content>
|
|
||||||
{messages.map((m) => (
|
|
||||||
<li key={m}>{m}</li>
|
|
||||||
))}
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverlayTrigger overlay={popover}>
|
|
||||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
|
||||||
</OverlayTrigger>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoadingIndicator: FunctionComponent<{
|
|
||||||
animation?: SpinnerProps["animation"];
|
|
||||||
}> = ({ children, animation: style }) => {
|
|
||||||
return (
|
|
||||||
<div className="d-flex flex-column flex-grow-1 align-items-center py-5">
|
|
||||||
<Spinner animation={style ?? "border"} className="mb-2"></Spinner>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TextPopoverProps {
|
|
||||||
children: ReactElement;
|
|
||||||
text: string | undefined | null;
|
|
||||||
placement?: OverlayTriggerProps["placement"];
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextPopover: FunctionComponent<TextPopoverProps> = ({
|
|
||||||
children,
|
|
||||||
text,
|
|
||||||
placement,
|
|
||||||
delay,
|
|
||||||
}) => {
|
|
||||||
if (isNull(text) || isUndefined(text)) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popover = (
|
|
||||||
<Popover className="mw-100 py-1" id={text}>
|
|
||||||
<span className="mx-2">{text}</span>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<OverlayTrigger delay={delay} overlay={popover} placement={placement}>
|
|
||||||
{children}
|
|
||||||
</OverlayTrigger>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export * from "./async";
|
|
||||||
export * from "./buttons";
|
|
||||||
export * from "./header";
|
|
||||||
export * from "./inputs";
|
export * from "./inputs";
|
||||||
export * from "./LanguageSelector";
|
export { default as Search } from "./Search";
|
||||||
export * from "./SearchBar";
|
|
||||||
export * from "./tables";
|
export * from "./tables";
|
||||||
|
export { default as Toolbox } from "./toolbox";
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconProps,
|
||||||
|
} from "@fortawesome/react-fontawesome";
|
||||||
|
import { ActionIcon, ActionIconProps } from "@mantine/core";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
export type ActionProps = ActionIconProps<"button"> & {
|
||||||
|
icon: IconDefinition;
|
||||||
|
iconProps?: Omit<FontAwesomeIconProps, "icon">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Action = forwardRef<HTMLButtonElement, ActionProps>(
|
||||||
|
({ icon, iconProps, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ActionIcon {...props} ref={ref}>
|
||||||
|
<FontAwesomeIcon icon={icon} {...iconProps}></FontAwesomeIcon>
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Action;
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useSelectorOptions } from "@/utilities";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { MultiSelector, MultiSelectorProps } from "./Selector";
|
||||||
|
|
||||||
|
export type ChipInputProps = Omit<
|
||||||
|
MultiSelectorProps<string>,
|
||||||
|
| "searchable"
|
||||||
|
| "creatable"
|
||||||
|
| "getCreateLabel"
|
||||||
|
| "onCreate"
|
||||||
|
| "options"
|
||||||
|
| "getkey"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
const options = useSelectorOptions(value ?? [], (v) => v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelector
|
||||||
|
{...props}
|
||||||
|
{...options}
|
||||||
|
creatable
|
||||||
|
searchable
|
||||||
|
getCreateLabel={(query) => `Add "${query}"`}
|
||||||
|
onCreate={(query) => {
|
||||||
|
onChange?.([...(value ?? []), query]);
|
||||||
|
}}
|
||||||
|
></MultiSelector>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChipInput;
|
|
@ -1,147 +0,0 @@
|
||||||
import {
|
|
||||||
FocusEvent,
|
|
||||||
FunctionComponent,
|
|
||||||
KeyboardEvent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];
|
|
||||||
|
|
||||||
export interface ChipsProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
defaultValue?: readonly string[];
|
|
||||||
value?: readonly string[];
|
|
||||||
onChange?: (v: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Chips: FunctionComponent<ChipsProps> = ({
|
|
||||||
defaultValue,
|
|
||||||
value,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const [chips, setChips] = useState<Readonly<string[]>>(() => {
|
|
||||||
if (value) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (defaultValue) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value) {
|
|
||||||
setChips(value);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const addChip = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setChips((cp) => {
|
|
||||||
const newChips = [...cp, value];
|
|
||||||
onChange && onChange(newChips);
|
|
||||||
return newChips;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeChip = useCallback(
|
|
||||||
(idx?: number) => {
|
|
||||||
setChips((cp) => {
|
|
||||||
const index = idx ?? cp.length - 1;
|
|
||||||
if (index !== -1) {
|
|
||||||
const newChips = [...cp];
|
|
||||||
newChips.splice(index, 1);
|
|
||||||
onChange && onChange(newChips);
|
|
||||||
return newChips;
|
|
||||||
} else {
|
|
||||||
return cp;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
|
||||||
if (input.current) {
|
|
||||||
input.current.value = "";
|
|
||||||
}
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const onKeyUp = useCallback(
|
|
||||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const pressed = event.key;
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
if (SplitKeys.includes(pressed) && value.length !== 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
addChip(value);
|
|
||||||
clearInput();
|
|
||||||
} else if (pressed === "Backspace" && value.length === 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
removeChip();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addChip, removeChip, clearInput]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const pressed = event.key;
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
if (SplitKeys.includes(pressed) && value.length !== 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onBlur = useCallback(
|
|
||||||
(event: FocusEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.value;
|
|
||||||
if (value.length !== 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
addChip(value);
|
|
||||||
clearInput();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addChip, clearInput]
|
|
||||||
);
|
|
||||||
|
|
||||||
const chipElements = useMemo(
|
|
||||||
() =>
|
|
||||||
chips.map((v, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
title={v}
|
|
||||||
className={`custom-chip ${disabled ? "" : "active"}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!disabled) {
|
|
||||||
removeChip(idx);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</span>
|
|
||||||
)),
|
|
||||||
[chips, removeChip, disabled]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="form-control custom-chip-input d-flex">
|
|
||||||
<div className="chip-container">{chipElements}</div>
|
|
||||||
<input
|
|
||||||
disabled={disabled}
|
|
||||||
className="main-input p-0"
|
|
||||||
ref={input}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onBlur={onBlur}
|
|
||||||
></input>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import {
|
||||||
|
faArrowUp,
|
||||||
|
faFileCirclePlus,
|
||||||
|
faXmark,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Box, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Dropzone,
|
||||||
|
DropzoneProps,
|
||||||
|
DropzoneStatus,
|
||||||
|
FullScreenDropzone,
|
||||||
|
FullScreenDropzoneProps,
|
||||||
|
} from "@mantine/dropzone";
|
||||||
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
|
||||||
|
export type FileProps = Omit<DropzoneProps, "children"> & {
|
||||||
|
inner?: FileInnerComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const File: FunctionComponent<FileProps> = ({
|
||||||
|
inner: Inner = FileInner,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dropzone {...props}>
|
||||||
|
{(status) => <Inner status={status}></Inner>}
|
||||||
|
</Dropzone>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileOverlayProps = Omit<FullScreenDropzoneProps, "children"> & {
|
||||||
|
inner?: FileInnerComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileOverlay: FunctionComponent<FileOverlayProps> = ({
|
||||||
|
inner: Inner = FileInner,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<FullScreenDropzone {...props}>
|
||||||
|
{(status) => <Inner status={status}></Inner>}
|
||||||
|
</FullScreenDropzone>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileInnerProps = {
|
||||||
|
status: DropzoneStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileInnerComponent = FunctionComponent<FileInnerProps>;
|
||||||
|
|
||||||
|
const FileInner: FileInnerComponent = ({ status }) => {
|
||||||
|
const { accepted, rejected } = status;
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
if (accepted) {
|
||||||
|
return faArrowUp;
|
||||||
|
} else if (rejected) {
|
||||||
|
return faXmark;
|
||||||
|
} else {
|
||||||
|
return faFileCirclePlus;
|
||||||
|
}
|
||||||
|
}, [accepted, rejected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack m="lg" align="center" spacing="xs" style={{ pointerEvents: "none" }}>
|
||||||
|
<Box mb="md">
|
||||||
|
<FontAwesomeIcon size="3x" icon={icon}></FontAwesomeIcon>
|
||||||
|
</Box>
|
||||||
|
<Text size="lg">Upload files here</Text>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
Drag and drop, or click to select
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default File;
|
|
@ -1,18 +1,11 @@
|
||||||
import { useFileSystem } from "@/apis/hooks";
|
import { useFileSystem } from "@/apis/hooks";
|
||||||
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
|
import { faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { faReply } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import { Autocomplete, AutocompleteProps } from "@mantine/core";
|
||||||
ChangeEvent,
|
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
FunctionComponent,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Dropdown, DropdownProps, Form, Spinner } from "react-bootstrap";
|
|
||||||
|
|
||||||
const backKey = "--back--";
|
// TODO: use fortawesome icons
|
||||||
|
const backKey = "⏎ Back";
|
||||||
|
|
||||||
function getLastSeparator(path: string): number {
|
function getLastSeparator(path: string): number {
|
||||||
let idx = path.lastIndexOf("/");
|
let idx = path.lastIndexOf("/");
|
||||||
|
@ -31,134 +24,81 @@ function extractPath(raw: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileBrowserProps {
|
export type FileBrowserProps = Omit<AutocompleteProps, "data"> & {
|
||||||
defaultValue?: string;
|
|
||||||
type: "sonarr" | "radarr" | "bazarr";
|
type: "sonarr" | "radarr" | "bazarr";
|
||||||
onChange?: (path: string) => void;
|
};
|
||||||
drop?: DropdownProps["drop"];
|
|
||||||
}
|
type FileTreeItem = {
|
||||||
|
value: string;
|
||||||
|
item?: FileTree;
|
||||||
|
};
|
||||||
|
|
||||||
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
type,
|
type,
|
||||||
onChange,
|
onChange,
|
||||||
drop,
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [show, canShow] = useState(false);
|
const [isShow, setIsShow] = useState(false);
|
||||||
const [text, setText] = useState(defaultValue ?? "");
|
const [value, setValue] = useState(defaultValue ?? "");
|
||||||
const [path, setPath] = useState(() => extractPath(text));
|
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 data = useMemo<FileTreeItem[]>(
|
||||||
const idx = getLastSeparator(text);
|
() => [
|
||||||
return text.slice(idx + 1);
|
{ value: backKey },
|
||||||
}, [text]);
|
...(tree?.map((v) => ({
|
||||||
|
value: v.path,
|
||||||
|
item: v,
|
||||||
|
})) ?? []),
|
||||||
|
],
|
||||||
|
[tree]
|
||||||
|
);
|
||||||
|
|
||||||
const previous = useMemo(() => {
|
const parent = useMemo(() => {
|
||||||
const idx = getLastSeparator(path.slice(0, -1));
|
const idx = getLastSeparator(path.slice(0, -1));
|
||||||
return path.slice(0, idx + 1);
|
return path.slice(0, idx + 1);
|
||||||
}, [path]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (text === path) {
|
if (value === path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPath = extractPath(text);
|
const newPath = extractPath(value);
|
||||||
if (newPath !== path) {
|
if (newPath !== path) {
|
||||||
setPath(newPath);
|
setPath(newPath);
|
||||||
onChange && onChange(newPath);
|
onChange && onChange(newPath);
|
||||||
}
|
}
|
||||||
}, [path, text, onChange]);
|
}, [path, value, onChange]);
|
||||||
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Autocomplete
|
||||||
show={show}
|
{...props}
|
||||||
drop={drop}
|
ref={ref}
|
||||||
onSelect={(key) => {
|
icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
|
||||||
if (!key) {
|
placeholder="Click to start"
|
||||||
return;
|
data={data}
|
||||||
}
|
value={value}
|
||||||
|
filter={(value, item) => {
|
||||||
if (key !== backKey) {
|
if (item.value === backKey) {
|
||||||
setText(key);
|
return true;
|
||||||
} else {
|
} else {
|
||||||
setText(previous);
|
return item.value.includes(value);
|
||||||
}
|
|
||||||
input.current?.focus();
|
|
||||||
}}
|
|
||||||
onToggle={(open, _, meta) => {
|
|
||||||
if (!open && meta.source !== "select") {
|
|
||||||
canShow(false);
|
|
||||||
} else if (open) {
|
|
||||||
canShow(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
onChange={(val) => {
|
||||||
<Dropdown.Toggle
|
if (val !== backKey) {
|
||||||
as={Form.Control}
|
setValue(val);
|
||||||
placeholder="Click to start"
|
} else {
|
||||||
type="text"
|
setValue(parent);
|
||||||
value={text}
|
}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
}}
|
||||||
setText(e.currentTarget.value);
|
onFocus={() => setIsShow(true)}
|
||||||
}}
|
onBlur={() => setIsShow(false)}
|
||||||
ref={input}
|
></Autocomplete>
|
||||||
></Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu
|
|
||||||
className="w-100"
|
|
||||||
style={{ maxHeight: 256, overflowY: "auto" }}
|
|
||||||
>
|
|
||||||
{requestItems()}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
import {
|
|
||||||
ChangeEvent,
|
|
||||||
FunctionComponent,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Form } from "react-bootstrap";
|
|
||||||
|
|
||||||
export interface FileFormProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
emptyText: string;
|
|
||||||
value?: File[];
|
|
||||||
onChange?: (files: File[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileForm: FunctionComponent<FileFormProps> = ({
|
|
||||||
value: files,
|
|
||||||
emptyText,
|
|
||||||
multiple,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const [fileList, setFileList] = useState<File[]>([]);
|
|
||||||
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (files) {
|
|
||||||
setFileList(files);
|
|
||||||
|
|
||||||
if (files.length === 0 && input.current) {
|
|
||||||
// Manual reset file input
|
|
||||||
input.current.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [files]);
|
|
||||||
|
|
||||||
const label = useMemo(() => {
|
|
||||||
if (fileList.length === 0) {
|
|
||||||
return emptyText;
|
|
||||||
} else {
|
|
||||||
if (multiple) {
|
|
||||||
return `${fileList.length} Files`;
|
|
||||||
} else {
|
|
||||||
return fileList[0].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [fileList, emptyText, multiple]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.File
|
|
||||||
disabled={disabled}
|
|
||||||
custom
|
|
||||||
label={label}
|
|
||||||
multiple={multiple}
|
|
||||||
ref={input}
|
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { files } = e.target;
|
|
||||||
if (files) {
|
|
||||||
const list: File[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
list.push(file);
|
|
||||||
}
|
|
||||||
setFileList(list);
|
|
||||||
onChange && onChange(list);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></Form.File>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,141 +1,169 @@
|
||||||
import clsx from "clsx";
|
import { LOG } from "@/utilities/console";
|
||||||
import { FocusEvent, useCallback, useMemo, useRef } from "react";
|
import {
|
||||||
import Select, { GroupBase, OnChangeValue } from "react-select";
|
MultiSelect,
|
||||||
import { SelectComponents } from "react-select/dist/declarations/src/components";
|
MultiSelectProps,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
SelectProps,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { isNull, isUndefined } from "lodash";
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
export type SelectorOption<T> = {
|
export type SelectorOption<T> = Override<
|
||||||
label: string;
|
{
|
||||||
value: T;
|
value: T;
|
||||||
};
|
label: string;
|
||||||
|
},
|
||||||
export type SelectorComponents<T, M extends boolean> = SelectComponents<
|
SelectItem
|
||||||
SelectorOption<T>,
|
|
||||||
M,
|
|
||||||
GroupBase<SelectorOption<T>>
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type SelectorValueType<T, M extends boolean> = M extends true
|
type SelectItemWithPayload<T> = SelectItem & {
|
||||||
? ReadonlyArray<T>
|
payload: T;
|
||||||
: Nullable<T>;
|
};
|
||||||
|
|
||||||
export interface SelectorProps<T, M extends boolean> {
|
function DefaultKeyBuilder<T>(value: T) {
|
||||||
className?: string;
|
if (typeof value === "string") {
|
||||||
placeholder?: string;
|
return value;
|
||||||
options: readonly SelectorOption<T>[];
|
} else if (typeof value === "number") {
|
||||||
disabled?: boolean;
|
return value.toString();
|
||||||
clearable?: boolean;
|
} else {
|
||||||
loading?: boolean;
|
LOG("error", "Unknown value type", value);
|
||||||
multiple?: M;
|
throw new Error(
|
||||||
onChange?: (k: SelectorValueType<T, M>) => void;
|
`Invalid type (${typeof value}) in the SelectorOption, please provide a label builder`
|
||||||
onFocus?: (e: FocusEvent<HTMLElement>) => void;
|
);
|
||||||
label?: (item: T) => string;
|
}
|
||||||
defaultValue?: SelectorValueType<T, M>;
|
|
||||||
value?: SelectorValueType<T, M>;
|
|
||||||
components?: Partial<
|
|
||||||
SelectComponents<SelectorOption<T>, M, GroupBase<SelectorOption<T>>>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Selector<T = string, M extends boolean = false>(
|
export type SelectorProps<T> = Override<
|
||||||
props: SelectorProps<T, M>
|
{
|
||||||
) {
|
value?: T | null;
|
||||||
const {
|
defaultValue?: T | null;
|
||||||
className,
|
options: SelectorOption<T>[];
|
||||||
placeholder,
|
onChange?: (value: T | null) => void;
|
||||||
label,
|
getkey?: (value: T) => string;
|
||||||
disabled,
|
},
|
||||||
clearable,
|
Omit<SelectProps, "data">
|
||||||
loading,
|
>;
|
||||||
options,
|
|
||||||
multiple,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
defaultValue,
|
|
||||||
components,
|
|
||||||
value,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
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(
|
const data = useMemo(
|
||||||
(item: T) => {
|
() =>
|
||||||
if (labelRef.current) {
|
options.map<SelectItemWithPayload<T>>(({ value, label, ...option }) => ({
|
||||||
return labelRef.current(item);
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isLoading={loading}
|
data={data}
|
||||||
placeholder={placeholder}
|
defaultValue={wrappedDefaultValue}
|
||||||
isSearchable={options.length >= 10}
|
value={wrappedValue}
|
||||||
isMulti={multiple}
|
onChange={wrappedOnChange}
|
||||||
closeMenuOnSelect={!multiple}
|
{...select}
|
||||||
defaultValue={defaultWrapper}
|
|
||||||
value={valueWrapper}
|
|
||||||
isClearable={clearable}
|
|
||||||
isDisabled={disabled}
|
|
||||||
options={options}
|
|
||||||
components={components}
|
|
||||||
className={clsx("custom-selector w-100", className)}
|
|
||||||
classNamePrefix="selector"
|
|
||||||
onFocus={onFocus}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
if (onChange) {
|
|
||||||
if (multiple === true) {
|
|
||||||
const values = (
|
|
||||||
newValue as OnChangeValue<SelectorOption<T>, true>
|
|
||||||
).map((v) => v.value) as ReadonlyArray<T>;
|
|
||||||
|
|
||||||
onChange(values as SelectorValueType<T, M>);
|
|
||||||
} else {
|
|
||||||
const value =
|
|
||||||
(newValue as OnChangeValue<SelectorOption<T>, false>)?.value ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
onChange(value as SelectorValueType<T, M>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></Select>
|
></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 "./FileBrowser";
|
||||||
export * from "./FileForm";
|
|
||||||
export * from "./Selector";
|
export * from "./Selector";
|
||||||
export * from "./Slider";
|
|
||||||
|
|
|
@ -4,19 +4,26 @@ import {
|
||||||
useMovieAddBlacklist,
|
useMovieAddBlacklist,
|
||||||
useMovieHistory,
|
useMovieHistory,
|
||||||
} from "@/apis/hooks";
|
} 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 { FunctionComponent, useMemo } from "react";
|
||||||
import { Column } from "react-table";
|
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 Language from "../bazarr/Language";
|
||||||
import { BlacklistButton } from "../inputs/blacklist";
|
import TextPopover from "../TextPopover";
|
||||||
|
|
||||||
const MovieHistoryView: FunctionComponent = () => {
|
interface MovieHistoryViewProps {
|
||||||
const movie = usePayload<Item.Movie>();
|
movie: Item.Movie;
|
||||||
|
}
|
||||||
|
|
||||||
const Modal = useModal({ size: "lg" });
|
const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
|
||||||
|
movie,
|
||||||
const history = useMovieHistory(movie?.radarrId);
|
}) => {
|
||||||
|
const history = useMovieHistory(movie.radarrId);
|
||||||
|
|
||||||
const { data } = history;
|
const { data } = history;
|
||||||
|
|
||||||
|
@ -24,17 +31,22 @@ const MovieHistoryView: FunctionComponent = () => {
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessor: "action",
|
accessor: "action",
|
||||||
className: "text-center",
|
Cell: (row) => (
|
||||||
Cell: (row) => {
|
<Center>
|
||||||
return <HistoryIcon action={row.value}></HistoryIcon>;
|
<HistoryIcon action={row.value}></HistoryIcon>
|
||||||
},
|
</Center>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Language",
|
Header: "Language",
|
||||||
accessor: "language",
|
accessor: "language",
|
||||||
Cell: ({ value }) => {
|
Cell: ({ value }) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
return <Language.Text value={value} long></Language.Text>;
|
return (
|
||||||
|
<Badge>
|
||||||
|
<Language.Text value={value} long></Language.Text>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -51,144 +63,173 @@ const MovieHistoryView: FunctionComponent = () => {
|
||||||
{
|
{
|
||||||
Header: "Date",
|
Header: "Date",
|
||||||
accessor: "timestamp",
|
accessor: "timestamp",
|
||||||
Cell: (row) => {
|
Cell: ({ value, row }) => {
|
||||||
if (row.value) {
|
return (
|
||||||
return (
|
<TextPopover text={row.original.parsed_timestamp}>
|
||||||
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
|
<Text>{value}</Text>
|
||||||
<span>{row.value}</span>
|
</TextPopover>
|
||||||
</TextPopover>
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Actions
|
// Actions
|
||||||
accessor: "blacklisted",
|
accessor: "blacklisted",
|
||||||
Cell: ({ row }) => {
|
Cell: ({ row, value }) => {
|
||||||
const { radarrId } = row.original;
|
const add = useMovieAddBlacklist();
|
||||||
const { mutateAsync } = useMovieAddBlacklist();
|
const { radarrId, provider, subs_id, language, subtitles_path } =
|
||||||
return (
|
row.original;
|
||||||
<BlacklistButton
|
|
||||||
update={history.refetch}
|
|
||||||
promise={(form) => mutateAsync({ id: radarrId, form })}
|
|
||||||
history={row.original}
|
|
||||||
></BlacklistButton>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[history.refetch]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
if (subs_id && provider && language) {
|
||||||
<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) {
|
|
||||||
return (
|
return (
|
||||||
<TextPopover text={row.row.original.parsed_timestamp} delay={1}>
|
<MutateAction
|
||||||
<span>{row.value}</span>
|
disabled={value}
|
||||||
</TextPopover>
|
icon={faFileExcel}
|
||||||
|
mutation={add}
|
||||||
|
args={() => ({
|
||||||
|
id: radarrId,
|
||||||
|
form: {
|
||||||
|
provider,
|
||||||
|
subs_id,
|
||||||
|
subtitles_path,
|
||||||
|
language: language.code2,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
></MutateAction>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<Modal title={`History - ${episode?.title ?? ""}`}>
|
<QueryOverlay result={history}>
|
||||||
<QueryOverlay result={history}>
|
<PageTable
|
||||||
<PageTable
|
columns={columns}
|
||||||
emptyText="No History Found"
|
data={data ?? []}
|
||||||
columns={columns}
|
tableStyles={{ emptyText: "No history found" }}
|
||||||
data={data ?? []}
|
></PageTable>
|
||||||
></PageTable>
|
</QueryOverlay>
|
||||||
</QueryOverlay>
|
);
|
||||||
</Modal>
|
};
|
||||||
|
|
||||||
|
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(
|
export const EpisodeHistoryModal = withModal(
|
||||||
EpisodeHistoryView,
|
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 { withModal } from "@/modules/modals";
|
||||||
import { createAndDispatchTask } from "@/modules/task/utilities";
|
import { task, TaskGroup } from "@/modules/task";
|
||||||
import { GetItemId, isMovie } from "@/utilities";
|
import { useTableStyles } from "@/styles";
|
||||||
|
import { BuildKey, GetItemId } from "@/utilities";
|
||||||
import {
|
import {
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
faCheck,
|
faCheck,
|
||||||
|
faCheckCircle,
|
||||||
faDownload,
|
faDownload,
|
||||||
|
faExclamationCircle,
|
||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
faTimes,
|
faTimes,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import clsx from "clsx";
|
|
||||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Col,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
Container,
|
Divider,
|
||||||
OverlayTrigger,
|
Group,
|
||||||
|
List,
|
||||||
Popover,
|
Popover,
|
||||||
Row,
|
Stack,
|
||||||
} from "react-bootstrap";
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useHover } from "@mantine/hooks";
|
||||||
|
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||||
import { UseQueryResult } from "react-query";
|
import { UseQueryResult } from "react-query";
|
||||||
import { Column } from "react-table";
|
import { Column } from "react-table";
|
||||||
import { LoadingIndicator, PageTable } from "..";
|
import { Action, PageTable } from "..";
|
||||||
import Language from "../bazarr/Language";
|
import Language from "../bazarr/Language";
|
||||||
|
|
||||||
type SupportType = Item.Movie | Item.Episode;
|
type SupportType = Item.Movie | Item.Episode;
|
||||||
|
@ -33,12 +39,11 @@ interface Props<T extends SupportType> {
|
||||||
query: (
|
query: (
|
||||||
id?: number
|
id?: number
|
||||||
) => UseQueryResult<SearchResultType[] | undefined, unknown>;
|
) => UseQueryResult<SearchResultType[] | undefined, unknown>;
|
||||||
|
item: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
const { download, query: useSearch } = props;
|
const { download, query: useSearch, item } = props;
|
||||||
|
|
||||||
const item = usePayload<T>();
|
|
||||||
|
|
||||||
const itemId = useMemo(() => GetItemId(item ?? {}), [item]);
|
const itemId = useMemo(() => GetItemId(item ?? {}), [item]);
|
||||||
|
|
||||||
|
@ -49,17 +54,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
const isStale = results.data === undefined;
|
const isStale = results.data === undefined;
|
||||||
|
|
||||||
const search = useCallback(() => {
|
const search = useCallback(() => {
|
||||||
if (itemId !== undefined) {
|
setId(itemId);
|
||||||
setId(itemId);
|
results.refetch();
|
||||||
results.refetch();
|
|
||||||
}
|
|
||||||
}, [itemId, results]);
|
}, [itemId, results]);
|
||||||
|
|
||||||
const columns = useMemo<Column<SearchResultType>[]>(
|
const columns = useMemo<Column<SearchResultType>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
Header: "Score",
|
Header: "Score",
|
||||||
accessor: (d) => `${d.score}%`,
|
accessor: "score",
|
||||||
|
Cell: ({ value }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return <Text className={classes.noWrap}>{value}%</Text>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "language",
|
accessor: "language",
|
||||||
|
@ -71,7 +78,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
name: "",
|
name: "",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Badge variant="secondary">
|
<Badge>
|
||||||
<Language.Text value={lang}></Language.Text>
|
<Language.Text value={lang}></Language.Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
@ -81,13 +88,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
Header: "Provider",
|
Header: "Provider",
|
||||||
accessor: "provider",
|
accessor: "provider",
|
||||||
Cell: (row) => {
|
Cell: (row) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
const value = row.value;
|
const value = row.value;
|
||||||
const { url } = row.row.original;
|
const { url } = row.row.original;
|
||||||
if (url) {
|
if (url) {
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<Anchor
|
||||||
|
className={classes.noWrap}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</a>
|
</Anchor>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return value;
|
return value;
|
||||||
|
@ -97,55 +110,44 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
{
|
{
|
||||||
Header: "Release",
|
Header: "Release",
|
||||||
accessor: "release_info",
|
accessor: "release_info",
|
||||||
className: "text-nowrap",
|
Cell: ({ value }) => {
|
||||||
Cell: (row) => {
|
const { classes } = useTableStyles();
|
||||||
const value = row.value;
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() => value.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>),
|
||||||
value.slice(1).map((v, idx) => (
|
|
||||||
<span className="release-text hidden-item" key={idx}>
|
|
||||||
{v}
|
|
||||||
</span>
|
|
||||||
)),
|
|
||||||
[value]
|
[value]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return <span className="text-muted">Cannot get release info</span>;
|
return <Text color="dimmed">Cannot get release info</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Stack spacing={0} onClick={() => setOpen((o) => !o)}>
|
||||||
className={clsx(
|
<Text className={classes.primary}>
|
||||||
"release-container d-flex justify-content-between align-items-center",
|
{value[0]}
|
||||||
{ "release-multi": value.length > 1 }
|
{value.length > 1 && (
|
||||||
)}
|
<FontAwesomeIcon
|
||||||
onClick={() => setOpen((o) => !o)}
|
icon={faCaretDown}
|
||||||
>
|
rotation={open ? 180 : undefined}
|
||||||
<div className="text-container">
|
></FontAwesomeIcon>
|
||||||
<span className="release-text">{value[0]}</span>
|
)}
|
||||||
<Collapse in={open}>
|
</Text>
|
||||||
<div>{items}</div>
|
<Collapse in={open}>
|
||||||
</Collapse>
|
<>{items}</>
|
||||||
</div>
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
{value.length > 1 && (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
className="release-icon"
|
|
||||||
icon={faCaretDown}
|
|
||||||
rotation={open ? 180 : undefined}
|
|
||||||
></FontAwesomeIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Upload",
|
Header: "Upload",
|
||||||
accessor: (d) => d.uploader ?? "-",
|
accessor: "uploader",
|
||||||
|
Cell: ({ value }) => {
|
||||||
|
const { classes } = useTableStyles();
|
||||||
|
return <Text className={classes.noWrap}>{value ?? "-"}</Text>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: "matches",
|
accessor: "matches",
|
||||||
|
@ -159,24 +161,23 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
Cell: ({ row }) => {
|
Cell: ({ row }) => {
|
||||||
const result = row.original;
|
const result = row.original;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Action
|
||||||
size="sm"
|
icon={faDownload}
|
||||||
|
color="brand"
|
||||||
variant="light"
|
variant="light"
|
||||||
disabled={item === null}
|
disabled={item === null}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
createAndDispatchTask(
|
task.create(
|
||||||
item.title,
|
item.title,
|
||||||
"download-subtitles",
|
TaskGroup.DownloadSubtitle,
|
||||||
download,
|
download,
|
||||||
item,
|
item,
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
></Action>
|
||||||
<FontAwesomeIcon icon={faDownload}></FontAwesomeIcon>
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -184,141 +185,84 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
[download, item]
|
[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 (
|
return (
|
||||||
<Modal title={title} footer={footer}>
|
<Stack>
|
||||||
{content()}
|
<Alert
|
||||||
</Modal>
|
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>>(
|
export const MovieSearchModal = withModal<Props<Item.Movie>>(
|
||||||
ManualSearchView,
|
ManualSearchView,
|
||||||
"movie-manual-search"
|
"movie-manual-search",
|
||||||
|
{ title: "Search Subtitles", size: "xl" }
|
||||||
);
|
);
|
||||||
export const EpisodeSearchModal = withModal<Props<Item.Episode>>(
|
export const EpisodeSearchModal = withModal<Props<Item.Episode>>(
|
||||||
ManualSearchView,
|
ManualSearchView,
|
||||||
"episode-manual-search"
|
"episode-manual-search",
|
||||||
|
{ title: "Search Subtitles", size: "xl" }
|
||||||
);
|
);
|
||||||
|
|
||||||
const StateIcon: FunctionComponent<{ matches: string[]; dont: string[] }> = ({
|
const StateIcon: FunctionComponent<{ matches: string[]; dont: string[] }> = ({
|
||||||
matches,
|
matches,
|
||||||
dont,
|
dont,
|
||||||
}) => {
|
}) => {
|
||||||
let icon = faCheck;
|
const hasIssues = dont.length > 0;
|
||||||
let color = "var(--success)";
|
|
||||||
if (dont.length > 0) {
|
|
||||||
icon = faInfoCircle;
|
|
||||||
color = "var(--warning)";
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchElements = useMemo(
|
const { ref, hovered } = useHover();
|
||||||
() =>
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverlayTrigger overlay={popover} placement={"left"}>
|
<Popover
|
||||||
<FontAwesomeIcon icon={icon} color={color}></FontAwesomeIcon>
|
opened={hovered}
|
||||||
</OverlayTrigger>
|
placement="center"
|
||||||
|
position="top"
|
||||||
|
target={
|
||||||
|
<Text color={hasIssues ? "yellow" : "green"} ref={ref}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={hasIssues ? faExclamationCircle : faCheckCircle}
|
||||||
|
></FontAwesomeIcon>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group align="flex-start" spacing="xl">
|
||||||
|
<Stack align="flex-start" spacing="xs">
|
||||||
|
<Text color="green">
|
||||||
|
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
|
||||||
|
</Text>
|
||||||
|
<List>
|
||||||
|
{matches.map((v, idx) => (
|
||||||
|
<List.Item key={BuildKey(idx, v, "match")}>{v}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
<Stack align="flex-start" spacing="xs">
|
||||||
|
<Text color="yellow">
|
||||||
|
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
|
||||||
|
</Text>
|
||||||
|
<List>
|
||||||
|
{dont.map((v, idx) => (
|
||||||
|
<List.Item key={BuildKey(idx, v, "miss")}>{v}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
import { useMovieSubtitleModification } from "@/apis/hooks";
|
|
||||||
import { usePayload, withModal } from "@/modules/modals";
|
|
||||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
|
||||||
import {
|
|
||||||
useLanguageProfileBy,
|
|
||||||
useProfileItemsToLanguages,
|
|
||||||
} from "@/utilities/languages";
|
|
||||||
import { FunctionComponent, useCallback } from "react";
|
|
||||||
import SubtitleUploader, {
|
|
||||||
PendingSubtitle,
|
|
||||||
Validator,
|
|
||||||
} from "./SubtitleUploadModal";
|
|
||||||
|
|
||||||
const MovieUploadModal: FunctionComponent = () => {
|
|
||||||
const payload = usePayload<Item.Movie>();
|
|
||||||
|
|
||||||
const profile = useLanguageProfileBy(payload?.profileId);
|
|
||||||
|
|
||||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
|
||||||
|
|
||||||
const update = useCallback(async (list: PendingSubtitle<unknown>[]) => {
|
|
||||||
return list;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
upload: { mutateAsync },
|
|
||||||
} = useMovieSubtitleModification();
|
|
||||||
|
|
||||||
const validate = useCallback<Validator<unknown>>(
|
|
||||||
(item) => {
|
|
||||||
if (item.language === null) {
|
|
||||||
return {
|
|
||||||
state: "error",
|
|
||||||
messages: ["Language is not selected"],
|
|
||||||
};
|
|
||||||
} else if (
|
|
||||||
payload?.subtitles.find((v) => v.code2 === item.language?.code2) !==
|
|
||||||
undefined
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
state: "warning",
|
|
||||||
messages: ["Override existing subtitle"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: "valid",
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[payload?.subtitles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const upload = useCallback(
|
|
||||||
(items: PendingSubtitle<unknown>[]) => {
|
|
||||||
if (payload === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { radarrId } = payload;
|
|
||||||
|
|
||||||
const tasks = items
|
|
||||||
.filter((v) => v.language !== null)
|
|
||||||
.map((v) => {
|
|
||||||
const { file, language, forced, hi } = v;
|
|
||||||
|
|
||||||
if (language === null) {
|
|
||||||
throw new Error("Language is not selected");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createTask(file.name, mutateAsync, {
|
|
||||||
radarrId,
|
|
||||||
form: {
|
|
||||||
file,
|
|
||||||
forced,
|
|
||||||
hi,
|
|
||||||
language: language.code2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchTask(tasks, "upload-subtitles");
|
|
||||||
},
|
|
||||||
[mutateAsync, payload]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubtitleUploader
|
|
||||||
hideAllLanguages
|
|
||||||
initial={{ forced: false }}
|
|
||||||
availableLanguages={availableLanguages}
|
|
||||||
columns={[]}
|
|
||||||
upload={upload}
|
|
||||||
update={update}
|
|
||||||
validate={validate}
|
|
||||||
></SubtitleUploader>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(MovieUploadModal, "movie-upload");
|
|
|
@ -1,175 +0,0 @@
|
||||||
import { useEpisodeSubtitleModification } from "@/apis/hooks";
|
|
||||||
import api from "@/apis/raw";
|
|
||||||
import { usePayload, withModal } from "@/modules/modals";
|
|
||||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
|
||||||
import {
|
|
||||||
useLanguageProfileBy,
|
|
||||||
useProfileItemsToLanguages,
|
|
||||||
} from "@/utilities/languages";
|
|
||||||
import { FunctionComponent, useCallback, useMemo } from "react";
|
|
||||||
import { Column } from "react-table";
|
|
||||||
import { Selector, SelectorOption } from "../inputs";
|
|
||||||
import SubtitleUploader, {
|
|
||||||
PendingSubtitle,
|
|
||||||
useRowMutation,
|
|
||||||
Validator,
|
|
||||||
} from "./SubtitleUploadModal";
|
|
||||||
|
|
||||||
interface Payload {
|
|
||||||
instance: Item.Episode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SeriesProps {
|
|
||||||
episodes: readonly Item.Episode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SeriesUploadModal: FunctionComponent<SeriesProps> = ({ episodes }) => {
|
|
||||||
const payload = usePayload<Item.Series>();
|
|
||||||
|
|
||||||
const profile = useLanguageProfileBy(payload?.profileId);
|
|
||||||
|
|
||||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
|
||||||
|
|
||||||
const {
|
|
||||||
upload: { mutateAsync },
|
|
||||||
} = useEpisodeSubtitleModification();
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
async (list: PendingSubtitle<Payload>[]) => {
|
|
||||||
const newList = [...list];
|
|
||||||
const names = list.map((v) => v.file.name);
|
|
||||||
|
|
||||||
if (names.length > 0) {
|
|
||||||
const results = await api.subtitles.info(names);
|
|
||||||
|
|
||||||
// TODO: Optimization
|
|
||||||
newList.forEach((v) => {
|
|
||||||
const info = results.find((f) => f.filename === v.file.name);
|
|
||||||
if (info) {
|
|
||||||
v.payload.instance =
|
|
||||||
episodes.find(
|
|
||||||
(e) => e.season === info.season && e.episode === info.episode
|
|
||||||
) ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return newList;
|
|
||||||
},
|
|
||||||
[episodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
const validate = useCallback<Validator<Payload>>((item) => {
|
|
||||||
const { language } = item;
|
|
||||||
const { instance } = item.payload;
|
|
||||||
if (language === null || instance === null) {
|
|
||||||
return {
|
|
||||||
state: "error",
|
|
||||||
messages: ["Language or Episode is not selected"],
|
|
||||||
};
|
|
||||||
} else if (
|
|
||||||
instance.subtitles.find((v) => v.code2 === language.code2) !== undefined
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
state: "warning",
|
|
||||||
messages: ["Override existing subtitle"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: "valid",
|
|
||||||
messages: [],
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const upload = useCallback(
|
|
||||||
(items: PendingSubtitle<Payload>[]) => {
|
|
||||||
if (payload === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sonarrSeriesId: seriesId } = payload;
|
|
||||||
|
|
||||||
const tasks = items
|
|
||||||
.filter((v) => v.payload.instance !== undefined)
|
|
||||||
.map((v) => {
|
|
||||||
const {
|
|
||||||
hi,
|
|
||||||
forced,
|
|
||||||
payload: { instance },
|
|
||||||
language,
|
|
||||||
} = v;
|
|
||||||
|
|
||||||
if (language === null || instance === null) {
|
|
||||||
throw new Error("Invalid state");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code2 } = language;
|
|
||||||
const { sonarrEpisodeId: episodeId } = instance;
|
|
||||||
|
|
||||||
const form: FormType.UploadSubtitle = {
|
|
||||||
file: v.file,
|
|
||||||
language: code2,
|
|
||||||
hi: hi,
|
|
||||||
forced: forced,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createTask(v.file.name, mutateAsync, {
|
|
||||||
seriesId,
|
|
||||||
episodeId,
|
|
||||||
form,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchTask(tasks, "upload-subtitles");
|
|
||||||
},
|
|
||||||
[mutateAsync, payload]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "instance",
|
|
||||||
Header: "Episode",
|
|
||||||
accessor: "payload",
|
|
||||||
className: "vw-1",
|
|
||||||
Cell: ({ value, row }) => {
|
|
||||||
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
|
|
||||||
label: `(${ep.season}x${ep.episode}) ${ep.title}`,
|
|
||||||
value: ep,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mutate = useRowMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Selector
|
|
||||||
disabled={row.original.state === "fetching"}
|
|
||||||
options={options}
|
|
||||||
value={value.instance}
|
|
||||||
onChange={(ep: Nullable<Item.Episode>) => {
|
|
||||||
if (ep) {
|
|
||||||
const newInfo = { ...row.original };
|
|
||||||
newInfo.payload.instance = ep;
|
|
||||||
mutate(row.index, newInfo);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></Selector>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[episodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubtitleUploader
|
|
||||||
columns={columns}
|
|
||||||
initial={{ instance: null }}
|
|
||||||
availableLanguages={availableLanguages}
|
|
||||||
upload={upload}
|
|
||||||
update={update}
|
|
||||||
validate={validate}
|
|
||||||
></SubtitleUploader>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(SeriesUploadModal, "series-upload");
|
|
|
@ -0,0 +1,123 @@
|
||||||
|
import Language from "@/components/bazarr/Language";
|
||||||
|
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
|
||||||
|
import { SimpleTable } from "@/components/tables";
|
||||||
|
import { useCustomSelection } from "@/components/tables/plugins";
|
||||||
|
import { withModal } from "@/modules/modals";
|
||||||
|
import { isMovie } from "@/utilities";
|
||||||
|
import { Badge, Button, Divider, Group, Stack } from "@mantine/core";
|
||||||
|
import { FunctionComponent, useMemo, useState } from "react";
|
||||||
|
import { Column, useRowSelect } from "react-table";
|
||||||
|
|
||||||
|
type SupportType = Item.Episode | Item.Movie;
|
||||||
|
|
||||||
|
type TableColumnType = FormType.ModifySubtitle & {
|
||||||
|
raw_language: Language.Info;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
|
||||||
|
if (isMovie(item)) {
|
||||||
|
return [item.radarrId, "movie"];
|
||||||
|
} else {
|
||||||
|
return [item.sonarrEpisodeId, "episode"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CanSelectSubtitle = (item: TableColumnType) => {
|
||||||
|
return item.path.endsWith(".srt");
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SubtitleToolViewProps {
|
||||||
|
payload: SupportType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
|
||||||
|
payload,
|
||||||
|
}) => {
|
||||||
|
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
||||||
|
|
||||||
|
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: "Language",
|
||||||
|
accessor: "raw_language",
|
||||||
|
Cell: ({ value }) => (
|
||||||
|
<Badge color="secondary">
|
||||||
|
<Language.Text value={value} long></Language.Text>
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "file",
|
||||||
|
Header: "File",
|
||||||
|
accessor: "path",
|
||||||
|
Cell: ({ value }) => {
|
||||||
|
const path = value;
|
||||||
|
|
||||||
|
let idx = path.lastIndexOf("/");
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
idx = path.lastIndexOf("\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
return path.slice(idx + 1);
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = useMemo<TableColumnType[]>(
|
||||||
|
() =>
|
||||||
|
payload.flatMap((item) => {
|
||||||
|
const [id, type] = getIdAndType(item);
|
||||||
|
return item.subtitles.flatMap((v) => {
|
||||||
|
if (v.path) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
language: v.code2,
|
||||||
|
path: v.path,
|
||||||
|
raw_language: v,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[payload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const plugins = [useRowSelect, useCustomSelection];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<SimpleTable
|
||||||
|
tableStyles={{ emptyText: "No external subtitles found" }}
|
||||||
|
plugins={plugins}
|
||||||
|
columns={columns}
|
||||||
|
onSelect={setSelections}
|
||||||
|
canSelect={CanSelectSubtitle}
|
||||||
|
data={data}
|
||||||
|
></SimpleTable>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Group>
|
||||||
|
<SubtitleToolsMenu selections={selections}>
|
||||||
|
<Button disabled={selections.length === 0} variant="light">
|
||||||
|
Select Action
|
||||||
|
</Button>
|
||||||
|
</SubtitleToolsMenu>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withModal(SubtitleToolView, "subtitle-tools", {
|
||||||
|
title: "Subtitle Tools",
|
||||||
|
size: "xl",
|
||||||
|
});
|
|
@ -1,362 +0,0 @@
|
||||||
import { useModal, useModalControl } from "@/modules/modals";
|
|
||||||
import { BuildKey } from "@/utilities";
|
|
||||||
import { LOG } from "@/utilities/console";
|
|
||||||
import {
|
|
||||||
faCheck,
|
|
||||||
faCircleNotch,
|
|
||||||
faInfoCircle,
|
|
||||||
faTimes,
|
|
||||||
faTrash,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Button, Container, Form } from "react-bootstrap";
|
|
||||||
import { Column } from "react-table";
|
|
||||||
import { LanguageSelector, MessageIcon } from "..";
|
|
||||||
import { FileForm } from "../inputs";
|
|
||||||
import { SimpleTable } from "../tables";
|
|
||||||
|
|
||||||
type ModifyFn<T> = (index: number, info?: PendingSubtitle<T>) => void;
|
|
||||||
|
|
||||||
const RowContext = createContext<ModifyFn<unknown>>(() => {
|
|
||||||
LOG("error", "RowContext not initialized");
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useRowMutation() {
|
|
||||||
return useContext(RowContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingSubtitle<P> {
|
|
||||||
file: File;
|
|
||||||
state: "valid" | "fetching" | "warning" | "error";
|
|
||||||
messages: string[];
|
|
||||||
language: Language.Info | null;
|
|
||||||
forced: boolean;
|
|
||||||
hi: boolean;
|
|
||||||
payload: P;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Validator<T> = (
|
|
||||||
item: PendingSubtitle<T>
|
|
||||||
) => Pick<PendingSubtitle<T>, "state" | "messages">;
|
|
||||||
|
|
||||||
interface Props<T = unknown> {
|
|
||||||
initial: T;
|
|
||||||
availableLanguages: Language.Info[];
|
|
||||||
upload: (items: PendingSubtitle<T>[]) => void;
|
|
||||||
update: (items: PendingSubtitle<T>[]) => Promise<PendingSubtitle<T>[]>;
|
|
||||||
validate: Validator<T>;
|
|
||||||
columns: Column<PendingSubtitle<T>>[];
|
|
||||||
hideAllLanguages?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubtitleUploader<T>(props: Props<T>) {
|
|
||||||
const {
|
|
||||||
initial,
|
|
||||||
columns,
|
|
||||||
upload,
|
|
||||||
update,
|
|
||||||
validate,
|
|
||||||
availableLanguages,
|
|
||||||
hideAllLanguages,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
|
|
||||||
|
|
||||||
const showTable = pending.length > 0;
|
|
||||||
|
|
||||||
const Modal = useModal({
|
|
||||||
size: showTable ? "xl" : "lg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { hide } = useModalControl();
|
|
||||||
|
|
||||||
const fileList = useMemo(() => pending.map((v) => v.file), [pending]);
|
|
||||||
|
|
||||||
const initialRef = useRef(initial);
|
|
||||||
|
|
||||||
const setFiles = useCallback(
|
|
||||||
async (files: File[]) => {
|
|
||||||
const initialLanguage =
|
|
||||||
availableLanguages.length > 0 ? availableLanguages[0] : null;
|
|
||||||
let list = files.map<PendingSubtitle<T>>((file) => ({
|
|
||||||
file,
|
|
||||||
state: "fetching",
|
|
||||||
messages: [],
|
|
||||||
language: initialLanguage,
|
|
||||||
forced: false,
|
|
||||||
hi: false,
|
|
||||||
payload: { ...initialRef.current },
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (update) {
|
|
||||||
setPending(list);
|
|
||||||
list = await update(list);
|
|
||||||
} else {
|
|
||||||
list = list.map<PendingSubtitle<T>>((v) => ({
|
|
||||||
...v,
|
|
||||||
state: "valid",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
list = list.map((v) => ({
|
|
||||||
...v,
|
|
||||||
...validate(v),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setPending(list);
|
|
||||||
},
|
|
||||||
[update, validate, availableLanguages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const modify = useCallback(
|
|
||||||
(index: number, info?: PendingSubtitle<T>) => {
|
|
||||||
setPending((pd) => {
|
|
||||||
const newPending = [...pd];
|
|
||||||
if (info) {
|
|
||||||
info = { ...info, ...validate(info) };
|
|
||||||
newPending[index] = info;
|
|
||||||
} else {
|
|
||||||
newPending.splice(index, 1);
|
|
||||||
}
|
|
||||||
return newPending;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[validate]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPending((pd) => {
|
|
||||||
const newPd = pd.map((v) => {
|
|
||||||
if (v.state !== "fetching") {
|
|
||||||
return { ...v, ...validate(v) };
|
|
||||||
} else {
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return newPd;
|
|
||||||
});
|
|
||||||
}, [validate]);
|
|
||||||
|
|
||||||
const columnsWithAction = useMemo<Column<PendingSubtitle<T>>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "icon",
|
|
||||||
accessor: "state",
|
|
||||||
className: "text-center",
|
|
||||||
Cell: ({ value, row }) => {
|
|
||||||
let icon = faCircleNotch;
|
|
||||||
let color: string | undefined = undefined;
|
|
||||||
let spin = false;
|
|
||||||
|
|
||||||
switch (value) {
|
|
||||||
case "fetching":
|
|
||||||
spin = true;
|
|
||||||
break;
|
|
||||||
case "warning":
|
|
||||||
icon = faInfoCircle;
|
|
||||||
color = "var(--warning)";
|
|
||||||
break;
|
|
||||||
case "valid":
|
|
||||||
icon = faCheck;
|
|
||||||
color = "var(--success)";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
icon = faTimes;
|
|
||||||
color = "var(--danger)";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = row.original.messages;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessageIcon
|
|
||||||
messages={messages}
|
|
||||||
color={color}
|
|
||||||
icon={icon}
|
|
||||||
spin={spin}
|
|
||||||
></MessageIcon>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: "File",
|
|
||||||
accessor: (d) => d.file.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hi",
|
|
||||||
Header: "HI",
|
|
||||||
accessor: "hi",
|
|
||||||
Cell: ({ row, value }) => {
|
|
||||||
const { original, index } = row;
|
|
||||||
const mutate = useRowMutation();
|
|
||||||
return (
|
|
||||||
<Form.Check
|
|
||||||
custom
|
|
||||||
disabled={original.state === "fetching"}
|
|
||||||
id={BuildKey(index, original.file.name, "hi")}
|
|
||||||
checked={value}
|
|
||||||
onChange={(v) => {
|
|
||||||
const newInfo = { ...row.original };
|
|
||||||
newInfo.hi = v.target.checked;
|
|
||||||
mutate(row.index, newInfo);
|
|
||||||
}}
|
|
||||||
></Form.Check>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "forced",
|
|
||||||
Header: "Forced",
|
|
||||||
accessor: "forced",
|
|
||||||
Cell: ({ row, value }) => {
|
|
||||||
const { original, index } = row;
|
|
||||||
const mutate = useRowMutation();
|
|
||||||
return (
|
|
||||||
<Form.Check
|
|
||||||
custom
|
|
||||||
disabled={original.state === "fetching"}
|
|
||||||
id={BuildKey(index, original.file.name, "forced")}
|
|
||||||
checked={value}
|
|
||||||
onChange={(v) => {
|
|
||||||
const newInfo = { ...row.original };
|
|
||||||
newInfo.forced = v.target.checked;
|
|
||||||
mutate(row.index, newInfo);
|
|
||||||
}}
|
|
||||||
></Form.Check>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "language",
|
|
||||||
Header: "Language",
|
|
||||||
accessor: "language",
|
|
||||||
className: "w-25",
|
|
||||||
Cell: ({ row, value }) => {
|
|
||||||
const mutate = useRowMutation();
|
|
||||||
return (
|
|
||||||
<LanguageSelector
|
|
||||||
disabled={row.original.state === "fetching"}
|
|
||||||
options={availableLanguages}
|
|
||||||
value={value}
|
|
||||||
onChange={(lang) => {
|
|
||||||
if (lang) {
|
|
||||||
const newInfo = { ...row.original };
|
|
||||||
newInfo.language = lang;
|
|
||||||
mutate(row.index, newInfo);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></LanguageSelector>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...columns,
|
|
||||||
{
|
|
||||||
id: "action",
|
|
||||||
accessor: "file",
|
|
||||||
Cell: ({ row }) => {
|
|
||||||
const mutate = useRowMutation();
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
disabled={row.original.state === "fetching"}
|
|
||||||
onClick={() => {
|
|
||||||
mutate(row.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[columns, availableLanguages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canUpload = useMemo(
|
|
||||||
() =>
|
|
||||||
pending.length > 0 &&
|
|
||||||
pending.every((v) => v.state === "valid" || v.state === "warning"),
|
|
||||||
[pending]
|
|
||||||
);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
hidden={!showTable}
|
|
||||||
variant="outline-secondary"
|
|
||||||
className="mr-2"
|
|
||||||
onClick={() => setFiles([])}
|
|
||||||
>
|
|
||||||
Clean
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={!canUpload || !showTable}
|
|
||||||
onClick={() => {
|
|
||||||
upload(pending);
|
|
||||||
setFiles([]);
|
|
||||||
hide();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="w-25" hidden={hideAllLanguages}>
|
|
||||||
<LanguageSelector
|
|
||||||
options={availableLanguages}
|
|
||||||
value={null}
|
|
||||||
disabled={!showTable}
|
|
||||||
onChange={(lang) => {
|
|
||||||
if (lang) {
|
|
||||||
setPending((pd) =>
|
|
||||||
pd
|
|
||||||
.map((v) => ({ ...v, language: lang }))
|
|
||||||
.map((v) => ({ ...v, ...validate(v) }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></LanguageSelector>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Update Subtitles" footer={footer}>
|
|
||||||
<Container fluid className="flex-column">
|
|
||||||
<Form>
|
|
||||||
<Form.Group>
|
|
||||||
<FileForm
|
|
||||||
disabled={showTable}
|
|
||||||
emptyText="Select..."
|
|
||||||
multiple
|
|
||||||
value={fileList}
|
|
||||||
onChange={setFiles}
|
|
||||||
></FileForm>
|
|
||||||
</Form.Group>
|
|
||||||
</Form>
|
|
||||||
<div hidden={!showTable}>
|
|
||||||
<RowContext.Provider value={modify as ModifyFn<unknown>}>
|
|
||||||
<SimpleTable
|
|
||||||
columns={columnsWithAction}
|
|
||||||
data={pending}
|
|
||||||
responsive={false}
|
|
||||||
></SimpleTable>
|
|
||||||
</RowContext.Provider>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SubtitleUploader;
|
|
|
@ -1,4 +1,2 @@
|
||||||
export * from "./HistoryModal";
|
export * from "./HistoryModal";
|
||||||
export { default as ItemEditorModal } from "./ItemEditorModal";
|
export { default as SubtitleToolsModal } from "./SubtitleToolsModal";
|
||||||
export { default as MovieUploadModal } from "./MovieUploadModal";
|
|
||||||
export { default as SeriesUploadModal } from "./SeriesUploadModal";
|
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
import { Selector } from "@/components";
|
|
||||||
import { useModal, withModal } from "@/modules/modals";
|
|
||||||
import { submodProcessColor } from "@/utilities";
|
|
||||||
import { FunctionComponent, useCallback, useState } from "react";
|
|
||||||
import { Button } from "react-bootstrap";
|
|
||||||
import { useProcess } from "./ToolContext";
|
|
||||||
import { colorOptions } from "./tools";
|
|
||||||
|
|
||||||
const ColorTool: FunctionComponent = () => {
|
|
||||||
const [selection, setSelection] = useState<Nullable<string>>(null);
|
|
||||||
|
|
||||||
const Modal = useModal();
|
|
||||||
|
|
||||||
const process = useProcess();
|
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
|
||||||
if (selection) {
|
|
||||||
const action = submodProcessColor(selection);
|
|
||||||
process(action);
|
|
||||||
}
|
|
||||||
}, [process, selection]);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<Button disabled={selection === null} onClick={submit}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Choose Color" footer={footer}>
|
|
||||||
<Selector options={colorOptions} onChange={setSelection}></Selector>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(ColorTool, "color-tool");
|
|
|
@ -1,65 +0,0 @@
|
||||||
import { useModal, withModal } from "@/modules/modals";
|
|
||||||
import { FunctionComponent, useCallback, useState } from "react";
|
|
||||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
|
||||||
import { useProcess } from "./ToolContext";
|
|
||||||
|
|
||||||
function submodProcessFrameRate(from: number, to: number) {
|
|
||||||
return `change_FPS(from=${from},to=${to})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FrameRateTool: FunctionComponent = () => {
|
|
||||||
const [from, setFrom] = useState<Nullable<number>>(null);
|
|
||||||
const [to, setTo] = useState<Nullable<number>>(null);
|
|
||||||
|
|
||||||
const canSave = from !== null && to !== null && from !== to;
|
|
||||||
|
|
||||||
const Modal = useModal();
|
|
||||||
|
|
||||||
const process = useProcess();
|
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
|
||||||
if (canSave) {
|
|
||||||
const action = submodProcessFrameRate(from, to);
|
|
||||||
process(action);
|
|
||||||
}
|
|
||||||
}, [canSave, from, process, to]);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<Button disabled={!canSave} onClick={submit}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Change Frame Rate" footer={footer}>
|
|
||||||
<InputGroup className="px-2">
|
|
||||||
<Form.Control
|
|
||||||
placeholder="From"
|
|
||||||
type="number"
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseFloat(e.currentTarget.value);
|
|
||||||
if (isNaN(value)) {
|
|
||||||
setFrom(null);
|
|
||||||
} else {
|
|
||||||
setFrom(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></Form.Control>
|
|
||||||
<Form.Control
|
|
||||||
placeholder="To"
|
|
||||||
type="number"
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseFloat(e.currentTarget.value);
|
|
||||||
if (isNaN(value)) {
|
|
||||||
setTo(null);
|
|
||||||
} else {
|
|
||||||
setTo(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></Form.Control>
|
|
||||||
</InputGroup>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(FrameRateTool, "frame-rate-tool");
|
|
|
@ -1,100 +0,0 @@
|
||||||
import { useModal, withModal } from "@/modules/modals";
|
|
||||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
ChangeEventHandler,
|
|
||||||
FunctionComponent,
|
|
||||||
useCallback,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
|
||||||
import { useProcess } from "./ToolContext";
|
|
||||||
|
|
||||||
function submodProcessOffset(h: number, m: number, s: number, ms: number) {
|
|
||||||
return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimeAdjustmentTool: FunctionComponent = () => {
|
|
||||||
const [isPlus, setPlus] = useState(true);
|
|
||||||
const [offset, setOffset] = useState<[number, number, number, number]>([
|
|
||||||
0, 0, 0, 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const Modal = useModal();
|
|
||||||
|
|
||||||
const updateOffset = useCallback(
|
|
||||||
(idx: number): ChangeEventHandler<HTMLInputElement> => {
|
|
||||||
return (e) => {
|
|
||||||
let value = parseFloat(e.currentTarget.value);
|
|
||||||
if (isNaN(value)) {
|
|
||||||
value = 0;
|
|
||||||
}
|
|
||||||
const newOffset = [...offset] as [number, number, number, number];
|
|
||||||
newOffset[idx] = value;
|
|
||||||
setOffset(newOffset);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canSave = offset.some((v) => v !== 0);
|
|
||||||
|
|
||||||
const process = useProcess();
|
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
|
||||||
if (canSave) {
|
|
||||||
const newOffset = offset.map((v) => (isPlus ? v : -v));
|
|
||||||
const action = submodProcessOffset(
|
|
||||||
newOffset[0],
|
|
||||||
newOffset[1],
|
|
||||||
newOffset[2],
|
|
||||||
newOffset[3]
|
|
||||||
);
|
|
||||||
process(action);
|
|
||||||
}
|
|
||||||
}, [canSave, offset, process, isPlus]);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<Button disabled={!canSave} onClick={submit}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Adjust Times" footer={footer}>
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroup.Prepend>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
title={isPlus ? "Later" : "Earlier"}
|
|
||||||
onClick={() => setPlus(!isPlus)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
|
|
||||||
</Button>
|
|
||||||
</InputGroup.Prepend>
|
|
||||||
<Form.Control
|
|
||||||
type="number"
|
|
||||||
placeholder="hour"
|
|
||||||
onChange={updateOffset(0)}
|
|
||||||
></Form.Control>
|
|
||||||
<Form.Control
|
|
||||||
type="number"
|
|
||||||
placeholder="min"
|
|
||||||
onChange={updateOffset(1)}
|
|
||||||
></Form.Control>
|
|
||||||
<Form.Control
|
|
||||||
type="number"
|
|
||||||
placeholder="sec"
|
|
||||||
onChange={updateOffset(2)}
|
|
||||||
></Form.Control>
|
|
||||||
<Form.Control
|
|
||||||
type="number"
|
|
||||||
placeholder="ms"
|
|
||||||
onChange={updateOffset(3)}
|
|
||||||
></Form.Control>
|
|
||||||
</InputGroup>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(TimeAdjustmentTool, "time-adjustment");
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
export type ProcessSubtitleType = (
|
|
||||||
action: string,
|
|
||||||
override?: Partial<FormType.ModifySubtitle>
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export const ProcessSubtitleContext = createContext<ProcessSubtitleType>(() => {
|
|
||||||
throw new Error("ProcessSubtitleContext not initialized");
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useProcess() {
|
|
||||||
return useContext(ProcessSubtitleContext);
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { LanguageSelector } from "@/components/LanguageSelector";
|
|
||||||
import { useModal, withModal } from "@/modules/modals";
|
|
||||||
import { useEnabledLanguages } from "@/utilities/languages";
|
|
||||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
|
||||||
import { Button, Form } from "react-bootstrap";
|
|
||||||
import { useProcess } from "./ToolContext";
|
|
||||||
import { availableTranslation } from "./tools";
|
|
||||||
|
|
||||||
const TranslationTool: FunctionComponent = () => {
|
|
||||||
const { data: languages } = useEnabledLanguages();
|
|
||||||
|
|
||||||
const available = useMemo(
|
|
||||||
() => languages.filter((v) => v.code2 in availableTranslation),
|
|
||||||
[languages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const Modal = useModal();
|
|
||||||
|
|
||||||
const [selectedLanguage, setLanguage] =
|
|
||||||
useState<Nullable<Language.Info>>(null);
|
|
||||||
|
|
||||||
const process = useProcess();
|
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
|
||||||
if (selectedLanguage) {
|
|
||||||
process("translate", { language: selectedLanguage.code2 });
|
|
||||||
}
|
|
||||||
}, [process, selectedLanguage]);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<Button disabled={!selectedLanguage} onClick={submit}>
|
|
||||||
Translate
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Modal title="Translation" footer={footer}>
|
|
||||||
<Form.Label>
|
|
||||||
Enabled languages not listed here are unsupported by Google Translate.
|
|
||||||
</Form.Label>
|
|
||||||
<LanguageSelector
|
|
||||||
options={available}
|
|
||||||
onChange={setLanguage}
|
|
||||||
></LanguageSelector>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withModal(TranslationTool, "translation-tool");
|
|
|
@ -1,230 +0,0 @@
|
||||||
import { useSubtitleAction } from "@/apis/hooks";
|
|
||||||
import Language from "@/components/bazarr/Language";
|
|
||||||
import { ActionButton, ActionButtonItem } from "@/components/buttons";
|
|
||||||
import { SimpleTable } from "@/components/tables";
|
|
||||||
import { useCustomSelection } from "@/components/tables/plugins";
|
|
||||||
import {
|
|
||||||
useModal,
|
|
||||||
useModalControl,
|
|
||||||
usePayload,
|
|
||||||
withModal,
|
|
||||||
} from "@/modules/modals";
|
|
||||||
import { createTask, dispatchTask } from "@/modules/task/utilities";
|
|
||||||
import { isMovie } from "@/utilities";
|
|
||||||
import { LOG } from "@/utilities/console";
|
|
||||||
import { isObject } from "lodash";
|
|
||||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
|
||||||
import { Badge, ButtonGroup, Dropdown } from "react-bootstrap";
|
|
||||||
import { Column, useRowSelect } from "react-table";
|
|
||||||
import {
|
|
||||||
ProcessSubtitleContext,
|
|
||||||
ProcessSubtitleType,
|
|
||||||
useProcess,
|
|
||||||
} from "./ToolContext";
|
|
||||||
import { tools } from "./tools";
|
|
||||||
import { ToolOptions } from "./types";
|
|
||||||
|
|
||||||
type SupportType = Item.Episode | Item.Movie;
|
|
||||||
|
|
||||||
type TableColumnType = FormType.ModifySubtitle & {
|
|
||||||
raw_language: Language.Info;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
|
|
||||||
if (isMovie(item)) {
|
|
||||||
return [item.radarrId, "movie"];
|
|
||||||
} else {
|
|
||||||
return [item.sonarrEpisodeId, "episode"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CanSelectSubtitle = (item: TableColumnType) => {
|
|
||||||
return item.path.endsWith(".srt");
|
|
||||||
};
|
|
||||||
|
|
||||||
function isElement(value: unknown): value is JSX.Element {
|
|
||||||
return isObject(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubtitleToolViewProps {
|
|
||||||
count: number;
|
|
||||||
tools: ToolOptions[];
|
|
||||||
select: (items: TableColumnType[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
|
|
||||||
tools,
|
|
||||||
count,
|
|
||||||
select,
|
|
||||||
}) => {
|
|
||||||
const payload = usePayload<SupportType[]>();
|
|
||||||
|
|
||||||
const Modal = useModal({
|
|
||||||
size: "lg",
|
|
||||||
});
|
|
||||||
const { show } = useModalControl();
|
|
||||||
|
|
||||||
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
Header: "Language",
|
|
||||||
accessor: "raw_language",
|
|
||||||
Cell: ({ value }) => (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Language.Text value={value} long></Language.Text>
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "file",
|
|
||||||
Header: "File",
|
|
||||||
accessor: "path",
|
|
||||||
Cell: ({ value }) => {
|
|
||||||
const path = value;
|
|
||||||
|
|
||||||
let idx = path.lastIndexOf("/");
|
|
||||||
|
|
||||||
if (idx === -1) {
|
|
||||||
idx = path.lastIndexOf("\\");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idx !== -1) {
|
|
||||||
return path.slice(idx + 1);
|
|
||||||
} else {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = useMemo<TableColumnType[]>(
|
|
||||||
() =>
|
|
||||||
payload?.flatMap((item) => {
|
|
||||||
const [id, type] = getIdAndType(item);
|
|
||||||
return item.subtitles.flatMap((v) => {
|
|
||||||
if (v.path !== null) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
language: v.code2,
|
|
||||||
path: v.path,
|
|
||||||
raw_language: v,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}) ?? [],
|
|
||||||
[payload]
|
|
||||||
);
|
|
||||||
|
|
||||||
const plugins = [useRowSelect, useCustomSelection];
|
|
||||||
|
|
||||||
const process = useProcess();
|
|
||||||
|
|
||||||
const footer = useMemo(() => {
|
|
||||||
const action = tools[0];
|
|
||||||
const others = tools.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
|
|
||||||
<ActionButton
|
|
||||||
size="sm"
|
|
||||||
disabled={count === 0}
|
|
||||||
icon={action.icon}
|
|
||||||
onClick={() => process(action.key)}
|
|
||||||
>
|
|
||||||
{action.name}
|
|
||||||
</ActionButton>
|
|
||||||
<Dropdown.Toggle
|
|
||||||
disabled={count === 0}
|
|
||||||
split
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
className="px-2"
|
|
||||||
></Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu>
|
|
||||||
{others.map((v) => (
|
|
||||||
<Dropdown.Item
|
|
||||||
key={v.key}
|
|
||||||
eventKey={v.modal ? undefined : v.key}
|
|
||||||
onSelect={() => {
|
|
||||||
if (v.modal) {
|
|
||||||
show(v.modal);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActionButtonItem icon={v.icon}>{v.name}</ActionButtonItem>
|
|
||||||
</Dropdown.Item>
|
|
||||||
))}
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}, [count, process, show, tools]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Subtitle Tools" footer={footer}>
|
|
||||||
<SimpleTable
|
|
||||||
emptyText="No External Subtitles Found"
|
|
||||||
plugins={plugins}
|
|
||||||
columns={columns}
|
|
||||||
onSelect={select}
|
|
||||||
canSelect={CanSelectSubtitle}
|
|
||||||
data={data}
|
|
||||||
></SimpleTable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SubtitleToolModal = withModal(SubtitleToolView, "subtitle-tools");
|
|
||||||
|
|
||||||
const SubtitleTools: FunctionComponent = () => {
|
|
||||||
const modals = useMemo(
|
|
||||||
() =>
|
|
||||||
tools
|
|
||||||
.map((t) => t.modal && <t.modal key={t.key}></t.modal>)
|
|
||||||
.filter(isElement),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { hide } = useModalControl();
|
|
||||||
const [selections, setSelections] = useState<TableColumnType[]>([]);
|
|
||||||
const { mutateAsync } = useSubtitleAction();
|
|
||||||
|
|
||||||
const process = useCallback<ProcessSubtitleType>(
|
|
||||||
(action, override) => {
|
|
||||||
LOG("info", "executing action", action);
|
|
||||||
hide(SubtitleToolModal.modalKey);
|
|
||||||
const tasks = selections.map((s) => {
|
|
||||||
const form: FormType.ModifySubtitle = {
|
|
||||||
id: s.id,
|
|
||||||
type: s.type,
|
|
||||||
language: s.language,
|
|
||||||
path: s.path,
|
|
||||||
...override,
|
|
||||||
};
|
|
||||||
return createTask(s.path, mutateAsync, { action, form });
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchTask(tasks, "modify-subtitles");
|
|
||||||
},
|
|
||||||
[hide, selections, mutateAsync]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProcessSubtitleContext.Provider value={process}>
|
|
||||||
<SubtitleToolModal
|
|
||||||
count={selections.length}
|
|
||||||
tools={tools}
|
|
||||||
select={setSelections}
|
|
||||||
></SubtitleToolModal>
|
|
||||||
{modals}
|
|
||||||
</ProcessSubtitleContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SubtitleTools;
|
|
|
@ -1,257 +0,0 @@
|
||||||
import { SelectorOption } from "@/components";
|
|
||||||
import {
|
|
||||||
faClock,
|
|
||||||
faCode,
|
|
||||||
faDeaf,
|
|
||||||
faExchangeAlt,
|
|
||||||
faFilm,
|
|
||||||
faImage,
|
|
||||||
faLanguage,
|
|
||||||
faMagic,
|
|
||||||
faPaintBrush,
|
|
||||||
faPlay,
|
|
||||||
faTextHeight,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import ColorTool from "./ColorTool";
|
|
||||||
import FrameRateTool from "./FrameRateTool";
|
|
||||||
import TimeTool from "./TimeTool";
|
|
||||||
import Translation from "./Translation";
|
|
||||||
import { ToolOptions } from "./types";
|
|
||||||
|
|
||||||
export const tools: ToolOptions[] = [
|
|
||||||
{
|
|
||||||
key: "sync",
|
|
||||||
icon: faPlay,
|
|
||||||
name: "Sync",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "remove_HI",
|
|
||||||
icon: faDeaf,
|
|
||||||
name: "Remove HI Tags",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "remove_tags",
|
|
||||||
icon: faCode,
|
|
||||||
name: "Remove Style Tags",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "OCR_fixes",
|
|
||||||
icon: faImage,
|
|
||||||
name: "OCR Fixes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "common",
|
|
||||||
icon: faMagic,
|
|
||||||
name: "Common Fixes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "fix_uppercase",
|
|
||||||
icon: faTextHeight,
|
|
||||||
name: "Fix Uppercase",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "reverse_rtl",
|
|
||||||
icon: faExchangeAlt,
|
|
||||||
name: "Reverse RTL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "add_color",
|
|
||||||
icon: faPaintBrush,
|
|
||||||
name: "Add Color",
|
|
||||||
modal: ColorTool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "change_frame_rate",
|
|
||||||
icon: faFilm,
|
|
||||||
name: "Change Frame Rate",
|
|
||||||
modal: FrameRateTool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "adjust_time",
|
|
||||||
icon: faClock,
|
|
||||||
name: "Adjust Times",
|
|
||||||
modal: TimeTool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "translation",
|
|
||||||
icon: faLanguage,
|
|
||||||
name: "Translate",
|
|
||||||
modal: Translation,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const availableTranslation = {
|
|
||||||
af: "afrikaans",
|
|
||||||
sq: "albanian",
|
|
||||||
am: "amharic",
|
|
||||||
ar: "arabic",
|
|
||||||
hy: "armenian",
|
|
||||||
az: "azerbaijani",
|
|
||||||
eu: "basque",
|
|
||||||
be: "belarusian",
|
|
||||||
bn: "bengali",
|
|
||||||
bs: "bosnian",
|
|
||||||
bg: "bulgarian",
|
|
||||||
ca: "catalan",
|
|
||||||
ceb: "cebuano",
|
|
||||||
ny: "chichewa",
|
|
||||||
zh: "chinese (simplified)",
|
|
||||||
zt: "chinese (traditional)",
|
|
||||||
co: "corsican",
|
|
||||||
hr: "croatian",
|
|
||||||
cs: "czech",
|
|
||||||
da: "danish",
|
|
||||||
nl: "dutch",
|
|
||||||
en: "english",
|
|
||||||
eo: "esperanto",
|
|
||||||
et: "estonian",
|
|
||||||
tl: "filipino",
|
|
||||||
fi: "finnish",
|
|
||||||
fr: "french",
|
|
||||||
fy: "frisian",
|
|
||||||
gl: "galician",
|
|
||||||
ka: "georgian",
|
|
||||||
de: "german",
|
|
||||||
el: "greek",
|
|
||||||
gu: "gujarati",
|
|
||||||
ht: "haitian creole",
|
|
||||||
ha: "hausa",
|
|
||||||
haw: "hawaiian",
|
|
||||||
iw: "hebrew",
|
|
||||||
hi: "hindi",
|
|
||||||
hmn: "hmong",
|
|
||||||
hu: "hungarian",
|
|
||||||
is: "icelandic",
|
|
||||||
ig: "igbo",
|
|
||||||
id: "indonesian",
|
|
||||||
ga: "irish",
|
|
||||||
it: "italian",
|
|
||||||
ja: "japanese",
|
|
||||||
jw: "javanese",
|
|
||||||
kn: "kannada",
|
|
||||||
kk: "kazakh",
|
|
||||||
km: "khmer",
|
|
||||||
ko: "korean",
|
|
||||||
ku: "kurdish (kurmanji)",
|
|
||||||
ky: "kyrgyz",
|
|
||||||
lo: "lao",
|
|
||||||
la: "latin",
|
|
||||||
lv: "latvian",
|
|
||||||
lt: "lithuanian",
|
|
||||||
lb: "luxembourgish",
|
|
||||||
mk: "macedonian",
|
|
||||||
mg: "malagasy",
|
|
||||||
ms: "malay",
|
|
||||||
ml: "malayalam",
|
|
||||||
mt: "maltese",
|
|
||||||
mi: "maori",
|
|
||||||
mr: "marathi",
|
|
||||||
mn: "mongolian",
|
|
||||||
my: "myanmar (burmese)",
|
|
||||||
ne: "nepali",
|
|
||||||
no: "norwegian",
|
|
||||||
ps: "pashto",
|
|
||||||
fa: "persian",
|
|
||||||
pl: "polish",
|
|
||||||
pt: "portuguese",
|
|
||||||
pa: "punjabi",
|
|
||||||
ro: "romanian",
|
|
||||||
ru: "russian",
|
|
||||||
sm: "samoan",
|
|
||||||
gd: "scots gaelic",
|
|
||||||
sr: "serbian",
|
|
||||||
st: "sesotho",
|
|
||||||
sn: "shona",
|
|
||||||
sd: "sindhi",
|
|
||||||
si: "sinhala",
|
|
||||||
sk: "slovak",
|
|
||||||
sl: "slovenian",
|
|
||||||
so: "somali",
|
|
||||||
es: "spanish",
|
|
||||||
su: "sundanese",
|
|
||||||
sw: "swahili",
|
|
||||||
sv: "swedish",
|
|
||||||
tg: "tajik",
|
|
||||||
ta: "tamil",
|
|
||||||
te: "telugu",
|
|
||||||
th: "thai",
|
|
||||||
tr: "turkish",
|
|
||||||
uk: "ukrainian",
|
|
||||||
ur: "urdu",
|
|
||||||
uz: "uzbek",
|
|
||||||
vi: "vietnamese",
|
|
||||||
cy: "welsh",
|
|
||||||
xh: "xhosa",
|
|
||||||
yi: "yiddish",
|
|
||||||
yo: "yoruba",
|
|
||||||
zu: "zulu",
|
|
||||||
fil: "Filipino",
|
|
||||||
he: "Hebrew",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const colorOptions: SelectorOption<string>[] = [
|
|
||||||
{
|
|
||||||
label: "White",
|
|
||||||
value: "white",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Light Gray",
|
|
||||||
value: "light-gray",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Red",
|
|
||||||
value: "red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Green",
|
|
||||||
value: "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Yellow",
|
|
||||||
value: "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Blue",
|
|
||||||
value: "blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Magenta",
|
|
||||||
value: "magenta",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cyan",
|
|
||||||
value: "cyan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Black",
|
|
||||||
value: "black",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Red",
|
|
||||||
value: "dark-red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Green",
|
|
||||||
value: "dark-green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Yellow",
|
|
||||||
value: "dark-yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Blue",
|
|
||||||
value: "dark-blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Magenta",
|
|
||||||
value: "dark-magenta",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Cyan",
|
|
||||||
value: "dark-cyan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dark Grey",
|
|
||||||
value: "dark-grey",
|
|
||||||
},
|
|
||||||
];
|
|
|
@ -1,9 +0,0 @@
|
||||||
import { ModalComponent } from "@/modules/modals/WithModal";
|
|
||||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
export interface ToolOptions {
|
|
||||||
key: string;
|
|
||||||
icon: IconDefinition;
|
|
||||||
name: string;
|
|
||||||
modal?: ModalComponent<unknown>;
|
|
||||||
}
|
|
|
@ -1,77 +1,44 @@
|
||||||
import { useMemo } from "react";
|
import { useIsLoading } from "@/contexts";
|
||||||
import { Table } from "react-bootstrap";
|
import { usePageSize } from "@/utilities/storage";
|
||||||
import {
|
import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core";
|
||||||
HeaderGroup,
|
import { ReactNode, useMemo } from "react";
|
||||||
Row,
|
import { HeaderGroup, Row, TableInstance } from "react-table";
|
||||||
TableBodyProps,
|
|
||||||
TableOptions,
|
|
||||||
TableProps,
|
|
||||||
} from "react-table";
|
|
||||||
|
|
||||||
export interface BaseTableProps<T extends object> extends TableStyleProps<T> {
|
export type BaseTableProps<T extends object> = TableInstance<T> & {
|
||||||
// Table Options
|
tableStyles?: TableStyleProps<T>;
|
||||||
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 interface TableStyleProps<T extends object> {
|
export interface TableStyleProps<T extends object> {
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
responsive?: boolean;
|
|
||||||
hoverable?: boolean;
|
|
||||||
striped?: boolean;
|
striped?: boolean;
|
||||||
borderless?: boolean;
|
placeholder?: number;
|
||||||
small?: boolean;
|
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
fixHeader?: boolean;
|
||||||
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
|
headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
|
||||||
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
|
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExtractResult<T extends object> {
|
const useStyles = createStyles((theme) => {
|
||||||
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;
|
|
||||||
return {
|
return {
|
||||||
style: {
|
container: {
|
||||||
emptyText,
|
display: "block",
|
||||||
responsive,
|
maxWidth: "100%",
|
||||||
hoverable,
|
overflowX: "auto",
|
||||||
striped,
|
|
||||||
borderless,
|
|
||||||
small,
|
|
||||||
hideHeader,
|
|
||||||
headersRenderer,
|
|
||||||
rowRenderer,
|
|
||||||
},
|
},
|
||||||
options,
|
table: {
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
},
|
||||||
|
header: {},
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
function DefaultHeaderRenderer<T extends object>(
|
function DefaultHeaderRenderer<T extends object>(
|
||||||
headers: HeaderGroup<T>[]
|
headers: HeaderGroup<T>[]
|
||||||
): JSX.Element[] {
|
): JSX.Element[] {
|
||||||
return headers.map((col) => (
|
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 (
|
return (
|
||||||
<tr {...row.getRowProps()}>
|
<tr {...row.getRowProps()}>
|
||||||
{row.cells.map((cell) => (
|
{row.cells.map((cell) => (
|
||||||
<td className={cell.column.className} {...cell.getCellProps()}>
|
<td {...cell.getCellProps()}>{cell.render("Cell")}</td>
|
||||||
{cell.render("Cell")}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</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>) {
|
export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
|
||||||
const {
|
const {
|
||||||
emptyText,
|
headerGroups,
|
||||||
responsive,
|
|
||||||
hoverable,
|
|
||||||
striped,
|
|
||||||
borderless,
|
|
||||||
small,
|
|
||||||
hideHeader,
|
|
||||||
|
|
||||||
headers,
|
|
||||||
rows,
|
rows,
|
||||||
headersRenderer,
|
|
||||||
rowRenderer,
|
|
||||||
prepareRow,
|
prepareRow,
|
||||||
tableProps,
|
getTableProps,
|
||||||
tableBodyProps,
|
getTableBodyProps,
|
||||||
|
tableStyles,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer;
|
||||||
|
const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer;
|
||||||
|
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
const colCount = useMemo(() => {
|
const colCount = useMemo(() => {
|
||||||
return headers.reduce(
|
return headerGroups.reduce(
|
||||||
(prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
|
(prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}, [headers]);
|
}, [headerGroups]);
|
||||||
|
|
||||||
const empty = rows.length === 0;
|
const empty = rows.length === 0;
|
||||||
|
|
||||||
const hRenderer = headersRenderer ?? DefaultHeaderRenderer;
|
const [pageSize] = usePageSize();
|
||||||
const rRenderer = rowRenderer ?? DefaultRowRenderer;
|
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 (
|
return (
|
||||||
<Table
|
<Box className={classes.container}>
|
||||||
size={small ? "sm" : undefined}
|
<Table
|
||||||
striped={striped ?? true}
|
className={classes.table}
|
||||||
borderless={borderless ?? true}
|
striped={tableStyles?.striped ?? true}
|
||||||
hover={hoverable}
|
{...getTableProps()}
|
||||||
responsive={responsive ?? true}
|
>
|
||||||
{...tableProps}
|
<thead className={classes.header} hidden={tableStyles?.hideHeader}>
|
||||||
>
|
{headerGroups.map((headerGroup) => (
|
||||||
<thead hidden={hideHeader}>
|
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||||
{headers.map((headerGroup) => (
|
{headersRenderer(headerGroup.headers)}
|
||||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
</tr>
|
||||||
{hRenderer(headerGroup.headers)}
|
))}
|
||||||
</tr>
|
</thead>
|
||||||
))}
|
<tbody {...getTableBodyProps()}>{body}</tbody>
|
||||||
</thead>
|
</Table>
|
||||||
<tbody {...tableBodyProps}>
|
</Box>
|
||||||
{emptyText && empty ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={colCount} className="text-center">
|
|
||||||
{emptyText}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => {
|
|
||||||
prepareRow(row);
|
|
||||||
return rRenderer(row);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Box, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
HeaderGroup,
|
HeaderGroup,
|
||||||
Row,
|
Row,
|
||||||
TableOptions,
|
|
||||||
useExpanded,
|
useExpanded,
|
||||||
useGroupBy,
|
useGroupBy,
|
||||||
useSortBy,
|
useSortBy,
|
||||||
} from "react-table";
|
} from "react-table";
|
||||||
import { TableStyleProps } from "./BaseTable";
|
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||||
import SimpleTable from "./SimpleTable";
|
|
||||||
|
|
||||||
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
|
function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
|
||||||
if (cell.isGrouped) {
|
if (cell.isGrouped) {
|
||||||
return (
|
return (
|
||||||
<span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span>
|
<div {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</div>
|
||||||
);
|
);
|
||||||
} else if (row.canExpand || cell.isAggregated) {
|
} else if (row.canExpand || cell.isAggregated) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -31,22 +30,16 @@ function renderRow<T extends object>(row: Row<T>) {
|
||||||
const rotation = row.isExpanded ? 90 : undefined;
|
const rotation = row.isExpanded ? 90 : undefined;
|
||||||
return (
|
return (
|
||||||
<tr {...row.getRowProps()}>
|
<tr {...row.getRowProps()}>
|
||||||
<td
|
<td {...cell.getCellProps()} colSpan={row.cells.length}>
|
||||||
className="p-0"
|
<Text {...row.getToggleRowExpandedProps()} p={2}>
|
||||||
{...cell.getCellProps()}
|
|
||||||
colSpan={row.cells.length}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
{...row.getToggleRowExpandedProps()}
|
|
||||||
className="d-flex align-items-center p-2"
|
|
||||||
>
|
|
||||||
{cell.render("Cell")}
|
{cell.render("Cell")}
|
||||||
<FontAwesomeIcon
|
<Box component="span" mx={12}>
|
||||||
className="mx-2"
|
<FontAwesomeIcon
|
||||||
icon={faChevronCircleRight}
|
icon={faChevronCircleRight}
|
||||||
rotation={rotation}
|
rotation={rotation}
|
||||||
></FontAwesomeIcon>
|
></FontAwesomeIcon>
|
||||||
</span>
|
</Box>
|
||||||
|
</Text>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
@ -59,9 +52,7 @@ function renderRow<T extends object>(row: Row<T>) {
|
||||||
{row.cells
|
{row.cells
|
||||||
.filter((cell) => !cell.isPlaceholder)
|
.filter((cell) => !cell.isPlaceholder)
|
||||||
.map((cell) => (
|
.map((cell) => (
|
||||||
<td className={cell.column.className} {...cell.getCellProps()}>
|
<td {...cell.getCellProps()}>{renderCell(cell, row)}</td>
|
||||||
{renderCell(cell, row)}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
@ -76,16 +67,19 @@ function renderHeaders<T extends object>(
|
||||||
.map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>);
|
.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>) {
|
function GroupTable<T extends object = object>(props: Props<T>) {
|
||||||
const plugins = [useGroupBy, useSortBy, useExpanded];
|
|
||||||
return (
|
return (
|
||||||
<SimpleTable
|
<SimpleTable
|
||||||
{...props}
|
{...props}
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
headersRenderer={renderHeaders}
|
tableStyles={{ headersRenderer: renderHeaders, rowRenderer: renderRow }}
|
||||||
rowRenderer={renderRow}
|
|
||||||
></SimpleTable>
|
></SimpleTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
import { FunctionComponent, useMemo } from "react";
|
import { useIsLoading } from "@/contexts";
|
||||||
import { Col, Container, Pagination, Row } from "react-bootstrap";
|
import { Group, Pagination, Text } from "@mantine/core";
|
||||||
import { PageControlAction } from "./types";
|
import { FunctionComponent } from "react";
|
||||||
interface Props {
|
interface Props {
|
||||||
count: number;
|
count: number;
|
||||||
index: number;
|
index: number;
|
||||||
size: number;
|
size: number;
|
||||||
total: number;
|
total: number;
|
||||||
canPrevious: boolean;
|
|
||||||
previous: () => void;
|
|
||||||
canNext: boolean;
|
|
||||||
next: () => void;
|
|
||||||
goto: (idx: number) => void;
|
goto: (idx: number) => void;
|
||||||
loadState?: PageControlAction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageControl: FunctionComponent<Props> = ({
|
const PageControl: FunctionComponent<Props> = ({
|
||||||
|
@ -19,77 +14,28 @@ const PageControl: FunctionComponent<Props> = ({
|
||||||
index,
|
index,
|
||||||
size,
|
size,
|
||||||
total,
|
total,
|
||||||
canPrevious,
|
|
||||||
previous,
|
|
||||||
canNext,
|
|
||||||
next,
|
|
||||||
goto,
|
goto,
|
||||||
loadState,
|
|
||||||
}) => {
|
}) => {
|
||||||
const empty = total === 0;
|
const empty = total === 0;
|
||||||
const start = empty ? 0 : size * index + 1;
|
const start = empty ? 0 : size * index + 1;
|
||||||
const end = Math.min(size * (index + 1), total);
|
const end = Math.min(size * (index + 1), total);
|
||||||
|
|
||||||
const loading = loadState !== undefined;
|
const isLoading = useIsLoading();
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid className="mb-3">
|
<Group p={16} position="apart">
|
||||||
<Row>
|
<Text size="sm">
|
||||||
<Col className="d-flex align-items-center justify-content-start">
|
Show {start} to {end} of {total} entries
|
||||||
<span>
|
</Text>
|
||||||
Show {start} to {end} of {total} entries
|
<Pagination
|
||||||
</span>
|
size="sm"
|
||||||
</Col>
|
color={isLoading ? "gray" : "primary"}
|
||||||
<Col className="d-flex justify-content-end">
|
page={index + 1}
|
||||||
<Pagination className="m-0" hidden={count <= 1}>
|
onChange={(page) => goto(page - 1)}
|
||||||
<Pagination.Prev
|
hidden={count <= 1}
|
||||||
onClick={previous}
|
total={count}
|
||||||
disabled={!canPrevious || loading}
|
></Pagination>
|
||||||
></Pagination.Prev>
|
</Group>
|
||||||
{pageButtons}
|
|
||||||
<Pagination.Next
|
|
||||||
onClick={next}
|
|
||||||
disabled={!canNext || loading}
|
|
||||||
></Pagination.Next>
|
|
||||||
</Pagination>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,74 +1,44 @@
|
||||||
import { ScrollToTop } from "@/utilities";
|
import { ScrollToTop } from "@/utilities";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { PluginHook, TableOptions, usePagination, useTable } from "react-table";
|
import { TableInstance, usePagination } from "react-table";
|
||||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
|
||||||
import PageControl from "./PageControl";
|
import PageControl from "./PageControl";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import { useDefaultSettings } from "./plugins";
|
||||||
|
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
type Props<T extends object> = SimpleTableProps<T> & {
|
||||||
TableStyleProps<T> & {
|
autoScroll?: boolean;
|
||||||
autoScroll?: boolean;
|
};
|
||||||
plugins?: PluginHook<T>[];
|
|
||||||
};
|
const tablePlugins = [useDefaultSettings, usePagination];
|
||||||
|
|
||||||
export default function PageTable<T extends object>(props: Props<T>) {
|
export default function PageTable<T extends object>(props: Props<T>) {
|
||||||
const { autoScroll, plugins, ...remain } = props;
|
const { autoScroll, plugins, ...remain } = props;
|
||||||
const { style, options } = useStyleAndOptions(remain);
|
|
||||||
|
|
||||||
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
|
const instance = useRef<TableInstance<T> | null>(null);
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Scroll to top when page is changed
|
// Scroll to top when page is changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScroll) {
|
if (autoScroll) {
|
||||||
ScrollToTop();
|
ScrollToTop();
|
||||||
}
|
}
|
||||||
}, [pageIndex, autoScroll]);
|
}, [instance.current?.state.pageIndex, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseTable
|
<SimpleTable
|
||||||
{...style}
|
{...remain}
|
||||||
headers={headerGroups}
|
instanceRef={instance}
|
||||||
rows={page}
|
plugins={[...tablePlugins, ...(plugins ?? [])]}
|
||||||
prepareRow={prepareRow}
|
></SimpleTable>
|
||||||
tableProps={getTableProps()}
|
{instance.current && (
|
||||||
tableBodyProps={getTableBodyProps()}
|
<PageControl
|
||||||
></BaseTable>
|
count={instance.current.pageCount}
|
||||||
<PageControl
|
index={instance.current.state.pageIndex}
|
||||||
count={pageCount}
|
size={instance.current.state.pageSize}
|
||||||
index={pageIndex}
|
total={instance.current.rows.length}
|
||||||
size={pageSize}
|
goto={instance.current.gotoPage}
|
||||||
total={rows.length}
|
></PageControl>
|
||||||
canPrevious={canPreviousPage}
|
)}
|
||||||
canNext={canNextPage}
|
|
||||||
previous={previousPage}
|
|
||||||
next={nextPage}
|
|
||||||
goto={gotoPage}
|
|
||||||
></PageControl>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,77 +1,37 @@
|
||||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
||||||
|
import { LoadingProvider } from "@/contexts";
|
||||||
import { ScrollToTop } from "@/utilities";
|
import { ScrollToTop } from "@/utilities";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
|
||||||
import { LoadingIndicator } from "..";
|
|
||||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
|
||||||
import PageControl from "./PageControl";
|
import PageControl from "./PageControl";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import SimpleTable, { SimpleTableProps } from "./SimpleTable";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
|
||||||
TableStyleProps<T> & {
|
query: UsePaginationQueryResult<T>;
|
||||||
plugins?: PluginHook<T>[];
|
};
|
||||||
query: UsePaginationQueryResult<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
export default function QueryPageTable<T extends object>(props: Props<T>) {
|
||||||
const { plugins, query, ...remain } = props;
|
const { query, ...remain } = props;
|
||||||
const { style, options } = useStyleAndOptions(remain);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data = { data: [], total: 0 },
|
||||||
isLoading,
|
paginationStatus: { page, pageCount, totalCount, pageSize, isPageLoading },
|
||||||
paginationStatus: {
|
controls: { gotoPage },
|
||||||
page,
|
|
||||||
pageCount,
|
|
||||||
totalCount,
|
|
||||||
canPrevious,
|
|
||||||
canNext,
|
|
||||||
pageSize,
|
|
||||||
},
|
|
||||||
controls: { previousPage, nextPage, gotoPage },
|
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const instance = useTable(
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
data: data?.data ?? [],
|
|
||||||
},
|
|
||||||
useDefaultSettings,
|
|
||||||
...(plugins ?? [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
|
||||||
instance;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ScrollToTop();
|
ScrollToTop();
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingIndicator></LoadingIndicator>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<LoadingProvider value={isPageLoading}>
|
||||||
<BaseTable
|
<SimpleTable {...remain} data={data.data}></SimpleTable>
|
||||||
{...style}
|
|
||||||
headers={headerGroups}
|
|
||||||
rows={rows}
|
|
||||||
prepareRow={prepareRow}
|
|
||||||
tableProps={getTableProps()}
|
|
||||||
tableBodyProps={getTableBodyProps()}
|
|
||||||
></BaseTable>
|
|
||||||
<PageControl
|
<PageControl
|
||||||
count={pageCount}
|
count={pageCount}
|
||||||
index={page}
|
index={page}
|
||||||
size={pageSize}
|
size={pageSize}
|
||||||
total={totalCount}
|
total={totalCount}
|
||||||
canPrevious={canPrevious}
|
|
||||||
canNext={canNext}
|
|
||||||
previous={previousPage}
|
|
||||||
next={nextPage}
|
|
||||||
goto={gotoPage}
|
goto={gotoPage}
|
||||||
></PageControl>
|
></PageControl>
|
||||||
</>
|
</LoadingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,23 @@
|
||||||
import { PluginHook, TableOptions, useTable } from "react-table";
|
import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
|
||||||
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
|
import BaseTable, { TableStyleProps } from "./BaseTable";
|
||||||
import { useDefaultSettings } from "./plugins";
|
import { useDefaultSettings } from "./plugins";
|
||||||
|
|
||||||
type Props<T extends object> = TableOptions<T> &
|
export type SimpleTableProps<T extends object> = TableOptions<T> & {
|
||||||
TableStyleProps<T> & {
|
plugins?: PluginHook<T>[];
|
||||||
plugins?: PluginHook<T>[];
|
instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
|
||||||
};
|
tableStyles?: TableStyleProps<T>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function SimpleTable<T extends object>(props: Props<T>) {
|
export default function SimpleTable<T extends object>(
|
||||||
const { plugins, ...other } = props;
|
props: SimpleTableProps<T>
|
||||||
const { style, options } = useStyleAndOptions(other);
|
) {
|
||||||
|
const { plugins, instanceRef, tableStyles, ...options } = props;
|
||||||
|
|
||||||
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
|
||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
if (instanceRef) {
|
||||||
instance;
|
instanceRef.current = instance;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
|
||||||
<BaseTable
|
|
||||||
{...style}
|
|
||||||
headers={headerGroups}
|
|
||||||
rows={rows}
|
|
||||||
prepareRow={prepareRow}
|
|
||||||
tableProps={getTableProps()}
|
|
||||||
tableBodyProps={getTableBodyProps()}
|
|
||||||
></BaseTable>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { Checkbox as MantineCheckbox } from "@mantine/core";
|
||||||
import { forwardRef, useEffect, useRef } from "react";
|
import { forwardRef, useEffect, useRef } from "react";
|
||||||
import { Form } from "react-bootstrap";
|
|
||||||
import {
|
import {
|
||||||
CellProps,
|
CellProps,
|
||||||
Column,
|
Column,
|
||||||
|
@ -41,13 +41,12 @@ const Checkbox = forwardRef<
|
||||||
}, [resolvedRef, indeterminate, checked, disabled]);
|
}, [resolvedRef, indeterminate, checked, disabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Check
|
<MantineCheckbox
|
||||||
custom
|
key={idIn}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
id={idIn}
|
|
||||||
ref={resolvedRef}
|
ref={resolvedRef}
|
||||||
{...rest}
|
{...rest}
|
||||||
></Form.Check>
|
></MantineCheckbox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
export type PageControlAction = "prev" | "next" | number;
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Button, ButtonProps, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
FunctionComponent,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type ToolboxButtonProps = Omit<
|
||||||
|
ButtonProps<"button">,
|
||||||
|
"color" | "variant" | "leftIcon"
|
||||||
|
> & {
|
||||||
|
icon: IconDefinition;
|
||||||
|
children: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
color="dark"
|
||||||
|
variant="subtle"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text size="xs">{children}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolboxMutateButtonProps<R, T extends () => Promise<R>> = {
|
||||||
|
promise: T;
|
||||||
|
onSuccess?: (item: R) => void;
|
||||||
|
} & Omit<ToolboxButtonProps, "onClick" | "loading">;
|
||||||
|
|
||||||
|
export function ToolboxMutateButton<R, T extends () => Promise<R>>(
|
||||||
|
props: PropsWithChildren<ToolboxMutateButtonProps<R, T>>
|
||||||
|
): JSX.Element {
|
||||||
|
const { promise, onSuccess, ...button } = props;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const click = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
promise().then((val) => {
|
||||||
|
setLoading(false);
|
||||||
|
onSuccess && onSuccess(val);
|
||||||
|
});
|
||||||
|
}, [onSuccess, promise]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolboxButton
|
||||||
|
loading={loading}
|
||||||
|
onClick={click}
|
||||||
|
{...button}
|
||||||
|
></ToolboxButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ToolboxButton;
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { createStyles, Group } from "@mantine/core";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import ToolboxButton, { ToolboxMutateButton } from "./Button";
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
group: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "light"
|
||||||
|
? theme.colors.gray[3]
|
||||||
|
: theme.colors.dark[5],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
declare type ToolboxComp = FunctionComponent & {
|
||||||
|
Button: typeof ToolboxButton;
|
||||||
|
MutateButton: typeof ToolboxMutateButton;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Toolbox: ToolboxComp = ({ children }) => {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
return (
|
||||||
|
<Group p={12} position="apart" className={classes.group}>
|
||||||
|
{children}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Toolbox.Button = ToolboxButton;
|
||||||
|
Toolbox.MutateButton = ToolboxMutateButton;
|
||||||
|
|
||||||
|
export default Toolbox;
|
|
@ -1,35 +0,0 @@
|
||||||
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
|
|
||||||
import { Container, Row } from "react-bootstrap";
|
|
||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { Column } from "react-table";
|
|
||||||
import { QueryPageTable } from "..";
|
|
||||||
|
|
||||||
interface Props<T extends History.Base> {
|
|
||||||
name: string;
|
|
||||||
query: UsePaginationQueryResult<T>;
|
|
||||||
columns: Column<T>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function HistoryView<T extends History.Base = History.Base>({
|
|
||||||
columns,
|
|
||||||
name,
|
|
||||||
query,
|
|
||||||
}: Props<T>) {
|
|
||||||
return (
|
|
||||||
<Container fluid>
|
|
||||||
<Helmet>
|
|
||||||
<title>{name} History - Bazarr</title>
|
|
||||||
</Helmet>
|
|
||||||
<Row>
|
|
||||||
<QueryPageTable
|
|
||||||
emptyText={`Nothing Found in ${name} History`}
|
|
||||||
columns={columns}
|
|
||||||
query={query}
|
|
||||||
data={[]}
|
|
||||||
></QueryPageTable>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HistoryView;
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { MantineNumberSize } from "@mantine/core";
|
||||||
|
|
||||||
|
export const GithubRepoRoot = "https://github.com/morpheus65535/bazarr";
|
||||||
|
|
||||||
|
export const Layout = {
|
||||||
|
NAVBAR_WIDTH: 200,
|
||||||
|
HEADER_HEIGHT: 64,
|
||||||
|
MOBILE_BREAKPOINT: "sm" as MantineNumberSize,
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const LoadingContext = createContext<boolean>(false);
|
||||||
|
|
||||||
|
export function useIsLoading() {
|
||||||
|
const context = useContext(LoadingContext);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingContext.Provider;
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const NavbarContext = createContext<{
|
||||||
|
showed: boolean;
|
||||||
|
show: (showed: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export function useNavbar() {
|
||||||
|
const context = useContext(NavbarContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("NavbarShowedContext not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavbarContext.Provider;
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const OnlineContext = createContext<{
|
||||||
|
online: boolean;
|
||||||
|
setOnline: (online: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export function useIsOnline() {
|
||||||
|
const context = useContext(OnlineContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useIsOnline must be used within a OnlineProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.online;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetOnline() {
|
||||||
|
const context = useContext(OnlineContext);
|
||||||
|
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useSetOnline must be used within a OnlineProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.setOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnlineContext.Provider;
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./Loading";
|
||||||
|
export { default as LoadingProvider } from "./Loading";
|
|
@ -1,4 +1,10 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { Entrance } from ".";
|
import { Main } from "./main";
|
||||||
|
|
||||||
ReactDOM.render(<Entrance />, document.getElementById("root"));
|
ReactDOM.render(
|
||||||
|
<StrictMode>
|
||||||
|
<Main />
|
||||||
|
</StrictMode>,
|
||||||
|
document.getElementById("root")
|
||||||
|
);
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import queryClient from "@/apis/queries";
|
|
||||||
import store from "@/modules/redux/store";
|
|
||||||
import "@/styles/index.scss";
|
|
||||||
import "@fontsource/roboto/300.css";
|
|
||||||
import { QueryClientProvider } from "react-query";
|
|
||||||
import { ReactQueryDevtools } from "react-query/devtools";
|
|
||||||
import { Provider } from "react-redux";
|
|
||||||
import { useRoutes } from "react-router-dom";
|
|
||||||
import { Router, useRouteItems } from "./Router";
|
|
||||||
import { Environment } from "./utilities";
|
|
||||||
|
|
||||||
const RouteApp = () => {
|
|
||||||
const items = useRouteItems();
|
|
||||||
|
|
||||||
return useRoutes(items);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Entrance = () => (
|
|
||||||
<Provider store={store}>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Router>
|
|
||||||
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
|
|
||||||
{/* <StrictMode> */}
|
|
||||||
{Environment.queryDev && <ReactQueryDevtools initialIsOpen={false} />}
|
|
||||||
<RouteApp></RouteApp>
|
|
||||||
{/* </StrictMode> */}
|
|
||||||
</Router>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</Provider>
|
|
||||||
);
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import queryClient from "@/apis/queries";
|
||||||
|
import ThemeProvider from "@/App/theme";
|
||||||
|
import { ModalsProvider } from "@/modules/modals";
|
||||||
|
import "@fontsource/roboto/300.css";
|
||||||
|
import { NotificationsProvider } from "@mantine/notifications";
|
||||||
|
import { QueryClientProvider } from "react-query";
|
||||||
|
import { ReactQueryDevtools } from "react-query/devtools";
|
||||||
|
import { useRoutes } from "react-router-dom";
|
||||||
|
import { Router, useRouteItems } from "./Router";
|
||||||
|
import { Environment } from "./utilities";
|
||||||
|
|
||||||
|
const RouteApp = () => {
|
||||||
|
const items = useRouteItems();
|
||||||
|
|
||||||
|
return useRoutes(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Main = () => {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ModalsProvider>
|
||||||
|
<NotificationsProvider limit={5}>
|
||||||
|
<Router>
|
||||||
|
{Environment.queryDev && (
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
)}
|
||||||
|
<RouteApp></RouteApp>
|
||||||
|
</Router>
|
||||||
|
</NotificationsProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,14 +0,0 @@
|
||||||
import { createContext, Dispatch, SetStateAction } from "react";
|
|
||||||
|
|
||||||
export interface ModalData {
|
|
||||||
key: string;
|
|
||||||
closeable: boolean;
|
|
||||||
size: "sm" | "lg" | "xl" | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModalSetter = {
|
|
||||||
[P in keyof Omit<ModalData, "key">]: Dispatch<SetStateAction<ModalData[P]>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ModalDataContext = createContext<ModalData | null>(null);
|
|
||||||
export const ModalSetterContext = createContext<ModalSetter | null>(null);
|
|
|
@ -1,44 +0,0 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import { FunctionComponent, useCallback, useState } from "react";
|
|
||||||
import { Modal } from "react-bootstrap";
|
|
||||||
import { useCurrentLayer, useModalControl, useModalData } from "./hooks";
|
|
||||||
|
|
||||||
interface Props {}
|
|
||||||
|
|
||||||
export const ModalWrapper: FunctionComponent<Props> = ({ children }) => {
|
|
||||||
const { size, closeable, key } = useModalData();
|
|
||||||
const [needExit, setExit] = useState(false);
|
|
||||||
|
|
||||||
const { hide: hideModal } = useModalControl();
|
|
||||||
|
|
||||||
const layer = useCurrentLayer();
|
|
||||||
const isShowed = layer !== -1;
|
|
||||||
|
|
||||||
const hide = useCallback(() => {
|
|
||||||
setExit(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const exit = useCallback(() => {
|
|
||||||
if (isShowed) {
|
|
||||||
hideModal(key);
|
|
||||||
}
|
|
||||||
setExit(false);
|
|
||||||
}, [isShowed, hideModal, key]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
centered
|
|
||||||
size={size}
|
|
||||||
show={isShowed && !needExit}
|
|
||||||
onHide={hide}
|
|
||||||
onExited={exit}
|
|
||||||
backdrop={closeable ? undefined : "static"}
|
|
||||||
className={clsx(`index-${layer}`)}
|
|
||||||
backdropClassName={clsx(`index-${layer}`)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalWrapper;
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {
|
||||||
|
ModalsProvider as MantineModalsProvider,
|
||||||
|
ModalsProviderProps as MantineModalsProviderProps,
|
||||||
|
} from "@mantine/modals";
|
||||||
|
import { FunctionComponent, useMemo } from "react";
|
||||||
|
import { ModalComponent, StaticModals } from "./WithModal";
|
||||||
|
|
||||||
|
const DefaultModalProps: MantineModalsProviderProps["modalProps"] = {
|
||||||
|
centered: true,
|
||||||
|
styles: {
|
||||||
|
modal: {
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModalsProvider: FunctionComponent = ({ children }) => {
|
||||||
|
const modals = useMemo(
|
||||||
|
() =>
|
||||||
|
StaticModals.reduce<Record<string, ModalComponent>>((prev, curr) => {
|
||||||
|
prev[curr.modalKey] = curr;
|
||||||
|
return prev;
|
||||||
|
}, {}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineModalsProvider modalProps={DefaultModalProps} modals={modals}>
|
||||||
|
{children}
|
||||||
|
</MantineModalsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalsProvider;
|
|
@ -1,52 +1,36 @@
|
||||||
import { FunctionComponent, useMemo, useState } from "react";
|
/* eslint-disable @typescript-eslint/ban-types */
|
||||||
import {
|
|
||||||
ModalData,
|
|
||||||
ModalDataContext,
|
|
||||||
ModalSetter,
|
|
||||||
ModalSetterContext,
|
|
||||||
} from "./ModalContext";
|
|
||||||
import ModalWrapper from "./ModalWrapper";
|
|
||||||
|
|
||||||
export interface ModalProps {}
|
import { ContextModalProps } from "@mantine/modals";
|
||||||
|
import { ModalSettings } from "@mantine/modals/lib/context";
|
||||||
|
import { createContext, FunctionComponent } from "react";
|
||||||
|
|
||||||
export type ModalComponent<P> = FunctionComponent<P> & {
|
export type ModalComponent<P extends Record<string, unknown> = {}> =
|
||||||
modalKey: string;
|
FunctionComponent<ContextModalProps<P>> & {
|
||||||
};
|
modalKey: string;
|
||||||
|
settings?: ModalSettings;
|
||||||
|
};
|
||||||
|
|
||||||
export default function withModal<T>(
|
export const StaticModals: ModalComponent[] = [];
|
||||||
|
|
||||||
|
export const ModalIdContext = createContext<string | null>(null);
|
||||||
|
|
||||||
|
export default function withModal<T extends {}>(
|
||||||
Content: FunctionComponent<T>,
|
Content: FunctionComponent<T>,
|
||||||
key: string
|
key: string,
|
||||||
|
defaultSettings?: ModalSettings
|
||||||
) {
|
) {
|
||||||
const Comp: ModalComponent<T> = (props: ModalProps & T) => {
|
const Comp: ModalComponent<T> = (props) => {
|
||||||
const [closeable, setCloseable] = useState(true);
|
const { id, innerProps } = props;
|
||||||
const [size, setSize] = useState<ModalData["size"]>(undefined);
|
|
||||||
const data: ModalData = useMemo(
|
|
||||||
() => ({
|
|
||||||
key,
|
|
||||||
size,
|
|
||||||
closeable,
|
|
||||||
}),
|
|
||||||
[closeable, size]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setter: ModalSetter = useMemo(
|
|
||||||
() => ({
|
|
||||||
closeable: setCloseable,
|
|
||||||
size: setSize,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDataContext.Provider value={data}>
|
<ModalIdContext.Provider value={id}>
|
||||||
<ModalSetterContext.Provider value={setter}>
|
<Content {...innerProps}></Content>
|
||||||
<ModalWrapper>
|
</ModalIdContext.Provider>
|
||||||
<Content {...props}></Content>
|
|
||||||
</ModalWrapper>
|
|
||||||
</ModalSetterContext.Provider>
|
|
||||||
</ModalDataContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Comp.modalKey = key;
|
Comp.modalKey = key;
|
||||||
|
Comp.settings = defaultSettings;
|
||||||
|
|
||||||
|
StaticModals.push(Comp as ModalComponent);
|
||||||
return Comp;
|
return Comp;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { FunctionComponent, ReactNode } from "react";
|
|
||||||
import { Modal } from "react-bootstrap";
|
|
||||||
import { useModalData } from "./hooks";
|
|
||||||
|
|
||||||
interface StandardModalProps {
|
|
||||||
title: string;
|
|
||||||
footer?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StandardModalView: FunctionComponent<StandardModalProps> = ({
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
title,
|
|
||||||
}) => {
|
|
||||||
const { closeable } = useModalData();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal.Header closeButton={closeable}>{title}</Modal.Header>
|
|
||||||
<Modal.Body>{children}</Modal.Body>
|
|
||||||
<Modal.Footer hidden={footer === undefined}>{footer}</Modal.Footer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,90 +1,46 @@
|
||||||
import {
|
/* eslint-disable @typescript-eslint/ban-types */
|
||||||
hideModalAction,
|
import { useModals as useMantineModals } from "@mantine/modals";
|
||||||
showModalAction,
|
import { ModalSettings } from "@mantine/modals/lib/context";
|
||||||
} from "@/modules/redux/actions/modal";
|
import { useCallback, useContext, useMemo } from "react";
|
||||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
import { ModalComponent, ModalIdContext } from "./WithModal";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { StandardModalView } from "./components";
|
|
||||||
import {
|
|
||||||
ModalData,
|
|
||||||
ModalDataContext,
|
|
||||||
ModalSetterContext,
|
|
||||||
} from "./ModalContext";
|
|
||||||
import { ModalComponent } from "./WithModal";
|
|
||||||
|
|
||||||
type ModalProps = Partial<Omit<ModalData, "key">> & {
|
export function useModals() {
|
||||||
onMounted?: () => void;
|
const { openContextModal: openMantineContextModal, ...rest } =
|
||||||
};
|
useMantineModals();
|
||||||
|
|
||||||
export function useModal(props?: ModalProps): typeof StandardModalView {
|
const openContextModal = useCallback(
|
||||||
const setter = useContext(ModalSetterContext);
|
<ARGS extends {}>(
|
||||||
|
modal: ModalComponent<ARGS>,
|
||||||
useEffect(() => {
|
props: ARGS,
|
||||||
if (setter && props) {
|
settings?: ModalSettings
|
||||||
setter.closeable(props.closeable ?? true);
|
) => {
|
||||||
setter.size(props.size);
|
openMantineContextModal(modal.modalKey, {
|
||||||
}
|
...modal.settings,
|
||||||
}, [props, setter]);
|
...settings,
|
||||||
|
innerProps: props,
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
[showAction]
|
[openMantineContextModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const hideAction = useReduxAction(hideModalAction);
|
const closeContextModal = useCallback(
|
||||||
|
(modal: ModalComponent) => {
|
||||||
const hide = useCallback(
|
rest.closeModal(modal.modalKey);
|
||||||
(key?: string) => {
|
|
||||||
hideAction(key);
|
|
||||||
},
|
},
|
||||||
[hideAction]
|
[rest]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { show, hide };
|
const id = useContext(ModalIdContext);
|
||||||
}
|
|
||||||
|
|
||||||
export function useModalData(): ModalData {
|
const closeSelf = useCallback(() => {
|
||||||
const data = useContext(ModalDataContext);
|
if (id) {
|
||||||
|
rest.closeModal(id);
|
||||||
if (data === null) {
|
}
|
||||||
throw new Error("useModalData should be used inside Modal");
|
}, [id, rest]);
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePayload<T>(): T | null {
|
|
||||||
const { key } = useModalData();
|
|
||||||
const stack = useReduxStore((s) => s.modal.stack);
|
|
||||||
|
|
||||||
|
// TODO: Performance
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (stack.find((m) => m.key === key)?.payload as T) ?? null,
|
() => ({ openContextModal, closeContextModal, closeSelf, ...rest }),
|
||||||
[stack, key]
|
[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…
Reference in New Issue