aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/src
diff options
context:
space:
mode:
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>2024-09-15 19:26:21 +0000
committerGitHub <[email protected]>2024-09-15 19:26:21 +0000
commitad80ac44537ead820808af481cd549104047b111 (patch)
tree8d8ef249443a1da1f874f2d7a3b2a80ac4d505f4 /frontend/src
parent97ea3a81242f604007fcd54c54f4acf17a97fade (diff)
parentff54bc83a9dbf07e83b563cd01d52acb165441c7 (diff)
downloadbazarr-ad80ac44537ead820808af481cd549104047b111.tar.gz
bazarr-ad80ac44537ead820808af481cd549104047b111.zip
Merge development into master
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App/Header.module.scss9
-rw-r--r--frontend/src/App/Header.tsx96
-rw-r--r--frontend/src/App/Navbar.module.scss56
-rw-r--r--frontend/src/App/Navbar.tsx166
-rw-r--r--frontend/src/App/ThemeLoader.tsx39
-rw-r--r--frontend/src/App/ThemeProvider.tsx61
-rw-r--r--frontend/src/App/app.test.tsx2
-rw-r--r--frontend/src/App/index.tsx30
-rw-r--r--frontend/src/App/theme.tsx87
-rw-r--r--frontend/src/Router/Redirector.tsx4
-rw-r--r--frontend/src/Router/index.tsx42
-rw-r--r--frontend/src/Router/type.d.ts2
-rw-r--r--frontend/src/apis/hooks/episodes.ts117
-rw-r--r--frontend/src/apis/hooks/histories.ts19
-rw-r--r--frontend/src/apis/hooks/languages.ts30
-rw-r--r--frontend/src/apis/hooks/movies.ts156
-rw-r--r--frontend/src/apis/hooks/providers.ts109
-rw-r--r--frontend/src/apis/hooks/series.ts97
-rw-r--r--frontend/src/apis/hooks/status.ts22
-rw-r--r--frontend/src/apis/hooks/subtitles.ts192
-rw-r--r--frontend/src/apis/hooks/system.ts340
-rw-r--r--frontend/src/apis/queries/hooks.ts40
-rw-r--r--frontend/src/apis/queries/index.ts8
-rw-r--r--frontend/src/apis/raw/client.ts6
-rw-r--r--frontend/src/assets/_bazarr.scss82
-rw-r--r--frontend/src/assets/_mantine.scss61
-rw-r--r--frontend/src/assets/_variables.module.scss18
-rw-r--r--frontend/src/assets/action_icon.module.scss16
-rw-r--r--frontend/src/assets/badge.module.scss54
-rw-r--r--frontend/src/assets/button.module.scss18
-rw-r--r--frontend/src/assets/pagination.module.scss3
-rw-r--r--frontend/src/components/ErrorBoundary.tsx2
-rw-r--r--frontend/src/components/Search.tsx74
-rw-r--r--frontend/src/components/StateIcon.tsx20
-rw-r--r--frontend/src/components/SubtitleToolsMenu.tsx28
-rw-r--r--frontend/src/components/TextPopover.tsx9
-rw-r--r--frontend/src/components/async/Lazy.tsx2
-rw-r--r--frontend/src/components/async/MutateAction.tsx7
-rw-r--r--frontend/src/components/async/MutateButton.tsx5
-rw-r--r--frontend/src/components/async/QueryOverlay.tsx8
-rw-r--r--frontend/src/components/bazarr/AudioList.tsx9
-rw-r--r--frontend/src/components/bazarr/HistoryIcon.tsx4
-rw-r--r--frontend/src/components/bazarr/Language.test.tsx18
-rw-r--r--frontend/src/components/bazarr/Language.tsx6
-rw-r--r--frontend/src/components/bazarr/LanguageProfile.tsx2
-rw-r--r--frontend/src/components/bazarr/LanguageSelector.tsx2
-rw-r--r--frontend/src/components/forms/ColorToolForm.tsx6
-rw-r--r--frontend/src/components/forms/FrameRateForm.tsx14
-rw-r--r--frontend/src/components/forms/ItemEditForm.tsx14
-rw-r--r--frontend/src/components/forms/MovieUploadForm.tsx187
-rw-r--r--frontend/src/components/forms/ProfileEditForm.module.scss13
-rw-r--r--frontend/src/components/forms/ProfileEditForm.tsx199
-rw-r--r--frontend/src/components/forms/SeriesUploadForm.tsx203
-rw-r--r--frontend/src/components/forms/SyncSubtitleForm.tsx73
-rw-r--r--frontend/src/components/forms/TimeOffsetForm.tsx13
-rw-r--r--frontend/src/components/forms/TranslationForm.tsx10
-rw-r--r--frontend/src/components/index.tsx2
-rw-r--r--frontend/src/components/inputs/Action.test.tsx8
-rw-r--r--frontend/src/components/inputs/Action.tsx12
-rw-r--r--frontend/src/components/inputs/ChipInput.test.tsx10
-rw-r--r--frontend/src/components/inputs/ChipInput.tsx50
-rw-r--r--frontend/src/components/inputs/DropContent.module.scss4
-rw-r--r--frontend/src/components/inputs/DropContent.tsx24
-rw-r--r--frontend/src/components/inputs/FileBrowser.tsx31
-rw-r--r--frontend/src/components/inputs/Selector.test.tsx35
-rw-r--r--frontend/src/components/inputs/Selector.tsx45
-rw-r--r--frontend/src/components/modals/HistoryModal.tsx203
-rw-r--r--frontend/src/components/modals/ManualSearchModal.tsx188
-rw-r--r--frontend/src/components/modals/SubtitleToolsModal.tsx75
-rw-r--r--frontend/src/components/tables/BaseTable.module.scss9
-rw-r--r--frontend/src/components/tables/BaseTable.tsx131
-rw-r--r--frontend/src/components/tables/GroupTable.tsx86
-rw-r--r--frontend/src/components/tables/PageControl.tsx7
-rw-r--r--frontend/src/components/tables/PageTable.tsx63
-rw-r--r--frontend/src/components/tables/QueryPageTable.tsx4
-rw-r--r--frontend/src/components/tables/SimpleTable.tsx60
-rw-r--r--frontend/src/components/tables/plugins/index.ts2
-rw-r--r--frontend/src/components/tables/plugins/useCustomSelection.tsx113
-rw-r--r--frontend/src/components/tables/plugins/useDefaultSettings.tsx33
-rw-r--r--frontend/src/components/toolbox/Button.tsx8
-rw-r--r--frontend/src/components/toolbox/Toolbox.module.scss9
-rw-r--r--frontend/src/components/toolbox/Toolbox.tsx (renamed from frontend/src/components/toolbox/index.tsx)15
-rw-r--r--frontend/src/constants.ts8
-rw-r--r--frontend/src/dom.tsx2
-rw-r--r--frontend/src/modules/modals/ModalsProvider.tsx2
-rw-r--r--frontend/src/modules/modals/WithModal.tsx2
-rw-r--r--frontend/src/modules/modals/hooks.ts15
-rw-r--r--frontend/src/modules/socketio/hooks.ts2
-rw-r--r--frontend/src/modules/socketio/index.ts8
-rw-r--r--frontend/src/modules/socketio/reducer.ts97
-rw-r--r--frontend/src/modules/task/index.ts4
-rw-r--r--frontend/src/modules/task/notification.ts14
-rw-r--r--frontend/src/pages/Authentication.test.tsx2
-rw-r--r--frontend/src/pages/Authentication.tsx8
-rw-r--r--frontend/src/pages/Blacklist/Movies/index.tsx8
-rw-r--r--frontend/src/pages/Blacklist/Movies/table.tsx86
-rw-r--r--frontend/src/pages/Blacklist/Series/index.tsx8
-rw-r--r--frontend/src/pages/Blacklist/Series/table.tsx94
-rw-r--r--frontend/src/pages/Episodes/components.tsx19
-rw-r--r--frontend/src/pages/Episodes/index.tsx85
-rw-r--r--frontend/src/pages/Episodes/table.tsx443
-rw-r--r--frontend/src/pages/History/Movies/index.tsx129
-rw-r--r--frontend/src/pages/History/Series/index.tsx144
-rw-r--r--frontend/src/pages/History/Statistics/HistoryStats.module.scss9
-rw-r--r--frontend/src/pages/History/Statistics/HistoryStats.tsx (renamed from frontend/src/pages/History/Statistics/index.tsx)59
-rw-r--r--frontend/src/pages/History/history.test.tsx2
-rw-r--r--frontend/src/pages/Movies/Details/index.tsx49
-rw-r--r--frontend/src/pages/Movies/Details/table.tsx231
-rw-r--r--frontend/src/pages/Movies/Editor.tsx64
-rw-r--r--frontend/src/pages/Movies/index.tsx103
-rw-r--r--frontend/src/pages/Movies/movies.test.tsx4
-rw-r--r--frontend/src/pages/Series/Editor.tsx60
-rw-r--r--frontend/src/pages/Series/index.tsx115
-rw-r--r--frontend/src/pages/Series/series.test.tsx4
-rw-r--r--frontend/src/pages/Settings/General/index.tsx35
-rw-r--r--frontend/src/pages/Settings/Languages/components.tsx11
-rw-r--r--frontend/src/pages/Settings/Languages/equals.test.ts2
-rw-r--r--frontend/src/pages/Settings/Languages/equals.tsx65
-rw-r--r--frontend/src/pages/Settings/Languages/index.tsx56
-rw-r--r--frontend/src/pages/Settings/Languages/table.tsx87
-rw-r--r--frontend/src/pages/Settings/Notifications/components.tsx31
-rw-r--r--frontend/src/pages/Settings/Notifications/index.tsx13
-rw-r--r--frontend/src/pages/Settings/Providers/components.tsx69
-rw-r--r--frontend/src/pages/Settings/Providers/index.tsx6
-rw-r--r--frontend/src/pages/Settings/Providers/list.ts55
-rw-r--r--frontend/src/pages/Settings/Radarr/index.tsx13
-rw-r--r--frontend/src/pages/Settings/Scheduler/index.tsx4
-rw-r--r--frontend/src/pages/Settings/Sonarr/index.tsx15
-rw-r--r--frontend/src/pages/Settings/Subtitles/index.tsx56
-rw-r--r--frontend/src/pages/Settings/Subtitles/options.ts2
-rw-r--r--frontend/src/pages/Settings/UI/index.tsx4
-rw-r--r--frontend/src/pages/Settings/components/Card.module.scss9
-rw-r--r--frontend/src/pages/Settings/components/Card.tsx50
-rw-r--r--frontend/src/pages/Settings/components/Layout.test.tsx2
-rw-r--r--frontend/src/pages/Settings/components/Layout.tsx22
-rw-r--r--frontend/src/pages/Settings/components/LayoutModal.tsx22
-rw-r--r--frontend/src/pages/Settings/components/Message.tsx4
-rw-r--r--frontend/src/pages/Settings/components/Section.test.tsx8
-rw-r--r--frontend/src/pages/Settings/components/Section.tsx4
-rw-r--r--frontend/src/pages/Settings/components/collapse.tsx6
-rw-r--r--frontend/src/pages/Settings/components/forms.test.tsx12
-rw-r--r--frontend/src/pages/Settings/components/forms.tsx59
-rw-r--r--frontend/src/pages/Settings/components/index.tsx10
-rw-r--r--frontend/src/pages/Settings/components/pathMapper.tsx43
-rw-r--r--frontend/src/pages/Settings/utilities/FormValues.ts4
-rw-r--r--frontend/src/pages/Settings/utilities/hooks.ts8
-rw-r--r--frontend/src/pages/System/Announcements/index.tsx6
-rw-r--r--frontend/src/pages/System/Announcements/table.tsx78
-rw-r--r--frontend/src/pages/System/Backups/index.tsx10
-rw-r--r--frontend/src/pages/System/Backups/table.tsx100
-rw-r--r--frontend/src/pages/System/Logs/index.tsx28
-rw-r--r--frontend/src/pages/System/Logs/modal.tsx4
-rw-r--r--frontend/src/pages/System/Logs/table.tsx44
-rw-r--r--frontend/src/pages/System/Providers/index.tsx10
-rw-r--r--frontend/src/pages/System/Providers/table.tsx20
-rw-r--r--frontend/src/pages/System/Releases/index.tsx14
-rw-r--r--frontend/src/pages/System/Status/index.tsx43
-rw-r--r--frontend/src/pages/System/Status/table.tsx37
-rw-r--r--frontend/src/pages/System/Tasks/index.tsx8
-rw-r--r--frontend/src/pages/System/Tasks/table.tsx69
-rw-r--r--frontend/src/pages/Wanted/Movies/index.tsx55
-rw-r--r--frontend/src/pages/Wanted/Series/index.tsx71
-rw-r--r--frontend/src/pages/errors/CriticalError.tsx6
-rw-r--r--frontend/src/pages/errors/NotFound.tsx4
-rw-r--r--frontend/src/pages/errors/UIError.tsx16
-rw-r--r--frontend/src/pages/views/HistoryView.tsx8
-rw-r--r--frontend/src/pages/views/ItemOverview.tsx116
-rw-r--r--frontend/src/pages/views/ItemView.tsx8
-rw-r--r--frontend/src/pages/views/MassEditor.tsx110
-rw-r--r--frontend/src/pages/views/WantedView.tsx10
-rw-r--r--frontend/src/providers.tsx12
-rw-r--r--frontend/src/styles/index.ts1
-rw-r--r--frontend/src/styles/table.ts19
-rw-r--r--frontend/src/tests/index.tsx4
-rw-r--r--frontend/src/tests/setup.ts2
-rw-r--r--frontend/src/types/api.d.ts1
-rw-r--r--frontend/src/types/react-table.d.ts2
-rw-r--r--frontend/src/types/settings.d.ts1
-rw-r--r--frontend/src/utilities/hooks.ts4
-rw-r--r--frontend/src/utilities/index.test.ts25
-rw-r--r--frontend/src/utilities/index.ts6
-rw-r--r--frontend/src/utilities/languages.ts6
-rw-r--r--frontend/src/utilities/storage.ts2
-rw-r--r--frontend/src/utilities/time.test.ts2
-rw-r--r--frontend/src/utilities/time.ts33
-rw-r--r--frontend/src/utilities/validate.ts2
186 files changed, 4379 insertions, 3422 deletions
diff --git a/frontend/src/App/Header.module.scss b/frontend/src/App/Header.module.scss
new file mode 100644
index 000000000..85b3661a9
--- /dev/null
+++ b/frontend/src/App/Header.module.scss
@@ -0,0 +1,9 @@
+.header {
+ @include light {
+ color: var(--mantine-color-gray-0);
+ }
+
+ @include dark {
+ color: var(--mantine-color-dark-0);
+ }
+}
diff --git a/frontend/src/App/Header.tsx b/frontend/src/App/Header.tsx
index c15071045..29c1d1a8d 100644
--- a/frontend/src/App/Header.tsx
+++ b/frontend/src/App/Header.tsx
@@ -1,38 +1,26 @@
-import { useSystem, useSystemSettings } from "@/apis/hooks";
-import { Action, Search } from "@/components";
-import { Layout } from "@/constants";
-import { useNavbar } from "@/contexts/Navbar";
-import { useIsOnline } from "@/contexts/Online";
-import { Environment, useGotoHomepage } from "@/utilities";
-import {
- faArrowRotateLeft,
- faGear,
- faPowerOff,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { FunctionComponent } from "react";
import {
Anchor,
+ AppShell,
Avatar,
Badge,
Burger,
Divider,
Group,
- Header,
- MediaQuery,
Menu,
- createStyles,
} from "@mantine/core";
-import { FunctionComponent } from "react";
-
-const useStyles = createStyles((theme) => {
- const headerBackgroundColor =
- theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
- return {
- header: {
- backgroundColor: headerBackgroundColor,
- },
- };
-});
+import {
+ faArrowRotateLeft,
+ faGear,
+ faPowerOff,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useSystem, useSystemSettings } from "@/apis/hooks";
+import { Action, Search } from "@/components";
+import { useNavbar } from "@/contexts/Navbar";
+import { useIsOnline } from "@/contexts/Online";
+import { Environment, useGotoHomepage } from "@/utilities";
+import styles from "./Header.module.scss";
const AppHeader: FunctionComponent = () => {
const { data: settings } = useSystemSettings();
@@ -47,39 +35,28 @@ const AppHeader: FunctionComponent = () => {
const goHome = useGotoHomepage();
- const { classes } = useStyles();
-
return (
- <Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
- <Group position="apart" noWrap>
- <Group noWrap>
- <MediaQuery
- smallerThan={Layout.MOBILE_BREAKPOINT}
- styles={{ display: "none" }}
- >
- <Anchor onClick={goHome}>
- <Avatar
- alt="brand"
- size={32}
- src={`${Environment.baseUrl}/images/logo64.png`}
- ></Avatar>
- </Anchor>
- </MediaQuery>
- <MediaQuery
- largerThan={Layout.MOBILE_BREAKPOINT}
- styles={{ display: "none" }}
- >
- <Burger
- opened={showed}
- onClick={() => show(!showed)}
- size="sm"
- ></Burger>
- </MediaQuery>
- <Badge size="lg" radius="sm">
+ <AppShell.Header p="md" className={styles.header}>
+ <Group justify="space-between" wrap="nowrap">
+ <Group wrap="nowrap">
+ <Anchor onClick={goHome} visibleFrom="sm">
+ <Avatar
+ alt="brand"
+ size={32}
+ src={`${Environment.baseUrl}/images/logo64.png`}
+ ></Avatar>
+ </Anchor>
+ <Burger
+ opened={showed}
+ onClick={() => show(!showed)}
+ size="sm"
+ hiddenFrom="sm"
+ ></Burger>
+ <Badge size="lg" radius="sm" variant="brand">
Bazarr
</Badge>
</Group>
- <Group spacing="xs" position="right" noWrap>
+ <Group gap="xs" justify="right" wrap="nowrap">
<Search></Search>
<Menu>
<Menu.Target>
@@ -87,21 +64,20 @@ const AppHeader: FunctionComponent = () => {
label="System"
tooltip={{ position: "left", openDelay: 2000 }}
loading={offline}
- color={offline ? "yellow" : undefined}
+ c={offline ? "yellow" : undefined}
icon={faGear}
size="lg"
- variant="light"
></Action>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
- icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
+ leftSection={<FontAwesomeIcon icon={faArrowRotateLeft} />}
onClick={() => restart()}
>
Restart
</Menu.Item>
<Menu.Item
- icon={<FontAwesomeIcon icon={faPowerOff} />}
+ leftSection={<FontAwesomeIcon icon={faPowerOff} />}
onClick={() => shutdown()}
>
Shutdown
@@ -114,7 +90,7 @@ const AppHeader: FunctionComponent = () => {
</Menu>
</Group>
</Group>
- </Header>
+ </AppShell.Header>
);
};
diff --git a/frontend/src/App/Navbar.module.scss b/frontend/src/App/Navbar.module.scss
new file mode 100644
index 000000000..ddb444e4d
--- /dev/null
+++ b/frontend/src/App/Navbar.module.scss
@@ -0,0 +1,56 @@
+.anchor {
+ border-color: var(--mantine-color-gray-5);
+ text-decoration: none;
+
+ @include dark {
+ border-color: var(--mantine-color-dark-5);
+ }
+
+ &.active {
+ border-left: 2px solid $color-brand-4;
+ background-color: var(--mantine-color-gray-1);
+
+ @include dark {
+ border-left: 2px solid $color-brand-8;
+ background-color: var(--mantine-color-dark-8);
+ }
+ }
+
+ &.hover {
+ background-color: var(--mantine-color-gray-0);
+
+ @include dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+ }
+}
+
+.badge {
+ margin-left: auto;
+ text-decoration: none;
+ box-shadow: var(--mantine-shadow-xs);
+}
+
+.icon {
+ width: 1.4rem;
+ margin-right: var(--mantine-spacing-xs);
+}
+
+.nav {
+ background-color: var(--mantine-color-gray-2);
+
+ @include dark {
+ background-color: var(--mantine-color-dark-8);
+ }
+}
+
+.text {
+ display: inline-flex;
+ align-items: center;
+ width: 100%;
+ color: var(--mantine-color-gray-8);
+
+ @include dark {
+ color: var(--mantine-color-gray-5);
+ }
+}
diff --git a/frontend/src/App/Navbar.tsx b/frontend/src/App/Navbar.tsx
index c626dc257..679a0e3e7 100644
--- a/frontend/src/App/Navbar.tsx
+++ b/frontend/src/App/Navbar.tsx
@@ -1,40 +1,40 @@
-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 React, {
+ createContext,
+ FunctionComponent,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
import {
Anchor,
+ AppShell,
Badge,
Collapse,
- createStyles,
Divider,
Group,
- Navbar as MantineNavbar,
Stack,
Text,
+ useComputedColorScheme,
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";
+ faHeart,
+ faMoon,
+ faSun,
+ IconDefinition,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import clsx from "clsx";
+import { Action } from "@/components";
+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 styles from "./Navbar.module.scss";
const Selection = createContext<{
selection: string | null;
@@ -97,11 +97,12 @@ function useIsActive(parent: string, route: RouteObject) {
}
const AppNavbar: FunctionComponent = () => {
- const { showed } = useNavbar();
const [selection, select] = useState<string | null>(null);
- const { colorScheme, toggleColorScheme } = useMantineColorScheme();
- const dark = colorScheme === "dark";
+ const { toggleColorScheme } = useMantineColorScheme();
+ const computedColorScheme = useComputedColorScheme("light");
+
+ const dark = computedColorScheme === "dark";
const routes = useRouteItems();
@@ -111,23 +112,10 @@ const AppNavbar: FunctionComponent = () => {
}, [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],
- },
- })}
- >
+ <AppShell.Navbar p="xs" className={styles.nav}>
<Selection.Provider value={{ selection, select }}>
- <MantineNavbar.Section grow>
- <Stack spacing={0}>
+ <AppShell.Section grow>
+ <Stack gap={0}>
{routes.map((route, idx) => (
<RouteItem
key={BuildKey("nav", idx)}
@@ -136,14 +124,13 @@ const AppNavbar: FunctionComponent = () => {
></RouteItem>
))}
</Stack>
- </MantineNavbar.Section>
+ </AppShell.Section>
<Divider></Divider>
- <MantineNavbar.Section mt="xs">
- <Group spacing="xs">
+ <AppShell.Section mt="xs">
+ <Group gap="xs">
<Action
label="Change Theme"
- color={dark ? "yellow" : "indigo"}
- variant="subtle"
+ c={dark ? "yellow" : "indigo"}
onClick={() => toggleColorScheme()}
icon={dark ? faSun : faMoon}
></Action>
@@ -151,17 +138,12 @@ const AppNavbar: FunctionComponent = () => {
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
target="_blank"
>
- <Action
- label="Donate"
- icon={faHeart}
- variant="subtle"
- color="red"
- ></Action>
+ <Action label="Donate" icon={faHeart} c="red"></Action>
</Anchor>
</Group>
- </MantineNavbar.Section>
+ </AppShell.Section>
</Selection.Provider>
- </MantineNavbar>
+ </AppShell.Navbar>
);
};
@@ -186,7 +168,7 @@ const RouteItem: FunctionComponent<{
if (children !== undefined) {
const elements = (
- <Stack spacing={0}>
+ <Stack gap={0}>
{children.map((child, idx) => (
<RouteItem
parent={link}
@@ -199,7 +181,7 @@ const RouteItem: FunctionComponent<{
if (name) {
return (
- <Stack spacing={0}>
+ <Stack gap={0}>
<NavbarItem
primary
name={name}
@@ -244,53 +226,6 @@ const RouteItem: FunctionComponent<{
}
};
-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];
-
- const textColor =
- theme.colorScheme === "light" ? theme.colors.gray[8] : theme.colors.gray[5];
-
- return {
- text: {
- display: "inline-flex",
- alignItems: "center",
- width: "100%",
- color: textColor,
- },
- 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,
- color: textColor,
- },
- };
-});
-
interface NavbarItemProps {
name: string;
link: string;
@@ -308,8 +243,6 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
onClick,
primary = false,
}) => {
- const { classes } = useStyles();
-
const { show } = useNavbar();
const { ref, hovered } = useHover();
@@ -335,9 +268,9 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
}}
className={({ isActive }) =>
clsx(
- clsx(classes.anchor, {
- [classes.active]: isActive,
- [classes.hover]: hovered,
+ clsx(styles.anchor, {
+ [styles.active]: isActive,
+ [styles.hover]: hovered,
}),
)
}
@@ -347,18 +280,19 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
inline
p="xs"
size="sm"
- weight={primary ? "bold" : "normal"}
- className={classes.text}
+ fw={primary ? "bold" : "normal"}
+ className={styles.text}
+ span
>
{icon && (
<FontAwesomeIcon
- className={classes.icon}
+ className={styles.icon}
icon={icon}
></FontAwesomeIcon>
)}
{name}
- {shouldHideBadge === false && (
- <Badge className={classes.badge} radius="xs">
+ {!shouldHideBadge && (
+ <Badge className={styles.badge} radius="xs">
{badge}
</Badge>
)}
diff --git a/frontend/src/App/ThemeLoader.tsx b/frontend/src/App/ThemeLoader.tsx
new file mode 100644
index 000000000..2bc7e4005
--- /dev/null
+++ b/frontend/src/App/ThemeLoader.tsx
@@ -0,0 +1,39 @@
+import { useCallback, useEffect, useState } from "react";
+import { MantineColorScheme, useMantineColorScheme } from "@mantine/core";
+import { useSystemSettings } from "@/apis/hooks";
+
+const ThemeProvider = () => {
+ const [localScheme, setLocalScheme] = useState<MantineColorScheme | null>(
+ null,
+ );
+ const { setColorScheme } = useMantineColorScheme();
+
+ const settings = useSystemSettings();
+
+ const settingsColorScheme = settings.data?.general
+ .theme as MantineColorScheme;
+
+ const setScheme = useCallback(
+ (colorScheme: MantineColorScheme) => {
+ setColorScheme(colorScheme);
+ },
+ [setColorScheme],
+ );
+
+ useEffect(() => {
+ if (!settingsColorScheme) {
+ return;
+ }
+
+ if (localScheme === settingsColorScheme) {
+ return;
+ }
+
+ setScheme(settingsColorScheme);
+ setLocalScheme(settingsColorScheme);
+ }, [settingsColorScheme, setScheme, localScheme]);
+
+ return <></>;
+};
+
+export default ThemeProvider;
diff --git a/frontend/src/App/ThemeProvider.tsx b/frontend/src/App/ThemeProvider.tsx
new file mode 100644
index 000000000..662a1ce69
--- /dev/null
+++ b/frontend/src/App/ThemeProvider.tsx
@@ -0,0 +1,61 @@
+import { FunctionComponent, PropsWithChildren } from "react";
+import {
+ ActionIcon,
+ Badge,
+ Button,
+ createTheme,
+ MantineProvider,
+ Pagination,
+} from "@mantine/core";
+import ThemeLoader from "@/App/ThemeLoader";
+import "@mantine/core/styles.layer.css";
+import "@mantine/notifications/styles.layer.css";
+import styleVars from "@/assets/_variables.module.scss";
+import actionIconClasses from "@/assets/action_icon.module.scss";
+import badgeClasses from "@/assets/badge.module.scss";
+import buttonClasses from "@/assets/button.module.scss";
+import paginationClasses from "@/assets/pagination.module.scss";
+
+const themeProvider = createTheme({
+ fontFamily: "Roboto, open sans, Helvetica Neue, Helvetica, Arial, sans-serif",
+ colors: {
+ brand: [
+ styleVars.colorBrand0,
+ styleVars.colorBrand1,
+ styleVars.colorBrand2,
+ styleVars.colorBrand3,
+ styleVars.colorBrand4,
+ styleVars.colorBrand5,
+ styleVars.colorBrand6,
+ styleVars.colorBrand7,
+ styleVars.colorBrand8,
+ styleVars.colorBrand9,
+ ],
+ },
+ primaryColor: "brand",
+ components: {
+ ActionIcon: ActionIcon.extend({
+ classNames: actionIconClasses,
+ }),
+ Badge: Badge.extend({
+ classNames: badgeClasses,
+ }),
+ Button: Button.extend({
+ classNames: buttonClasses,
+ }),
+ Pagination: Pagination.extend({
+ classNames: paginationClasses,
+ }),
+ },
+});
+
+const ThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
+ return (
+ <MantineProvider theme={themeProvider} defaultColorScheme="auto">
+ <ThemeLoader />
+ {children}
+ </MantineProvider>
+ );
+};
+
+export default ThemeProvider;
diff --git a/frontend/src/App/app.test.tsx b/frontend/src/App/app.test.tsx
index f6236cdc9..db9895305 100644
--- a/frontend/src/App/app.test.tsx
+++ b/frontend/src/App/app.test.tsx
@@ -1,5 +1,5 @@
-import { render } from "@/tests";
import { describe, it } from "vitest";
+import { render } from "@/tests";
import App from ".";
describe("App", () => {
diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx
index 4e09a97da..a8ef9f3fb 100644
--- a/frontend/src/App/index.tsx
+++ b/frontend/src/App/index.tsx
@@ -1,18 +1,18 @@
+import { FunctionComponent, useEffect, useState } from "react";
+import { Outlet, useNavigate } from "react-router-dom";
+import { AppShell } from "@mantine/core";
+import { useWindowEvent } from "@mantine/hooks";
+import { showNotification } from "@mantine/notifications";
import AppNavbar from "@/App/Navbar";
-import { RouterNames } from "@/Router/RouterNames";
import ErrorBoundary from "@/components/ErrorBoundary";
-import { Layout } from "@/constants";
import NavbarProvider from "@/contexts/Navbar";
import OnlineProvider from "@/contexts/Online";
import { notification } from "@/modules/task";
import CriticalError from "@/pages/errors/CriticalError";
+import { RouterNames } from "@/Router/RouterNames";
import { Environment } from "@/utilities";
-import { AppShell } from "@mantine/core";
-import { useWindowEvent } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
-import { FunctionComponent, useEffect, useState } from "react";
-import { Outlet, useNavigate } from "react-router-dom";
import AppHeader from "./Header";
+import styleVars from "@/assets/_variables.module.scss";
const App: FunctionComponent = () => {
const navigate = useNavigate();
@@ -55,13 +55,19 @@ const App: FunctionComponent = () => {
<NavbarProvider value={{ showed: navbar, show: setNavbar }}>
<OnlineProvider value={{ online, setOnline }}>
<AppShell
- navbarOffsetBreakpoint={Layout.MOBILE_BREAKPOINT}
- header={<AppHeader></AppHeader>}
- navbar={<AppNavbar></AppNavbar>}
+ navbar={{
+ width: styleVars.navBarWidth,
+ breakpoint: "sm",
+ collapsed: { mobile: !navbar },
+ }}
+ header={{ height: { base: styleVars.headerHeight } }}
padding={0}
- fixed
>
- <Outlet></Outlet>
+ <AppHeader></AppHeader>
+ <AppNavbar></AppNavbar>
+ <AppShell.Main>
+ <Outlet></Outlet>
+ </AppShell.Main>
</AppShell>
</OnlineProvider>
</NavbarProvider>
diff --git a/frontend/src/App/theme.tsx b/frontend/src/App/theme.tsx
deleted file mode 100644
index 947b4f7a8..000000000
--- a/frontend/src/App/theme.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useSystemSettings } from "@/apis/hooks";
-import {
- ColorScheme,
- ColorSchemeProvider,
- createEmotionCache,
- MantineProvider,
- MantineThemeOverride,
-} from "@mantine/core";
-import { useColorScheme } from "@mantine/hooks";
-import {
- FunctionComponent,
- PropsWithChildren,
- 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 settings = useSystemSettings();
- const settingsColorScheme = settings.data?.general.theme;
-
- let preferredColorScheme: ColorScheme = useColorScheme();
- switch (settingsColorScheme) {
- case "light":
- preferredColorScheme = "light" as ColorScheme;
- break;
- case "dark":
- preferredColorScheme = "dark" as ColorScheme;
- break;
- }
-
- 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 emotionCache = createEmotionCache({ key: "bazarr" });
-
-const ThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
- const { colorScheme, toggleColorScheme } = useAutoColorScheme();
-
- return (
- <ColorSchemeProvider
- colorScheme={colorScheme}
- toggleColorScheme={toggleColorScheme}
- >
- <MantineProvider
- withGlobalStyles
- withNormalizeCSS
- theme={{ colorScheme, ...theme }}
- emotionCache={emotionCache}
- >
- {children}
- </MantineProvider>
- </ColorSchemeProvider>
- );
-};
-
-export default ThemeProvider;
diff --git a/frontend/src/Router/Redirector.tsx b/frontend/src/Router/Redirector.tsx
index 064522bbc..07878c9db 100644
--- a/frontend/src/Router/Redirector.tsx
+++ b/frontend/src/Router/Redirector.tsx
@@ -1,7 +1,7 @@
-import { useSystemSettings } from "@/apis/hooks";
-import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, useEffect } from "react";
import { useNavigate } from "react-router-dom";
+import { LoadingOverlay } from "@mantine/core";
+import { useSystemSettings } from "@/apis/hooks";
const Redirector: FunctionComponent = () => {
const { data } = useSystemSettings();
diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx
index 335cf2d75..d600fc87d 100644
--- a/frontend/src/Router/index.tsx
+++ b/frontend/src/Router/index.tsx
@@ -1,11 +1,29 @@
-import App from "@/App";
+import {
+ createContext,
+ FunctionComponent,
+ lazy,
+ useContext,
+ useMemo,
+} from "react";
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+import {
+ faClock,
+ faCogs,
+ faExclamationTriangle,
+ faFileExcel,
+ faFilm,
+ faLaptop,
+ faPlay,
+} from "@fortawesome/free-solid-svg-icons";
import { useBadges } from "@/apis/hooks";
import { useEnabledStatus } from "@/apis/hooks/site";
+import App from "@/App";
import { Lazy } from "@/components/async";
import Authentication from "@/pages/Authentication";
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import BlacklistSeriesView from "@/pages/Blacklist/Series";
import Episodes from "@/pages/Episodes";
+import NotFound from "@/pages/errors/NotFound";
import MoviesHistoryView from "@/pages/History/Movies";
import SeriesHistoryView from "@/pages/History/Series";
import MovieView from "@/pages/Movies";
@@ -30,30 +48,14 @@ import SystemReleasesView from "@/pages/System/Releases";
import SystemTasksView from "@/pages/System/Tasks";
import WantedMoviesView from "@/pages/Wanted/Movies";
import WantedSeriesView from "@/pages/Wanted/Series";
-import NotFound from "@/pages/errors/NotFound";
import { Environment } from "@/utilities";
-import {
- faClock,
- faCogs,
- faExclamationTriangle,
- faFileExcel,
- faFilm,
- faLaptop,
- faPlay,
-} from "@fortawesome/free-solid-svg-icons";
-import {
- FunctionComponent,
- createContext,
- lazy,
- useContext,
- useMemo,
-} from "react";
-import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Redirector from "./Redirector";
import { RouterNames } from "./RouterNames";
import { CustomRouteObject } from "./type";
-const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
+const HistoryStats = lazy(
+ () => import("@/pages/History/Statistics/HistoryStats"),
+);
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
function useRoutes(): CustomRouteObject[] {
diff --git a/frontend/src/Router/type.d.ts b/frontend/src/Router/type.d.ts
index 0276a30dd..f1cfdaae7 100644
--- a/frontend/src/Router/type.d.ts
+++ b/frontend/src/Router/type.d.ts
@@ -1,5 +1,5 @@
-import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { RouteObject } from "react-router-dom";
+import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
declare namespace Route {
export type Item = {
diff --git a/frontend/src/apis/hooks/episodes.ts b/frontend/src/apis/hooks/episodes.ts
index d182dc7e6..956fd103f 100644
--- a/frontend/src/apis/hooks/episodes.ts
+++ b/frontend/src/apis/hooks/episodes.ts
@@ -1,12 +1,13 @@
+import { useEffect } from "react";
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
-} from "react-query";
-import { usePaginationQuery } from "../queries/hooks";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+} from "@tanstack/react-query";
+import { usePaginationQuery } from "@/apis/queries/hooks";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
episodes.forEach((item) => {
@@ -24,30 +25,21 @@ const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
});
};
-export function useEpisodesByIds(ids: number[]) {
- const client = useQueryClient();
- return useQuery(
- [QueryKeys.Series, QueryKeys.Episodes, ids],
- () => api.episodes.byEpisodeId(ids),
- {
- onSuccess: (data) => {
- cacheEpisodes(client, data);
- },
- },
- );
-}
-
export function useEpisodesBySeriesId(id: number) {
const client = useQueryClient();
- return useQuery(
- [QueryKeys.Series, id, QueryKeys.Episodes, QueryKeys.All],
- () => api.episodes.bySeriesId([id]),
- {
- onSuccess: (data) => {
- cacheEpisodes(client, data);
- },
- },
- );
+
+ const query = useQuery({
+ queryKey: [QueryKeys.Series, id, QueryKeys.Episodes, QueryKeys.All],
+ queryFn: () => api.episodes.bySeriesId([id]),
+ });
+
+ useEffect(() => {
+ if (query.isSuccess && query.data) {
+ cacheEpisodes(client, query.data);
+ }
+ }, [query.isSuccess, query.data, client]);
+
+ return query;
}
export function useEpisodeWantedPagination() {
@@ -57,17 +49,18 @@ export function useEpisodeWantedPagination() {
}
export function useEpisodeBlacklist() {
- return useQuery(
- [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
- () => api.episodes.blacklist(),
- );
+ return useQuery({
+ queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ queryFn: () => api.episodes.blacklist(),
+ });
}
export function useEpisodeAddBlacklist() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
- (param: {
+ return useMutation({
+ mutationKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+
+ mutationFn: (param: {
seriesId: number;
episodeId: number;
form: FormType.AddBlacklist;
@@ -75,35 +68,33 @@ export function useEpisodeAddBlacklist() {
const { seriesId, episodeId, form } = param;
return api.episodes.addBlacklist(seriesId, episodeId, form);
},
- {
- onSuccess: (_, { seriesId, episodeId }) => {
- client.invalidateQueries([
- QueryKeys.Series,
- QueryKeys.Episodes,
- QueryKeys.Blacklist,
- ]);
- client.invalidateQueries([QueryKeys.Series, seriesId]);
- },
+
+ onSuccess: (_, { seriesId }) => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Series, seriesId],
+ });
},
- );
+ });
}
export function useEpisodeDeleteBlacklist() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
- (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
+ return useMutation({
+ mutationKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+
+ mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.episodes.deleteBlacklist(param.all, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([
- QueryKeys.Series,
- QueryKeys.Episodes,
- QueryKeys.Blacklist,
- ]);
- },
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
+ });
},
- );
+ });
}
export function useEpisodeHistoryPagination() {
@@ -115,12 +106,20 @@ export function useEpisodeHistoryPagination() {
}
export function useEpisodeHistory(episodeId?: number) {
- return useQuery(
- [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History, episodeId],
- () => {
+ return useQuery({
+ queryKey: [
+ QueryKeys.Series,
+ QueryKeys.Episodes,
+ QueryKeys.History,
+ episodeId,
+ ],
+
+ queryFn: () => {
if (episodeId) {
return api.episodes.historyBy(episodeId);
}
+
+ return [];
},
- );
+ });
}
diff --git a/frontend/src/apis/hooks/histories.ts b/frontend/src/apis/hooks/histories.ts
index d8fc0676f..6368d11db 100644
--- a/frontend/src/apis/hooks/histories.ts
+++ b/frontend/src/apis/hooks/histories.ts
@@ -1,6 +1,6 @@
-import { useQuery } from "react-query";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+import { useQuery } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
export function useHistoryStats(
time: History.TimeFrameOptions,
@@ -8,14 +8,19 @@ export function useHistoryStats(
provider: System.Provider | null,
language: Language.Info | null,
) {
- return useQuery(
- [QueryKeys.System, QueryKeys.History, { time, action, provider, language }],
- () =>
+ return useQuery({
+ queryKey: [
+ QueryKeys.System,
+ QueryKeys.History,
+ { time, action, provider, language },
+ ],
+
+ queryFn: () =>
api.history.stats(
time,
action ?? undefined,
provider?.name,
language?.code2,
),
- );
+ });
}
diff --git a/frontend/src/apis/hooks/languages.ts b/frontend/src/apis/hooks/languages.ts
index 149d80716..4cbdce69b 100644
--- a/frontend/src/apis/hooks/languages.ts
+++ b/frontend/src/apis/hooks/languages.ts
@@ -1,23 +1,19 @@
-import { useQuery } from "react-query";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+import { useQuery } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
export function useLanguages(history?: boolean) {
- return useQuery(
- [QueryKeys.System, QueryKeys.Languages, history ?? false],
- () => api.system.languages(history),
- {
- staleTime: Infinity,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Languages, history ?? false],
+ queryFn: () => api.system.languages(history),
+ staleTime: Infinity,
+ });
}
export function useLanguageProfiles() {
- return useQuery(
- [QueryKeys.System, QueryKeys.LanguagesProfiles],
- () => api.system.languagesProfileList(),
- {
- staleTime: Infinity,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.LanguagesProfiles],
+ queryFn: () => api.system.languagesProfileList(),
+ staleTime: Infinity,
+ });
}
diff --git a/frontend/src/apis/hooks/movies.ts b/frontend/src/apis/hooks/movies.ts
index ee8fe1100..6b1c5c2a5 100644
--- a/frontend/src/apis/hooks/movies.ts
+++ b/frontend/src/apis/hooks/movies.ts
@@ -1,12 +1,13 @@
+import { useEffect } from "react";
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
-} from "react-query";
-import { usePaginationQuery } from "../queries/hooks";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+} from "@tanstack/react-query";
+import { usePaginationQuery } from "@/apis/queries/hooks";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
movies.forEach((item) => {
@@ -14,33 +15,32 @@ const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
});
};
-export function useMoviesByIds(ids: number[]) {
- const client = useQueryClient();
- return useQuery([QueryKeys.Movies, ...ids], () => api.movies.movies(ids), {
- onSuccess: (data) => {
- cacheMovies(client, data);
- },
- });
-}
-
export function useMovieById(id: number) {
- return useQuery([QueryKeys.Movies, id], async () => {
- const response = await api.movies.movies([id]);
- return response.length > 0 ? response[0] : undefined;
+ return useQuery({
+ queryKey: [QueryKeys.Movies, id],
+
+ queryFn: async () => {
+ const response = await api.movies.movies([id]);
+ return response.length > 0 ? response[0] : undefined;
+ },
});
}
export function useMovies() {
const client = useQueryClient();
- return useQuery(
- [QueryKeys.Movies, QueryKeys.All],
- () => api.movies.movies(),
- {
- onSuccess: (data) => {
- cacheMovies(client, data);
- },
- },
- );
+
+ const query = useQuery({
+ queryKey: [QueryKeys.Movies, QueryKeys.All],
+ queryFn: () => api.movies.movies(),
+ });
+
+ useEffect(() => {
+ if (query.isSuccess && query.data) {
+ cacheMovies(client, query.data);
+ }
+ }, [query.isSuccess, query.data, client]);
+
+ return query;
}
export function useMoviesPagination() {
@@ -51,32 +51,37 @@ export function useMoviesPagination() {
export function useMovieModification() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Movies],
- (form: FormType.ModifyItem) => api.movies.modify(form),
- {
- onSuccess: (_, form) => {
- form.id.forEach((v) => {
- client.invalidateQueries([QueryKeys.Movies, v]);
+ return useMutation({
+ mutationKey: [QueryKeys.Movies],
+ mutationFn: (form: FormType.ModifyItem) => api.movies.modify(form),
+
+ onSuccess: (_, form) => {
+ form.id.forEach((v) => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, v],
});
- // TODO: query less
- client.invalidateQueries([QueryKeys.Movies]);
- },
+ });
+
+ // TODO: query less
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies],
+ });
},
- );
+ });
}
export function useMovieAction() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Actions, QueryKeys.Movies],
- (form: FormType.MoviesAction) => api.movies.action(form),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.Movies]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.Actions, QueryKeys.Movies],
+ mutationFn: (form: FormType.MoviesAction) => api.movies.action(form),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies],
+ });
},
- );
+ });
}
export function useMovieWantedPagination() {
@@ -86,40 +91,49 @@ export function useMovieWantedPagination() {
}
export function useMovieBlacklist() {
- return useQuery([QueryKeys.Movies, QueryKeys.Blacklist], () =>
- api.movies.blacklist(),
- );
+ return useQuery({
+ queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+
+ queryFn: () => api.movies.blacklist(),
+ });
}
export function useMovieAddBlacklist() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Movies, QueryKeys.Blacklist],
- (param: { id: number; form: FormType.AddBlacklist }) => {
+ return useMutation({
+ mutationKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+
+ mutationFn: (param: { id: number; form: FormType.AddBlacklist }) => {
const { id, form } = param;
return api.movies.addBlacklist(id, form);
},
- {
- onSuccess: (_, { id }) => {
- client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
- client.invalidateQueries([QueryKeys.Movies, id]);
- },
+
+ onSuccess: (_, { id }) => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, id],
+ });
},
- );
+ });
}
export function useMovieDeleteBlacklist() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Movies, QueryKeys.Blacklist],
- (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
+ return useMutation({
+ mutationKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+
+ mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.movies.deleteBlacklist(param.all, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
- },
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+ });
},
- );
+ });
}
export function useMovieHistoryPagination() {
@@ -131,9 +145,15 @@ export function useMovieHistoryPagination() {
}
export function useMovieHistory(radarrId?: number) {
- return useQuery([QueryKeys.Movies, QueryKeys.History, radarrId], () => {
- if (radarrId) {
- return api.movies.historyBy(radarrId);
- }
+ return useQuery({
+ queryKey: [QueryKeys.Movies, QueryKeys.History, radarrId],
+
+ queryFn: () => {
+ if (radarrId) {
+ return api.movies.historyBy(radarrId);
+ }
+
+ return [];
+ },
});
}
diff --git a/frontend/src/apis/hooks/providers.ts b/frontend/src/apis/hooks/providers.ts
index 5e4fed602..b40a24e2f 100644
--- a/frontend/src/apis/hooks/providers.ts
+++ b/frontend/src/apis/hooks/providers.ts
@@ -1,66 +1,82 @@
-import { useMutation, useQuery, useQueryClient } from "react-query";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
export function useSystemProviders(history?: boolean) {
- return useQuery(
- [QueryKeys.System, QueryKeys.Providers, history ?? false],
- () => api.providers.providers(history),
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Providers, history ?? false],
+ queryFn: () => api.providers.providers(history),
+ });
}
export function useMoviesProvider(radarrId?: number) {
- return useQuery(
- [QueryKeys.System, QueryKeys.Providers, QueryKeys.Movies, radarrId],
- () => {
+ return useQuery({
+ queryKey: [
+ QueryKeys.System,
+ QueryKeys.Providers,
+ QueryKeys.Movies,
+ radarrId,
+ ],
+
+ queryFn: () => {
if (radarrId) {
return api.providers.movies(radarrId);
}
+
+ return [];
},
- {
- staleTime: 0,
- },
- );
+
+ staleTime: 0,
+ });
}
export function useEpisodesProvider(episodeId?: number) {
- return useQuery(
- [QueryKeys.System, QueryKeys.Providers, QueryKeys.Episodes, episodeId],
- () => {
+ return useQuery({
+ queryKey: [
+ QueryKeys.System,
+ QueryKeys.Providers,
+ QueryKeys.Episodes,
+ episodeId,
+ ],
+
+ queryFn: () => {
if (episodeId) {
return api.providers.episodes(episodeId);
}
+
+ return [];
},
- {
- staleTime: 0,
- },
- );
+
+ staleTime: 0,
+ });
}
export function useResetProvider() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Providers],
- () => api.providers.reset(),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Providers]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Providers],
+ mutationFn: () => api.providers.reset(),
+
+ onSuccess: () => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Providers],
+ });
},
- );
+ });
}
export function useDownloadEpisodeSubtitles() {
const client = useQueryClient();
- return useMutation(
- [
+ return useMutation({
+ mutationKey: [
QueryKeys.System,
QueryKeys.Providers,
QueryKeys.Subtitles,
QueryKeys.Episodes,
],
- (param: {
+
+ mutationFn: (param: {
seriesId: number;
episodeId: number;
form: FormType.ManualDownload;
@@ -70,30 +86,33 @@ export function useDownloadEpisodeSubtitles() {
param.episodeId,
param.form,
),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Series, param.seriesId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, param.seriesId],
+ });
},
- );
+ });
}
export function useDownloadMovieSubtitles() {
const client = useQueryClient();
- return useMutation(
- [
+ return useMutation({
+ mutationKey: [
QueryKeys.System,
QueryKeys.Providers,
QueryKeys.Subtitles,
QueryKeys.Movies,
],
- (param: { radarrId: number; form: FormType.ManualDownload }) =>
+
+ mutationFn: (param: { radarrId: number; form: FormType.ManualDownload }) =>
api.providers.downloadMovieSubtitle(param.radarrId, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, param.radarrId],
+ });
},
- );
+ });
}
diff --git a/frontend/src/apis/hooks/series.ts b/frontend/src/apis/hooks/series.ts
index 1e395a6e5..2add91194 100644
--- a/frontend/src/apis/hooks/series.ts
+++ b/frontend/src/apis/hooks/series.ts
@@ -1,12 +1,13 @@
+import { useEffect } from "react";
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
-} from "react-query";
-import { usePaginationQuery } from "../queries/hooks";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+} from "@tanstack/react-query";
+import { usePaginationQuery } from "@/apis/queries/hooks";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
function cacheSeries(client: QueryClient, series: Item.Series[]) {
series.forEach((item) => {
@@ -16,31 +17,47 @@ function cacheSeries(client: QueryClient, series: Item.Series[]) {
export function useSeriesByIds(ids: number[]) {
const client = useQueryClient();
- return useQuery([QueryKeys.Series, ...ids], () => api.series.series(ids), {
- onSuccess: (data) => {
- cacheSeries(client, data);
- },
+
+ const query = useQuery({
+ queryKey: [QueryKeys.Series, ...ids],
+ queryFn: () => api.series.series(ids),
});
+
+ useEffect(() => {
+ if (query.isSuccess && query.data) {
+ cacheSeries(client, query.data);
+ }
+ }, [query.isSuccess, query.data, client]);
+
+ return query;
}
export function useSeriesById(id: number) {
- return useQuery([QueryKeys.Series, id], async () => {
- const response = await api.series.series([id]);
- return response.length > 0 ? response[0] : undefined;
+ return useQuery({
+ queryKey: [QueryKeys.Series, id],
+
+ queryFn: async () => {
+ const response = await api.series.series([id]);
+ return response.length > 0 ? response[0] : undefined;
+ },
});
}
export function useSeries() {
const client = useQueryClient();
- return useQuery(
- [QueryKeys.Series, QueryKeys.All],
- () => api.series.series(),
- {
- onSuccess: (data) => {
- cacheSeries(client, data);
- },
- },
- );
+
+ const query = useQuery({
+ queryKey: [QueryKeys.Series, QueryKeys.All],
+ queryFn: () => api.series.series(),
+ });
+
+ useEffect(() => {
+ if (query.isSuccess && query.data) {
+ cacheSeries(client, query.data);
+ }
+ }, [query.isSuccess, query.data, client]);
+
+ return query;
}
export function useSeriesPagination() {
@@ -51,29 +68,33 @@ export function useSeriesPagination() {
export function useSeriesModification() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Series],
- (form: FormType.ModifyItem) => api.series.modify(form),
- {
- onSuccess: (_, form) => {
- form.id.forEach((v) => {
- client.invalidateQueries([QueryKeys.Series, v]);
+ return useMutation({
+ mutationKey: [QueryKeys.Series],
+ mutationFn: (form: FormType.ModifyItem) => api.series.modify(form),
+
+ onSuccess: (_, form) => {
+ form.id.forEach((v) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, v],
});
- client.invalidateQueries([QueryKeys.Series]);
- },
+ });
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series],
+ });
},
- );
+ });
}
export function useSeriesAction() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.Actions, QueryKeys.Series],
- (form: FormType.SeriesAction) => api.series.action(form),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.Series]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.Actions, QueryKeys.Series],
+ mutationFn: (form: FormType.SeriesAction) => api.series.action(form),
+
+ onSuccess: () => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series],
+ });
},
- );
+ });
}
diff --git a/frontend/src/apis/hooks/status.ts b/frontend/src/apis/hooks/status.ts
index 46a73cfda..66af943fc 100644
--- a/frontend/src/apis/hooks/status.ts
+++ b/frontend/src/apis/hooks/status.ts
@@ -1,16 +1,28 @@
-import { useIsMutating } from "react-query";
-import { QueryKeys } from "../queries/keys";
+import { useIsMutating } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
export function useIsAnyActionRunning() {
- return useIsMutating([QueryKeys.Actions]) > 0;
+ return (
+ useIsMutating({
+ mutationKey: [QueryKeys.Actions],
+ }) > 0
+ );
}
export function useIsMovieActionRunning() {
- return useIsMutating([QueryKeys.Actions, QueryKeys.Movies]) > 0;
+ return (
+ useIsMutating({
+ mutationKey: [QueryKeys.Actions, QueryKeys.Movies],
+ }) > 0
+ );
}
export function useIsSeriesActionRunning() {
- return useIsMutating([QueryKeys.Actions, QueryKeys.Series]) > 0;
+ return (
+ useIsMutating({
+ mutationKey: [QueryKeys.Actions, QueryKeys.Series],
+ }) > 0
+ );
}
export function useIsAnyMutationRunning() {
diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts
index 7864cdcbb..dfc263ff6 100644
--- a/frontend/src/apis/hooks/subtitles.ts
+++ b/frontend/src/apis/hooks/subtitles.ts
@@ -1,6 +1,6 @@
-import { useMutation, useQuery, useQueryClient } from "react-query";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
export function useSubtitleAction() {
const client = useQueryClient();
@@ -8,23 +8,29 @@ export function useSubtitleAction() {
action: string;
form: FormType.ModifySubtitle;
}
- return useMutation(
- [QueryKeys.Subtitles],
- (param: Param) => api.subtitles.modify(param.action, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.History]);
-
- // TODO: Query less
- const { type, id } = param.form;
- if (type === "episode") {
- client.invalidateQueries([QueryKeys.Series, id]);
- } else {
- client.invalidateQueries([QueryKeys.Movies, id]);
- }
- },
+ return useMutation({
+ mutationKey: [QueryKeys.Subtitles],
+ mutationFn: (param: Param) =>
+ api.subtitles.modify(param.action, param.form),
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.History],
+ });
+
+ // TODO: Query less
+ const { type, id } = param.form;
+ if (type === "episode") {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, id],
+ });
+ } else {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, id],
+ });
+ }
},
- );
+ });
}
export function useEpisodeSubtitleModification() {
@@ -36,42 +42,48 @@ export function useEpisodeSubtitleModification() {
form: T;
}
- const download = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Episodes],
- (param: Param<FormType.Subtitle>) =>
+ const download = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Episodes],
+
+ mutationFn: (param: Param<FormType.Subtitle>) =>
api.episodes.downloadSubtitles(
param.seriesId,
param.episodeId,
param.form,
),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Series, param.seriesId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, param.seriesId],
+ });
},
- );
+ });
- const remove = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Episodes],
- (param: Param<FormType.DeleteSubtitle>) =>
+ const remove = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Episodes],
+
+ mutationFn: (param: Param<FormType.DeleteSubtitle>) =>
api.episodes.deleteSubtitles(param.seriesId, param.episodeId, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Series, param.seriesId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, param.seriesId],
+ });
},
- );
+ });
- const upload = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Episodes],
- (param: Param<FormType.UploadSubtitle>) =>
+ const upload = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Episodes],
+
+ mutationFn: (param: Param<FormType.UploadSubtitle>) =>
api.episodes.uploadSubtitles(param.seriesId, param.episodeId, param.form),
- {
- onSuccess: (_, { seriesId }) => {
- client.invalidateQueries([QueryKeys.Series, seriesId]);
- },
+
+ onSuccess: (_, { seriesId }) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Series, seriesId],
+ });
},
- );
+ });
return { download, remove, upload };
}
@@ -84,46 +96,54 @@ export function useMovieSubtitleModification() {
form: T;
}
- const download = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Movies],
- (param: Param<FormType.Subtitle>) =>
+ const download = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Movies],
+
+ mutationFn: (param: Param<FormType.Subtitle>) =>
api.movies.downloadSubtitles(param.radarrId, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, param.radarrId],
+ });
},
- );
+ });
+
+ const remove = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Movies],
- const remove = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Movies],
- (param: Param<FormType.DeleteSubtitle>) =>
+ mutationFn: (param: Param<FormType.DeleteSubtitle>) =>
api.movies.deleteSubtitles(param.radarrId, param.form),
- {
- onSuccess: (_, param) => {
- client.invalidateQueries([QueryKeys.Movies, param.radarrId]);
- },
+
+ onSuccess: (_, param) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, param.radarrId],
+ });
},
- );
+ });
- const upload = useMutation(
- [QueryKeys.Subtitles, QueryKeys.Movies],
- (param: Param<FormType.UploadSubtitle>) =>
+ const upload = useMutation({
+ mutationKey: [QueryKeys.Subtitles, QueryKeys.Movies],
+
+ mutationFn: (param: Param<FormType.UploadSubtitle>) =>
api.movies.uploadSubtitles(param.radarrId, param.form),
- {
- onSuccess: (_, { radarrId }) => {
- client.invalidateQueries([QueryKeys.Movies, radarrId]);
- },
+
+ onSuccess: (_, { radarrId }) => {
+ client.invalidateQueries({
+ queryKey: [QueryKeys.Movies, radarrId],
+ });
},
- );
+ });
return { download, remove, upload };
}
export function useSubtitleInfos(names: string[]) {
- return useQuery([QueryKeys.Subtitles, QueryKeys.Infos, names], () =>
- api.subtitles.info(names),
- );
+ return useQuery({
+ queryKey: [QueryKeys.Subtitles, QueryKeys.Infos, names],
+
+ queryFn: () => api.subtitles.info(names),
+ });
}
export function useRefTracksByEpisodeId(
@@ -131,11 +151,17 @@ export function useRefTracksByEpisodeId(
sonarrEpisodeId: number,
isEpisode: boolean,
) {
- return useQuery(
- [QueryKeys.Episodes, sonarrEpisodeId, QueryKeys.Subtitles, subtitlesPath],
- () => api.subtitles.getRefTracksByEpisodeId(subtitlesPath, sonarrEpisodeId),
- { enabled: isEpisode },
- );
+ return useQuery({
+ queryKey: [
+ QueryKeys.Episodes,
+ sonarrEpisodeId,
+ QueryKeys.Subtitles,
+ subtitlesPath,
+ ],
+ queryFn: () =>
+ api.subtitles.getRefTracksByEpisodeId(subtitlesPath, sonarrEpisodeId),
+ enabled: isEpisode,
+ });
}
export function useRefTracksByMovieId(
@@ -143,9 +169,15 @@ export function useRefTracksByMovieId(
radarrMovieId: number,
isMovie: boolean,
) {
- return useQuery(
- [QueryKeys.Movies, radarrMovieId, QueryKeys.Subtitles, subtitlesPath],
- () => api.subtitles.getRefTracksByMovieId(subtitlesPath, radarrMovieId),
- { enabled: isMovie },
- );
+ return useQuery({
+ queryKey: [
+ QueryKeys.Movies,
+ radarrMovieId,
+ QueryKeys.Subtitles,
+ subtitlesPath,
+ ],
+ queryFn: () =>
+ api.subtitles.getRefTracksByMovieId(subtitlesPath, radarrMovieId),
+ enabled: isMovie,
+ });
}
diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts
index 26946e910..a0ce17fb9 100644
--- a/frontend/src/apis/hooks/system.ts
+++ b/frontend/src/apis/hooks/system.ts
@@ -1,20 +1,18 @@
+import { useMemo } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryKeys } from "@/apis/queries/keys";
+import api from "@/apis/raw";
import { Environment } from "@/utilities";
import { setAuthenticated } from "@/utilities/event";
-import { useMemo } from "react";
-import { useMutation, useQuery, useQueryClient } from "react-query";
-import { QueryKeys } from "../queries/keys";
-import api from "../raw";
export function useBadges() {
- return useQuery(
- [QueryKeys.System, QueryKeys.Badges],
- () => api.badges.all(),
- {
- refetchOnWindowFocus: "always",
- refetchInterval: 1000 * 60,
- staleTime: 1000 * 10,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Badges],
+ queryFn: () => api.badges.all(),
+ refetchOnWindowFocus: "always",
+ refetchInterval: 1000 * 60,
+ staleTime: 1000 * 10,
+ });
}
export function useFileSystem(
@@ -22,9 +20,10 @@ export function useFileSystem(
path: string,
enabled: boolean,
) {
- return useQuery(
- [QueryKeys.FileSystem, type, path],
- () => {
+ return useQuery({
+ queryKey: [QueryKeys.FileSystem, type, path],
+
+ queryFn: () => {
if (type === "bazarr") {
return api.files.bazarr(path);
} else if (type === "radarr") {
@@ -32,53 +31,68 @@ export function useFileSystem(
} else if (type === "sonarr") {
return api.files.sonarr(path);
}
+
+ return [];
},
- {
- enabled,
- },
- );
+
+ enabled,
+ });
}
export function useSystemSettings() {
- return useQuery(
- [QueryKeys.System, QueryKeys.Settings],
- () => api.system.settings(),
- {
- staleTime: Infinity,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Settings],
+ queryFn: () => api.system.settings(),
+ staleTime: Infinity,
+ });
}
export function useSettingsMutation() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Settings],
- (data: LooseObject) => api.system.updateSettings(data),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System]);
- client.invalidateQueries([QueryKeys.Series]);
- client.invalidateQueries([QueryKeys.Episodes]);
- client.invalidateQueries([QueryKeys.Movies]);
- client.invalidateQueries([QueryKeys.Wanted]);
- client.invalidateQueries([QueryKeys.Badges]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Settings],
+ mutationFn: (data: LooseObject) => api.system.updateSettings(data),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Series],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Episodes],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Wanted],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Badges],
+ });
},
- );
+ });
}
export function useServerSearch(query: string, enabled: boolean) {
- return useQuery(
- [QueryKeys.System, QueryKeys.Search, query],
- () => api.system.search(query),
- {
- enabled,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Search, query],
+ queryFn: () => api.system.search(query),
+ enabled,
+ });
}
export function useSystemLogs() {
- return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), {
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Logs],
+ queryFn: () => api.system.logs(),
refetchOnWindowFocus: "always",
refetchInterval: 1000 * 60,
staleTime: 1000 * 10,
@@ -87,171 +101,189 @@ export function useSystemLogs() {
export function useDeleteLogs() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Logs],
- () => api.system.deleteLogs(),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Logs]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Logs],
+ mutationFn: () => api.system.deleteLogs(),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Logs],
+ });
},
- );
+ });
}
export function useSystemAnnouncements() {
- return useQuery(
- [QueryKeys.System, QueryKeys.Announcements],
- () => api.system.announcements(),
- {
- refetchOnWindowFocus: "always",
- refetchInterval: 1000 * 60,
- staleTime: 1000 * 10,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Announcements],
+ queryFn: () => api.system.announcements(),
+ refetchOnWindowFocus: "always",
+ refetchInterval: 1000 * 60,
+ staleTime: 1000 * 10,
+ });
}
export function useSystemAnnouncementsAddDismiss() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Announcements],
- (param: { hash: string }) => {
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Announcements],
+
+ mutationFn: (param: { hash: string }) => {
const { hash } = param;
return api.system.addAnnouncementsDismiss(hash);
},
- {
- onSuccess: (_, { hash }) => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Announcements]);
- client.invalidateQueries([QueryKeys.System, QueryKeys.Badges]);
- },
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Announcements],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Badges],
+ });
},
- );
+ });
}
export function useSystemTasks() {
- return useQuery(
- [QueryKeys.System, QueryKeys.Tasks],
- () => api.system.tasks(),
- {
- refetchOnWindowFocus: "always",
- refetchInterval: 1000 * 60,
- staleTime: 1000 * 10,
- },
- );
+ return useQuery({
+ queryKey: [QueryKeys.System, QueryKeys.Tasks],
+ queryFn: () => api.system.tasks(),
+ refetchOnWindowFocus: "always",
+ refetchInterval: 1000 * 60,
+ staleTime: 1000 * 10,
+ });
}
export function useRunTask() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Tasks],
- (id: string) => api.system.runTask(id),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
- client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Tasks],
+ mutationFn: (id: string) => api.system.runTask(id),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Tasks],
+ });
+
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Backups],
+ });
},
- );
+ });
}
export function useSystemBackups() {
- return useQuery([QueryKeys.System, "backups"], () => api.system.backups());
+ return useQuery({
+ queryKey: [QueryKeys.System, "backups"],
+ queryFn: () => api.system.backups(),
+ });
}
export function useCreateBackups() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Backups],
- () => api.system.createBackups(),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Backups],
+ mutationFn: () => api.system.createBackups(),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Backups],
+ });
},
- );
+ });
}
export function useRestoreBackups() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Backups],
- (filename: string) => api.system.restoreBackups(filename),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Backups],
+ mutationFn: (filename: string) => api.system.restoreBackups(filename),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Backups],
+ });
},
- );
+ });
}
export function useDeleteBackups() {
const client = useQueryClient();
- return useMutation(
- [QueryKeys.System, QueryKeys.Backups],
- (filename: string) => api.system.deleteBackups(filename),
- {
- onSuccess: () => {
- client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
- },
+ return useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Backups],
+ mutationFn: (filename: string) => api.system.deleteBackups(filename),
+
+ onSuccess: () => {
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Backups],
+ });
},
- );
+ });
}
export function useSystemStatus() {
- return useQuery([QueryKeys.System, "status"], () => api.system.status());
+ return useQuery({
+ queryKey: [QueryKeys.System, "status"],
+ queryFn: () => api.system.status(),
+ });
}
export function useSystemHealth() {
- return useQuery([QueryKeys.System, "health"], () => api.system.health());
+ return useQuery({
+ queryKey: [QueryKeys.System, "health"],
+ queryFn: () => api.system.health(),
+ });
}
export function useSystemReleases() {
- return useQuery([QueryKeys.System, "releases"], () => api.system.releases());
+ return useQuery({
+ queryKey: [QueryKeys.System, "releases"],
+ queryFn: () => api.system.releases(),
+ });
}
export function useSystem() {
const client = useQueryClient();
- const { mutate: logout, isLoading: isLoggingOut } = useMutation(
- [QueryKeys.System, QueryKeys.Actions],
- () => api.system.logout(),
- {
- onSuccess: () => {
- setAuthenticated(false);
- client.clear();
- },
+ const { mutate: logout, isPending: isLoggingOut } = useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Actions],
+ mutationFn: () => api.system.logout(),
+
+ onSuccess: () => {
+ setAuthenticated(false);
+ client.clear();
},
- );
+ });
+
+ const { mutate: login, isPending: isLoggingIn } = useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Actions],
- const { mutate: login, isLoading: isLoggingIn } = useMutation(
- [QueryKeys.System, QueryKeys.Actions],
- (param: { username: string; password: string }) =>
+ mutationFn: (param: { username: string; password: string }) =>
api.system.login(param.username, param.password),
- {
- onSuccess: () => {
- // TODO: Hard-coded value
- window.location.replace(Environment.baseUrl);
- },
+
+ onSuccess: () => {
+ // TODO: Hard-coded value
+ window.location.replace(Environment.baseUrl);
},
- );
+ });
- const { mutate: shutdown, isLoading: isShuttingDown } = useMutation(
- [QueryKeys.System, QueryKeys.Actions],
- () => api.system.shutdown(),
- {
- onSuccess: () => {
- client.clear();
- },
+ const { mutate: shutdown, isPending: isShuttingDown } = useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Actions],
+ mutationFn: () => api.system.shutdown(),
+
+ onSuccess: () => {
+ client.clear();
},
- );
+ });
+
+ const { mutate: restart, isPending: isRestarting } = useMutation({
+ mutationKey: [QueryKeys.System, QueryKeys.Actions],
+ mutationFn: () => api.system.restart(),
- const { mutate: restart, isLoading: isRestarting } = useMutation(
- [QueryKeys.System, QueryKeys.Actions],
- () => api.system.restart(),
- {
- onSuccess: () => {
- client.clear();
- },
+ onSuccess: () => {
+ client.clear();
},
- );
+ });
return useMemo(
() => ({
diff --git a/frontend/src/apis/queries/hooks.ts b/frontend/src/apis/queries/hooks.ts
index 5f9bf63d3..2f17b5efe 100644
--- a/frontend/src/apis/queries/hooks.ts
+++ b/frontend/src/apis/queries/hooks.ts
@@ -1,12 +1,12 @@
-import { GetItemId, useOnValueChange } from "@/utilities";
-import { usePageSize } from "@/utilities/storage";
import { useCallback, useEffect, useState } from "react";
import {
QueryKey,
- UseQueryResult,
useQuery,
useQueryClient,
-} from "react-query";
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { GetItemId, useOnValueChange } from "@/utilities";
+import { usePageSize } from "@/utilities/storage";
import { QueryKeys } from "./keys";
export type UsePaginationQueryResult<T extends object> = UseQueryResult<
@@ -39,31 +39,31 @@ export function usePaginationQuery<
const start = page * pageSize;
- const results = useQuery(
- [...queryKey, QueryKeys.Range, { start, size: pageSize }],
- () => {
+ const results = useQuery({
+ queryKey: [...queryKey, QueryKeys.Range, { start, size: pageSize }],
+
+ queryFn: () => {
const param: Parameter.Range = {
start,
length: pageSize,
};
return queryFn(param);
},
- {
- onSuccess: ({ data }) => {
- if (cacheIndividual) {
- data.forEach((item) => {
- const id = GetItemId(item);
- if (id) {
- client.setQueryData([...queryKey, id], item);
- }
- });
- }
- },
- },
- );
+ });
const { data } = results;
+ useEffect(() => {
+ if (results.isSuccess && results.data && cacheIndividual) {
+ results.data.data.forEach((item) => {
+ const id = GetItemId(item);
+ if (id) {
+ client.setQueryData([...queryKey, id], item);
+ }
+ });
+ }
+ }, [results.isSuccess, results.data, client, cacheIndividual, queryKey]);
+
const totalCount = data?.total ?? 0;
const pageCount = Math.ceil(totalCount / pageSize);
diff --git a/frontend/src/apis/queries/index.ts b/frontend/src/apis/queries/index.ts
index a1a17ffd9..75958f111 100644
--- a/frontend/src/apis/queries/index.ts
+++ b/frontend/src/apis/queries/index.ts
@@ -1,4 +1,4 @@
-import { QueryClient } from "react-query";
+import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
@@ -6,7 +6,11 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60,
- keepPreviousData: true,
+ networkMode: "offlineFirst",
+ placeholderData: (previousData: object) => previousData,
+ },
+ mutations: {
+ networkMode: "offlineFirst",
},
},
});
diff --git a/frontend/src/apis/raw/client.ts b/frontend/src/apis/raw/client.ts
index d77b81205..f3c2f53a3 100644
--- a/frontend/src/apis/raw/client.ts
+++ b/frontend/src/apis/raw/client.ts
@@ -1,10 +1,10 @@
+import { showNotification } from "@mantine/notifications";
+import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import socketio from "@/modules/socketio";
import { notification } from "@/modules/task";
+import { Environment } from "@/utilities";
import { LOG } from "@/utilities/console";
import { setAuthenticated } from "@/utilities/event";
-import { showNotification } from "@mantine/notifications";
-import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
-import { Environment } from "../../utilities";
function GetErrorMessage(data: unknown, defaultMsg = "Unknown error"): string {
if (typeof data === "string") {
diff --git a/frontend/src/assets/_bazarr.scss b/frontend/src/assets/_bazarr.scss
new file mode 100644
index 000000000..96e44e619
--- /dev/null
+++ b/frontend/src/assets/_bazarr.scss
@@ -0,0 +1,82 @@
+$color-brand-0: #f8f0fc;
+$color-brand-1: #f3d9fa;
+$color-brand-2: #eebefa;
+$color-brand-3: #e599f7;
+$color-brand-4: #da77f2;
+$color-brand-5: #cc5de8;
+$color-brand-6: #be4bdb;
+$color-brand-7: #ae3ec9;
+$color-brand-8: #9c36b5;
+$color-brand-9: #862e9c;
+
+// Based on Mantine Cyan
+$color-highlight-0: #e3fafc;
+$color-highlight-1: #c5f6fa;
+$color-highlight-2: #99e9f2;
+$color-highlight-3: #66d9e8;
+$color-highlight-4: #3bc9db;
+$color-highlight-5: #22b8cf;
+$color-highlight-6: #15aabf;
+$color-highlight-7: #1098ad;
+$color-highlight-8: #0c8599;
+$color-highlight-9: #0b7285;
+
+// Based on Mantine Yellow
+$color-warning-0: #fff9db;
+$color-warning-1: #fff3bf;
+$color-warning-2: #ffec99;
+$color-warning-3: #ffe066;
+$color-warning-4: #ffd43b;
+$color-warning-5: #fcc419;
+$color-warning-6: #fab005;
+$color-warning-7: #f59f00;
+$color-warning-8: #f08c00;
+$color-warning-9: #e67700;
+
+// Based on Mantine Gray
+$color-disabled-0: #f8f9fa;
+$color-disabled-1: #f1f3f5;
+$color-disabled-2: #e9ecef;
+$color-disabled-3: #dee2e6;
+$color-disabled-4: #ced4da;
+$color-disabled-5: #adb5bd;
+$color-disabled-6: #868e96;
+$color-disabled-7: #495057;
+$color-disabled-8: #343a40;
+$color-disabled-9: #212529;
+
+$header-height: 64px;
+
+:global {
+ .table-long-break {
+ overflow-wrap: anywhere;
+ }
+
+ .table-primary {
+ display: inline-block;
+
+ font-size: var(--mantine-font-size-sm);
+
+ @include smaller-than($mantine-breakpoint-sm) {
+ min-width: 12rem;
+ }
+ }
+
+ .table-no-wrap {
+ white-space: nowrap;
+ }
+
+ .table-select {
+ display: inline-block;
+
+ @include smaller-than($mantine-breakpoint-sm) {
+ min-width: 10rem;
+ }
+ }
+}
+
+:root {
+ @include dark {
+ --mantine-color-body: var(--mantine-color-dark-8);
+ }
+}
diff --git a/frontend/src/assets/_mantine.scss b/frontend/src/assets/_mantine.scss
new file mode 100644
index 000000000..93412c636
--- /dev/null
+++ b/frontend/src/assets/_mantine.scss
@@ -0,0 +1,61 @@
+@use "sass:math";
+
+$mantine-breakpoint-xs: "36em";
+$mantine-breakpoint-sm: "48em";
+$mantine-breakpoint-md: "62em";
+$mantine-breakpoint-lg: "75em";
+$mantine-breakpoint-xl: "88em";
+
+@function rem($value) {
+ @return #{math.div(math.div($value, $value * 0 + 1), 16)}rem;
+}
+
+@mixin light {
+ [data-mantine-color-scheme="light"] & {
+ @content;
+ }
+}
+
+@mixin dark {
+ [data-mantine-color-scheme="dark"] & {
+ @content;
+ }
+}
+
+@mixin hover {
+ @media (hover: hover) {
+ &:hover {
+ @content;
+ }
+ }
+
+ @media (hover: none) {
+ &:active {
+ @content;
+ }
+ }
+}
+
+@mixin smaller-than($breakpoint) {
+ @media (max-width: $breakpoint) {
+ @content;
+ }
+}
+
+@mixin larger-than($breakpoint) {
+ @media (min-width: $breakpoint) {
+ @content;
+ }
+}
+
+@mixin rtl {
+ [dir="rtl"] & {
+ @content;
+ }
+}
+
+@mixin ltr {
+ [dir="ltr"] & {
+ @content;
+ }
+}
diff --git a/frontend/src/assets/_variables.module.scss b/frontend/src/assets/_variables.module.scss
new file mode 100644
index 000000000..262d285b2
--- /dev/null
+++ b/frontend/src/assets/_variables.module.scss
@@ -0,0 +1,18 @@
+$navbar-width: 200;
+
+:export {
+ colorBrand0: $color-brand-0;
+ colorBrand1: $color-brand-1;
+ colorBrand2: $color-brand-2;
+ colorBrand3: $color-brand-3;
+ colorBrand4: $color-brand-4;
+ colorBrand5: $color-brand-5;
+ colorBrand6: $color-brand-6;
+ colorBrand7: $color-brand-7;
+ colorBrand8: $color-brand-8;
+ colorBrand9: $color-brand-9;
+
+ headerHeight: $header-height;
+
+ navBarWidth: $navbar-width;
+}
diff --git a/frontend/src/assets/action_icon.module.scss b/frontend/src/assets/action_icon.module.scss
new file mode 100644
index 000000000..d23cb6ce2
--- /dev/null
+++ b/frontend/src/assets/action_icon.module.scss
@@ -0,0 +1,16 @@
+@layer mantine {
+ .root {
+ --ai-bg: transparent;
+
+ @include light {
+ color: var(--mantine-color-dark-2);
+ --ai-hover: var(--mantine-color-gray-1);
+ --ai-hover-color: var(--mantine-color-gray-1);
+ }
+
+ @include dark {
+ color: var(--mantine-color-dark-0);
+ --ai-hover: var(--mantine-color-gray-8);
+ }
+ }
+}
diff --git a/frontend/src/assets/badge.module.scss b/frontend/src/assets/badge.module.scss
new file mode 100644
index 000000000..5b31be59e
--- /dev/null
+++ b/frontend/src/assets/badge.module.scss
@@ -0,0 +1,54 @@
+@layer mantine {
+ .root {
+ background-color: transparentize($color-brand-6, 0.8);
+
+ &[data-variant="warning"] {
+ color: lighten($color-warning-2, 0.8);
+ background-color: transparentize($color-warning-6, 0.8);
+ }
+
+ &[data-variant="highlight"] {
+ color: lighten($color-highlight-2, 1);
+ background-color: transparentize($color-highlight-5, 0.8);
+ }
+
+ &[data-variant="disabled"] {
+ color: lighten($color-disabled-0, 1);
+ background-color: transparentize($color-disabled-7, 0.8);
+ }
+
+ &[data-variant="light"] {
+ color: var(--mantine-color-dark-0);
+ background-color: transparentize($color-disabled-9, 0.8);
+ }
+
+ @include light {
+ color: $color-brand-6;
+ background-color: transparentize($color-brand-3, 0.8);
+
+ &[data-variant="warning"] {
+ color: darken($color-warning-7, 1);
+ background-color: transparentize($color-warning-6, 0.8);
+ }
+
+ &[data-variant="disabled"] {
+ color: darken($color-disabled-6, 1);
+ background-color: transparentize($color-disabled-4, 0.8);
+ }
+
+ &[data-variant="highlight"] {
+ color: darken($color-highlight-6, 1);
+ background-color: transparentize($color-highlight-5, 0.8);
+ }
+
+ &[data-variant="light"] {
+ color: var(--mantine-color-black);
+ background-color: var(--mantine-color-gray-5);
+ }
+ }
+ }
+
+ .label {
+ overflow: visible;
+ }
+}
diff --git a/frontend/src/assets/button.module.scss b/frontend/src/assets/button.module.scss
new file mode 100644
index 000000000..0c466a4c4
--- /dev/null
+++ b/frontend/src/assets/button.module.scss
@@ -0,0 +1,18 @@
+@layer mantine {
+ .root {
+ @include dark {
+ color: var(--mantine-color-dark-0);
+ }
+
+ &[data-variant="danger"] {
+ background-color: var(--mantine-color-red-9);
+ color: var(--mantine-color-red-0);
+ }
+ }
+
+ .root:disabled {
+ @include dark {
+ color: var(--mantine-color-dark-9);
+ }
+ }
+}
diff --git a/frontend/src/assets/pagination.module.scss b/frontend/src/assets/pagination.module.scss
new file mode 100644
index 000000000..2b66d7510
--- /dev/null
+++ b/frontend/src/assets/pagination.module.scss
@@ -0,0 +1,3 @@
+.control {
+ --pagination-active-bg: var(--mantine-color-brand-filled);
+}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
index a29200e47..4e39dd9dc 100644
--- a/frontend/src/components/ErrorBoundary.tsx
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
-import UIError from "@/pages/errors/UIError";
import { Component, PropsWithChildren } from "react";
+import UIError from "@/pages/errors/UIError";
interface State {
error: Error | null;
diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx
index bc4a9f8d3..b506afee3 100644
--- a/frontend/src/components/Search.tsx
+++ b/frontend/src/components/Search.tsx
@@ -1,15 +1,10 @@
-import { useServerSearch } from "@/apis/hooks";
-import { useDebouncedValue } from "@/utilities";
+import { FunctionComponent, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- Anchor,
- Autocomplete,
- createStyles,
- SelectItemProps,
-} from "@mantine/core";
-import { forwardRef, FunctionComponent, useMemo, useState } from "react";
-import { Link } from "react-router-dom";
+import { useServerSearch } from "@/apis/hooks";
+import { useDebouncedValue } from "@/utilities";
type SearchResultItem = {
value: string;
@@ -18,7 +13,7 @@ type SearchResultItem = {
function useSearch(query: string) {
const debouncedQuery = useDebouncedValue(query, 500);
- const { data } = useServerSearch(debouncedQuery, debouncedQuery.length > 0);
+ const { data } = useServerSearch(debouncedQuery, debouncedQuery.length >= 0);
return useMemo<SearchResultItem[]>(
() =>
@@ -31,7 +26,6 @@ function useSearch(query: string) {
} else {
throw new Error("Unknown search result");
}
-
return {
value: `${v.title} (${v.year})`,
link,
@@ -41,59 +35,43 @@ function useSearch(query: string) {
);
}
-const useStyles = createStyles((theme) => {
- return {
- result: {
- color:
- theme.colorScheme === "light"
- ? theme.colors.dark[8]
- : theme.colors.gray[1],
- },
- };
-});
-
-type ResultCompProps = SelectItemProps & SearchResultItem;
-
-const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>(
- ({ link, value }, ref) => {
- const styles = useStyles();
+const optionsFilter: OptionsFilter = ({ options, search }) => {
+ const lowercaseSearch = search.toLowerCase();
+ const trimmedSearch = search.trim();
+ return (options as ComboboxItem[]).filter((option) => {
return (
- <Anchor
- component={Link}
- to={link}
- underline={false}
- className={styles.classes.result}
- p="sm"
- >
- {value}
- </Anchor>
+ option.value.toLowerCase().includes(lowercaseSearch) ||
+ option.value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .includes(trimmedSearch)
);
- },
-);
+ });
+};
const Search: FunctionComponent = () => {
+ const navigate = useNavigate();
const [query, setQuery] = useState("");
const results = useSearch(query);
return (
<Autocomplete
- icon={<FontAwesomeIcon icon={faSearch} />}
- itemComponent={ResultComponent}
+ leftSection={<FontAwesomeIcon icon={faSearch} />}
+ renderOption={(input) => <Text p="xs">{input.option.value}</Text>}
placeholder="Search"
size="sm"
data={results}
value={query}
+ scrollAreaProps={{ type: "auto" }}
+ maxDropdownHeight={400}
onChange={setQuery}
onBlur={() => setQuery("")}
- filter={(value, item) =>
- item.value.toLowerCase().includes(value.toLowerCase().trim()) ||
- item.value
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .includes(value.trim())
+ filter={optionsFilter}
+ onOptionSubmit={(option) =>
+ navigate(results.find((a) => a.value === option)?.link || "/")
}
></Autocomplete>
);
diff --git a/frontend/src/components/StateIcon.tsx b/frontend/src/components/StateIcon.tsx
index f9683f63a..31e0b5243 100644
--- a/frontend/src/components/StateIcon.tsx
+++ b/frontend/src/components/StateIcon.tsx
@@ -1,4 +1,6 @@
-import { BuildKey } from "@/utilities";
+import { FunctionComponent } from "react";
+import { Group, List, Popover, Stack, Text } from "@mantine/core";
+import { useHover } from "@mantine/hooks";
import {
faCheck,
faCheckCircle,
@@ -7,9 +9,7 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Group, List, Popover, Stack, Text } from "@mantine/core";
-import { useHover } from "@mantine/hooks";
-import { FunctionComponent } from "react";
+import { BuildKey } from "@/utilities";
interface StateIconProps {
matches: string[];
@@ -31,7 +31,7 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
return <FontAwesomeIcon icon={faListCheck} />;
} else {
return (
- <Text color={hasIssues ? "yellow" : "green"}>
+ <Text c={hasIssues ? "yellow" : "green"} span>
<FontAwesomeIcon
icon={hasIssues ? faExclamationCircle : faCheckCircle}
/>
@@ -48,9 +48,9 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
</Text>
</Popover.Target>
<Popover.Dropdown>
- <Group position="left" spacing="xl" noWrap grow>
- <Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
- <Text color="green">
+ <Group justify="left" gap="xl" wrap="nowrap" grow>
+ <Stack align="flex-start" justify="flex-start" gap="xs" mb="auto">
+ <Text c="green">
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
</Text>
<List>
@@ -59,8 +59,8 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
))}
</List>
</Stack>
- <Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
- <Text color="yellow">
+ <Stack align="flex-start" justify="flex-start" gap="xs" mb="auto">
+ <Text c="yellow">
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
</Text>
<List>
diff --git a/frontend/src/components/SubtitleToolsMenu.tsx b/frontend/src/components/SubtitleToolsMenu.tsx
index e36a1e9e1..545c87478 100644
--- a/frontend/src/components/SubtitleToolsMenu.tsx
+++ b/frontend/src/components/SubtitleToolsMenu.tsx
@@ -1,11 +1,5 @@
-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 { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
+import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
import {
faClock,
faCode,
@@ -23,8 +17,14 @@ import {
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";
+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 { SyncSubtitleModal } from "./forms/SyncSubtitleForm";
export interface ToolOptions {
@@ -127,6 +127,8 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
type: s.type,
language: s.language,
path: s.path,
+ hi: s.hi,
+ forced: s.forced,
};
task.create(s.path, name, mutateAsync, { action, form });
});
@@ -148,7 +150,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
<Menu.Item
key={tool.key}
disabled={disabledTools}
- icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
+ leftSection={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
onClick={() => {
if (tool.modal) {
modals.openContextModal(tool.modal, { selections });
@@ -164,7 +166,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
<Menu.Label>Actions</Menu.Label>
<Menu.Item
disabled={selections.length !== 0 || onAction === undefined}
- icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
+ leftSection={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
onClick={() => {
onAction?.("search");
}}
@@ -174,7 +176,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
<Menu.Item
disabled={selections.length === 0 || onAction === undefined}
color="red"
- icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
+ leftSection={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
onClick={() => {
modals.openConfirmModal({
title: "The following subtitles will be deleted",
diff --git a/frontend/src/components/TextPopover.tsx b/frontend/src/components/TextPopover.tsx
index 8fda5913e..03dd58700 100644
--- a/frontend/src/components/TextPopover.tsx
+++ b/frontend/src/components/TextPopover.tsx
@@ -1,7 +1,7 @@
+import { FunctionComponent, ReactElement } from "react";
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;
@@ -21,7 +21,12 @@ const TextPopover: FunctionComponent<TextPopoverProps> = ({
}
return (
- <Tooltip opened={hovered} label={text} {...tooltip}>
+ <Tooltip
+ opened={hovered}
+ label={text}
+ {...tooltip}
+ style={{ textWrap: "wrap" }}
+ >
<div ref={ref}>{children}</div>
</Tooltip>
);
diff --git a/frontend/src/components/async/Lazy.tsx b/frontend/src/components/async/Lazy.tsx
index 2a0496223..317c0feb3 100644
--- a/frontend/src/components/async/Lazy.tsx
+++ b/frontend/src/components/async/Lazy.tsx
@@ -1,5 +1,5 @@
-import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, PropsWithChildren, Suspense } from "react";
+import { LoadingOverlay } from "@mantine/core";
const Lazy: FunctionComponent<PropsWithChildren> = ({ children }) => {
return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>;
diff --git a/frontend/src/components/async/MutateAction.tsx b/frontend/src/components/async/MutateAction.tsx
index 920fe4ff3..92c102ea9 100644
--- a/frontend/src/components/async/MutateAction.tsx
+++ b/frontend/src/components/async/MutateAction.tsx
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
-import { UseMutationResult } from "react-query";
-import { Action } from "../inputs";
-import { ActionProps } from "../inputs/Action";
+import { UseMutationResult } from "@tanstack/react-query";
+import { Action } from "@/components/inputs";
+import { ActionProps } from "@/components/inputs/Action";
type MutateActionProps<DATA, VAR> = Omit<
ActionProps,
@@ -16,7 +16,6 @@ type MutateActionProps<DATA, VAR> = Omit<
function MutateAction<DATA, VAR>({
mutation,
- noReset,
onSuccess,
onError,
args,
diff --git a/frontend/src/components/async/MutateButton.tsx b/frontend/src/components/async/MutateButton.tsx
index 8d0f68541..908c2dfda 100644
--- a/frontend/src/components/async/MutateButton.tsx
+++ b/frontend/src/components/async/MutateButton.tsx
@@ -1,6 +1,6 @@
-import { Button, ButtonProps } from "@mantine/core";
import { useCallback, useState } from "react";
-import { UseMutationResult } from "react-query";
+import { Button, ButtonProps } from "@mantine/core";
+import { UseMutationResult } from "@tanstack/react-query";
type MutateButtonProps<DATA, VAR> = Omit<
ButtonProps,
@@ -15,7 +15,6 @@ type MutateButtonProps<DATA, VAR> = Omit<
function MutateButton<DATA, VAR>({
mutation,
- noReset,
onSuccess,
onError,
args,
diff --git a/frontend/src/components/async/QueryOverlay.tsx b/frontend/src/components/async/QueryOverlay.tsx
index 24b95ab18..1672989ff 100644
--- a/frontend/src/components/async/QueryOverlay.tsx
+++ b/frontend/src/components/async/QueryOverlay.tsx
@@ -1,7 +1,7 @@
-import { LoadingProvider } from "@/contexts";
-import { LoadingOverlay } from "@mantine/core";
import { FunctionComponent, ReactNode } from "react";
-import { UseQueryResult } from "react-query";
+import { LoadingOverlay } from "@mantine/core";
+import { UseQueryResult } from "@tanstack/react-query";
+import { LoadingProvider } from "@/contexts";
interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>;
@@ -12,7 +12,7 @@ interface QueryOverlayProps {
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children,
global = false,
- result: { isLoading, isError, error },
+ result: { isLoading },
}) => {
return (
<LoadingProvider value={isLoading}>
diff --git a/frontend/src/components/bazarr/AudioList.tsx b/frontend/src/components/bazarr/AudioList.tsx
index ac9cce743..f0dc07c0d 100644
--- a/frontend/src/components/bazarr/AudioList.tsx
+++ b/frontend/src/components/bazarr/AudioList.tsx
@@ -1,6 +1,7 @@
-import { BuildKey } from "@/utilities";
-import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
import { FunctionComponent } from "react";
+import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core";
+import { BuildKey } from "@/utilities";
+import { normalizeAudioLanguage } from "@/utilities/languages";
export type AudioListProps = GroupProps & {
audios: Language.Info[];
@@ -13,10 +14,10 @@ const AudioList: FunctionComponent<AudioListProps> = ({
...group
}) => {
return (
- <Group spacing="xs" {...group}>
+ <Group gap="xs" {...group}>
{audios.map((audio, idx) => (
<Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}>
- {audio.name}
+ {normalizeAudioLanguage(audio.name)}
</Badge>
))}
</Group>
diff --git a/frontend/src/components/bazarr/HistoryIcon.tsx b/frontend/src/components/bazarr/HistoryIcon.tsx
index e6c0f2411..add0cd1fd 100644
--- a/frontend/src/components/bazarr/HistoryIcon.tsx
+++ b/frontend/src/components/bazarr/HistoryIcon.tsx
@@ -1,3 +1,5 @@
+import { FunctionComponent } from "react";
+import { Tooltip } from "@mantine/core";
import {
faClock,
faClosedCaptioning,
@@ -9,8 +11,6 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Tooltip } from "@mantine/core";
-import { FunctionComponent } from "react";
enum HistoryAction {
Delete = 0,
diff --git a/frontend/src/components/bazarr/Language.test.tsx b/frontend/src/components/bazarr/Language.test.tsx
index 9e0e0fab8..2cad5d4c8 100644
--- a/frontend/src/components/bazarr/Language.test.tsx
+++ b/frontend/src/components/bazarr/Language.test.tsx
@@ -1,5 +1,5 @@
-import { rawRender, screen } from "@/tests";
import { describe, it } from "vitest";
+import { render, screen } from "@/tests";
import { Language } from ".";
describe("Language text", () => {
@@ -9,13 +9,13 @@ describe("Language text", () => {
};
it("should show short text", () => {
- rawRender(<Language.Text value={testLanguage}></Language.Text>);
+ render(<Language.Text value={testLanguage}></Language.Text>);
expect(screen.getByText(testLanguage.code2)).toBeDefined();
});
it("should show long text", () => {
- rawRender(<Language.Text value={testLanguage} long></Language.Text>);
+ render(<Language.Text value={testLanguage} long></Language.Text>);
expect(screen.getByText(testLanguage.name)).toBeDefined();
});
@@ -23,7 +23,7 @@ describe("Language text", () => {
const testLanguageWithHi: Language.Info = { ...testLanguage, hi: true };
it("should show short text with HI", () => {
- rawRender(<Language.Text value={testLanguageWithHi}></Language.Text>);
+ render(<Language.Text value={testLanguageWithHi}></Language.Text>);
const expectedText = `${testLanguageWithHi.code2}:HI`;
@@ -31,7 +31,7 @@ describe("Language text", () => {
});
it("should show long text with HI", () => {
- rawRender(<Language.Text value={testLanguageWithHi} long></Language.Text>);
+ render(<Language.Text value={testLanguageWithHi} long></Language.Text>);
const expectedText = `${testLanguageWithHi.name} HI`;
@@ -44,7 +44,7 @@ describe("Language text", () => {
};
it("should show short text with Forced", () => {
- rawRender(<Language.Text value={testLanguageWithForced}></Language.Text>);
+ render(<Language.Text value={testLanguageWithForced}></Language.Text>);
const expectedText = `${testLanguageWithHi.code2}:Forced`;
@@ -52,9 +52,7 @@ describe("Language text", () => {
});
it("should show long text with Forced", () => {
- rawRender(
- <Language.Text value={testLanguageWithForced} long></Language.Text>,
- );
+ render(<Language.Text value={testLanguageWithForced} long></Language.Text>);
const expectedText = `${testLanguageWithHi.name} Forced`;
@@ -75,7 +73,7 @@ describe("Language list", () => {
];
it("should show all languages", () => {
- rawRender(<Language.List value={elements}></Language.List>);
+ render(<Language.List value={elements}></Language.List>);
elements.forEach((value) => {
expect(screen.getByText(value.name)).toBeDefined();
diff --git a/frontend/src/components/bazarr/Language.tsx b/frontend/src/components/bazarr/Language.tsx
index e5627c82e..6315d9102 100644
--- a/frontend/src/components/bazarr/Language.tsx
+++ b/frontend/src/components/bazarr/Language.tsx
@@ -1,6 +1,6 @@
-import { BuildKey } from "@/utilities";
-import { Badge, Group, Text, TextProps } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
+import { Badge, Group, Text, TextProps } from "@mantine/core";
+import { BuildKey } from "@/utilities";
type LanguageTextProps = TextProps & {
value: Language.Info;
@@ -49,7 +49,7 @@ type LanguageListProps = {
const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => {
return (
- <Group spacing="xs">
+ <Group gap="xs">
{value.map((v) => (
<Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge>
))}
diff --git a/frontend/src/components/bazarr/LanguageProfile.tsx b/frontend/src/components/bazarr/LanguageProfile.tsx
index 75b7b73ca..a234268c3 100644
--- a/frontend/src/components/bazarr/LanguageProfile.tsx
+++ b/frontend/src/components/bazarr/LanguageProfile.tsx
@@ -1,5 +1,5 @@
-import { useLanguageProfiles } from "@/apis/hooks";
import { FunctionComponent, useMemo } from "react";
+import { useLanguageProfiles } from "@/apis/hooks";
interface Props {
index: number | null;
diff --git a/frontend/src/components/bazarr/LanguageSelector.tsx b/frontend/src/components/bazarr/LanguageSelector.tsx
index c2219ca7c..8954403bd 100644
--- a/frontend/src/components/bazarr/LanguageSelector.tsx
+++ b/frontend/src/components/bazarr/LanguageSelector.tsx
@@ -1,7 +1,7 @@
+import { FunctionComponent, useMemo } from "react";
import { useLanguages } from "@/apis/hooks";
import { Selector, SelectorProps } from "@/components/inputs";
import { useSelectorOptions } from "@/utilities";
-import { FunctionComponent, useMemo } from "react";
interface LanguageSelectorProps
extends Omit<SelectorProps<Language.Server>, "options" | "getkey"> {
diff --git a/frontend/src/components/forms/ColorToolForm.tsx b/frontend/src/components/forms/ColorToolForm.tsx
index a37819bee..9deac9bf4 100644
--- a/frontend/src/components/forms/ColorToolForm.tsx
+++ b/frontend/src/components/forms/ColorToolForm.tsx
@@ -1,11 +1,11 @@
+import { FunctionComponent } from "react";
+import { Button, Divider, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
import { useSubtitleAction } from "@/apis/hooks";
import { Selector, SelectorOption } from "@/components";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
-import { Button, Divider, Stack } from "@mantine/core";
-import { useForm } from "@mantine/form";
-import { FunctionComponent } from "react";
const TaskName = "Changing Color";
diff --git a/frontend/src/components/forms/FrameRateForm.tsx b/frontend/src/components/forms/FrameRateForm.tsx
index 7e7eca24c..7c57daf28 100644
--- a/frontend/src/components/forms/FrameRateForm.tsx
+++ b/frontend/src/components/forms/FrameRateForm.tsx
@@ -1,10 +1,10 @@
+import { FunctionComponent } from "react";
+import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
-import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
-import { useForm } from "@mantine/form";
-import { FunctionComponent } from "react";
const TaskName = "Changing Frame Rate";
@@ -55,15 +55,17 @@ const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
})}
>
<Stack>
- <Group spacing="xs" grow>
+ <Group gap="xs" grow>
<NumberInput
placeholder="From"
- precision={2}
+ decimalScale={2}
+ fixedDecimalScale
{...form.getInputProps("from")}
></NumberInput>
<NumberInput
placeholder="To"
- precision={2}
+ decimalScale={2}
+ fixedDecimalScale
{...form.getInputProps("to")}
></NumberInput>
</Group>
diff --git a/frontend/src/components/forms/ItemEditForm.tsx b/frontend/src/components/forms/ItemEditForm.tsx
index 9f3856d54..392338500 100644
--- a/frontend/src/components/forms/ItemEditForm.tsx
+++ b/frontend/src/components/forms/ItemEditForm.tsx
@@ -1,11 +1,11 @@
+import { FunctionComponent, useMemo } from "react";
+import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { UseMutationResult } from "@tanstack/react-query";
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/form";
-import { FunctionComponent, useMemo } from "react";
-import { UseMutationResult } from "react-query";
interface Props {
mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>;
@@ -21,7 +21,7 @@ const ItemEditForm: FunctionComponent<Props> = ({
onCancel,
}) => {
const { data, isFetching } = useLanguageProfiles();
- const { isLoading, mutate } = mutation;
+ const { isPending, mutate } = mutation;
const modals = useModals();
const profileOptions = useSelectorOptions(
@@ -47,7 +47,7 @@ const ItemEditForm: FunctionComponent<Props> = ({
(v) => v.code2,
);
- const isOverlayVisible = isLoading || isFetching || item === null;
+ const isOverlayVisible = isPending || isFetching || item === null;
return (
<form
@@ -80,7 +80,7 @@ const ItemEditForm: FunctionComponent<Props> = ({
label="Languages Profile"
></Selector>
<Divider></Divider>
- <Group position="right">
+ <Group justify="right">
<Button
disabled={isOverlayVisible}
onClick={() => {
diff --git a/frontend/src/components/forms/MovieUploadForm.tsx b/frontend/src/components/forms/MovieUploadForm.tsx
index b51614770..8e318d7ad 100644
--- a/frontend/src/components/forms/MovieUploadForm.tsx
+++ b/frontend/src/components/forms/MovieUploadForm.tsx
@@ -1,37 +1,35 @@
-import { useMovieSubtitleModification } from "@/apis/hooks";
-import { useModals, withModal } from "@/modules/modals";
-import { TaskGroup, task } from "@/modules/task";
-import { useTableStyles } from "@/styles";
-import { useArrayAction, useSelectorOptions } from "@/utilities";
-import FormUtils from "@/utilities/form";
-import {
- useLanguageProfileBy,
- useProfileItemsToLanguages,
-} from "@/utilities/languages";
-import {
- faCheck,
- faCircleNotch,
- faInfoCircle,
- faTimes,
- faTrash,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { FunctionComponent, useEffect, useMemo } from "react";
import {
Button,
Checkbox,
- createStyles,
Divider,
MantineColor,
Stack,
Text,
} from "@mantine/core";
import { useForm } from "@mantine/form";
+import {
+ faCheck,
+ faCircleNotch,
+ faInfoCircle,
+ faTimes,
+ faTrash,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import { isString } from "lodash";
-import { FunctionComponent, useEffect, useMemo } from "react";
-import { Column } from "react-table";
-import TextPopover from "../TextPopover";
-import { Action, Selector } from "../inputs";
-import { SimpleTable } from "../tables";
+import { useMovieSubtitleModification } from "@/apis/hooks";
+import { Action, Selector } from "@/components/inputs";
+import SimpleTable from "@/components/tables/SimpleTable";
+import TextPopover from "@/components/TextPopover";
+import { useModals, withModal } from "@/modules/modals";
+import { task, TaskGroup } from "@/modules/task";
+import { useArrayAction, useSelectorOptions } from "@/utilities";
+import FormUtils from "@/utilities/form";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "@/utilities/languages";
type SubtitleFile = {
file: File;
@@ -79,21 +77,12 @@ interface Props {
onComplete?: () => void;
}
-const useStyles = createStyles((theme) => {
- return {
- wrapper: {
- overflowWrap: "anywhere",
- },
- };
-});
-
const MovieUploadForm: FunctionComponent<Props> = ({
files,
movie,
onComplete,
}) => {
const modals = useModals();
- const { classes } = useStyles();
const profile = useLanguageProfileBy(movie.profileId);
@@ -154,63 +143,77 @@ const MovieUploadForm: FunctionComponent<Props> = ({
});
});
- 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]);
+ const ValidateResultCell = ({
+ validateResult,
+ }: {
+ validateResult: SubtitleValidateResult | undefined;
+ }) => {
+ const icon = useMemo(() => {
+ switch (validateResult?.state) {
+ case "valid":
+ return faCheck;
+ case "warning":
+ return faInfoCircle;
+ case "error":
+ return faTimes;
+ default:
+ return faCircleNotch;
+ }
+ }, [validateResult?.state]);
- const color = useMemo<MantineColor | undefined>(() => {
- switch (value?.state) {
- case "valid":
- return "green";
- case "warning":
- return "yellow";
- case "error":
- return "red";
- default:
- return undefined;
- }
- }, [value?.state]);
+ const color = useMemo<MantineColor | undefined>(() => {
+ switch (validateResult?.state) {
+ case "valid":
+ return "green";
+ case "warning":
+ return "yellow";
+ case "error":
+ return "red";
+ default:
+ return undefined;
+ }
+ }, [validateResult?.state]);
- return (
- <TextPopover text={value?.messages}>
- <Text color={color} inline>
- <FontAwesomeIcon icon={icon}></FontAwesomeIcon>
- </Text>
- </TextPopover>
- );
+ return (
+ <TextPopover text={validateResult?.messages}>
+ <Text c={color} inline>
+ <FontAwesomeIcon icon={icon} />
+ </Text>
+ </TextPopover>
+ );
+ };
+
+ const columns = useMemo<ColumnDef<SubtitleFile>[]>(
+ () => [
+ {
+ id: "validateResult",
+ cell: ({
+ row: {
+ original: { validateResult },
+ },
+ }) => {
+ return <ValidateResultCell validateResult={validateResult} />;
},
},
{
- Header: "File",
+ header: "File",
id: "filename",
- accessor: "file",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
-
- return <Text className={classes.primary}>{value.name}</Text>;
+ accessorKey: "file",
+ cell: ({
+ row: {
+ original: { file },
+ },
+ }) => {
+ return <Text className="table-primary">{file.name}</Text>;
},
},
{
- Header: "Forced",
- accessor: "forced",
- Cell: ({ row: { original, index }, value }) => {
+ header: "Forced",
+ accessorKey: "forced",
+ cell: ({ row: { original, index } }) => {
return (
<Checkbox
- checked={value}
+ checked={original.forced}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, forced: checked });
}}
@@ -219,12 +222,12 @@ const MovieUploadForm: FunctionComponent<Props> = ({
},
},
{
- Header: "HI",
- accessor: "hi",
- Cell: ({ row: { original, index }, value }) => {
+ header: "HI",
+ accessorKey: "hi",
+ cell: ({ row: { original, index } }) => {
return (
<Checkbox
- checked={value}
+ checked={original.hi}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, { ...original, hi: checked });
}}
@@ -233,15 +236,14 @@ const MovieUploadForm: FunctionComponent<Props> = ({
},
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ row: { original, index }, value }) => {
- const { classes } = useTableStyles();
+ header: "Language",
+ accessorKey: "language",
+ cell: ({ row: { original, index } }) => {
return (
<Selector
{...languageOptions}
- className={classes.select}
- value={value}
+ className="table-long-break"
+ value={original.language}
onChange={(item) => {
action.mutate(index, { ...original, language: item });
}}
@@ -251,13 +253,12 @@ const MovieUploadForm: FunctionComponent<Props> = ({
},
{
id: "action",
- accessor: "file",
- Cell: ({ row: { index } }) => {
+ cell: ({ row: { index } }) => {
return (
<Action
label="Remove"
icon={faTrash}
- color="red"
+ c="red"
onClick={() => action.remove(index)}
></Action>
);
@@ -289,7 +290,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
modals.closeSelf();
})}
>
- <Stack className={classes.wrapper}>
+ <Stack className="table-long-break">
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
<Divider></Divider>
<Button type="submit">Upload</Button>
diff --git a/frontend/src/components/forms/ProfileEditForm.module.scss b/frontend/src/components/forms/ProfileEditForm.module.scss
new file mode 100644
index 000000000..3d4a8e177
--- /dev/null
+++ b/frontend/src/components/forms/ProfileEditForm.module.scss
@@ -0,0 +1,13 @@
+.content {
+ @include smaller-than($mantine-breakpoint-md) {
+ padding: 0;
+ }
+}
+
+.evenly {
+ flex-wrap: wrap;
+
+ & > div {
+ flex: 1;
+ }
+}
diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx
index eecacd73e..267951fcb 100644
--- a/frontend/src/components/forms/ProfileEditForm.tsx
+++ b/frontend/src/components/forms/ProfileEditForm.tsx
@@ -1,14 +1,9 @@
-import { Action, Selector, SelectorOption, SimpleTable } from "@/components";
-import { useModals, withModal } from "@/modules/modals";
-import { useTableStyles } from "@/styles";
-import { useArrayAction, useSelectorOptions } from "@/utilities";
-import { LOG } from "@/utilities/console";
-import FormUtils from "@/utilities/form";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import React, { FunctionComponent, useCallback, useMemo } from "react";
import {
Accordion,
Button,
Checkbox,
+ Flex,
Select,
Stack,
Switch,
@@ -16,9 +11,16 @@ import {
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
-import { FunctionComponent, useCallback, useMemo } from "react";
-import { Column } from "react-table";
-import ChipInput from "../inputs/ChipInput";
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
+import { Action, Selector, SelectorOption } from "@/components";
+import ChipInput from "@/components/inputs/ChipInput";
+import SimpleTable from "@/components/tables/SimpleTable";
+import { useModals, withModal } from "@/modules/modals";
+import { useArrayAction, useSelectorOptions } from "@/utilities";
+import { LOG } from "@/utilities/console";
+import FormUtils from "@/utilities/form";
+import styles from "./ProfileEditForm.module.scss";
export const anyCutoff = 65535;
@@ -71,9 +73,16 @@ const ProfileEditForm: FunctionComponent<Props> = ({
(value) => value.length > 0,
"Must have a name",
),
+ tag: FormUtils.validation((value) => {
+ if (!value) {
+ return true;
+ }
+
+ return /^[a-z_0-9-]+$/.test(value);
+ }, "Only lowercase alphanumeric characters, underscores (_) and hyphens (-) are allowed"),
items: FormUtils.validation(
(value) => value.length > 0,
- "Must contain at lease 1 language",
+ "Must contain at least 1 language",
),
},
});
@@ -145,78 +154,88 @@ const ProfileEditForm: FunctionComponent<Props> = ({
}
}, [form, languages]);
- const columns = useMemo<Column<Language.ProfileItem>[]>(
+ const LanguageCell = React.memo(
+ ({ item, index }: { item: Language.ProfileItem; index: number }) => {
+ const code = useMemo(
+ () =>
+ languageOptions.options.find((l) => l.value.code2 === item.language)
+ ?.value ?? null,
+ [item.language],
+ );
+
+ return (
+ <Selector
+ {...languageOptions}
+ className="table-select"
+ value={code}
+ onChange={(value) => {
+ if (value) {
+ item.language = value.code2;
+ action.mutate(index, { ...item, language: value.code2 });
+ }
+ }}
+ ></Selector>
+ );
+ },
+ );
+
+ const SubtitleTypeCell = React.memo(
+ ({ item, index }: { item: Language.ProfileItem; index: number }) => {
+ const selectValue = useMemo(() => {
+ if (item.forced === "True") {
+ return "forced";
+ } else if (item.hi === "True") {
+ return "hi";
+ } else {
+ return "normal";
+ }
+ }, [item.forced, item.hi]);
+
+ return (
+ <Select
+ value={selectValue}
+ data={subtitlesTypeOptions}
+ onChange={(value) => {
+ if (value) {
+ action.mutate(index, {
+ ...item,
+ hi: value === "hi" ? "True" : "False",
+ forced: value === "forced" ? "True" : "False",
+ });
+ }
+ }}
+ ></Select>
+ );
+ },
+ );
+
+ const columns = useMemo<ColumnDef<Language.ProfileItem>[]>(
() => [
{
- Header: "ID",
- accessor: "id",
+ header: "ID",
+ accessorKey: "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: "Language",
+ accessorKey: "language",
+ cell: ({ row: { original: item, index } }) => {
+ return <LanguageCell item={item} index={index} />;
},
},
{
- Header: "Subtitles Type",
- accessor: "forced",
- Cell: ({ row: { original: item, index }, value }) => {
- const selectValue = useMemo(() => {
- if (item.forced === "True") {
- return "forced";
- } else if (item.hi === "True") {
- return "hi";
- } else {
- return "normal";
- }
- }, [item.forced, item.hi]);
-
- return (
- <Select
- value={selectValue}
- data={subtitlesTypeOptions}
- onChange={(value) => {
- if (value) {
- action.mutate(index, {
- ...item,
- hi: value === "hi" ? "True" : "False",
- forced: value === "forced" ? "True" : "False",
- });
- }
- }}
- ></Select>
- );
+ header: "Subtitles Type",
+ accessorKey: "forced",
+ cell: ({ row: { original: item, index } }) => {
+ return <SubtitleTypeCell item={item} index={index} />;
},
},
{
- Header: "Exclude If Matching Audio",
- accessor: "audio_exclude",
- Cell: ({ row: { original: item, index }, value }) => {
+ header: "Exclude If Matching Audio",
+ accessorKey: "audio_exclude",
+ cell: ({ row: { original: item, index } }) => {
return (
<Checkbox
- checked={value === "True"}
+ checked={item.audio_exclude === "True"}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...item,
@@ -230,20 +249,19 @@ const ProfileEditForm: FunctionComponent<Props> = ({
},
{
id: "action",
- accessor: "id",
- Cell: ({ row }) => {
+ cell: ({ row }) => {
return (
<Action
label="Remove"
icon={faTrash}
- color="red"
+ c="red"
onClick={() => action.remove(row.index)}
></Action>
);
},
},
],
- [action, languageOptions],
+ [action, LanguageCell, SubtitleTypeCell],
);
return (
@@ -255,29 +273,40 @@ const ProfileEditForm: FunctionComponent<Props> = ({
})}
>
<Stack>
- <TextInput label="Name" {...form.getInputProps("name")}></TextInput>
+ <Flex
+ direction={{ base: "column", sm: "row" }}
+ gap="sm"
+ className={styles.evenly}
+ >
+ <TextInput label="Name" {...form.getInputProps("name")}></TextInput>
+ <TextInput
+ label="Tag"
+ {...form.getInputProps("tag")}
+ onBlur={() =>
+ form.setFieldValue(
+ "tag",
+ (prev) =>
+ prev?.toLowerCase().trim().replace(/\s+/g, "_") ?? undefined,
+ )
+ }
+ ></TextInput>
+ </Flex>
<Accordion
multiple
chevronPosition="right"
defaultValue={["Languages"]}
- styles={(theme) => ({
- content: {
- [theme.fn.smallerThan("md")]: {
- padding: 0,
- },
- },
- })}
+ className={styles.content}
>
<Accordion.Item value="Languages">
<Stack>
- {form.errors.items}
<SimpleTable
columns={columns}
data={form.values.items}
></SimpleTable>
- <Button fullWidth color="light" onClick={addItem}>
+ <Button fullWidth onClick={addItem}>
Add Language
</Button>
+ <Text c="var(--mantine-color-error)">{form.errors.items}</Text>
<Selector
clearable
label="Cutoff"
diff --git a/frontend/src/components/forms/SeriesUploadForm.tsx b/frontend/src/components/forms/SeriesUploadForm.tsx
index 5ce9c821a..e4482cab4 100644
--- a/frontend/src/components/forms/SeriesUploadForm.tsx
+++ b/frontend/src/components/forms/SeriesUploadForm.tsx
@@ -1,41 +1,39 @@
+import { FunctionComponent, useEffect, useMemo } from "react";
+import {
+ Button,
+ Checkbox,
+ Divider,
+ MantineColor,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import {
+ faCheck,
+ faCircleNotch,
+ faInfoCircle,
+ faTimes,
+ faTrash,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
+import { isString } from "lodash";
import {
useEpisodesBySeriesId,
useEpisodeSubtitleModification,
useSubtitleInfos,
} from "@/apis/hooks";
+import { Action, Selector } from "@/components/inputs";
+import SimpleTable from "@/components/tables/SimpleTable";
+import TextPopover from "@/components/TextPopover";
import { useModals, withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
-import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
-import {
- faCheck,
- faCircleNotch,
- faInfoCircle,
- faTimes,
- faTrash,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- Button,
- Checkbox,
- createStyles,
- Divider,
- MantineColor,
- Stack,
- Text,
-} from "@mantine/core";
-import { useForm } from "@mantine/form";
-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;
@@ -86,21 +84,12 @@ interface Props {
onComplete?: VoidFunction;
}
-const useStyles = createStyles((theme) => {
- return {
- wrapper: {
- overflowWrap: "anywhere",
- },
- };
-});
-
const SeriesUploadForm: FunctionComponent<Props> = ({
series,
files,
onComplete,
}) => {
const modals = useModals();
- const { classes } = useStyles();
const episodes = useEpisodesBySeriesId(series.sonarrSeriesId);
const episodeOptions = useSelectorOptions(
episodes.data ?? [],
@@ -180,62 +169,79 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
}
}, [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]);
+ const ValidateResultCell = ({
+ validateResult,
+ }: {
+ validateResult: SubtitleValidateResult | undefined;
+ }) => {
+ const icon = useMemo(() => {
+ switch (validateResult?.state) {
+ case "valid":
+ return faCheck;
+ case "warning":
+ return faInfoCircle;
+ case "error":
+ return faTimes;
+ default:
+ return faCircleNotch;
+ }
+ }, [validateResult?.state]);
- const color = useMemo<MantineColor | undefined>(() => {
- switch (value?.state) {
- case "valid":
- return "green";
- case "warning":
- return "yellow";
- case "error":
- return "red";
- default:
- return undefined;
- }
- }, [value?.state]);
+ const color = useMemo<MantineColor | undefined>(() => {
+ switch (validateResult?.state) {
+ case "valid":
+ return "green";
+ case "warning":
+ return "yellow";
+ case "error":
+ return "red";
+ default:
+ return undefined;
+ }
+ }, [validateResult?.state]);
- return (
- <TextPopover text={value?.messages}>
- <Text color={color} inline>
- <FontAwesomeIcon icon={icon}></FontAwesomeIcon>
- </Text>
- </TextPopover>
- );
+ return (
+ <TextPopover text={validateResult?.messages}>
+ <Text c={color} inline>
+ <FontAwesomeIcon icon={icon}></FontAwesomeIcon>
+ </Text>
+ </TextPopover>
+ );
+ };
+
+ const columns = useMemo<ColumnDef<SubtitleFile>[]>(
+ () => [
+ {
+ id: "validateResult",
+ cell: ({
+ row: {
+ original: { validateResult },
+ },
+ }) => {
+ return <ValidateResultCell validateResult={validateResult} />;
},
},
{
- Header: "File",
+ header: "File",
id: "filename",
- accessor: "file",
- Cell: ({ value: { name } }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.primary}>{name}</Text>;
+ accessorKey: "file",
+ cell: ({
+ row: {
+ original: {
+ file: { name },
+ },
+ },
+ }) => {
+ return <Text className="table-primary">{name}</Text>;
},
},
{
- Header: "Forced",
- accessor: "forced",
- Cell: ({ row: { original, index }, value }) => {
+ header: "Forced",
+ accessorKey: "forced",
+ cell: ({ row: { original, index } }) => {
return (
<Checkbox
- checked={value}
+ checked={original.forced}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
@@ -248,12 +254,12 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
},
},
{
- Header: "HI",
- accessor: "hi",
- Cell: ({ row: { original, index }, value }) => {
+ header: "HI",
+ accessorKey: "hi",
+ cell: ({ row: { original, index } }) => {
return (
<Checkbox
- checked={value}
+ checked={original.hi}
onChange={({ currentTarget: { checked } }) => {
action.mutate(index, {
...original,
@@ -266,7 +272,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
},
},
{
- Header: (
+ header: () => (
<Selector
{...languageOptions}
value={null}
@@ -281,14 +287,13 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
}}
></Selector>
),
- accessor: "language",
- Cell: ({ row: { original, index }, value }) => {
- const { classes } = useTableStyles();
+ accessorKey: "language",
+ cell: ({ row: { original, index } }) => {
return (
<Selector
{...languageOptions}
- className={classes.select}
- value={value}
+ className="table-select"
+ value={original.language}
onChange={(item) => {
action.mutate(index, { ...original, language: item });
}}
@@ -298,18 +303,17 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
},
{
id: "episode",
- Header: "Episode",
- accessor: "episode",
- Cell: ({ value, row }) => {
- const { classes } = useTableStyles();
+ header: "Episode",
+ accessorKey: "episode",
+ cell: ({ row: { original, index } }) => {
return (
<Selector
{...episodeOptions}
searchable
- className={classes.select}
- value={value}
+ className="table-select"
+ value={original.episode}
onChange={(item) => {
- action.mutate(row.index, { ...row.original, episode: item });
+ action.mutate(index, { ...original, episode: item });
}}
></Selector>
);
@@ -317,13 +321,12 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
},
{
id: "action",
- accessor: "file",
- Cell: ({ row: { index } }) => {
+ cell: ({ row: { index } }) => {
return (
<Action
label="Remove"
icon={faTrash}
- color="red"
+ c="red"
onClick={() => action.remove(index)}
></Action>
);
@@ -368,7 +371,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
modals.closeSelf();
})}
>
- <Stack className={classes.wrapper}>
+ <Stack className="table-long-break">
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
<Divider></Divider>
<Button type="submit">Upload</Button>
diff --git a/frontend/src/components/forms/SyncSubtitleForm.tsx b/frontend/src/components/forms/SyncSubtitleForm.tsx
index b5136fc85..63953fb2d 100644
--- a/frontend/src/components/forms/SyncSubtitleForm.tsx
+++ b/frontend/src/components/forms/SyncSubtitleForm.tsx
@@ -1,20 +1,23 @@
/* eslint-disable camelcase */
-
+import { FunctionComponent } from "react";
+import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
useRefTracksByEpisodeId,
useRefTracksByMovieId,
useSubtitleAction,
} from "@/apis/hooks";
+import {
+ GroupedSelector,
+ GroupedSelectorOptions,
+ Selector,
+} from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { syncMaxOffsetSecondsOptions } from "@/pages/Settings/Subtitles/options";
-import { toPython } from "@/utilities";
-import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
-import { useForm } from "@mantine/form";
-import { FunctionComponent } from "react";
-import { Selector, SelectorOption } from "../inputs";
+import { fromPython, toPython } from "@/utilities";
const TaskName = "Syncing Subtitle";
@@ -37,15 +40,21 @@ function useReferencedSubtitles(
const mediaData = mediaType === "episode" ? episodeData : movieData;
- const subtitles: { group: string; value: string; label: string }[] = [];
+ const subtitles: GroupedSelectorOptions<string>[] = [];
if (!mediaData.data) {
return [];
} else {
if (mediaData.data.audio_tracks.length > 0) {
+ const embeddedAudioGroup: GroupedSelectorOptions<string> = {
+ group: "Embedded audio tracks",
+ items: [],
+ };
+
+ subtitles.push(embeddedAudioGroup);
+
mediaData.data.audio_tracks.forEach((item) => {
- subtitles.push({
- group: "Embedded audio tracks",
+ embeddedAudioGroup.items.push({
value: item.stream,
label: `${item.name || item.language} (${item.stream})`,
});
@@ -53,9 +62,15 @@ function useReferencedSubtitles(
}
if (mediaData.data.embedded_subtitles_tracks.length > 0) {
+ const embeddedSubtitlesTrackGroup: GroupedSelectorOptions<string> = {
+ group: "Embedded subtitles tracks",
+ items: [],
+ };
+
+ subtitles.push(embeddedSubtitlesTrackGroup);
+
mediaData.data.embedded_subtitles_tracks.forEach((item) => {
- subtitles.push({
- group: "Embedded subtitles tracks",
+ embeddedSubtitlesTrackGroup.items.push({
value: item.stream,
label: `${item.name || item.language} (${item.stream})`,
});
@@ -63,10 +78,16 @@ function useReferencedSubtitles(
}
if (mediaData.data.external_subtitles_tracks.length > 0) {
+ const externalSubtitlesFilesGroup: GroupedSelectorOptions<string> = {
+ group: "External Subtitles files",
+ items: [],
+ };
+
+ subtitles.push(externalSubtitlesFilesGroup);
+
mediaData.data.external_subtitles_tracks.forEach((item) => {
if (item) {
- subtitles.push({
- group: "External Subtitles files",
+ externalSubtitlesFilesGroup.items.push({
value: item.path,
label: item.name,
});
@@ -88,6 +109,8 @@ interface FormValues {
maxOffsetSeconds?: string;
noFixFramerate: boolean;
gss: boolean;
+ hi?: boolean;
+ forced?: boolean;
}
const SyncSubtitleForm: FunctionComponent<Props> = ({
@@ -101,20 +124,20 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({
const { mutateAsync } = useSubtitleAction();
const modals = useModals();
- const mediaType = selections[0].type;
- const mediaId = selections[0].id;
- const subtitlesPath = selections[0].path;
+ const subtitle = selections[0];
- const subtitles: SelectorOption<string>[] = useReferencedSubtitles(
- mediaType,
- mediaId,
- subtitlesPath,
- );
+ const mediaType = subtitle.type;
+ const mediaId = subtitle.id;
+ const subtitlesPath = subtitle.path;
+
+ const subtitles = useReferencedSubtitles(mediaType, mediaId, subtitlesPath);
const form = useForm<FormValues>({
initialValues: {
noFixFramerate: false,
gss: false,
+ hi: fromPython(subtitle.hi),
+ forced: fromPython(subtitle.forced),
},
});
@@ -145,14 +168,14 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({
>
<Text size="sm">{selections.length} subtitles selected</Text>
</Alert>
- <Selector
+ <GroupedSelector
clearable
disabled={subtitles.length === 0 || selections.length !== 1}
label="Reference"
placeholder="Default: choose automatically within video file"
options={subtitles}
{...form.getInputProps("reference")}
- ></Selector>
+ ></GroupedSelector>
<Selector
clearable
label="Max Offset Seconds"
diff --git a/frontend/src/components/forms/TimeOffsetForm.tsx b/frontend/src/components/forms/TimeOffsetForm.tsx
index 2792d64d8..1a7739dd9 100644
--- a/frontend/src/components/forms/TimeOffsetForm.tsx
+++ b/frontend/src/components/forms/TimeOffsetForm.tsx
@@ -1,12 +1,12 @@
+import { FunctionComponent } from "react";
+import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
-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/form";
-import { FunctionComponent } from "react";
const TaskName = "Changing Time";
@@ -70,10 +70,11 @@ const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
})}
>
<Stack>
- <Group align="end" spacing="xs" noWrap>
+ <Group align="end" gap="xs" wrap="nowrap">
<Button
color="gray"
variant="filled"
+ style={{ overflow: "visible" }}
onClick={() =>
form.setValues((f) => ({ ...f, positive: !f.positive }))
}
diff --git a/frontend/src/components/forms/TranslationForm.tsx b/frontend/src/components/forms/TranslationForm.tsx
index 976b2f72f..20aa08478 100644
--- a/frontend/src/components/forms/TranslationForm.tsx
+++ b/frontend/src/components/forms/TranslationForm.tsx
@@ -1,14 +1,14 @@
+import { FunctionComponent, useMemo } from "react";
+import { Alert, Button, Divider, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { isObject } from "lodash";
import { useSubtitleAction } from "@/apis/hooks";
+import { Selector } from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import { useEnabledLanguages } from "@/utilities/languages";
-import { Alert, Button, Divider, Stack } from "@mantine/core";
-import { useForm } from "@mantine/form";
-import { isObject } from "lodash";
-import { FunctionComponent, useMemo } from "react";
-import { Selector } from "../inputs";
const TaskName = "Translating Subtitles";
diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx
index c3d7b4763..5ea97cf04 100644
--- a/frontend/src/components/index.tsx
+++ b/frontend/src/components/index.tsx
@@ -1,4 +1,4 @@
export { default as Search } from "./Search";
export * from "./inputs";
export * from "./tables";
-export { default as Toolbox } from "./toolbox";
+export { default as Toolbox } from "./toolbox/Toolbox";
diff --git a/frontend/src/components/inputs/Action.test.tsx b/frontend/src/components/inputs/Action.test.tsx
index 189aca076..dc8972630 100644
--- a/frontend/src/components/inputs/Action.test.tsx
+++ b/frontend/src/components/inputs/Action.test.tsx
@@ -1,7 +1,7 @@
-import { rawRender, screen } from "@/tests";
import { faStickyNote } from "@fortawesome/free-regular-svg-icons";
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
+import { render, screen } from "@/tests";
import Action from "./Action";
const testLabel = "Test Label";
@@ -9,7 +9,7 @@ const testIcon = faStickyNote;
describe("Action button", () => {
it("should be a button", () => {
- rawRender(<Action icon={testIcon} label={testLabel}></Action>);
+ render(<Action icon={testIcon} label={testLabel}></Action>);
const element = screen.getByRole("button", { name: testLabel });
expect(element.getAttribute("type")).toEqual("button");
@@ -17,7 +17,7 @@ describe("Action button", () => {
});
it("should show icon", () => {
- rawRender(<Action icon={testIcon} label={testLabel}></Action>);
+ render(<Action icon={testIcon} label={testLabel}></Action>);
// TODO: use getBy...
const element = screen.getByRole("img", { hidden: true });
@@ -27,7 +27,7 @@ describe("Action button", () => {
it("should call on-click event when clicked", async () => {
const onClickFn = vitest.fn();
- rawRender(
+ render(
<Action icon={testIcon} label={testLabel} onClick={onClickFn}></Action>,
);
diff --git a/frontend/src/components/inputs/Action.tsx b/frontend/src/components/inputs/Action.tsx
index 236baf112..95477f090 100644
--- a/frontend/src/components/inputs/Action.tsx
+++ b/frontend/src/components/inputs/Action.tsx
@@ -1,15 +1,15 @@
-import { IconDefinition } from "@fortawesome/fontawesome-common-types";
-import {
- FontAwesomeIcon,
- FontAwesomeIconProps,
-} from "@fortawesome/react-fontawesome";
+import { forwardRef } from "react";
import {
ActionIcon,
ActionIconProps,
Tooltip,
TooltipProps,
} from "@mantine/core";
-import { forwardRef } from "react";
+import { IconDefinition } from "@fortawesome/fontawesome-common-types";
+import {
+ FontAwesomeIcon,
+ FontAwesomeIconProps,
+} from "@fortawesome/react-fontawesome";
export type ActionProps = MantineComp<ActionIconProps, "button"> & {
icon: IconDefinition;
diff --git a/frontend/src/components/inputs/ChipInput.test.tsx b/frontend/src/components/inputs/ChipInput.test.tsx
index cb52ee30c..4035966fc 100644
--- a/frontend/src/components/inputs/ChipInput.test.tsx
+++ b/frontend/src/components/inputs/ChipInput.test.tsx
@@ -1,6 +1,6 @@
-import { rawRender, screen } from "@/tests";
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
+import { render, screen } from "@/tests";
import ChipInput from "./ChipInput";
describe("ChipInput", () => {
@@ -8,7 +8,7 @@ describe("ChipInput", () => {
// TODO: Support default value
it.skip("should works with default value", () => {
- rawRender(<ChipInput defaultValue={existedValues}></ChipInput>);
+ render(<ChipInput defaultValue={existedValues}></ChipInput>);
existedValues.forEach((value) => {
expect(screen.getByText(value)).toBeDefined();
@@ -16,7 +16,7 @@ describe("ChipInput", () => {
});
it("should works with value", () => {
- rawRender(<ChipInput value={existedValues}></ChipInput>);
+ render(<ChipInput value={existedValues}></ChipInput>);
existedValues.forEach((value) => {
expect(screen.getByText(value)).toBeDefined();
@@ -29,9 +29,7 @@ describe("ChipInput", () => {
expect(values).toContain(typedValue);
});
- rawRender(
- <ChipInput value={existedValues} onChange={mockedFn}></ChipInput>,
- );
+ render(<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>);
const element = screen.getByRole("searchbox");
diff --git a/frontend/src/components/inputs/ChipInput.tsx b/frontend/src/components/inputs/ChipInput.tsx
index 4308f7189..1fa57084c 100644
--- a/frontend/src/components/inputs/ChipInput.tsx
+++ b/frontend/src/components/inputs/ChipInput.tsx
@@ -1,35 +1,29 @@
-import { useSelectorOptions } from "@/utilities";
import { FunctionComponent } from "react";
-import { MultiSelector, MultiSelectorProps } from "./Selector";
+import { TagsInput } from "@mantine/core";
-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);
+export interface ChipInputProps {
+ defaultValue?: string[] | undefined;
+ value?: readonly string[] | null;
+ label?: string;
+ onChange?: (value: string[]) => void;
+}
+const ChipInput: FunctionComponent<ChipInputProps> = ({
+ defaultValue,
+ value,
+ label,
+ onChange,
+}: ChipInputProps) => {
+ // TODO: Replace with our own custom implementation instead of just using the
+ // built-in TagsInput. https://mantine.dev/combobox/?e=MultiSelectCreatable
return (
- <MultiSelector
- {...props}
- {...options}
- creatable
- searchable
- getCreateLabel={(query) => `Add "${query}"`}
- onCreate={(query) => {
- onChange?.([...(value ?? []), query]);
- return query;
- }}
- buildOption={(value) => value}
- ></MultiSelector>
+ <TagsInput
+ defaultValue={defaultValue}
+ label={label}
+ value={value ? value?.map((v) => v) : []}
+ onChange={onChange}
+ clearable
+ ></TagsInput>
);
};
diff --git a/frontend/src/components/inputs/DropContent.module.scss b/frontend/src/components/inputs/DropContent.module.scss
new file mode 100644
index 000000000..c6c0f848a
--- /dev/null
+++ b/frontend/src/components/inputs/DropContent.module.scss
@@ -0,0 +1,4 @@
+.container {
+ pointer-events: none;
+ min-height: 220px;
+}
diff --git a/frontend/src/components/inputs/DropContent.tsx b/frontend/src/components/inputs/DropContent.tsx
index 38556220d..c4bcf2877 100644
--- a/frontend/src/components/inputs/DropContent.tsx
+++ b/frontend/src/components/inputs/DropContent.tsx
@@ -1,27 +1,17 @@
+import { FunctionComponent } from "react";
+import { Group, Stack, Text } from "@mantine/core";
+import { Dropzone } from "@mantine/dropzone";
import {
faArrowUp,
faFileCirclePlus,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Group, Stack, Text, createStyles } from "@mantine/core";
-import { Dropzone } from "@mantine/dropzone";
-import { FunctionComponent } from "react";
-
-const useStyle = createStyles((theme) => {
- return {
- container: {
- pointerEvents: "none",
- minHeight: 220,
- },
- };
-});
+import styles from "./DropContent.module.scss";
export const DropContent: FunctionComponent = () => {
- const { classes } = useStyle();
-
return (
- <Group position="center" spacing="xl" className={classes.container}>
+ <Group justify="center" gap="xl" className={styles.container}>
<Dropzone.Idle>
<FontAwesomeIcon icon={faFileCirclePlus} size="2x" />
</Dropzone.Idle>
@@ -31,9 +21,9 @@ export const DropContent: FunctionComponent = () => {
<Dropzone.Reject>
<FontAwesomeIcon icon={faXmark} size="2x" />
</Dropzone.Reject>
- <Stack spacing={0}>
+ <Stack gap={0}>
<Text size="lg">Upload Subtitles</Text>
- <Text color="dimmed" size="sm">
+ <Text c="dimmed" size="sm">
Attach as many files as you like, you will need to select file
metadata before uploading
</Text>
diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx
index ce57a4938..bba66a66e 100644
--- a/frontend/src/components/inputs/FileBrowser.tsx
+++ b/frontend/src/components/inputs/FileBrowser.tsx
@@ -1,8 +1,13 @@
-import { useFileSystem } from "@/apis/hooks";
+import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Autocomplete,
+ AutocompleteProps,
+ ComboboxItem,
+ OptionsFilter,
+} from "@mantine/core";
import { faFolder } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Autocomplete, AutocompleteProps } from "@mantine/core";
-import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
+import { useFileSystem } from "@/apis/hooks";
// TODO: use fortawesome icons
const backKey = "⏎ Back";
@@ -75,24 +80,28 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
const ref = useRef<HTMLInputElement>(null);
+ const optionsFilter: OptionsFilter = ({ options, search }) => {
+ return (options as ComboboxItem[]).filter((option) => {
+ if (search === backKey) {
+ return true;
+ }
+
+ return option.value.includes(search);
+ });
+ };
+
return (
<Autocomplete
{...props}
ref={ref}
- icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
+ leftSection={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
placeholder="Click to start"
data={data}
value={value}
// Temporary solution of infinite dropdown items, fix later
limit={NaN}
maxDropdownHeight={240}
- filter={(value, item) => {
- if (item.value === backKey) {
- return true;
- } else {
- return item.value.includes(value);
- }
- }}
+ filter={optionsFilter}
onChange={(val) => {
if (val !== backKey) {
setValue(val);
diff --git a/frontend/src/components/inputs/Selector.test.tsx b/frontend/src/components/inputs/Selector.test.tsx
index a7b6cfb85..a28772a2d 100644
--- a/frontend/src/components/inputs/Selector.test.tsx
+++ b/frontend/src/components/inputs/Selector.test.tsx
@@ -1,6 +1,6 @@
-import { rawRender, screen } from "@/tests";
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
+import { render, screen } from "@/tests";
import { Selector, SelectorOption } from "./Selector";
const selectorName = "Test Selections";
@@ -18,20 +18,17 @@ const testOptions: SelectorOption<string>[] = [
describe("Selector", () => {
describe("options", () => {
it("should work with the SelectorOption", () => {
- rawRender(
- <Selector name={selectorName} options={testOptions}></Selector>,
- );
+ render(<Selector name={selectorName} options={testOptions}></Selector>);
- // TODO: selectorName
- expect(screen.getByRole("searchbox")).toBeDefined();
+ testOptions.forEach((o) => {
+ expect(screen.getByText(o.label)).toBeDefined();
+ });
});
it("should display when clicked", async () => {
- rawRender(
- <Selector name={selectorName} options={testOptions}></Selector>,
- );
+ render(<Selector name={selectorName} options={testOptions}></Selector>);
- const element = screen.getByRole("searchbox");
+ const element = screen.getByTestId("input-selector");
await userEvent.click(element);
@@ -44,7 +41,7 @@ describe("Selector", () => {
it("shouldn't show default value", async () => {
const option = testOptions[0];
- rawRender(
+ render(
<Selector
name={selectorName}
options={testOptions}
@@ -57,7 +54,7 @@ describe("Selector", () => {
it("shouldn't show value", async () => {
const option = testOptions[0];
- rawRender(
+ render(
<Selector
name={selectorName}
options={testOptions}
@@ -75,7 +72,7 @@ describe("Selector", () => {
const mockedFn = vitest.fn((value: string | null) => {
expect(value).toEqual(clickedOption.value);
});
- rawRender(
+ render(
<Selector
name={selectorName}
options={testOptions}
@@ -83,13 +80,13 @@ describe("Selector", () => {
></Selector>,
);
- const element = screen.getByRole("searchbox");
+ const element = screen.getByTestId("input-selector");
await userEvent.click(element);
await userEvent.click(screen.getByText(clickedOption.label));
- expect(mockedFn).toBeCalled();
+ expect(mockedFn).toHaveBeenCalled();
});
});
@@ -115,7 +112,7 @@ describe("Selector", () => {
const mockedFn = vitest.fn((value: { name: string } | null) => {
expect(value).toEqual(clickedOption.value);
});
- rawRender(
+ render(
<Selector
name={selectorName}
options={objectOptions}
@@ -124,20 +121,20 @@ describe("Selector", () => {
></Selector>,
);
- const element = screen.getByRole("searchbox");
+ const element = screen.getByTestId("input-selector");
await userEvent.click(element);
await userEvent.click(screen.getByText(clickedOption.label));
- expect(mockedFn).toBeCalled();
+ expect(mockedFn).toHaveBeenCalled();
});
});
describe("placeholder", () => {
it("should show when no selection", () => {
const placeholder = "Empty Selection";
- rawRender(
+ render(
<Selector
name={selectorName}
options={testOptions}
diff --git a/frontend/src/components/inputs/Selector.tsx b/frontend/src/components/inputs/Selector.tsx
index 0af276fc4..092fd24e7 100644
--- a/frontend/src/components/inputs/Selector.tsx
+++ b/frontend/src/components/inputs/Selector.tsx
@@ -1,23 +1,24 @@
-import { LOG } from "@/utilities/console";
+import { useCallback, useMemo, useRef } from "react";
import {
+ ComboboxItem,
+ ComboboxItemGroup,
MultiSelect,
MultiSelectProps,
Select,
- SelectItem,
SelectProps,
} from "@mantine/core";
import { isNull, isUndefined } from "lodash";
-import { useCallback, useMemo, useRef } from "react";
+import { LOG } from "@/utilities/console";
export type SelectorOption<T> = Override<
{
value: T;
label: string;
},
- SelectItem
+ ComboboxItem
>;
-type SelectItemWithPayload<T> = SelectItem & {
+type SelectItemWithPayload<T> = ComboboxItem & {
payload: T;
};
@@ -34,6 +35,33 @@ function DefaultKeyBuilder<T>(value: T) {
}
}
+export interface GroupedSelectorOptions<T> {
+ group: string;
+ items: SelectorOption<T>[];
+}
+
+export type GroupedSelectorProps<T> = Override<
+ {
+ options: ComboboxItemGroup[];
+ getkey?: (value: T) => string;
+ },
+ Omit<SelectProps, "data">
+>;
+
+export function GroupedSelector<T>({
+ options,
+ ...select
+}: GroupedSelectorProps<T>) {
+ return (
+ <Select
+ data-testid="input-selector"
+ comboboxProps={{ withinPortal: true }}
+ data={options}
+ {...select}
+ ></Select>
+ );
+}
+
export type SelectorProps<T> = Override<
{
value?: T | null;
@@ -84,7 +112,7 @@ export function Selector<T>({
}, [defaultValue, keyRef]);
const wrappedOnChange = useCallback(
- (value: string) => {
+ (value: string | null) => {
const payload = data.find((v) => v.value === value)?.payload ?? null;
onChange?.(payload);
},
@@ -93,7 +121,8 @@ export function Selector<T>({
return (
<Select
- withinPortal={true}
+ data-testid="input-selector"
+ comboboxProps={{ withinPortal: true }}
data={data}
defaultValue={wrappedDefaultValue}
value={wrappedValue}
@@ -144,6 +173,7 @@ export function MultiSelector<T>({
() => value && value.map(labelRef.current),
[value],
);
+
const wrappedDefaultValue = useMemo(
() => defaultValue && defaultValue.map(labelRef.current),
[defaultValue],
@@ -168,6 +198,7 @@ export function MultiSelector<T>({
return (
<MultiSelect
{...select}
+ hidePickedOptions
value={wrappedValue}
defaultValue={wrappedDefaultValue}
onChange={wrappedOnChange}
diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx
index cc4197c44..88d57ac65 100644
--- a/frontend/src/components/modals/HistoryModal.tsx
+++ b/frontend/src/components/modals/HistoryModal.tsx
@@ -1,23 +1,23 @@
/* eslint-disable camelcase */
+import { FunctionComponent, useMemo } from "react";
+import { Badge, Center, Text } from "@mantine/core";
+import { faFileExcel, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import {
useEpisodeAddBlacklist,
useEpisodeHistory,
useMovieAddBlacklist,
useMovieHistory,
} from "@/apis/hooks";
+import MutateAction from "@/components/async/MutateAction";
+import QueryOverlay from "@/components/async/QueryOverlay";
+import { HistoryIcon } from "@/components/bazarr";
+import Language from "@/components/bazarr/Language";
import StateIcon from "@/components/StateIcon";
+import PageTable from "@/components/tables/PageTable";
+import TextPopover from "@/components/TextPopover";
import { withModal } from "@/modules/modals";
-import { faFileExcel, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Badge, Center, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
-import { PageTable } from "..";
-import TextPopover from "../TextPopover";
-import MutateAction from "../async/MutateAction";
-import QueryOverlay from "../async/QueryOverlay";
-import { HistoryIcon } from "../bazarr";
-import Language from "../bazarr/Language";
interface MovieHistoryViewProps {
movie: Item.Movie;
@@ -30,24 +30,34 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
const { data } = history;
- const columns = useMemo<Column<History.Movie>[]>(
+ const addMovieToBlacklist = useMovieAddBlacklist();
+
+ const columns = useMemo<ColumnDef<History.Movie>[]>(
() => [
{
- accessor: "action",
- Cell: (row) => (
+ id: "action",
+ cell: ({
+ row: {
+ original: { action },
+ },
+ }) => (
<Center>
- <HistoryIcon action={row.value}></HistoryIcon>
+ <HistoryIcon action={action}></HistoryIcon>
</Center>
),
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
return (
<Badge>
- <Language.Text value={value} long></Language.Text>
+ <Language.Text value={language} long></Language.Text>
</Badge>
);
} else {
@@ -56,17 +66,20 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
},
},
{
- Header: "Provider",
- accessor: "provider",
+ header: "Provider",
+ accessorKey: "provider",
},
{
- Header: "Score",
- accessor: "score",
+ header: "Score",
+ accessorKey: "score",
},
{
- accessor: "matches",
- Cell: (row) => {
- const { matches, dont_matches: dont } = row.row.original;
+ id: "matches",
+ cell: ({
+ row: {
+ original: { matches, dont_matches: dont },
+ },
+ }) => {
if (matches.length || dont.length) {
return (
<StateIcon
@@ -81,31 +94,42 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
},
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: ({ value, row }) => {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp: parsedTimestamp },
+ },
+ }) => {
return (
- <TextPopover text={row.original.parsed_timestamp}>
- <Text>{value}</Text>
+ <TextPopover text={parsedTimestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
},
},
{
// Actions
- accessor: "blacklisted",
- Cell: ({ row, value }) => {
- const add = useMovieAddBlacklist();
- const { radarrId, provider, subs_id, language, subtitles_path } =
- row.original;
-
+ id: "blacklisted",
+ cell: ({
+ row: {
+ original: {
+ blacklisted,
+ radarrId,
+ provider,
+ subs_id,
+ language,
+ subtitles_path,
+ },
+ },
+ }) => {
if (subs_id && provider && language) {
return (
<MutateAction
label="Add to Blacklist"
- disabled={value}
+ disabled={blacklisted}
icon={faFileExcel}
- mutation={add}
+ mutation={addMovieToBlacklist}
args={() => ({
id: radarrId,
form: {
@@ -123,7 +147,7 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({
},
},
],
- [],
+ [addMovieToBlacklist],
);
return (
@@ -153,24 +177,34 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
const { data } = history;
- const columns = useMemo<Column<History.Episode>[]>(
+ const addEpisodeToBlacklist = useEpisodeAddBlacklist();
+
+ const columns = useMemo<ColumnDef<History.Episode>[]>(
() => [
{
- accessor: "action",
- Cell: (row) => (
+ id: "action",
+ cell: ({
+ row: {
+ original: { action },
+ },
+ }) => (
<Center>
- <HistoryIcon action={row.value}></HistoryIcon>
+ <HistoryIcon action={action}></HistoryIcon>
</Center>
),
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
return (
<Badge>
- <Language.Text value={value} long></Language.Text>
+ <Language.Text value={language} long></Language.Text>
</Badge>
);
} else {
@@ -179,16 +213,16 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
},
},
{
- Header: "Provider",
- accessor: "provider",
+ header: "Provider",
+ accessorKey: "provider",
},
{
- Header: "Score",
- accessor: "score",
+ header: "Score",
+ accessorKey: "score",
},
{
- accessor: "matches",
- Cell: (row) => {
+ id: "matches",
+ cell: (row) => {
const { matches, dont_matches: dont } = row.row.original;
if (matches.length || dont.length) {
return (
@@ -204,21 +238,29 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
},
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: ({ row, value }) => {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp: parsedTimestamp },
+ },
+ }) => {
return (
- <TextPopover text={row.original.parsed_timestamp}>
- <Text>{value}</Text>
+ <TextPopover text={parsedTimestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
},
},
{
- accessor: "description",
- Cell: ({ value }) => {
+ id: "description",
+ cell: ({
+ row: {
+ original: { description },
+ },
+ }) => {
return (
- <TextPopover text={value}>
+ <TextPopover text={description}>
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
</TextPopover>
);
@@ -226,25 +268,27 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
},
{
// Actions
- accessor: "blacklisted",
- Cell: ({ row, value }) => {
- const {
- sonarrEpisodeId,
- sonarrSeriesId,
- provider,
- subs_id,
- language,
- subtitles_path,
- } = row.original;
- const add = useEpisodeAddBlacklist();
-
+ id: "blacklisted",
+ cell: ({
+ row: {
+ original: {
+ blacklisted,
+ sonarrEpisodeId,
+ sonarrSeriesId,
+ provider,
+ subs_id,
+ language,
+ subtitles_path,
+ },
+ },
+ }) => {
if (subs_id && provider && language) {
return (
<MutateAction
label="Add to Blacklist"
- disabled={value}
+ disabled={blacklisted}
icon={faFileExcel}
- mutation={add}
+ mutation={addEpisodeToBlacklist}
args={() => ({
seriesId: sonarrSeriesId,
episodeId: sonarrEpisodeId,
@@ -263,12 +307,13 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({
},
},
],
- [],
+ [addEpisodeToBlacklist],
);
return (
<QueryOverlay result={history}>
<PageTable
+ autoScroll={false}
tableStyles={{ emptyText: "No history found", placeholder: 5 }}
columns={columns}
data={data ?? []}
diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx
index 24799130d..81a49f0f3 100644
--- a/frontend/src/components/modals/ManualSearchModal.tsx
+++ b/frontend/src/components/modals/ManualSearchModal.tsx
@@ -1,13 +1,4 @@
-import { withModal } from "@/modules/modals";
-import { task, TaskGroup } from "@/modules/task";
-import { useTableStyles } from "@/styles";
-import { GetItemId } from "@/utilities";
-import {
- faCaretDown,
- faDownload,
- faInfoCircle,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { useCallback, useMemo, useState } from "react";
import {
Alert,
Anchor,
@@ -19,21 +10,28 @@ import {
Stack,
Text,
} from "@mantine/core";
+import {
+ faCaretDown,
+ faDownload,
+ faInfoCircle,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { UseQueryResult } from "@tanstack/react-query";
+import { ColumnDef } from "@tanstack/react-table";
import { isString } from "lodash";
-import { useCallback, useMemo, useState } from "react";
-import { UseQueryResult } from "react-query";
-import { Column } from "react-table";
-import { Action, PageTable } from "..";
-import Language from "../bazarr/Language";
-import StateIcon from "../StateIcon";
+import { Action } from "@/components";
+import Language from "@/components/bazarr/Language";
+import StateIcon from "@/components/StateIcon";
+import PageTable from "@/components/tables/PageTable";
+import { withModal } from "@/modules/modals";
+import { task, TaskGroup } from "@/modules/task";
+import { GetItemId } from "@/utilities";
type SupportType = Item.Movie | Item.Episode;
interface Props<T extends SupportType> {
download: (item: T, result: SearchResultType) => Promise<void>;
- query: (
- id?: number,
- ) => UseQueryResult<SearchResultType[] | undefined, unknown>;
+ query: (id?: number) => UseQueryResult<SearchResultType[] | undefined>;
item: T;
}
@@ -50,27 +48,67 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
const search = useCallback(() => {
setSearchStarted(true);
- results.refetch();
+
+ void results.refetch();
}, [results]);
- const columns = useMemo<Column<SearchResultType>[]>(
+ const ReleaseInfoCell = React.memo(
+ ({ releaseInfo }: { releaseInfo: string[] }) => {
+ const [open, setOpen] = useState(false);
+
+ const items = useMemo(
+ () => releaseInfo.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>),
+ [releaseInfo],
+ );
+
+ if (releaseInfo.length === 0) {
+ return <Text c="dimmed">Cannot get release info</Text>;
+ }
+
+ return (
+ <Stack gap={0} onClick={() => setOpen((o) => !o)}>
+ <Text className="table-primary" span>
+ {releaseInfo[0]}
+ {releaseInfo.length > 1 && (
+ <FontAwesomeIcon
+ icon={faCaretDown}
+ rotation={open ? 180 : undefined}
+ ></FontAwesomeIcon>
+ )}
+ </Text>
+ <Collapse in={open}>
+ <>{items}</>
+ </Collapse>
+ </Stack>
+ );
+ },
+ );
+
+ const columns = useMemo<ColumnDef<SearchResultType>[]>(
() => [
{
- Header: "Score",
- accessor: "score",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}%</Text>;
+ header: "Score",
+ accessorKey: "score",
+ cell: ({
+ row: {
+ original: { score },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{score}%</Text>;
},
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ row: { original }, value }) => {
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language, hearing_impaired: hi, forced },
+ },
+ }) => {
const lang: Language.Info = {
- code2: value,
- hi: original.hearing_impaired === "True",
- forced: original.forced === "True",
+ code2: language,
+ hi: hi === "True",
+ forced: forced === "True",
name: "",
};
return (
@@ -81,16 +119,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
},
},
{
- Header: "Provider",
- accessor: "provider",
- Cell: (row) => {
- const { classes } = useTableStyles();
- const value = row.value;
- const { url } = row.row.original;
+ header: "Provider",
+ accessorKey: "provider",
+ cell: ({
+ row: {
+ original: { provider, url },
+ },
+ }) => {
+ const value = provider;
+
if (url) {
return (
<Anchor
- className={classes.noWrap}
+ className="table-no-wrap"
href={url}
target="_blank"
rel="noopener noreferrer"
@@ -104,51 +145,31 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
},
},
{
- Header: "Release",
- accessor: "release_info",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- const [open, setOpen] = useState(false);
-
- const items = useMemo(
- () => value.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>),
- [value],
- );
-
- if (value.length === 0) {
- return <Text color="dimmed">Cannot get release info</Text>;
- }
-
- return (
- <Stack spacing={0} onClick={() => setOpen((o) => !o)}>
- <Text className={classes.primary}>
- {value[0]}
- {value.length > 1 && (
- <FontAwesomeIcon
- icon={faCaretDown}
- rotation={open ? 180 : undefined}
- ></FontAwesomeIcon>
- )}
- </Text>
- <Collapse in={open}>
- <>{items}</>
- </Collapse>
- </Stack>
- );
+ header: "Release",
+ accessorKey: "release_info",
+ cell: ({
+ row: {
+ original: { release_info: releaseInfo },
+ },
+ }) => {
+ return <ReleaseInfoCell releaseInfo={releaseInfo} />;
},
},
{
- Header: "Uploader",
- accessor: "uploader",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value ?? "-"}</Text>;
+ header: "Uploader",
+ accessorKey: "uploader",
+ cell: ({
+ row: {
+ original: { uploader },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{uploader ?? "-"}</Text>;
},
},
{
- Header: "Match",
- accessor: "matches",
- Cell: (row) => {
+ header: "Match",
+ accessorKey: "matches",
+ cell: (row) => {
const { matches, dont_matches: dont } = row.row.original;
return (
<StateIcon
@@ -160,16 +181,15 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
},
},
{
- Header: "Get",
- accessor: "subtitle",
- Cell: ({ row }) => {
+ header: "Get",
+ accessorKey: "subtitle",
+ cell: ({ row }) => {
const result = row.original;
return (
<Action
label="Download"
icon={faDownload}
- color="brand"
- variant="light"
+ c="brand"
disabled={item === null}
onClick={() => {
if (!item) return;
@@ -187,7 +207,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
},
},
],
- [download, item],
+ [download, item, ReleaseInfoCell],
);
const bSceneNameAvailable =
diff --git a/frontend/src/components/modals/SubtitleToolsModal.tsx b/frontend/src/components/modals/SubtitleToolsModal.tsx
index 2ba99ec73..dca20d159 100644
--- a/frontend/src/components/modals/SubtitleToolsModal.tsx
+++ b/frontend/src/components/modals/SubtitleToolsModal.tsx
@@ -1,12 +1,19 @@
+import { FunctionComponent, useMemo, useState } from "react";
+import {
+ Badge,
+ Button,
+ Checkbox,
+ Divider,
+ Group,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { ColumnDef } from "@tanstack/react-table";
import Language from "@/components/bazarr/Language";
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
-import { SimpleTable } from "@/components/tables";
-import { useCustomSelection } from "@/components/tables/plugins";
+import SimpleTable from "@/components/tables/SimpleTable";
import { withModal } from "@/modules/modals";
import { isMovie } from "@/utilities";
-import { Badge, Button, Divider, Group, Stack, Text } from "@mantine/core";
-import { FunctionComponent, useMemo, useState } from "react";
-import { Column, useRowSelect } from "react-table";
type SupportType = Item.Episode | Item.Movie;
@@ -35,24 +42,53 @@ const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
}) => {
const [selections, setSelections] = useState<TableColumnType[]>([]);
- const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
+ const columns = useMemo<ColumnDef<TableColumnType>[]>(
() => [
{
- Header: "Language",
- accessor: "raw_language",
- Cell: ({ value }) => (
+ id: "selection",
+ header: ({ table }) => {
+ return (
+ <Checkbox
+ id="table-header-selection"
+ indeterminate={table.getIsSomeRowsSelected()}
+ checked={table.getIsAllRowsSelected()}
+ onChange={table.getToggleAllRowsSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ cell: ({ row: { index, getIsSelected, getToggleSelectedHandler } }) => {
+ return (
+ <Checkbox
+ id={`table-cell-${index}`}
+ checked={getIsSelected()}
+ onChange={getToggleSelectedHandler()}
+ onClick={getToggleSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ },
+ {
+ header: "Language",
+ accessorKey: "raw_language",
+ cell: ({
+ row: {
+ original: { raw_language: rawLanguage },
+ },
+ }) => (
<Badge color="secondary">
- <Language.Text value={value} long></Language.Text>
+ <Language.Text value={rawLanguage} long></Language.Text>
</Badge>
),
},
{
id: "file",
- Header: "File",
- accessor: "path",
- Cell: ({ value }) => {
- const path = value;
-
+ header: "File",
+ accessorKey: "path",
+ cell: ({
+ row: {
+ original: { path },
+ },
+ }) => {
let idx = path.lastIndexOf("/");
if (idx === -1) {
@@ -94,16 +130,15 @@ const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
[payload],
);
- const plugins = [useRowSelect, useCustomSelection];
-
return (
<Stack>
<SimpleTable
tableStyles={{ emptyText: "No external subtitles found" }}
- plugins={plugins}
+ enableRowSelection={(row) => CanSelectSubtitle(row.original)}
+ onRowSelectionChanged={(rows) =>
+ setSelections(rows.map((r) => r.original))
+ }
columns={columns}
- onSelect={setSelections}
- canSelect={CanSelectSubtitle}
data={data}
></SimpleTable>
<Divider></Divider>
diff --git a/frontend/src/components/tables/BaseTable.module.scss b/frontend/src/components/tables/BaseTable.module.scss
new file mode 100644
index 000000000..e1e1eff0b
--- /dev/null
+++ b/frontend/src/components/tables/BaseTable.module.scss
@@ -0,0 +1,9 @@
+.container {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.table {
+ border-collapse: collapse;
+}
diff --git a/frontend/src/components/tables/BaseTable.tsx b/frontend/src/components/tables/BaseTable.tsx
index 6ec49e61a..b5a867b14 100644
--- a/frontend/src/components/tables/BaseTable.tsx
+++ b/frontend/src/components/tables/BaseTable.tsx
@@ -1,10 +1,17 @@
+import React, { ReactNode, useMemo } from "react";
+import { Box, Skeleton, Table, Text } from "@mantine/core";
+import {
+ flexRender,
+ Header,
+ Row,
+ Table as TableInstance,
+} from "@tanstack/react-table";
import { useIsLoading } from "@/contexts";
import { usePageSize } from "@/utilities/storage";
-import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core";
-import { ReactNode, useMemo } from "react";
-import { HeaderGroup, Row, TableInstance } from "react-table";
+import styles from "@/components/tables/BaseTable.module.scss";
-export type BaseTableProps<T extends object> = TableInstance<T> & {
+export type BaseTableProps<T extends object> = {
+ instance: TableInstance<T>;
tableStyles?: TableStyleProps<T>;
};
@@ -14,116 +21,92 @@ export interface TableStyleProps<T extends object> {
placeholder?: number;
hideHeader?: boolean;
fixHeader?: boolean;
- headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[];
- rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
+ headersRenderer?: (headers: Header<T, unknown>[]) => React.JSX.Element[];
+ rowRenderer?: (row: Row<T>) => Nullable<React.JSX.Element>;
}
-const useStyles = createStyles((theme) => {
- return {
- container: {
- display: "block",
- maxWidth: "100%",
- overflowX: "auto",
- },
- table: {
- borderCollapse: "collapse",
- },
- header: {},
- };
-});
-
function DefaultHeaderRenderer<T extends object>(
- headers: HeaderGroup<T>[],
-): JSX.Element[] {
- return headers.map((col) => (
- <th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}>
- {col.render("Header")}
- </th>
+ headers: Header<T, unknown>[],
+): React.JSX.Element[] {
+ return headers.map((header) => (
+ <Table.Th style={{ whiteSpace: "nowrap" }} key={header.id}>
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ </Table.Th>
));
}
-function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
+function DefaultRowRenderer<T extends object>(
+ row: Row<T>,
+): React.JSX.Element | null {
return (
- <tr {...row.getRowProps()}>
- {row.cells.map((cell) => (
- <td {...cell.getCellProps()}>{cell.render("Cell")}</td>
+ <Table.Tr key={row.id}>
+ {row.getVisibleCells().map((cell) => (
+ <Table.Td key={cell.id}>
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </Table.Td>
))}
- </tr>
+ </Table.Tr>
);
}
export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
- const {
- headerGroups,
- rows: tableRows,
- page: tablePages,
- prepareRow,
- getTableProps,
- getTableBodyProps,
- tableStyles,
- } = props;
+ const { instance, tableStyles } = props;
const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer;
const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer;
- const { classes } = useStyles();
-
const colCount = useMemo(() => {
- return headerGroups.reduce(
- (prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
- 0,
- );
- }, [headerGroups]);
+ return instance
+ .getHeaderGroups()
+ .reduce(
+ (prev, curr) =>
+ curr.headers.length > prev ? curr.headers.length : prev,
+ 0,
+ );
+ }, [instance]);
- // Switch to usePagination plugin if enabled
- const rows = tablePages ?? tableRows;
-
- const empty = rows.length === 0;
+ const empty = instance.getRowCount() === 0;
const pageSize = usePageSize();
const isLoading = useIsLoading();
let body: ReactNode;
+
if (isLoading) {
body = Array(tableStyles?.placeholder ?? pageSize)
.fill(0)
.map((_, i) => (
- <tr key={i}>
- <td colSpan={colCount}>
+ <Table.Tr key={i}>
+ <Table.Td colSpan={colCount}>
<Skeleton height={24}></Skeleton>
- </td>
- </tr>
+ </Table.Td>
+ </Table.Tr>
));
} else if (empty && tableStyles?.emptyText) {
body = (
- <tr>
- <td colSpan={colCount}>
- <Text align="center">{tableStyles.emptyText}</Text>
- </td>
- </tr>
+ <Table.Tr>
+ <Table.Td colSpan={colCount}>
+ <Text ta="center">{tableStyles.emptyText}</Text>
+ </Table.Td>
+ </Table.Tr>
);
} else {
- body = rows.map((row) => {
- prepareRow(row);
+ body = instance.getRowModel().rows.map((row) => {
return rowRenderer(row);
});
}
return (
- <Box className={classes.container}>
- <Table
- className={classes.table}
- striped={tableStyles?.striped ?? true}
- {...getTableProps()}
- >
- <thead className={classes.header} hidden={tableStyles?.hideHeader}>
- {headerGroups.map((headerGroup) => (
- <tr {...headerGroup.getHeaderGroupProps()}>
+ <Box className={styles.container}>
+ <Table className={styles.table} striped={tableStyles?.striped ?? true}>
+ <Table.Thead hidden={tableStyles?.hideHeader}>
+ {instance.getHeaderGroups().map((headerGroup) => (
+ <Table.Tr key={headerGroup.id}>
{headersRenderer(headerGroup.headers)}
- </tr>
+ </Table.Tr>
))}
- </thead>
- <tbody {...getTableBodyProps()}>{body}</tbody>
+ </Table.Thead>
+ <Table.Tbody>{body}</Table.Tbody>
</Table>
</Box>
);
diff --git a/frontend/src/components/tables/GroupTable.tsx b/frontend/src/components/tables/GroupTable.tsx
index 3a8be3d1b..b14edf3e6 100644
--- a/frontend/src/components/tables/GroupTable.tsx
+++ b/frontend/src/components/tables/GroupTable.tsx
@@ -1,38 +1,44 @@
+import React, { Fragment } from "react";
+import { Box, Table, Text } from "@mantine/core";
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Box, Text } from "@mantine/core";
import {
Cell,
- HeaderGroup,
+ flexRender,
+ getExpandedRowModel,
+ getGroupedRowModel,
+ Header,
Row,
- useExpanded,
- useGroupBy,
- useSortBy,
-} from "react-table";
-import SimpleTable, { SimpleTableProps } from "./SimpleTable";
+} from "@tanstack/react-table";
+import SimpleTable, { SimpleTableProps } from "@/components/tables/SimpleTable";
-function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) {
- if (cell.isGrouped) {
+function renderCell<T extends object = object>(
+ cell: Cell<T, unknown>,
+ row: Row<T>,
+) {
+ if (cell.getIsGrouped()) {
return (
- <div {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</div>
+ <div>{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
);
- } else if (row.canExpand || cell.isAggregated) {
+ } else if (row.getCanExpand() || cell.getIsAggregated()) {
return null;
} else {
- return cell.render("Cell");
+ return flexRender(cell.column.columnDef.cell, cell.getContext());
}
}
function renderRow<T extends object>(row: Row<T>) {
- if (row.canExpand) {
- const cell = row.cells.find((cell) => cell.isGrouped);
+ if (row.getCanExpand()) {
+ const cell = row.getVisibleCells().find((cell) => cell.getIsGrouped());
+
if (cell) {
- const rotation = row.isExpanded ? 90 : undefined;
+ const rotation = row.getIsExpanded() ? 90 : undefined;
+
return (
- <tr {...row.getRowProps()}>
- <td {...cell.getCellProps()} colSpan={row.cells.length}>
- <Text {...row.getToggleRowExpandedProps()} p={2}>
- {cell.render("Cell")}
+ <Table.Tr key={row.id} style={{ cursor: "pointer" }}>
+ <Table.Td key={cell.id} colSpan={row.getVisibleCells().length}>
+ <Text p={2} onClick={() => row.toggleExpanded()}>
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
<Box component="span" mx={12}>
<FontAwesomeIcon
icon={faChevronCircleRight}
@@ -40,45 +46,55 @@ function renderRow<T extends object>(row: Row<T>) {
></FontAwesomeIcon>
</Box>
</Text>
- </td>
- </tr>
+ </Table.Td>
+ </Table.Tr>
);
} else {
return null;
}
} else {
return (
- <tr {...row.getRowProps()}>
- {row.cells
- .filter((cell) => !cell.isPlaceholder)
+ <Table.Tr key={row.id}>
+ {row
+ .getVisibleCells()
+ .filter((cell) => !cell.getIsPlaceholder())
.map((cell) => (
- <td {...cell.getCellProps()}>{renderCell(cell, row)}</td>
+ <Table.Td key={cell.id}>{renderCell(cell, row)}</Table.Td>
))}
- </tr>
+ </Table.Tr>
);
}
}
function renderHeaders<T extends object>(
- headers: HeaderGroup<T>[],
-): JSX.Element[] {
- return headers
- .filter((col) => !col.isGrouped)
- .map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>);
+ headers: Header<T, unknown>[],
+): React.JSX.Element[] {
+ return headers.map((header) => {
+ if (header.column.getIsGrouped()) {
+ return <Fragment key={header.id}></Fragment>;
+ }
+
+ return (
+ <Table.Th key={header.id} colSpan={header.colSpan}>
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ </Table.Th>
+ );
+ });
}
type Props<T extends object> = Omit<
SimpleTableProps<T>,
- "plugins" | "headersRenderer" | "rowRenderer"
+ "headersRenderer" | "rowRenderer"
>;
-const plugins = [useGroupBy, useSortBy, useExpanded];
-
function GroupTable<T extends object = object>(props: Props<T>) {
return (
<SimpleTable
{...props}
- plugins={plugins}
+ enableGrouping
+ enableExpanding
+ getGroupedRowModel={getGroupedRowModel()}
+ getExpandedRowModel={getExpandedRowModel()}
tableStyles={{ headersRenderer: renderHeaders, rowRenderer: renderRow }}
></SimpleTable>
);
diff --git a/frontend/src/components/tables/PageControl.tsx b/frontend/src/components/tables/PageControl.tsx
index 0767593de..bcdf290e3 100644
--- a/frontend/src/components/tables/PageControl.tsx
+++ b/frontend/src/components/tables/PageControl.tsx
@@ -1,6 +1,7 @@
-import { useIsLoading } from "@/contexts";
-import { Group, Pagination, Text } from "@mantine/core";
import { FunctionComponent, useEffect } from "react";
+import { Group, Pagination, Text } from "@mantine/core";
+import { useIsLoading } from "@/contexts";
+
interface Props {
count: number;
index: number;
@@ -28,7 +29,7 @@ const PageControl: FunctionComponent<Props> = ({
}, [total, goto]);
return (
- <Group p={16} position="apart">
+ <Group p={16} justify="space-between">
<Text size="sm">
Show {start} to {end} of {total} entries
</Text>
diff --git a/frontend/src/components/tables/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx
index 4f64fe7b8..476ff2c2b 100644
--- a/frontend/src/components/tables/PageTable.tsx
+++ b/frontend/src/components/tables/PageTable.tsx
@@ -1,55 +1,62 @@
+import { MutableRefObject, useEffect } from "react";
+import {
+ getCoreRowModel,
+ getPaginationRowModel,
+ Table,
+ TableOptions,
+ useReactTable,
+} from "@tanstack/react-table";
+import BaseTable, { TableStyleProps } from "@/components/tables/BaseTable";
import { ScrollToTop } from "@/utilities";
import { usePageSize } from "@/utilities/storage";
-import { useEffect } from "react";
-import { usePagination, useTable } from "react-table";
-import BaseTable from "./BaseTable";
import PageControl from "./PageControl";
-import { SimpleTableProps } from "./SimpleTable";
-import { useDefaultSettings } from "./plugins";
-type Props<T extends object> = SimpleTableProps<T> & {
+type Props<T extends object> = Omit<TableOptions<T>, "getCoreRowModel"> & {
+ instanceRef?: MutableRefObject<Table<T> | null>;
+ tableStyles?: TableStyleProps<T>;
autoScroll?: boolean;
};
-const tablePlugins = [useDefaultSettings, usePagination];
-
export default function PageTable<T extends object>(props: Props<T>) {
- const { autoScroll = true, plugins, instanceRef, ...options } = props;
+ const { instanceRef, autoScroll, ...options } = props;
- const instance = useTable(
- options,
- useDefaultSettings,
- ...tablePlugins,
- ...(plugins ?? []),
- );
+ const pageSize = usePageSize();
- // use page size as specified in UI settings
- instance.state.pageSize = usePageSize();
+ const instance = useReactTable({
+ ...options,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ initialState: {
+ pagination: {
+ pageSize: pageSize,
+ },
+ },
+ });
if (instanceRef) {
instanceRef.current = instance;
}
+ const pageIndex = instance.getState().pagination.pageIndex;
+
// Scroll to top when page is changed
useEffect(() => {
if (autoScroll) {
ScrollToTop();
}
- }, [instance.state.pageIndex, autoScroll]);
+ }, [pageIndex, autoScroll]);
+
+ const state = instance.getState();
return (
<>
- <BaseTable
- {...options}
- {...instance}
- plugins={[...tablePlugins, ...(plugins ?? [])]}
- ></BaseTable>
+ <BaseTable {...options} instance={instance}></BaseTable>
<PageControl
- count={instance.pageCount}
- index={instance.state.pageIndex}
- size={instance.state.pageSize}
- total={instance.rows.length}
- goto={instance.gotoPage}
+ count={instance.getPageCount()}
+ index={state.pagination.pageIndex}
+ size={pageSize}
+ total={instance.getRowCount()}
+ goto={instance.setPageIndex}
></PageControl>
</>
);
diff --git a/frontend/src/components/tables/QueryPageTable.tsx b/frontend/src/components/tables/QueryPageTable.tsx
index 81eccee13..797d7a08e 100644
--- a/frontend/src/components/tables/QueryPageTable.tsx
+++ b/frontend/src/components/tables/QueryPageTable.tsx
@@ -1,9 +1,9 @@
+import { useEffect } from "react";
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
+import SimpleTable, { SimpleTableProps } from "@/components/tables/SimpleTable";
import { LoadingProvider } from "@/contexts";
import { ScrollToTop } from "@/utilities";
-import { useEffect } from "react";
import PageControl from "./PageControl";
-import SimpleTable, { SimpleTableProps } from "./SimpleTable";
type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & {
query: UsePaginationQueryResult<T>;
diff --git a/frontend/src/components/tables/SimpleTable.tsx b/frontend/src/components/tables/SimpleTable.tsx
index 90f76c7f2..e3e0b7ff3 100644
--- a/frontend/src/components/tables/SimpleTable.tsx
+++ b/frontend/src/components/tables/SimpleTable.tsx
@@ -1,23 +1,65 @@
-import { PluginHook, TableInstance, TableOptions, useTable } from "react-table";
-import BaseTable, { TableStyleProps } from "./BaseTable";
-import { useDefaultSettings } from "./plugins";
+import { MutableRefObject, useEffect, useMemo } from "react";
+import {
+ getCoreRowModel,
+ Row,
+ Table,
+ TableOptions,
+ useReactTable,
+} from "@tanstack/react-table";
+import BaseTable, { TableStyleProps } from "@/components/tables/BaseTable";
+import { usePageSize } from "@/utilities/storage";
-export type SimpleTableProps<T extends object> = TableOptions<T> & {
- plugins?: PluginHook<T>[];
- instanceRef?: React.MutableRefObject<TableInstance<T> | null>;
+export type SimpleTableProps<T extends object> = Omit<
+ TableOptions<T>,
+ "getCoreRowModel"
+> & {
+ instanceRef?: MutableRefObject<Table<T> | null>;
tableStyles?: TableStyleProps<T>;
+ onRowSelectionChanged?: (selectedRows: Row<T>[]) => void;
+ onAllRowsExpandedChanged?: (isAllRowsExpanded: boolean) => void;
};
export default function SimpleTable<T extends object>(
props: SimpleTableProps<T>,
) {
- const { plugins, instanceRef, tableStyles, ...options } = props;
+ const {
+ instanceRef,
+ tableStyles,
+ onRowSelectionChanged,
+ onAllRowsExpandedChanged,
+ ...options
+ } = props;
- const instance = useTable(options, useDefaultSettings, ...(plugins ?? []));
+ const pageSize = usePageSize();
+
+ const instance = useReactTable({
+ ...options,
+ getCoreRowModel: getCoreRowModel(),
+ autoResetPageIndex: false,
+ autoResetExpanded: false,
+ pageCount: pageSize,
+ });
if (instanceRef) {
instanceRef.current = instance;
}
- return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>;
+ const selectedRows = instance.getSelectedRowModel().rows;
+
+ const memoizedRows = useMemo(() => selectedRows, [selectedRows]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const memoizedRowSelectionChanged = useMemo(() => onRowSelectionChanged, []);
+
+ const isAllRowsExpanded = instance.getIsAllRowsExpanded();
+
+ useEffect(() => {
+ memoizedRowSelectionChanged?.(memoizedRows);
+ }, [memoizedRowSelectionChanged, memoizedRows]);
+
+ useEffect(() => {
+ onAllRowsExpandedChanged?.(isAllRowsExpanded);
+ }, [onAllRowsExpandedChanged, isAllRowsExpanded]);
+
+ return <BaseTable tableStyles={tableStyles} instance={instance}></BaseTable>;
}
diff --git a/frontend/src/components/tables/plugins/index.ts b/frontend/src/components/tables/plugins/index.ts
deleted file mode 100644
index 39490a113..000000000
--- a/frontend/src/components/tables/plugins/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as useCustomSelection } from "./useCustomSelection";
-export { default as useDefaultSettings } from "./useDefaultSettings";
diff --git a/frontend/src/components/tables/plugins/useCustomSelection.tsx b/frontend/src/components/tables/plugins/useCustomSelection.tsx
deleted file mode 100644
index d6ea82de4..000000000
--- a/frontend/src/components/tables/plugins/useCustomSelection.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { Checkbox as MantineCheckbox } from "@mantine/core";
-import { forwardRef, useEffect, useRef } from "react";
-import {
- CellProps,
- Column,
- ColumnInstance,
- HeaderProps,
- Hooks,
- MetaBase,
- TableInstance,
- TableToggleCommonProps,
- ensurePluginOrder,
-} from "react-table";
-
-const pluginName = "useCustomSelection";
-
-const checkboxId = "---selection---";
-
-interface CheckboxProps {
- idIn: string;
- disabled?: boolean;
-}
-
-const Checkbox = forwardRef<
- HTMLInputElement,
- TableToggleCommonProps & CheckboxProps
->(({ indeterminate, checked, disabled, idIn, ...rest }, ref) => {
- const defaultRef = useRef<HTMLInputElement>(null);
- const resolvedRef = ref || defaultRef;
-
- useEffect(() => {
- if (typeof resolvedRef === "object" && resolvedRef.current) {
- resolvedRef.current.indeterminate = indeterminate ?? false;
-
- if (disabled) {
- resolvedRef.current.checked = false;
- } else {
- resolvedRef.current.checked = checked ?? false;
- }
- }
- }, [resolvedRef, indeterminate, checked, disabled]);
-
- return (
- <MantineCheckbox
- key={idIn}
- disabled={disabled}
- ref={resolvedRef}
- {...rest}
- ></MantineCheckbox>
- );
-});
-
-function useCustomSelection<T extends object>(hooks: Hooks<T>) {
- hooks.visibleColumns.push(visibleColumns);
- hooks.useInstance.push(useInstance);
-}
-
-useCustomSelection.pluginName = pluginName;
-
-function useInstance<T extends object>(instance: TableInstance<T>) {
- const {
- plugins,
- rows,
- onSelect,
- canSelect,
- state: { selectedRowIds },
- } = instance;
-
- ensurePluginOrder(plugins, ["useRowSelect"], pluginName);
-
- useEffect(() => {
- // Performance
- let items = Object.keys(selectedRowIds).flatMap(
- (v) => rows.find((n) => n.id === v)?.original ?? [],
- );
-
- if (canSelect) {
- items = items.filter((v) => canSelect(v));
- }
-
- onSelect && onSelect(items);
- }, [selectedRowIds, onSelect, rows, canSelect]);
-}
-
-function visibleColumns<T extends object>(
- columns: ColumnInstance<T>[],
- meta: MetaBase<T>,
-): Column<T>[] {
- const { instance } = meta;
- const checkbox: Column<T> = {
- id: checkboxId,
- Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<T>) => (
- <Checkbox
- idIn="table-header-selection"
- {...getToggleAllRowsSelectedProps()}
- ></Checkbox>
- ),
- Cell: ({ row }: CellProps<T>) => {
- const canSelect = instance.canSelect;
- const disabled = (canSelect && !canSelect(row.original)) ?? false;
- return (
- <Checkbox
- idIn={`table-cell-${row.index}`}
- disabled={disabled}
- {...row.getToggleRowSelectedProps()}
- ></Checkbox>
- );
- },
- };
- return [checkbox, ...columns];
-}
-
-export default useCustomSelection;
diff --git a/frontend/src/components/tables/plugins/useDefaultSettings.tsx b/frontend/src/components/tables/plugins/useDefaultSettings.tsx
deleted file mode 100644
index c833c9f79..000000000
--- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { usePageSize } from "@/utilities/storage";
-import { Hooks, TableOptions } from "react-table";
-
-const pluginName = "useLocalSettings";
-
-function useDefaultSettings<T extends object>(hooks: Hooks<T>) {
- hooks.useOptions.push(useOptions);
-}
-useDefaultSettings.pluginName = pluginName;
-
-function useOptions<T extends object>(options: TableOptions<T>) {
- const pageSize = usePageSize();
-
- if (options.autoResetPage === undefined) {
- options.autoResetPage = false;
- }
-
- if (options.autoResetExpanded === undefined) {
- options.autoResetExpanded = false;
- }
-
- if (options.initialState === undefined) {
- options.initialState = {};
- }
-
- if (options.initialState.pageSize === undefined) {
- options.initialState.pageSize = pageSize;
- }
-
- return options;
-}
-
-export default useDefaultSettings;
diff --git a/frontend/src/components/toolbox/Button.tsx b/frontend/src/components/toolbox/Button.tsx
index 735ef3ca1..0a1d311e1 100644
--- a/frontend/src/components/toolbox/Button.tsx
+++ b/frontend/src/components/toolbox/Button.tsx
@@ -1,6 +1,3 @@
-import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Button, ButtonProps, Text } from "@mantine/core";
import {
ComponentProps,
FunctionComponent,
@@ -8,6 +5,9 @@ import {
useCallback,
useState,
} from "react";
+import { Button, ButtonProps, Text } from "@mantine/core";
+import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type ToolboxButtonProps = Omit<ButtonProps, "color" | "variant" | "leftIcon"> &
Omit<ComponentProps<"button">, "ref"> & {
@@ -24,7 +24,7 @@ const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
<Button
color="dark"
variant="subtle"
- leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
+ leftSection={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
{...props}
>
<Text size="xs">{children}</Text>
diff --git a/frontend/src/components/toolbox/Toolbox.module.scss b/frontend/src/components/toolbox/Toolbox.module.scss
new file mode 100644
index 000000000..10529fd27
--- /dev/null
+++ b/frontend/src/components/toolbox/Toolbox.module.scss
@@ -0,0 +1,9 @@
+.group {
+ @include light {
+ background-color: var(--mantine-color-gray-3);
+ }
+
+ @include dark {
+ background-color: var(--mantine-color-dark-5);
+ }
+}
diff --git a/frontend/src/components/toolbox/index.tsx b/frontend/src/components/toolbox/Toolbox.tsx
index 6995e111d..f67ac60b1 100644
--- a/frontend/src/components/toolbox/index.tsx
+++ b/frontend/src/components/toolbox/Toolbox.tsx
@@ -1,15 +1,7 @@
-import { createStyles, Group } from "@mantine/core";
import { FunctionComponent, PropsWithChildren } from "react";
+import { Group } from "@mantine/core";
import ToolboxButton, { ToolboxMutateButton } from "./Button";
-
-const useStyles = createStyles((theme) => ({
- group: {
- backgroundColor:
- theme.colorScheme === "light"
- ? theme.colors.gray[3]
- : theme.colors.dark[5],
- },
-}));
+import styles from "./Toolbox.module.scss";
declare type ToolboxComp = FunctionComponent<PropsWithChildren> & {
Button: typeof ToolboxButton;
@@ -17,9 +9,8 @@ declare type ToolboxComp = FunctionComponent<PropsWithChildren> & {
};
const Toolbox: ToolboxComp = ({ children }) => {
- const { classes } = useStyles();
return (
- <Group p={12} position="apart" className={classes.group}>
+ <Group p={12} justify="space-between" className={styles.group}>
{children}
</Group>
);
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts
index 8defd1ad9..6320fa4f3 100644
--- a/frontend/src/constants.ts
+++ b/frontend/src/constants.ts
@@ -1,9 +1 @@
-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,
-};
diff --git a/frontend/src/dom.tsx b/frontend/src/dom.tsx
index 07b2078a8..f3b7ec03e 100644
--- a/frontend/src/dom.tsx
+++ b/frontend/src/dom.tsx
@@ -1,7 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import { Router } from "./Router";
import { AllProviders } from "./providers";
+import { Router } from "./Router";
const container = document.getElementById("root");
diff --git a/frontend/src/modules/modals/ModalsProvider.tsx b/frontend/src/modules/modals/ModalsProvider.tsx
index c704a7ae5..65c987848 100644
--- a/frontend/src/modules/modals/ModalsProvider.tsx
+++ b/frontend/src/modules/modals/ModalsProvider.tsx
@@ -1,8 +1,8 @@
+import { FunctionComponent, PropsWithChildren, useMemo } from "react";
import {
ModalsProvider as MantineModalsProvider,
ModalsProviderProps as MantineModalsProviderProps,
} from "@mantine/modals";
-import { FunctionComponent, PropsWithChildren, useMemo } from "react";
import { ModalComponent, StaticModals } from "./WithModal";
const DefaultModalProps: MantineModalsProviderProps["modalProps"] = {
diff --git a/frontend/src/modules/modals/WithModal.tsx b/frontend/src/modules/modals/WithModal.tsx
index eadd6b932..de7b79155 100644
--- a/frontend/src/modules/modals/WithModal.tsx
+++ b/frontend/src/modules/modals/WithModal.tsx
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/ban-types */
+import { createContext, FunctionComponent } from "react";
import { ContextModalProps } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
-import { createContext, FunctionComponent } from "react";
export type ModalComponent<P extends Record<string, unknown> = {}> =
FunctionComponent<ContextModalProps<P>> & {
diff --git a/frontend/src/modules/modals/hooks.ts b/frontend/src/modules/modals/hooks.ts
index 40e891b8e..667e429d3 100644
--- a/frontend/src/modules/modals/hooks.ts
+++ b/frontend/src/modules/modals/hooks.ts
@@ -1,15 +1,12 @@
/* eslint-disable @typescript-eslint/ban-types */
+import { useCallback, useContext, useMemo } from "react";
import { useModals as useMantineModals } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
-import { useCallback, useContext, useMemo } from "react";
import { ModalComponent, ModalIdContext } from "./WithModal";
export function useModals() {
- const {
- openContextModal: openMantineContextModal,
- closeContextModal: closeContextModalRaw,
- ...rest
- } = useMantineModals();
+ const { openContextModal: openMantineContextModal, ...rest } =
+ useMantineModals();
const openContextModal = useCallback(
<ARGS extends {}>(
@@ -26,7 +23,7 @@ export function useModals() {
[openMantineContextModal],
);
- const closeContextModal = useCallback(
+ const closeContext = useCallback(
(modal: ModalComponent) => {
rest.closeModal(modal.modalKey);
},
@@ -43,7 +40,7 @@ export function useModals() {
// TODO: Performance
return useMemo(
- () => ({ openContextModal, closeContextModal, closeSelf, ...rest }),
- [closeContextModal, closeSelf, openContextModal, rest],
+ () => ({ openContextModal, closeContext, closeSelf, ...rest }),
+ [closeContext, closeSelf, openContextModal, rest],
);
}
diff --git a/frontend/src/modules/socketio/hooks.ts b/frontend/src/modules/socketio/hooks.ts
index 741fa5f4a..8d02cce6a 100644
--- a/frontend/src/modules/socketio/hooks.ts
+++ b/frontend/src/modules/socketio/hooks.ts
@@ -1,6 +1,6 @@
import { useEffect } from "react";
+import { LOG } from "@/utilities/console";
import Socketio from ".";
-import { LOG } from "../../utilities/console";
export function useSocketIOReducer(reducer: SocketIO.Reducer) {
useEffect(() => {
diff --git a/frontend/src/modules/socketio/index.ts b/frontend/src/modules/socketio/index.ts
index 64a8d6511..67c556480 100644
--- a/frontend/src/modules/socketio/index.ts
+++ b/frontend/src/modules/socketio/index.ts
@@ -1,8 +1,8 @@
+import { onlineManager } from "@tanstack/react-query";
import { debounce, forIn, remove, uniq } from "lodash";
-import { onlineManager } from "react-query";
-import { Socket, io } from "socket.io-client";
-import { Environment, isDevEnv, isTestEnv } from "../../utilities";
-import { ENSURE, GROUP, LOG } from "../../utilities/console";
+import { io, Socket } from "socket.io-client";
+import { Environment, isDevEnv, isTestEnv } from "@/utilities";
+import { ENSURE, GROUP, LOG } from "@/utilities/console";
import { createDefaultReducer } from "./reducer";
class SocketIOClient {
diff --git a/frontend/src/modules/socketio/reducer.ts b/frontend/src/modules/socketio/reducer.ts
index 403fc0ce0..378ab12fc 100644
--- a/frontend/src/modules/socketio/reducer.ts
+++ b/frontend/src/modules/socketio/reducer.ts
@@ -1,9 +1,9 @@
+import { cleanNotifications, showNotification } from "@mantine/notifications";
import queryClient from "@/apis/queries";
import { QueryKeys } from "@/apis/queries/keys";
+import { notification, task } from "@/modules/task";
import { LOG } from "@/utilities/console";
import { setCriticalError, setOnlineStatus } from "@/utilities/event";
-import { cleanNotifications, showNotification } from "@mantine/notifications";
-import { notification, task } from "../task";
export function createDefaultReducer(): SocketIO.Reducer[] {
return [
@@ -27,7 +27,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (msg) => {
msg
.map((message) => notification.info("Notification", message))
- .forEach(showNotification);
+ .forEach((data) => showNotification(data));
},
},
{
@@ -40,13 +40,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => {
LOG("info", "Invalidating series", ids);
ids.forEach((id) => {
- queryClient.invalidateQueries([QueryKeys.Series, id]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Series, id],
+ });
});
},
delete: (ids) => {
LOG("info", "Invalidating series", ids);
ids.forEach((id) => {
- queryClient.invalidateQueries([QueryKeys.Series, id]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Series, id],
+ });
});
},
},
@@ -55,13 +59,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => {
LOG("info", "Invalidating movies", ids);
ids.forEach((id) => {
- queryClient.invalidateQueries([QueryKeys.Movies, id]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, id],
+ });
});
},
delete: (ids) => {
LOG("info", "Invalidating movies", ids);
ids.forEach((id) => {
- queryClient.invalidateQueries([QueryKeys.Movies, id]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, id],
+ });
});
},
},
@@ -78,10 +86,9 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id,
]);
if (episode !== undefined) {
- queryClient.invalidateQueries([
- QueryKeys.Series,
- episode.sonarrSeriesId,
- ]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
+ });
}
});
},
@@ -93,95 +100,117 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id,
]);
if (episode !== undefined) {
- queryClient.invalidateQueries([
- QueryKeys.Series,
- episode.sonarrSeriesId,
- ]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
+ });
}
});
},
},
{
key: "episode-wanted",
- update: (ids) => {
+ update: () => {
// Find a better way to update wanted
- queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
+ });
},
delete: () => {
- queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
+ });
},
},
{
key: "movie-wanted",
- update: (ids) => {
+ update: () => {
// Find a better way to update wanted
- queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
+ });
},
delete: () => {
- queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
+ });
},
},
{
key: "settings",
any: () => {
- queryClient.invalidateQueries([QueryKeys.System]);
+ void queryClient.invalidateQueries({ queryKey: [QueryKeys.System] });
},
},
{
key: "languages",
any: () => {
- queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Languages]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Languages],
+ });
},
},
{
key: "badges",
any: () => {
- queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Badges]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Badges],
+ });
},
},
{
key: "movie-history",
any: () => {
- queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.History]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.History],
+ });
},
},
{
key: "movie-blacklist",
any: () => {
- queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
+ });
},
},
{
key: "episode-history",
any: () => {
- queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.History]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Episodes, QueryKeys.History],
+ });
},
},
{
key: "episode-blacklist",
any: () => {
- queryClient.invalidateQueries([
- QueryKeys.Episodes,
- QueryKeys.Blacklist,
- ]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Episodes, QueryKeys.Blacklist],
+ });
},
},
{
key: "reset-episode-wanted",
any: () => {
- queryClient.invalidateQueries([QueryKeys.Episodes, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
+ });
},
},
{
key: "reset-movie-wanted",
any: () => {
- queryClient.invalidateQueries([QueryKeys.Movies, QueryKeys.Wanted]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
+ });
},
},
{
key: "task",
any: () => {
- queryClient.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.System, QueryKeys.Tasks],
+ });
},
},
];
diff --git a/frontend/src/modules/task/index.ts b/frontend/src/modules/task/index.ts
index f4dc956cd..59efcaf56 100644
--- a/frontend/src/modules/task/index.ts
+++ b/frontend/src/modules/task/index.ts
@@ -1,10 +1,10 @@
-import { LOG } from "@/utilities/console";
import {
hideNotification,
showNotification,
updateNotification,
} from "@mantine/notifications";
import { uniqueId } from "lodash";
+import { LOG } from "@/utilities/console";
import { notification } from "./notification";
class TaskDispatcher {
@@ -133,7 +133,7 @@ class TaskDispatcher {
public removeProgress(ids: string[]) {
setTimeout(
- () => ids.forEach(hideNotification),
+ () => ids.forEach((id) => hideNotification(id)),
notification.PROGRESS_TIMEOUT,
);
}
diff --git a/frontend/src/modules/task/notification.ts b/frontend/src/modules/task/notification.ts
index bb796b213..97601b452 100644
--- a/frontend/src/modules/task/notification.ts
+++ b/frontend/src/modules/task/notification.ts
@@ -1,7 +1,7 @@
-import { NotificationProps } from "@mantine/notifications";
+import { NotificationData } from "@mantine/notifications";
export const notification = {
- info: (title: string, message: string): NotificationProps => {
+ info: (title: string, message: string): NotificationData => {
return {
title,
message,
@@ -9,7 +9,7 @@ export const notification = {
};
},
- warn: (title: string, message: string): NotificationProps => {
+ warn: (title: string, message: string): NotificationData => {
return {
title,
message,
@@ -18,7 +18,7 @@ export const notification = {
};
},
- error: (title: string, message: string): NotificationProps => {
+ error: (title: string, message: string): NotificationData => {
return {
title,
message,
@@ -33,7 +33,7 @@ export const notification = {
pending: (
id: string,
header: string,
- ): NotificationProps & { id: string } => {
+ ): NotificationData & { id: string } => {
return {
id,
title: header,
@@ -48,7 +48,7 @@ export const notification = {
body: string,
current: number,
total: number,
- ): NotificationProps & { id: string } => {
+ ): NotificationData & { id: string } => {
return {
id,
title: header,
@@ -57,7 +57,7 @@ export const notification = {
autoClose: false,
};
},
- end: (id: string, header: string): NotificationProps & { id: string } => {
+ end: (id: string, header: string): NotificationData & { id: string } => {
return {
id,
title: header,
diff --git a/frontend/src/pages/Authentication.test.tsx b/frontend/src/pages/Authentication.test.tsx
index 95bfe3f47..e5dee6e44 100644
--- a/frontend/src/pages/Authentication.test.tsx
+++ b/frontend/src/pages/Authentication.test.tsx
@@ -1,5 +1,5 @@
-import { render, screen } from "@/tests";
import { describe, it } from "vitest";
+import { render, screen } from "@/tests";
import Authentication from "./Authentication";
describe("Authentication", () => {
diff --git a/frontend/src/pages/Authentication.tsx b/frontend/src/pages/Authentication.tsx
index baf21f6cd..7a164c6c4 100644
--- a/frontend/src/pages/Authentication.tsx
+++ b/frontend/src/pages/Authentication.tsx
@@ -1,5 +1,4 @@
-import { useSystem } from "@/apis/hooks";
-import { Environment } from "@/utilities";
+import { FunctionComponent } from "react";
import {
Avatar,
Button,
@@ -11,7 +10,8 @@ import {
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
-import { FunctionComponent } from "react";
+import { useSystem } from "@/apis/hooks";
+import { Environment } from "@/utilities";
const Authentication: FunctionComponent = () => {
const { login } = useSystem();
@@ -52,7 +52,7 @@ const Authentication: FunctionComponent = () => {
{...form.getInputProps("password")}
></PasswordInput>
<Divider></Divider>
- <Button fullWidth uppercase type="submit">
+ <Button fullWidth tt="uppercase" type="submit">
Login
</Button>
</Stack>
diff --git a/frontend/src/pages/Blacklist/Movies/index.tsx b/frontend/src/pages/Blacklist/Movies/index.tsx
index 67c6a2a7d..9e552fa7d 100644
--- a/frontend/src/pages/Blacklist/Movies/index.tsx
+++ b/frontend/src/pages/Blacklist/Movies/index.tsx
@@ -1,13 +1,13 @@
+import { FunctionComponent } from "react";
+import { Container, Stack } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
import {
useMovieBlacklist,
useMovieDeleteBlacklist,
} from "@/apis/hooks/movies";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Container, Stack } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
import Table from "./table";
const BlacklistMoviesView: FunctionComponent = () => {
diff --git a/frontend/src/pages/Blacklist/Movies/table.tsx b/frontend/src/pages/Blacklist/Movies/table.tsx
index 9ab06f2ba..00730a850 100644
--- a/frontend/src/pages/Blacklist/Movies/table.tsx
+++ b/frontend/src/pages/Blacklist/Movies/table.tsx
@@ -1,58 +1,70 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Text } from "@mantine/core";
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { useMovieDeleteBlacklist } from "@/apis/hooks";
-import { PageTable } from "@/components";
import MutateAction from "@/components/async/MutateAction";
import Language from "@/components/bazarr/Language";
+import PageTable from "@/components/tables/PageTable";
import TextPopover from "@/components/TextPopover";
-import { useTableStyles } from "@/styles";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Anchor, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
interface Props {
- blacklist: readonly Blacklist.Movie[];
+ blacklist: Blacklist.Movie[];
}
const Table: FunctionComponent<Props> = ({ blacklist }) => {
- const columns = useMemo<Column<Blacklist.Movie>[]>(
+ const remove = useMovieDeleteBlacklist();
+
+ const columns = useMemo<ColumnDef<Blacklist.Movie>[]>(
() => [
{
- Header: "Name",
- accessor: "title",
- Cell: (row) => {
- const target = `/movies/${row.row.original.radarrId}`;
- const { classes } = useTableStyles();
+ header: "Name",
+ accessorKey: "title",
+ cell: ({
+ row: {
+ original: { radarrId },
+ },
+ }) => {
+ const target = `/movies/${radarrId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {row.value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {radarrId}
</Anchor>
);
},
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
- return <Language.Text value={value} long></Language.Text>;
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
+ return <Language.Text value={language} long></Language.Text>;
} else {
return null;
}
},
},
{
- Header: "Provider",
- accessor: "provider",
+ header: "Provider",
+ accessorKey: "provider",
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: (row) => {
- if (row.value) {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp: parsedTimestamp },
+ },
+ }) => {
+ if (timestamp) {
return (
- <TextPopover text={row.row.original.parsed_timestamp}>
- <Text>{row.value}</Text>
+ <TextPopover text={parsedTimestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
} else {
@@ -61,10 +73,12 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
},
{
- accessor: "subs_id",
- Cell: ({ row, value }) => {
- const remove = useMovieDeleteBlacklist();
-
+ id: "subs_id",
+ cell: ({
+ row: {
+ original: { subs_id: subsId, provider },
+ },
+ }) => {
return (
<MutateAction
label="Remove from Blacklist"
@@ -74,9 +88,9 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
args={() => ({
all: false,
form: {
- provider: row.original.provider,
+ provider: provider,
// eslint-disable-next-line camelcase
- subs_id: value,
+ subs_id: subsId,
},
})}
></MutateAction>
@@ -84,7 +98,7 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
},
],
- [],
+ [remove],
);
return (
<PageTable
diff --git a/frontend/src/pages/Blacklist/Series/index.tsx b/frontend/src/pages/Blacklist/Series/index.tsx
index a4a6d3638..3bdec2b19 100644
--- a/frontend/src/pages/Blacklist/Series/index.tsx
+++ b/frontend/src/pages/Blacklist/Series/index.tsx
@@ -1,10 +1,10 @@
+import { FunctionComponent } from "react";
+import { Container, Stack } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { useEpisodeBlacklist, useEpisodeDeleteBlacklist } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Container, Stack } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
import Table from "./table";
const BlacklistSeriesView: FunctionComponent = () => {
diff --git a/frontend/src/pages/Blacklist/Series/table.tsx b/frontend/src/pages/Blacklist/Series/table.tsx
index a67069717..3d67e637d 100644
--- a/frontend/src/pages/Blacklist/Series/table.tsx
+++ b/frontend/src/pages/Blacklist/Series/table.tsx
@@ -1,65 +1,77 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Text } from "@mantine/core";
+import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { useEpisodeDeleteBlacklist } from "@/apis/hooks";
-import { PageTable } from "@/components";
import MutateAction from "@/components/async/MutateAction";
import Language from "@/components/bazarr/Language";
+import PageTable from "@/components/tables/PageTable";
import TextPopover from "@/components/TextPopover";
-import { useTableStyles } from "@/styles";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Anchor, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
interface Props {
- blacklist: readonly Blacklist.Episode[];
+ blacklist: Blacklist.Episode[];
}
const Table: FunctionComponent<Props> = ({ blacklist }) => {
- const columns = useMemo<Column<Blacklist.Episode>[]>(
+ const removeFromBlacklist = useEpisodeDeleteBlacklist();
+
+ const columns = useMemo<ColumnDef<Blacklist.Episode>[]>(
() => [
{
- Header: "Series",
- accessor: "seriesTitle",
- Cell: (row) => {
- const { classes } = useTableStyles();
- const target = `/series/${row.row.original.sonarrSeriesId}`;
+ header: "Series",
+ accessorKey: "seriesTitle",
+ cell: ({
+ row: {
+ original: { sonarrSeriesId, seriesTitle },
+ },
+ }) => {
+ const target = `/series/${sonarrSeriesId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {row.value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {seriesTitle}
</Anchor>
);
},
},
{
- Header: "Episode",
- accessor: "episode_number",
+ header: "Episode",
+ accessorKey: "episode_number",
},
{
- accessor: "episodeTitle",
+ id: "episodeTitle",
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
- return <Language.Text value={value} long></Language.Text>;
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
+ return <Language.Text value={language} long></Language.Text>;
} else {
return null;
}
},
},
{
- Header: "Provider",
- accessor: "provider",
+ header: "Provider",
+ accessorKey: "provider",
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: (row) => {
- if (row.value) {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp: parsedTimestamp },
+ },
+ }) => {
+ if (timestamp) {
return (
- <TextPopover text={row.row.original.parsed_timestamp}>
- <Text>{row.value}</Text>
+ <TextPopover text={parsedTimestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
} else {
@@ -68,22 +80,24 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
},
{
- accessor: "subs_id",
- Cell: ({ row, value }) => {
- const remove = useEpisodeDeleteBlacklist();
-
+ id: "subs_id",
+ cell: ({
+ row: {
+ original: { subs_id: subsId, provider },
+ },
+ }) => {
return (
<MutateAction
label="Remove from Blacklist"
noReset
icon={faTrash}
- mutation={remove}
+ mutation={removeFromBlacklist}
args={() => ({
all: false,
form: {
- provider: row.original.provider,
+ provider: provider,
// eslint-disable-next-line camelcase
- subs_id: value,
+ subs_id: subsId,
},
})}
></MutateAction>
@@ -91,7 +105,7 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
},
},
],
- [],
+ [removeFromBlacklist],
);
return (
<PageTable
diff --git a/frontend/src/pages/Episodes/components.tsx b/frontend/src/pages/Episodes/components.tsx
index 698785d5e..7b21393fa 100644
--- a/frontend/src/pages/Episodes/components.tsx
+++ b/frontend/src/pages/Episodes/components.tsx
@@ -1,9 +1,10 @@
+import { FunctionComponent, useMemo, useState } from "react";
+import { Badge, MantineColor, Tooltip } from "@mantine/core";
import { useEpisodeSubtitleModification } from "@/apis/hooks";
import Language from "@/components/bazarr/Language";
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
import { task, TaskGroup } from "@/modules/task";
-import { Badge, MantineColor, Tooltip } from "@mantine/core";
-import { FunctionComponent, useMemo, useState } from "react";
+import { toPython } from "@/utilities";
interface Props {
seriesId: number;
@@ -24,13 +25,13 @@ export const Subtitle: FunctionComponent<Props> = ({
const disabled = subtitle.path === null;
- const color: MantineColor | undefined = useMemo(() => {
+ const variant: MantineColor | undefined = useMemo(() => {
if (opened && !disabled) {
- return "cyan";
+ return "highlight";
} else if (missing) {
- return "yellow";
+ return "warning";
} else if (disabled) {
- return "gray";
+ return "disabled";
}
}, [disabled, missing, opened]);
@@ -43,14 +44,16 @@ export const Subtitle: FunctionComponent<Props> = ({
type: "episode",
language: subtitle.code2,
path: subtitle.path,
+ forced: toPython(subtitle.forced),
+ hi: toPython(subtitle.hi),
});
}
return list;
- }, [episodeId, subtitle.code2, subtitle.path]);
+ }, [episodeId, subtitle.code2, subtitle.path, subtitle.forced, subtitle.hi]);
const ctx = (
- <Badge color={color}>
+ <Badge variant={variant}>
<Language.Text value={subtitle} long={false}></Language.Text>
</Badge>
);
diff --git a/frontend/src/pages/Episodes/index.tsx b/frontend/src/pages/Episodes/index.tsx
index 28e375744..8075e77a1 100644
--- a/frontend/src/pages/Episodes/index.tsx
+++ b/frontend/src/pages/Episodes/index.tsx
@@ -1,4 +1,27 @@
-import { RouterNames } from "@/Router/RouterNames";
+import {
+ FunctionComponent,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Navigate, useParams } from "react-router-dom";
+import { Container, Group, Stack } from "@mantine/core";
+import { Dropzone } from "@mantine/dropzone";
+import { useDocumentTitle } from "@mantine/hooks";
+import { showNotification } from "@mantine/notifications";
+import {
+ faAdjust,
+ faBriefcase,
+ faCircleChevronDown,
+ faCircleChevronRight,
+ faCloudUploadAlt,
+ faHdd,
+ faSearch,
+ faSync,
+ faWrench,
+} from "@fortawesome/free-solid-svg-icons";
+import { Table as TableInstance } from "@tanstack/table-core/build/lib/types";
import {
useEpisodesBySeriesId,
useIsAnyActionRunning,
@@ -12,41 +35,13 @@ import { ItemEditModal } from "@/components/forms/ItemEditForm";
import { SeriesUploadModal } from "@/components/forms/SeriesUploadForm";
import { SubtitleToolsModal } from "@/components/modals";
import { useModals } from "@/modules/modals";
-import { TaskGroup, notification, task } from "@/modules/task";
+import { notification, task, TaskGroup } from "@/modules/task";
import ItemOverview from "@/pages/views/ItemOverview";
+import { RouterNames } from "@/Router/RouterNames";
import { useLanguageProfileBy } from "@/utilities/languages";
-import {
- faAdjust,
- faBriefcase,
- faCircleChevronDown,
- faCircleChevronRight,
- faCloudUploadAlt,
- faHdd,
- faSearch,
- faSync,
- faWrench,
-} from "@fortawesome/free-solid-svg-icons";
-import { Container, Group, Stack } from "@mantine/core";
-import { Dropzone } from "@mantine/dropzone";
-import { useDocumentTitle } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
-import {
- FunctionComponent,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { Navigate, useParams } from "react-router-dom";
import Table from "./table";
const SeriesEpisodesView: FunctionComponent = () => {
- const [state, setState] = useState({
- expand: false,
- buttonText: "Expand All",
- initial: true,
- });
-
const params = useParams();
const id = Number.parseInt(params.id as string);
@@ -102,18 +97,18 @@ const SeriesEpisodesView: FunctionComponent = () => {
useDocumentTitle(`${series?.title ?? "Unknown Series"} - Bazarr (Series)`);
+ const tableRef = useRef<TableInstance<Item.Episode> | null>(null);
+
+ const [isAllRowExpanded, setIsAllRowExpanded] = useState(
+ tableRef?.current?.getIsAllRowsExpanded(),
+ );
+
const openDropzone = useRef<VoidFunction>(null);
if (isNaN(id) || (isFetched && !series)) {
return <Navigate to={RouterNames.NotFound}></Navigate>;
}
- const toggleState = () => {
- state.expand
- ? setState({ expand: false, buttonText: "Expand All", initial: false })
- : setState({ expand: true, buttonText: "Collapse All", initial: false });
- };
-
return (
<Container px={0} fluid>
<QueryOverlay result={seriesQuery}>
@@ -125,7 +120,7 @@ const SeriesEpisodesView: FunctionComponent = () => {
<DropContent></DropContent>
</Dropzone.FullScreen>
<Toolbox>
- <Group spacing="xs">
+ <Group gap="xs">
<Toolbox.Button
icon={faSync}
disabled={!available || hasTask}
@@ -160,7 +155,7 @@ const SeriesEpisodesView: FunctionComponent = () => {
Search
</Toolbox.Button>
</Group>
- <Group spacing="xs">
+ <Group gap="xs">
<Toolbox.Button
disabled={
series === undefined ||
@@ -210,12 +205,14 @@ const SeriesEpisodesView: FunctionComponent = () => {
Edit Series
</Toolbox.Button>
<Toolbox.Button
- icon={state.expand ? faCircleChevronRight : faCircleChevronDown}
+ icon={
+ isAllRowExpanded ? faCircleChevronRight : faCircleChevronDown
+ }
onClick={() => {
- toggleState();
+ tableRef.current?.toggleAllRowsExpanded();
}}
>
- {state.buttonText}
+ {isAllRowExpanded ? "Collapse All" : "Expand All"}
</Toolbox.Button>
</Group>
</Toolbox>
@@ -223,11 +220,11 @@ const SeriesEpisodesView: FunctionComponent = () => {
<ItemOverview item={series ?? null} details={details}></ItemOverview>
<QueryOverlay result={episodesQuery}>
<Table
- expand={state.expand}
- initial={state.initial}
+ ref={tableRef}
episodes={episodes ?? null}
profile={profile}
disabled={hasTask || !series || series.profileId === null}
+ onAllRowsExpandedChanged={setIsAllRowExpanded}
></Table>
</QueryOverlay>
</Stack>
diff --git a/frontend/src/pages/Episodes/table.tsx b/frontend/src/pages/Episodes/table.tsx
index 5a310c359..7b8d4494f 100644
--- a/frontend/src/pages/Episodes/table.tsx
+++ b/frontend/src/pages/Episodes/table.tsx
@@ -1,253 +1,250 @@
+import React, { forwardRef, useCallback, useEffect, useMemo } from "react";
+import { Group, Text } from "@mantine/core";
+import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
+import {
+ faBookmark,
+ faHistory,
+ faUser,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef, Table as TableInstance } from "@tanstack/react-table";
import { useDownloadEpisodeSubtitles, useEpisodesProvider } from "@/apis/hooks";
import { useShowOnlyDesired } from "@/apis/hooks/site";
import { Action, GroupTable } from "@/components";
-import TextPopover from "@/components/TextPopover";
import { AudioList } from "@/components/bazarr";
import { EpisodeHistoryModal } from "@/components/modals";
import { EpisodeSearchModal } from "@/components/modals/ManualSearchModal";
+import TextPopover from "@/components/TextPopover";
import { useModals } from "@/modules/modals";
-import { useTableStyles } from "@/styles";
import { BuildKey, filterSubtitleBy } from "@/utilities";
import { useProfileItemsToLanguages } from "@/utilities/languages";
-import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
-import {
- faBookmark,
- faHistory,
- faUser,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Group, Text } from "@mantine/core";
-import {
- FunctionComponent,
- useCallback,
- useEffect,
- useMemo,
- useRef,
-} from "react";
-import { Column, TableInstance } from "react-table";
import { Subtitle } from "./components";
interface Props {
episodes: Item.Episode[] | null;
disabled?: boolean;
profile?: Language.Profile;
- expand?: boolean;
- initial?: boolean;
+ onAllRowsExpandedChanged: (isAllRowsExpanded: boolean) => void;
}
-const Table: FunctionComponent<Props> = ({
- episodes,
- profile,
- disabled,
- expand,
- initial,
-}) => {
- const onlyDesired = useShowOnlyDesired();
-
- const profileItems = useProfileItemsToLanguages(profile);
- const { mutateAsync } = useDownloadEpisodeSubtitles();
-
- const download = useCallback(
- (item: Item.Episode, result: SearchResultType) => {
- const {
- language,
- hearing_impaired: hi,
- forced,
- provider,
- subtitle,
- original_format: originalFormat,
- } = result;
- const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item;
-
- return mutateAsync({
- seriesId,
- episodeId,
- form: {
+const Table = forwardRef<TableInstance<Item.Episode> | null, Props>(
+ ({ episodes, profile, disabled, onAllRowsExpandedChanged }, ref) => {
+ const onlyDesired = useShowOnlyDesired();
+
+ const tableRef =
+ ref as React.MutableRefObject<TableInstance<Item.Episode> | null>;
+
+ const profileItems = useProfileItemsToLanguages(profile);
+
+ const { mutateAsync } = useDownloadEpisodeSubtitles();
+
+ const modals = useModals();
+
+ const download = useCallback(
+ (item: Item.Episode, result: SearchResultType) => {
+ const {
language,
- hi,
+ hearing_impaired: hi,
forced,
provider,
subtitle,
- // eslint-disable-next-line camelcase
original_format: originalFormat,
+ } = result;
+ const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item;
+
+ return mutateAsync({
+ seriesId,
+ episodeId,
+ form: {
+ language,
+ hi,
+ forced,
+ provider,
+ subtitle,
+ // eslint-disable-next-line camelcase
+ original_format: originalFormat,
+ },
+ });
+ },
+ [mutateAsync],
+ );
+
+ const SubtitlesCell = React.memo(
+ ({ episode }: { episode: Item.Episode }) => {
+ const seriesId = episode.sonarrSeriesId;
+
+ const elements = useMemo(() => {
+ const episodeId = episode.sonarrEpisodeId;
+
+ const missing = episode.missing_subtitles.map((val, idx) => (
+ <Subtitle
+ missing
+ key={BuildKey(idx, val.code2, "missing")}
+ seriesId={seriesId}
+ episodeId={episodeId}
+ subtitle={val}
+ ></Subtitle>
+ ));
+
+ let rawSubtitles = episode.subtitles;
+ if (onlyDesired) {
+ rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems);
+ }
+
+ const subtitles = rawSubtitles.map((val, idx) => (
+ <Subtitle
+ key={BuildKey(idx, val.code2, "valid")}
+ seriesId={seriesId}
+ episodeId={episodeId}
+ subtitle={val}
+ ></Subtitle>
+ ));
+
+ return [...missing, ...subtitles];
+ }, [episode, seriesId]);
+
+ return (
+ <Group gap="xs" wrap="nowrap">
+ {elements}
+ </Group>
+ );
+ },
+ );
+
+ const columns = useMemo<ColumnDef<Item.Episode>[]>(
+ () => [
+ {
+ id: "monitored",
+ cell: ({
+ row: {
+ original: { monitored },
+ },
+ }) => {
+ return (
+ <FontAwesomeIcon
+ title={monitored ? "monitored" : "unmonitored"}
+ icon={monitored ? faBookmark : farBookmark}
+ ></FontAwesomeIcon>
+ );
+ },
},
- });
- },
- [mutateAsync],
- );
-
- const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
- () => [
- {
- accessor: "monitored",
- Cell: (row) => {
- return (
- <FontAwesomeIcon
- title={row.value ? "monitored" : "unmonitored"}
- icon={row.value ? faBookmark : farBookmark}
- ></FontAwesomeIcon>
- );
+ {
+ header: "",
+ accessorKey: "season",
+ cell: ({
+ row: {
+ original: { season },
+ },
+ }) => {
+ return <Text span>Season {season}</Text>;
+ },
},
- },
- {
- accessor: "season",
- Cell: (row) => {
- return <Text>Season {row.value}</Text>;
+ {
+ header: "Episode",
+ accessorKey: "episode",
},
- },
- {
- Header: "Episode",
- accessor: "episode",
- },
- {
- Header: "Title",
- accessor: "title",
- Cell: ({ value, row }) => {
- const { classes } = useTableStyles();
-
- return (
- <TextPopover text={row.original.sceneName}>
- <Text className={classes.primary}>{value}</Text>
- </TextPopover>
- );
+ {
+ header: "Title",
+ accessorKey: "title",
+ cell: ({
+ row: {
+ original: { sceneName, title },
+ },
+ }) => {
+ return (
+ <TextPopover text={sceneName}>
+ <Text className="table-primary">{title}</Text>
+ </TextPopover>
+ );
+ },
},
- },
- {
- Header: "Audio",
- accessor: "audio_language",
- Cell: ({ value }) => <AudioList audios={value}></AudioList>,
- },
- {
- Header: "Subtitles",
- accessor: "missing_subtitles",
- Cell: ({ row }) => {
- const episode = row.original;
-
- const seriesId = episode.sonarrSeriesId;
-
- const elements = useMemo(() => {
- const episodeId = episode.sonarrEpisodeId;
-
- const missing = episode.missing_subtitles.map((val, idx) => (
- <Subtitle
- missing
- key={BuildKey(idx, val.code2, "missing")}
- seriesId={seriesId}
- episodeId={episodeId}
- subtitle={val}
- ></Subtitle>
- ));
-
- let rawSubtitles = episode.subtitles;
- if (onlyDesired) {
- rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems);
- }
-
- const subtitles = rawSubtitles.map((val, idx) => (
- <Subtitle
- key={BuildKey(idx, val.code2, "valid")}
- seriesId={seriesId}
- episodeId={episodeId}
- subtitle={val}
- ></Subtitle>
- ));
-
- return [...missing, ...subtitles];
- }, [episode, seriesId]);
-
- return (
- <Group spacing="xs" noWrap>
- {elements}
- </Group>
- );
+ {
+ header: "Audio",
+ accessorKey: "audio_language",
+ cell: ({
+ row: {
+ original: { audio_language: audioLanguage },
+ },
+ }) => <AudioList audios={audioLanguage}></AudioList>,
},
- },
- {
- Header: "Actions",
- accessor: "sonarrEpisodeId",
- Cell: ({ row }) => {
- const modals = useModals();
- return (
- <Group spacing="xs" noWrap>
- <Action
- label="Manual Search"
- disabled={disabled}
- color="dark"
- onClick={() => {
- modals.openContextModal(EpisodeSearchModal, {
- item: row.original,
- download,
- query: useEpisodesProvider,
- });
- }}
- icon={faUser}
- ></Action>
- <Action
- label="History"
- disabled={disabled}
- color="dark"
- onClick={() => {
- modals.openContextModal(
- EpisodeHistoryModal,
- {
- episode: row.original,
- },
- {
- title: `History - ${row.original.title}`,
- },
- );
- }}
- icon={faHistory}
- ></Action>
- </Group>
- );
+ {
+ header: "Subtitles",
+ accessorKey: "missing_subtitles",
+ cell: ({ row: { original } }) => {
+ return <SubtitlesCell episode={original} />;
+ },
},
- },
- ],
- [onlyDesired, profileItems, disabled, download],
- );
-
- const maxSeason = useMemo(
- () =>
- episodes?.reduce<number>(
- (prev, curr) => Math.max(prev, curr.season),
- 0,
- ) ?? 0,
- [episodes],
- );
-
- const instance = useRef<TableInstance<Item.Episode> | null>(null);
-
- useEffect(() => {
- if (instance.current) {
- if (initial) {
- // start with all rows collapsed
- instance.current.toggleAllRowsExpanded(false);
- // expand the last/current season on initial display
- instance.current.toggleRowExpanded([`season:${maxSeason}`], true);
- } else {
- if (expand !== undefined) {
- instance.current.toggleAllRowsExpanded(expand);
- }
- }
- }
- }, [maxSeason, expand, initial]);
-
- return (
- <GroupTable
- columns={columns}
- data={episodes ?? []}
- instanceRef={instance}
- initialState={{
- sortBy: [
- { id: "season", desc: true },
- { id: "episode", desc: true },
- ],
- groupBy: ["season"],
- }}
- tableStyles={{ emptyText: "No Episode Found For This Series" }}
- ></GroupTable>
- );
-};
+ {
+ header: "Actions",
+ cell: ({ row }) => {
+ return (
+ <Group gap="xs" wrap="nowrap">
+ <Action
+ label="Manual Search"
+ disabled={disabled}
+ onClick={() => {
+ modals.openContextModal(EpisodeSearchModal, {
+ item: row.original,
+ download,
+ query: useEpisodesProvider,
+ });
+ }}
+ icon={faUser}
+ ></Action>
+ <Action
+ label="History"
+ disabled={disabled}
+ onClick={() => {
+ modals.openContextModal(
+ EpisodeHistoryModal,
+ {
+ episode: row.original,
+ },
+ {
+ title: `History - ${row.original.title}`,
+ },
+ );
+ }}
+ icon={faHistory}
+ ></Action>
+ </Group>
+ );
+ },
+ },
+ ],
+ [disabled, download, modals, SubtitlesCell],
+ );
+
+ const maxSeason = useMemo(
+ () =>
+ episodes?.reduce<number>(
+ (prev, curr) => Math.max(prev, curr.season),
+ 0,
+ ) ?? 0,
+ [episodes],
+ );
+
+ useEffect(() => {
+ tableRef?.current?.setExpanded(() => ({ [`season:${maxSeason}`]: true }));
+ }, [tableRef, maxSeason]);
+
+ return (
+ <GroupTable
+ columns={columns}
+ data={episodes ?? []}
+ instanceRef={tableRef}
+ onAllRowsExpandedChanged={onAllRowsExpandedChanged}
+ initialState={{
+ sorting: [
+ { id: "season", desc: true },
+ { id: "episode", desc: true },
+ ],
+ grouping: ["season"],
+ }}
+ tableStyles={{ emptyText: "No Episode Found For This Series" }}
+ ></GroupTable>
+ );
+ },
+);
export default Table;
diff --git a/frontend/src/pages/History/Movies/index.tsx b/frontend/src/pages/History/Movies/index.tsx
index ee4e98df0..92d1aa280 100644
--- a/frontend/src/pages/History/Movies/index.tsx
+++ b/frontend/src/pages/History/Movies/index.tsx
@@ -1,4 +1,14 @@
/* eslint-disable camelcase */
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Badge, Text } from "@mantine/core";
+import {
+ faFileExcel,
+ faInfoCircle,
+ faRecycle,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import { useMovieAddBlacklist, useMovieHistoryPagination } from "@/apis/hooks";
import { MutateAction } from "@/components/async";
import { HistoryIcon } from "@/components/bazarr";
@@ -6,46 +16,42 @@ import Language from "@/components/bazarr/Language";
import StateIcon from "@/components/StateIcon";
import TextPopover from "@/components/TextPopover";
import HistoryView from "@/pages/views/HistoryView";
-import { useTableStyles } from "@/styles";
-import {
- faFileExcel,
- faInfoCircle,
- faRecycle,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Badge, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const MoviesHistoryView: FunctionComponent = () => {
- const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
+ const addToBlacklist = useMovieAddBlacklist();
+
+ const columns = useMemo<ColumnDef<History.Movie>[]>(
() => [
{
- accessor: "action",
- Cell: (row) => <HistoryIcon action={row.value}></HistoryIcon>,
+ id: "action",
+ cell: ({ row }) => (
+ <HistoryIcon action={row.original.action}></HistoryIcon>
+ ),
},
{
- Header: "Name",
- accessor: "title",
- Cell: ({ row, value }) => {
- const { classes } = useTableStyles();
+ header: "Name",
+ accessorKey: "title",
+ cell: ({ row }) => {
const target = `/movies/${row.original.radarrId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {row.original.title}
</Anchor>
);
},
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
return (
<Badge>
- <Language.Text value={value} long></Language.Text>
+ <Language.Text value={language} long></Language.Text>
</Badge>
);
} else {
@@ -54,13 +60,13 @@ const MoviesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Score",
- accessor: "score",
+ header: "Score",
+ accessorKey: "score",
},
{
- Header: "Match",
- accessor: "matches",
- Cell: (row) => {
+ header: "Match",
+ accessorKey: "matches",
+ cell: (row) => {
const { matches, dont_matches: dont } = row.row.original;
if (matches.length || dont.length) {
return (
@@ -76,13 +82,17 @@ const MoviesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: (row) => {
- if (row.value) {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp },
+ },
+ }) => {
+ if (timestamp) {
return (
- <TextPopover text={row.row.original.parsed_timestamp}>
- <Text>{row.value}</Text>
+ <TextPopover text={parsed_timestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
} else {
@@ -91,21 +101,29 @@ const MoviesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Info",
- accessor: "description",
- Cell: ({ value }) => {
+ header: "Info",
+ accessorKey: "description",
+ cell: ({
+ row: {
+ original: { description },
+ },
+ }) => {
return (
- <TextPopover text={value}>
+ <TextPopover text={description}>
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
</TextPopover>
);
},
},
{
- Header: "Upgrade",
- accessor: "upgradable",
- Cell: (row) => {
- if (row.value) {
+ header: "Upgrade",
+ accessorKey: "upgradable",
+ cell: ({
+ row: {
+ original: { upgradable },
+ },
+ }) => {
+ if (upgradable) {
return (
<TextPopover text="This Subtitle File Is Eligible For An Upgrade.">
<FontAwesomeIcon size="sm" icon={faRecycle}></FontAwesomeIcon>
@@ -117,20 +135,25 @@ const MoviesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Blacklist",
- accessor: "blacklisted",
- Cell: ({ row, value }) => {
- const add = useMovieAddBlacklist();
- const { radarrId, provider, subs_id, language, subtitles_path } =
- row.original;
+ header: "Blacklist",
+ accessorKey: "blacklisted",
+ cell: ({ row }) => {
+ const {
+ blacklisted,
+ radarrId,
+ provider,
+ subs_id,
+ language,
+ subtitles_path,
+ } = row.original;
if (subs_id && provider && language) {
return (
<MutateAction
label="Add to Blacklist"
- disabled={value}
+ disabled={blacklisted}
icon={faFileExcel}
- mutation={add}
+ mutation={addToBlacklist}
args={() => ({
id: radarrId,
form: {
@@ -148,7 +171,7 @@ const MoviesHistoryView: FunctionComponent = () => {
},
},
],
- [],
+ [addToBlacklist],
);
const query = useMovieHistoryPagination();
diff --git a/frontend/src/pages/History/Series/index.tsx b/frontend/src/pages/History/Series/index.tsx
index d6b1469bf..a5d75516a 100644
--- a/frontend/src/pages/History/Series/index.tsx
+++ b/frontend/src/pages/History/Series/index.tsx
@@ -1,4 +1,14 @@
/* eslint-disable camelcase */
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Badge, Text } from "@mantine/core";
+import {
+ faFileExcel,
+ faInfoCircle,
+ faRecycle,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import {
useEpisodeAddBlacklist,
useEpisodeHistoryPagination,
@@ -9,59 +19,62 @@ import Language from "@/components/bazarr/Language";
import StateIcon from "@/components/StateIcon";
import TextPopover from "@/components/TextPopover";
import HistoryView from "@/pages/views/HistoryView";
-import { useTableStyles } from "@/styles";
-import {
- faFileExcel,
- faInfoCircle,
- faRecycle,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Badge, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const SeriesHistoryView: FunctionComponent = () => {
- const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
+ const addToBlacklist = useEpisodeAddBlacklist();
+
+ const columns = useMemo<ColumnDef<History.Episode>[]>(
() => [
{
- accessor: "action",
- Cell: ({ value }) => <HistoryIcon action={value}></HistoryIcon>,
+ id: "action",
+ cell: ({ row: { original } }) => (
+ <HistoryIcon action={original.action}></HistoryIcon>
+ ),
},
{
- Header: "Series",
- accessor: "seriesTitle",
- Cell: (row) => {
- const { classes } = useTableStyles();
- const target = `/series/${row.row.original.sonarrSeriesId}`;
+ header: "Series",
+ accessorKey: "seriesTitle",
+ cell: ({
+ row: {
+ original: { seriesTitle, sonarrSeriesId },
+ },
+ }) => {
+ const target = `/series/${sonarrSeriesId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {row.value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {seriesTitle}
</Anchor>
);
},
},
{
- Header: "Episode",
- accessor: "episode_number",
+ header: "Episode",
+ accessorKey: "episode_number",
},
{
- Header: "Title",
- accessor: "episodeTitle",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}</Text>;
+ header: "Title",
+ accessorKey: "episodeTitle",
+ cell: ({
+ row: {
+ original: { episodeTitle },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{episodeTitle}</Text>;
},
},
{
- Header: "Language",
- accessor: "language",
- Cell: ({ value }) => {
- if (value) {
+ header: "Language",
+ accessorKey: "language",
+ cell: ({
+ row: {
+ original: { language },
+ },
+ }) => {
+ if (language) {
return (
<Badge color="secondary">
- <Language.Text value={value} long></Language.Text>
+ <Language.Text value={language} long></Language.Text>
</Badge>
);
} else {
@@ -70,13 +83,13 @@ const SeriesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Score",
- accessor: "score",
+ header: "Score",
+ accessorKey: "score",
},
{
- Header: "Match",
- accessor: "matches",
- Cell: (row) => {
+ header: "Match",
+ accessorKey: "matches",
+ cell: (row) => {
const { matches, dont_matches: dont } = row.row.original;
if (matches.length || dont.length) {
return (
@@ -92,13 +105,17 @@ const SeriesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Date",
- accessor: "timestamp",
- Cell: (row) => {
- if (row.value) {
+ header: "Date",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp, parsed_timestamp },
+ },
+ }) => {
+ if (timestamp) {
return (
- <TextPopover text={row.row.original.parsed_timestamp}>
- <Text>{row.value}</Text>
+ <TextPopover text={parsed_timestamp}>
+ <Text>{timestamp}</Text>
</TextPopover>
);
} else {
@@ -107,21 +124,29 @@ const SeriesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Info",
- accessor: "description",
- Cell: ({ row, value }) => {
+ header: "Info",
+ accessorKey: "description",
+ cell: ({
+ row: {
+ original: { description },
+ },
+ }) => {
return (
- <TextPopover text={value}>
+ <TextPopover text={description}>
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
</TextPopover>
);
},
},
{
- Header: "Upgrade",
- accessor: "upgradable",
- Cell: (row) => {
- if (row.value) {
+ header: "Upgrade",
+ accessorKey: "upgradable",
+ cell: ({
+ row: {
+ original: { upgradable },
+ },
+ }) => {
+ if (upgradable) {
return (
<TextPopover text="This Subtitle File Is Eligible For An Upgrade.">
<FontAwesomeIcon size="sm" icon={faRecycle}></FontAwesomeIcon>
@@ -133,9 +158,9 @@ const SeriesHistoryView: FunctionComponent = () => {
},
},
{
- Header: "Blacklist",
- accessor: "blacklisted",
- Cell: ({ row, value }) => {
+ header: "Blacklist",
+ accessorKey: "blacklisted",
+ cell: ({ row }) => {
const {
sonarrEpisodeId,
sonarrSeriesId,
@@ -143,16 +168,15 @@ const SeriesHistoryView: FunctionComponent = () => {
subs_id,
language,
subtitles_path,
+ blacklisted,
} = row.original;
- const add = useEpisodeAddBlacklist();
-
if (subs_id && provider && language) {
return (
<MutateAction
label="Add to Blacklist"
- disabled={value}
+ disabled={blacklisted}
icon={faFileExcel}
- mutation={add}
+ mutation={addToBlacklist}
args={() => ({
seriesId: sonarrSeriesId,
episodeId: sonarrEpisodeId,
@@ -171,7 +195,7 @@ const SeriesHistoryView: FunctionComponent = () => {
},
},
],
- [],
+ [addToBlacklist],
);
const query = useEpisodeHistoryPagination();
diff --git a/frontend/src/pages/History/Statistics/HistoryStats.module.scss b/frontend/src/pages/History/Statistics/HistoryStats.module.scss
new file mode 100644
index 000000000..3c7c04e10
--- /dev/null
+++ b/frontend/src/pages/History/Statistics/HistoryStats.module.scss
@@ -0,0 +1,9 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - $header-height);
+}
+
+.chart {
+ height: 90%;
+}
diff --git a/frontend/src/pages/History/Statistics/index.tsx b/frontend/src/pages/History/Statistics/HistoryStats.tsx
index 243225538..0e2d34400 100644
--- a/frontend/src/pages/History/Statistics/index.tsx
+++ b/frontend/src/pages/History/Statistics/HistoryStats.tsx
@@ -1,23 +1,7 @@
-import {
- useHistoryStats,
- useLanguages,
- useSystemProviders,
-} from "@/apis/hooks";
-import { Selector, Toolbox } from "@/components";
-import { QueryOverlay } from "@/components/async";
-import Language from "@/components/bazarr/Language";
-import { Layout } from "@/constants";
-import { useSelectorOptions } from "@/utilities";
-import {
- Box,
- Container,
- SimpleGrid,
- createStyles,
- useMantineTheme,
-} from "@mantine/core";
+import { FunctionComponent, useMemo, useState } from "react";
+import { Box, Container, SimpleGrid, useMantineTheme } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { merge } from "lodash";
-import { FunctionComponent, useMemo, useState } from "react";
import {
Bar,
BarChart,
@@ -28,18 +12,16 @@ import {
XAxis,
YAxis,
} from "recharts";
+import {
+ useHistoryStats,
+ useLanguages,
+ useSystemProviders,
+} from "@/apis/hooks";
+import { Selector, Toolbox } from "@/components";
+import { QueryOverlay } from "@/components/async";
+import { useSelectorOptions } from "@/utilities";
import { actionOptions, timeFrameOptions } from "./options";
-
-const useStyles = createStyles((theme) => ({
- container: {
- display: "flex",
- flexDirection: "column",
- height: `calc(100vh - ${Layout.HEADER_HEIGHT}px)`,
- },
- chart: {
- height: "90%",
- },
-}));
+import styles from "./HistoryStats.module.scss";
const HistoryStats: FunctionComponent = () => {
const { data: providers } = useSystemProviders(true);
@@ -71,8 +53,8 @@ const HistoryStats: FunctionComponent = () => {
date: v.date,
series: v.count,
}));
- const result = merge(movies, series);
- return result;
+
+ return merge(movies, series);
} else {
return [];
}
@@ -80,20 +62,13 @@ const HistoryStats: FunctionComponent = () => {
useDocumentTitle("History Statistics - Bazarr");
- const { classes } = useStyles();
const theme = useMantineTheme();
return (
- <Container fluid px={0} className={classes.container}>
+ <Container fluid px={0} className={styles.container}>
<QueryOverlay result={stats}>
<Toolbox>
- <SimpleGrid
- cols={4}
- breakpoints={[
- { maxWidth: "sm", cols: 4 },
- { maxWidth: "xs", cols: 2 },
- ]}
- >
+ <SimpleGrid cols={{ base: 4, xs: 2 }}>
<Selector
placeholder="Time..."
options={timeFrameOptions}
@@ -123,9 +98,9 @@ const HistoryStats: FunctionComponent = () => {
></Selector>
</SimpleGrid>
</Toolbox>
- <Box className={classes.chart} m="xs">
+ <Box className={styles.chart} m="xs">
<ResponsiveContainer>
- <BarChart className={classes.chart} data={convertedData}>
+ <BarChart className={styles.chart} data={convertedData}>
<CartesianGrid strokeDasharray="4 2"></CartesianGrid>
<XAxis dataKey="date"></XAxis>
<YAxis allowDecimals={false}></YAxis>
diff --git a/frontend/src/pages/History/history.test.tsx b/frontend/src/pages/History/history.test.tsx
index 1de1e6c5d..277a268fb 100644
--- a/frontend/src/pages/History/history.test.tsx
+++ b/frontend/src/pages/History/history.test.tsx
@@ -1,7 +1,7 @@
import { renderTest, RenderTestCase } from "@/tests/render";
+import HistoryStats from "./Statistics/HistoryStats";
import MoviesHistoryView from "./Movies";
import SeriesHistoryView from "./Series";
-import HistoryStats from "./Statistics";
const cases: RenderTestCase[] = [
{
diff --git a/frontend/src/pages/Movies/Details/index.tsx b/frontend/src/pages/Movies/Details/index.tsx
index a6b4b0aa8..709f03905 100644
--- a/frontend/src/pages/Movies/Details/index.tsx
+++ b/frontend/src/pages/Movies/Details/index.tsx
@@ -1,4 +1,21 @@
-import { RouterNames } from "@/Router/RouterNames";
+import { FunctionComponent, useCallback, useRef } from "react";
+import { Navigate, useParams } from "react-router-dom";
+import { Container, Group, Menu, Stack } from "@mantine/core";
+import { Dropzone } from "@mantine/dropzone";
+import { useDocumentTitle } from "@mantine/hooks";
+import { showNotification } from "@mantine/notifications";
+import {
+ faCloudUploadAlt,
+ faEllipsis,
+ faHistory,
+ faSearch,
+ faSync,
+ faToolbox,
+ faUser,
+ faWrench,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { isNumber } from "lodash";
import {
useDownloadMovieSubtitles,
useIsMovieActionRunning,
@@ -16,27 +33,10 @@ import { MovieUploadModal } from "@/components/forms/MovieUploadForm";
import { MovieHistoryModal, SubtitleToolsModal } from "@/components/modals";
import { MovieSearchModal } from "@/components/modals/ManualSearchModal";
import { useModals } from "@/modules/modals";
-import { TaskGroup, notification, task } from "@/modules/task";
+import { notification, task, TaskGroup } from "@/modules/task";
import ItemOverview from "@/pages/views/ItemOverview";
+import { RouterNames } from "@/Router/RouterNames";
import { useLanguageProfileBy } from "@/utilities/languages";
-import {
- faCloudUploadAlt,
- faEllipsis,
- faHistory,
- faSearch,
- faSync,
- faToolbox,
- faUser,
- faWrench,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Container, Group, Menu, Stack } from "@mantine/core";
-import { Dropzone } from "@mantine/dropzone";
-import { useDocumentTitle } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
-import { isNumber } from "lodash";
-import { FunctionComponent, useCallback, useRef } from "react";
-import { Navigate, useParams } from "react-router-dom";
import Table from "./table";
const MovieDetailView: FunctionComponent = () => {
@@ -123,7 +123,7 @@ const MovieDetailView: FunctionComponent = () => {
<DropContent></DropContent>
</Dropzone.FullScreen>
<Toolbox>
- <Group spacing="xs">
+ <Group gap="xs">
<Toolbox.Button
icon={faSync}
disabled={hasTask}
@@ -168,7 +168,7 @@ const MovieDetailView: FunctionComponent = () => {
Manual
</Toolbox.Button>
</Group>
- <Group spacing="xs">
+ <Group gap="xs">
<Toolbox.Button
disabled={!allowEdit || movie.profileId === null || hasTask}
icon={faCloudUploadAlt}
@@ -198,14 +198,13 @@ const MovieDetailView: FunctionComponent = () => {
<Menu.Target>
<Action
label="More Actions"
- color="dark"
icon={faEllipsis}
disabled={hasTask}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
- icon={<FontAwesomeIcon icon={faToolbox} />}
+ leftSection={<FontAwesomeIcon icon={faToolbox} />}
onClick={() => {
if (movie) {
modals.openContextModal(SubtitleToolsModal, {
@@ -217,7 +216,7 @@ const MovieDetailView: FunctionComponent = () => {
Mass Edit
</Menu.Item>
<Menu.Item
- icon={<FontAwesomeIcon icon={faHistory} />}
+ leftSection={<FontAwesomeIcon icon={faHistory} />}
onClick={() => {
if (movie) {
modals.openContextModal(MovieHistoryModal, { movie });
diff --git a/frontend/src/pages/Movies/Details/table.tsx b/frontend/src/pages/Movies/Details/table.tsx
index 0a327b745..7d0c20a30 100644
--- a/frontend/src/pages/Movies/Details/table.tsx
+++ b/frontend/src/pages/Movies/Details/table.tsx
@@ -1,17 +1,17 @@
+import React, { FunctionComponent, useMemo } from "react";
+import { Badge, Text, TextProps } from "@mantine/core";
+import { faEllipsis, faSearch } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
+import { isString } from "lodash";
import { useMovieSubtitleModification } from "@/apis/hooks";
import { useShowOnlyDesired } from "@/apis/hooks/site";
-import { Action, SimpleTable } from "@/components";
+import { Action } from "@/components";
import Language from "@/components/bazarr/Language";
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
+import SimpleTable from "@/components/tables/SimpleTable";
import { task, TaskGroup } from "@/modules/task";
-import { useTableStyles } from "@/styles";
-import { filterSubtitleBy } from "@/utilities";
+import { filterSubtitleBy, toPython } from "@/utilities";
import { useProfileItemsToLanguages } from "@/utilities/languages";
-import { faEllipsis, faSearch } from "@fortawesome/free-solid-svg-icons";
-import { Badge, Text, TextProps } from "@mantine/core";
-import { isString } from "lodash";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
const missingText = "Missing Subtitles";
@@ -34,35 +34,125 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
const profileItems = useProfileItemsToLanguages(profile);
- const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
+ const { download, remove } = useMovieSubtitleModification();
+
+ const CodeCell = React.memo(({ item }: { item: Subtitle }) => {
+ const { code2, path, hi, forced } = item;
+
+ const selections = useMemo(() => {
+ const list: FormType.ModifySubtitle[] = [];
+
+ if (path && !isSubtitleMissing(path) && movie !== null) {
+ list.push({
+ type: "movie",
+ path,
+ id: movie.radarrId,
+ language: code2,
+ forced: toPython(forced),
+ hi: toPython(hi),
+ });
+ }
+
+ return list;
+ }, [code2, path, forced, hi]);
+
+ if (movie === null) {
+ return null;
+ }
+
+ const { radarrId } = movie;
+
+ if (isSubtitleMissing(path)) {
+ return (
+ <Action
+ label="Search Subtitle"
+ icon={faSearch}
+ disabled={disabled}
+ onClick={() => {
+ task.create(
+ movie.title,
+ TaskGroup.SearchSubtitle,
+ download.mutateAsync,
+ {
+ radarrId,
+ form: {
+ language: code2,
+ forced,
+ hi,
+ },
+ },
+ );
+ }}
+ ></Action>
+ );
+ }
+
+ return (
+ <SubtitleToolsMenu
+ selections={selections}
+ onAction={(action) => {
+ if (action === "delete" && path) {
+ task.create(
+ movie.title,
+ TaskGroup.DeleteSubtitle,
+ remove.mutateAsync,
+ {
+ radarrId,
+ form: {
+ language: code2,
+ forced,
+ hi,
+ path,
+ },
+ },
+ );
+ } else if (action === "search") {
+ throw new Error("This shouldn't happen, please report the bug");
+ }
+ }}
+ >
+ <Action
+ label="Subtitle Actions"
+ disabled={isSubtitleTrack(path)}
+ icon={faEllipsis}
+ ></Action>
+ </SubtitleToolsMenu>
+ );
+ });
+
+ const columns = useMemo<ColumnDef<Subtitle>[]>(
() => [
{
- Header: "Subtitle Path",
- accessor: "path",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
-
+ header: "Subtitle Path",
+ accessorKey: "path",
+ cell: ({
+ row: {
+ original: { path },
+ },
+ }) => {
const props: TextProps = {
- className: classes.primary,
+ className: "table-primary",
};
- if (isSubtitleTrack(value)) {
- return <Text {...props}>Video File Subtitle Track</Text>;
- } else if (isSubtitleMissing(value)) {
+ if (isSubtitleTrack(path)) {
+ return (
+ <Text className="table-primary">Video File Subtitle Track</Text>
+ );
+ } else if (isSubtitleMissing(path)) {
return (
- <Text {...props} color="dimmed">
- {value}
+ <Text {...props} c="dimmed">
+ {path}
</Text>
);
} else {
- return <Text {...props}>{value}</Text>;
+ return <Text {...props}>{path}</Text>;
}
},
},
{
- Header: "Language",
- accessor: "name",
- Cell: ({ row }) => {
+ header: "Language",
+ accessorKey: "name",
+ cell: ({ row }) => {
if (row.original.path === missingText) {
return (
<Badge color="primary">
@@ -79,98 +169,13 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
},
},
{
- accessor: "code2",
- Cell: ({ row }) => {
- const {
- original: { code2, path, hi, forced },
- } = row;
-
- const { download, remove } = useMovieSubtitleModification();
-
- const selections = useMemo(() => {
- const list: FormType.ModifySubtitle[] = [];
-
- if (path && !isSubtitleMissing(path) && movie !== null) {
- list.push({
- type: "movie",
- path,
- id: movie.radarrId,
- language: code2,
- });
- }
-
- return list;
- }, [code2, path]);
-
- if (movie === null) {
- return null;
- }
-
- const { radarrId } = movie;
-
- if (isSubtitleMissing(path)) {
- return (
- <Action
- label="Search Subtitle"
- icon={faSearch}
- disabled={disabled}
- onClick={() => {
- task.create(
- movie.title,
- TaskGroup.SearchSubtitle,
- download.mutateAsync,
- {
- radarrId,
- form: {
- language: code2,
- forced,
- hi,
- },
- },
- );
- }}
- ></Action>
- );
- }
-
- return (
- <SubtitleToolsMenu
- selections={selections}
- onAction={(action) => {
- if (action === "delete" && path) {
- task.create(
- movie.title,
- TaskGroup.DeleteSubtitle,
- remove.mutateAsync,
- {
- radarrId,
- form: {
- language: code2,
- forced,
- hi,
- path,
- },
- },
- );
- } else if (action === "search") {
- throw new Error(
- "This shouldn't happen, please report the bug",
- );
- }
- }}
- >
- <Action
- label="Subtitle Actions"
- disabled={isSubtitleTrack(path)}
- color="dark"
- icon={faEllipsis}
- ></Action>
- </SubtitleToolsMenu>
- );
+ id: "code2",
+ cell: ({ row: { original } }) => {
+ return <CodeCell item={original} />;
},
},
],
- [movie, disabled],
+ [CodeCell],
);
const data: Subtitle[] = useMemo(() => {
diff --git a/frontend/src/pages/Movies/Editor.tsx b/frontend/src/pages/Movies/Editor.tsx
index a196f9deb..1ec84a52c 100644
--- a/frontend/src/pages/Movies/Editor.tsx
+++ b/frontend/src/pages/Movies/Editor.tsx
@@ -1,42 +1,74 @@
+import { FunctionComponent, useMemo } from "react";
+import { Checkbox } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { ColumnDef } from "@tanstack/react-table";
import { useMovieModification, useMovies } from "@/apis/hooks";
import { QueryOverlay } from "@/components/async";
import { AudioList } from "@/components/bazarr";
import LanguageProfileName from "@/components/bazarr/LanguageProfile";
import MassEditor from "@/pages/views/MassEditor";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
const MovieMassEditor: FunctionComponent = () => {
const query = useMovies();
const mutation = useMovieModification();
- const columns = useMemo<Column<Item.Movie>[]>(
+ useDocumentTitle("Movies - Bazarr (Mass Editor)");
+
+ const columns = useMemo<ColumnDef<Item.Movie>[]>(
() => [
{
- Header: "Name",
- accessor: "title",
+ id: "selection",
+ header: ({ table }) => {
+ return (
+ <Checkbox
+ id="table-header-selection"
+ indeterminate={table.getIsSomeRowsSelected()}
+ checked={table.getIsAllRowsSelected()}
+ onChange={table.getToggleAllRowsSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ cell: ({ row: { index, getIsSelected, getToggleSelectedHandler } }) => {
+ return (
+ <Checkbox
+ id={`table-cell-${index}`}
+ checked={getIsSelected()}
+ onChange={getToggleSelectedHandler()}
+ onClick={getToggleSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ },
+ {
+ header: "Name",
+ accessorKey: "title",
},
{
- Header: "Audio",
- accessor: "audio_language",
- Cell: ({ value }) => {
- return <AudioList audios={value}></AudioList>;
+ header: "Audio",
+ accessorKey: "audio_language",
+ cell: ({
+ row: {
+ original: { audio_language: audioLanguage },
+ },
+ }) => {
+ return <AudioList audios={audioLanguage}></AudioList>;
},
},
{
- Header: "Languages Profile",
- accessor: "profileId",
- Cell: ({ value }) => {
- return <LanguageProfileName index={value}></LanguageProfileName>;
+ header: "Languages Profile",
+ accessorKey: "profileId",
+ cell: ({
+ row: {
+ original: { profileId },
+ },
+ }) => {
+ return <LanguageProfileName index={profileId}></LanguageProfileName>;
},
},
],
[],
);
- useDocumentTitle("Movies - Bazarr (Mass Editor)");
-
return (
<QueryOverlay result={query}>
<MassEditor
diff --git a/frontend/src/pages/Movies/index.tsx b/frontend/src/pages/Movies/index.tsx
index dd9f531e1..0429e1fdd 100644
--- a/frontend/src/pages/Movies/index.tsx
+++ b/frontend/src/pages/Movies/index.tsx
@@ -1,3 +1,11 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Badge, Container } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
+import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import { useMovieModification, useMoviesPagination } from "@/apis/hooks";
import { Action } from "@/components";
import { AudioList } from "@/components/bazarr";
@@ -6,68 +14,84 @@ import LanguageProfileName from "@/components/bazarr/LanguageProfile";
import { ItemEditModal } from "@/components/forms/ItemEditForm";
import { useModals } from "@/modules/modals";
import ItemView from "@/pages/views/ItemView";
-import { useTableStyles } from "@/styles";
import { BuildKey } from "@/utilities";
-import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
-import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Badge, Container } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const MovieView: FunctionComponent = () => {
+ const modifyMovie = useMovieModification();
+
+ const modals = useModals();
+
const query = useMoviesPagination();
- const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
+ const columns = useMemo<ColumnDef<Item.Movie>[]>(
() => [
{
- accessor: "monitored",
- Cell: ({ value }) => (
+ id: "monitored",
+ cell: ({
+ row: {
+ original: { monitored },
+ },
+ }) => (
<FontAwesomeIcon
- title={value ? "monitored" : "unmonitored"}
- icon={value ? faBookmark : farBookmark}
+ title={monitored ? "monitored" : "unmonitored"}
+ icon={monitored ? faBookmark : farBookmark}
></FontAwesomeIcon>
),
},
{
- Header: "Name",
- accessor: "title",
- Cell: ({ row, value }) => {
- const { classes } = useTableStyles();
- const target = `/movies/${row.original.radarrId}`;
+ header: "Name",
+ accessorKey: "title",
+ cell: ({
+ row: {
+ original: { title, radarrId },
+ },
+ }) => {
+ const target = `/movies/${radarrId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {title}
</Anchor>
);
},
},
{
- Header: "Audio",
- accessor: "audio_language",
- Cell: ({ value }) => {
- return <AudioList audios={value}></AudioList>;
+ header: "Audio",
+ accessorKey: "audio_language",
+ cell: ({
+ row: {
+ original: { audio_language: audioLanguage },
+ },
+ }) => {
+ return <AudioList audios={audioLanguage}></AudioList>;
},
},
{
- Header: "Languages Profile",
- accessor: "profileId",
- Cell: ({ value }) => {
+ header: "Languages Profile",
+ accessorKey: "profileId",
+ cell: ({
+ row: {
+ original: { profileId },
+ },
+ }) => {
return (
- <LanguageProfileName index={value} empty=""></LanguageProfileName>
+ <LanguageProfileName
+ index={profileId}
+ empty=""
+ ></LanguageProfileName>
);
},
},
{
- Header: "Missing Subtitles",
- accessor: "missing_subtitles",
- Cell: (row) => {
- const missing = row.value;
+ header: "Missing Subtitles",
+ accessorKey: "missing_subtitles",
+ cell: ({
+ row: {
+ original: { missing_subtitles: missingSubtitles },
+ },
+ }) => {
return (
<>
- {missing.map((v) => (
+ {missingSubtitles.map((v) => (
<Badge
mr="xs"
color="yellow"
@@ -81,20 +105,17 @@ const MovieView: FunctionComponent = () => {
},
},
{
- accessor: "radarrId",
- Cell: ({ row }) => {
- const modals = useModals();
- const mutation = useMovieModification();
+ id: "radarrId",
+ cell: ({ row }) => {
return (
<Action
label="Edit Movie"
tooltip={{ position: "left" }}
- variant="light"
onClick={() =>
modals.openContextModal(
ItemEditModal,
{
- mutation,
+ mutation: modifyMovie,
item: row.original,
},
{
@@ -108,7 +129,7 @@ const MovieView: FunctionComponent = () => {
},
},
],
- [],
+ [modals, modifyMovie],
);
useDocumentTitle("Movies - Bazarr");
diff --git a/frontend/src/pages/Movies/movies.test.tsx b/frontend/src/pages/Movies/movies.test.tsx
index fe5691a15..c4ac8133a 100644
--- a/frontend/src/pages/Movies/movies.test.tsx
+++ b/frontend/src/pages/Movies/movies.test.tsx
@@ -1,7 +1,7 @@
-import { render } from "@/tests";
import { describe } from "vitest";
-import MovieView from ".";
+import { render } from "@/tests";
import MovieMassEditor from "./Editor";
+import MovieView from ".";
describe("Movies page", () => {
it("should render", () => {
diff --git a/frontend/src/pages/Series/Editor.tsx b/frontend/src/pages/Series/Editor.tsx
index 4db9a4c1d..45a277d17 100644
--- a/frontend/src/pages/Series/Editor.tsx
+++ b/frontend/src/pages/Series/Editor.tsx
@@ -1,34 +1,66 @@
+import { FunctionComponent, useMemo } from "react";
+import { Checkbox } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { ColumnDef } from "@tanstack/react-table";
import { useSeries, useSeriesModification } from "@/apis/hooks";
import { QueryOverlay } from "@/components/async";
import { AudioList } from "@/components/bazarr";
import LanguageProfileName from "@/components/bazarr/LanguageProfile";
import MassEditor from "@/pages/views/MassEditor";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
const SeriesMassEditor: FunctionComponent = () => {
const query = useSeries();
const mutation = useSeriesModification();
- const columns = useMemo<Column<Item.Series>[]>(
+ const columns = useMemo<ColumnDef<Item.Series>[]>(
() => [
{
- Header: "Name",
- accessor: "title",
+ id: "selection",
+ header: ({ table }) => {
+ return (
+ <Checkbox
+ id="table-header-selection"
+ indeterminate={table.getIsSomeRowsSelected()}
+ checked={table.getIsAllRowsSelected()}
+ onChange={table.getToggleAllRowsSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ cell: ({ row: { index, getIsSelected, getToggleSelectedHandler } }) => {
+ return (
+ <Checkbox
+ id={`table-cell-${index}`}
+ checked={getIsSelected()}
+ onChange={getToggleSelectedHandler()}
+ onClick={getToggleSelectedHandler()}
+ ></Checkbox>
+ );
+ },
+ },
+ {
+ header: "Name",
+ accessorKey: "title",
},
{
- Header: "Audio",
- accessor: "audio_language",
- Cell: ({ value }) => {
- return <AudioList audios={value}></AudioList>;
+ header: "Audio",
+ accessorKey: "audio_language",
+ cell: ({
+ row: {
+ original: { audio_language: audioLanguage },
+ },
+ }) => {
+ return <AudioList audios={audioLanguage}></AudioList>;
},
},
{
- Header: "Languages Profile",
- accessor: "profileId",
- Cell: ({ value }) => {
- return <LanguageProfileName index={value}></LanguageProfileName>;
+ header: "Languages Profile",
+ accessorKey: "profileId",
+ cell: ({
+ row: {
+ original: { profileId },
+ },
+ }) => {
+ return <LanguageProfileName index={profileId}></LanguageProfileName>;
},
},
],
diff --git a/frontend/src/pages/Series/index.tsx b/frontend/src/pages/Series/index.tsx
index 66921347c..c142a6767 100644
--- a/frontend/src/pages/Series/index.tsx
+++ b/frontend/src/pages/Series/index.tsx
@@ -1,94 +1,109 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Container, Progress } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
+import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import { useSeriesModification, useSeriesPagination } from "@/apis/hooks";
import { Action } from "@/components";
import LanguageProfileName from "@/components/bazarr/LanguageProfile";
import { ItemEditModal } from "@/components/forms/ItemEditForm";
import { useModals } from "@/modules/modals";
import ItemView from "@/pages/views/ItemView";
-import { useTableStyles } from "@/styles";
-import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
-import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Container, Progress } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const SeriesView: FunctionComponent = () => {
const mutation = useSeriesModification();
const query = useSeriesPagination();
- const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>(
+ const modals = useModals();
+
+ const columns = useMemo<ColumnDef<Item.Series>[]>(
() => [
{
- accessor: "monitored",
- Cell: ({ value }) => (
+ id: "monitored",
+ cell: ({
+ row: {
+ original: { monitored },
+ },
+ }) => (
<FontAwesomeIcon
- title={value ? "monitored" : "unmonitored"}
- icon={value ? faBookmark : farBookmark}
+ title={monitored ? "monitored" : "unmonitored"}
+ icon={monitored ? faBookmark : farBookmark}
></FontAwesomeIcon>
),
},
{
- Header: "Name",
- accessor: "title",
- Cell: ({ row, value }) => {
- const { classes } = useTableStyles();
- const target = `/series/${row.original.sonarrSeriesId}`;
+ header: "Name",
+ accessorKey: "title",
+ cell: ({ row: { original } }) => {
+ const target = `/series/${original.sonarrSeriesId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {original.title}
</Anchor>
);
},
},
{
- Header: "Languages Profile",
- accessor: "profileId",
- Cell: ({ value }) => {
+ header: "Languages Profile",
+ accessorKey: "profileId",
+ cell: ({ row: { original } }) => {
return (
- <LanguageProfileName index={value} empty=""></LanguageProfileName>
+ <LanguageProfileName
+ index={original.profileId}
+ empty=""
+ ></LanguageProfileName>
);
},
},
{
- Header: "Episodes",
- accessor: "episodeFileCount",
- Cell: (row) => {
+ header: "Episodes",
+ accessorKey: "episodeFileCount",
+ cell: (row) => {
const { episodeFileCount, episodeMissingCount, profileId, title } =
row.row.original;
- let progress = 0;
- let label = "";
- if (episodeFileCount === 0 || !profileId) {
- progress = 0.0;
- } else {
- progress = (1.0 - episodeMissingCount / episodeFileCount) * 100.0;
- label = `${
- episodeFileCount - episodeMissingCount
- }/${episodeFileCount}`;
- }
+ const label = `${episodeFileCount - episodeMissingCount}/${episodeFileCount}`;
return (
- <Progress
- key={title}
- size="xl"
- color={episodeMissingCount === 0 ? "brand" : "yellow"}
- value={progress}
- label={label}
- ></Progress>
+ <Progress.Root key={title} size="xl">
+ <Progress.Section
+ value={
+ episodeFileCount === 0 || !profileId
+ ? 0
+ : (1.0 - episodeMissingCount / episodeFileCount) * 100.0
+ }
+ color={episodeMissingCount === 0 ? "brand" : "yellow"}
+ >
+ <Progress.Label>{label}</Progress.Label>
+ </Progress.Section>
+ {episodeMissingCount === episodeFileCount && (
+ <Progress.Label
+ styles={{
+ label: {
+ position: "absolute",
+ top: "3px",
+ left: "50%",
+ transform: "translateX(-50%)",
+ },
+ }}
+ >
+ {label}
+ </Progress.Label>
+ )}
+ </Progress.Root>
);
},
},
{
- accessor: "sonarrSeriesId",
- Cell: ({ row: { original } }) => {
- const modals = useModals();
+ id: "sonarrSeriesId",
+ cell: ({ row: { original } }) => {
return (
<Action
label="Edit Series"
tooltip={{ position: "left" }}
- variant="light"
onClick={() =>
modals.openContextModal(
ItemEditModal,
@@ -107,7 +122,7 @@ const SeriesView: FunctionComponent = () => {
},
},
],
- [mutation],
+ [mutation, modals],
);
useDocumentTitle("Series - Bazarr");
diff --git a/frontend/src/pages/Series/series.test.tsx b/frontend/src/pages/Series/series.test.tsx
index 6813c6e19..b8fd9fad5 100644
--- a/frontend/src/pages/Series/series.test.tsx
+++ b/frontend/src/pages/Series/series.test.tsx
@@ -1,7 +1,7 @@
-import { render } from "@/tests";
import { describe } from "vitest";
-import SeriesView from ".";
+import { render } from "@/tests";
import SeriesMassEditor from "./Editor";
+import SeriesView from ".";
describe("Series page", () => {
it("should render", () => {
diff --git a/frontend/src/pages/Settings/General/index.tsx b/frontend/src/pages/Settings/General/index.tsx
index 8cc5ea8c3..6db3ee7fc 100644
--- a/frontend/src/pages/Settings/General/index.tsx
+++ b/frontend/src/pages/Settings/General/index.tsx
@@ -1,12 +1,11 @@
-import { Environment, toggleState } from "@/utilities";
+import { FunctionComponent, useState } from "react";
+import { Box, Group as MantineGroup, Text as MantineText } from "@mantine/core";
+import { useClipboard } from "@mantine/hooks";
import {
faCheck,
faClipboard,
faSync,
} from "@fortawesome/free-solid-svg-icons";
-import { Group as MantineGroup, Text as MantineText } from "@mantine/core";
-import { useClipboard } from "@mantine/hooks";
-import { FunctionComponent, useState } from "react";
import {
Action,
Check,
@@ -20,7 +19,8 @@ import {
Section,
Selector,
Text,
-} from "../components";
+} from "@/pages/Settings/components";
+import { Environment, toggleState } from "@/utilities";
import { branchOptions, proxyOptions, securityOptions } from "./options";
const characters = "abcdef0123456789";
@@ -43,10 +43,10 @@ const SettingsGeneralView: FunctionComponent = () => {
<Section header="Host">
<Text
label="Address"
- placeholder="0.0.0.0"
+ placeholder="*"
settingKey="settings-general-ip"
></Text>
- <Message>Valid IPv4 address or '0.0.0.0' for all interfaces</Message>
+ <Message>Valid IP address or '*' for all interfaces</Message>
<Number
label="Port"
placeholder="6767"
@@ -54,7 +54,7 @@ const SettingsGeneralView: FunctionComponent = () => {
></Number>
<Text
label="Base URL"
- icon="/"
+ leftSection="/"
settingKey="settings-general-base_url"
settingOptions={{
onLoaded: (s) => s.general.base_url?.slice(1) ?? "",
@@ -87,15 +87,14 @@ const SettingsGeneralView: FunctionComponent = () => {
rightSectionWidth={95}
rightSectionProps={{ style: { justifyContent: "flex-end" } }}
rightSection={
- <MantineGroup spacing="xs" mx="xs" position="right">
+ <MantineGroup gap="xs" mx="xs" justify="right">
{
// Clipboard API is only available in secure contexts See: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#interfaces
window.isSecureContext && (
<Action
label="Copy API Key"
- variant="light"
settingKey={settingApiKey}
- color={copied ? "green" : undefined}
+ c={copied ? "green" : undefined}
icon={copied ? faCheck : faClipboard}
onClick={(update, value) => {
if (value) {
@@ -108,9 +107,8 @@ const SettingsGeneralView: FunctionComponent = () => {
}
<Action
label="Regenerate"
- variant="light"
settingKey={settingApiKey}
- color="red"
+ c="red"
icon={faSync}
onClick={(update) => {
update(generateApiKey());
@@ -204,13 +202,12 @@ const SettingsGeneralView: FunctionComponent = () => {
<Number
label="Retention"
settingKey="settings-backup-retention"
- styles={{
- rightSection: { width: "4rem", justifyContent: "flex-end" },
- }}
rightSection={
- <MantineText size="xs" px="sm" color="dimmed">
- Days
- </MantineText>
+ <Box w="4rem" style={{ justifyContent: "flex-end" }}>
+ <MantineText size="xs" px="sm" c="dimmed">
+ Days
+ </MantineText>
+ </Box>
}
></Number>
</Section>
diff --git a/frontend/src/pages/Settings/Languages/components.tsx b/frontend/src/pages/Settings/Languages/components.tsx
index de3e89c3e..9c3cf8e94 100644
--- a/frontend/src/pages/Settings/Languages/components.tsx
+++ b/frontend/src/pages/Settings/Languages/components.tsx
@@ -1,16 +1,15 @@
+import { FunctionComponent, useMemo } from "react";
+import { Input } from "@mantine/core";
import {
MultiSelector,
MultiSelectorProps,
SelectorOption,
} from "@/components";
-import { Language } from "@/components/bazarr";
+import { Selector, SelectorProps } from "@/pages/Settings/components";
+import { useFormActions } from "@/pages/Settings/utilities/FormValues";
+import { BaseInput } from "@/pages/Settings/utilities/hooks";
import { useSelectorOptions } from "@/utilities";
-import { Input } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
import { useLatestEnabledLanguages, useLatestProfiles } from ".";
-import { Selector, SelectorProps } from "../components";
-import { useFormActions } from "../utilities/FormValues";
-import { BaseInput } from "../utilities/hooks";
type LanguageSelectorProps = Omit<
MultiSelectorProps<Language.Info>,
diff --git a/frontend/src/pages/Settings/Languages/equals.test.ts b/frontend/src/pages/Settings/Languages/equals.test.ts
index ead613946..5a74db797 100644
--- a/frontend/src/pages/Settings/Languages/equals.test.ts
+++ b/frontend/src/pages/Settings/Languages/equals.test.ts
@@ -1,10 +1,10 @@
+import { describe, expect, it } from "vitest";
import {
decodeEqualData,
encodeEqualData,
LanguageEqualData,
LanguageEqualImmediateData,
} from "@/pages/Settings/Languages/equals";
-import { describe, expect, it } from "vitest";
describe("Equals Parser", () => {
it("should parse from string correctly", () => {
diff --git a/frontend/src/pages/Settings/Languages/equals.tsx b/frontend/src/pages/Settings/Languages/equals.tsx
index a4fe95eee..08642f27e 100644
--- a/frontend/src/pages/Settings/Languages/equals.tsx
+++ b/frontend/src/pages/Settings/Languages/equals.tsx
@@ -1,15 +1,16 @@
+import { FunctionComponent, useCallback, useMemo } from "react";
+import { Button, Checkbox } from "@mantine/core";
+import { faEquals, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import { useLanguages } from "@/apis/hooks";
-import { Action, SimpleTable } from "@/components";
+import { Action } from "@/components";
import LanguageSelector from "@/components/bazarr/LanguageSelector";
+import SimpleTable from "@/components/tables/SimpleTable";
import { languageEqualsKey } from "@/pages/Settings/keys";
import { useFormActions } from "@/pages/Settings/utilities/FormValues";
import { useSettingValue } from "@/pages/Settings/utilities/hooks";
import { LOG } from "@/utilities/console";
-import { faEquals, faTrash } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Button, Checkbox } from "@mantine/core";
-import { FunctionComponent, useCallback, useMemo } from "react";
-import { Column } from "react-table";
interface GenericEqualTarget<T> {
content: T;
@@ -196,22 +197,22 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
[equals, setEquals],
);
- const columns = useMemo<Column<LanguageEqualData>[]>(
+ const columns = useMemo<ColumnDef<LanguageEqualData>[]>(
() => [
{
- Header: "Source",
+ header: "Source",
id: "source-lang",
- accessor: "source",
- Cell: ({ value: { content }, row }) => {
+ accessorKey: "source",
+ cell: ({ row: { original, index } }) => {
return (
<LanguageSelector
enabled
- value={content}
+ value={original.source.content}
onChange={(result) => {
if (result !== null) {
- update(row.index, {
- ...row.original,
- source: { ...row.original.source, content: result },
+ update(index, {
+ ...original,
+ source: { ...original.source, content: result },
});
}
}}
@@ -221,12 +222,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "source-hi",
- accessor: "source",
- Cell: ({ value: { hi }, row }) => {
+ cell: ({ row }) => {
return (
<Checkbox
label="HI"
- checked={hi}
+ checked={row.original.source.hi}
onChange={({ currentTarget: { checked } }) => {
update(row.index, {
...row.original,
@@ -243,12 +243,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "source-forced",
- accessor: "source",
- Cell: ({ value: { forced }, row }) => {
+ cell: ({ row }) => {
return (
<Checkbox
label="Forced"
- checked={forced}
+ checked={row.original.source.forced}
onChange={({ currentTarget: { checked } }) => {
update(row.index, {
...row.original,
@@ -265,19 +264,18 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "equal-icon",
- Cell: () => {
+ cell: () => {
return <FontAwesomeIcon icon={faEquals} />;
},
},
{
- Header: "Target",
+ header: "Target",
id: "target-lang",
- accessor: "target",
- Cell: ({ value: { content }, row }) => {
+ cell: ({ row }) => {
return (
<LanguageSelector
enabled
- value={content}
+ value={row.original.target.content}
onChange={(result) => {
if (result !== null) {
update(row.index, {
@@ -292,12 +290,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "target-hi",
- accessor: "target",
- Cell: ({ value: { hi }, row }) => {
+ cell: ({ row }) => {
return (
<Checkbox
label="HI"
- checked={hi}
+ checked={row.original.target.hi}
onChange={({ currentTarget: { checked } }) => {
update(row.index, {
...row.original,
@@ -314,12 +311,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "target-forced",
- accessor: "target",
- Cell: ({ value: { forced }, row }) => {
+ cell: ({ row }) => {
return (
<Checkbox
label="Forced"
- checked={forced}
+ checked={row.original.target.forced}
onChange={({ currentTarget: { checked } }) => {
update(row.index, {
...row.original,
@@ -336,13 +332,12 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
},
{
id: "action",
- accessor: "target",
- Cell: ({ row }) => {
+ cell: ({ row }) => {
return (
<Action
label="Remove"
icon={faTrash}
- color="red"
+ c="red"
onClick={() => remove(row.index)}
></Action>
);
@@ -355,7 +350,7 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => {
return (
<>
<SimpleTable data={equals} columns={columns}></SimpleTable>
- <Button fullWidth disabled={!canAdd} color="light" onClick={add}>
+ <Button fullWidth disabled={!canAdd} onClick={add}>
{canAdd ? "Add Equal" : "No Enabled Languages"}
</Button>
</>
diff --git a/frontend/src/pages/Settings/Languages/index.tsx b/frontend/src/pages/Settings/Languages/index.tsx
index 61733c992..1bd9d72a8 100644
--- a/frontend/src/pages/Settings/Languages/index.tsx
+++ b/frontend/src/pages/Settings/Languages/index.tsx
@@ -1,21 +1,23 @@
-import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
-import { useEnabledLanguages } from "@/utilities/languages";
import { FunctionComponent } from "react";
+import { Text as MantineText } from "@mantine/core";
+import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
import {
Check,
+ Chips,
CollapseBox,
Layout,
Message,
Section,
Selector,
-} from "../components";
+} from "@/pages/Settings/components";
import {
defaultUndAudioLang,
defaultUndEmbeddedSubtitlesLang,
enabledLanguageKey,
languageProfileKey,
-} from "../keys";
-import { useSettingValue } from "../utilities/hooks";
+} from "@/pages/Settings/keys";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
+import { useEnabledLanguages } from "@/utilities/languages";
import { LanguageSelector, ProfileSelector } from "./components";
import EqualsTable from "./equals";
import Table from "./table";
@@ -115,6 +117,50 @@ const SettingsLanguagesView: FunctionComponent = () => {
<Section header="Languages Profile">
<Table></Table>
</Section>
+ <Section header="Tag-Based Automatic Language Profile Selection Settings">
+ <Message>
+ If enabled, Bazarr will look at the names of all tags of a Series from
+ Sonarr (or a Movie from Radarr) to find a matching Bazarr language
+ profile tag. It will use as the language profile the FIRST tag from
+ Sonarr/Radarr that matches the tag of a Bazarr language profile
+ EXACTLY. If multiple tags match, there is no guarantee as to which one
+ will be used, so choose your tag names carefully. Also, if you update
+ the tag names in Sonarr/Radarr, Bazarr will detect this and repeat the
+ matching process for the affected shows. However, if a show's only
+ matching tag is removed from Sonarr/Radarr, Bazarr will NOT remove the
+ show's existing language profile for that reason. But if you wish to
+ have language profiles removed automatically by tag value, simply
+ enter a list of one or more tags in the{" "}
+ <MantineText fw={700} span>
+ Remove Profile Tags
+ </MantineText>{" "}
+ entry list below. If your video tag matches one of the tags in that
+ list, then Bazarr will remove the language profile for that video. If
+ there is a conflict between profile selection and profile removal,
+ then profile removal wins out and is performed.
+ </Message>
+ <Check
+ label="Series"
+ settingKey="settings-general-serie_tag_enabled"
+ ></Check>
+ <Check
+ label="Movies"
+ settingKey="settings-general-movie_tag_enabled"
+ ></Check>
+ <Chips
+ label="Remove Profile Tags"
+ settingKey="settings-general-remove_profile_tags"
+ sanitizeFn={(values: string[] | null) =>
+ values?.map((item) =>
+ item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
+ )
+ }
+ ></Chips>
+ <Message>
+ Enter tag values that will trigger a language profile removal. Leave
+ empty if you don't want Bazarr to remove language profiles.
+ </Message>
+ </Section>
<Section header="Default Settings">
<Check
label="Series"
diff --git a/frontend/src/pages/Settings/Languages/table.tsx b/frontend/src/pages/Settings/Languages/table.tsx
index a1ee217e8..03971a5cc 100644
--- a/frontend/src/pages/Settings/Languages/table.tsx
+++ b/frontend/src/pages/Settings/Languages/table.tsx
@@ -1,18 +1,19 @@
-import { Action, SimpleTable } from "@/components";
+import { FunctionComponent, useCallback, useMemo } from "react";
+import { Badge, Button, Group } from "@mantine/core";
+import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
+import { cloneDeep } from "lodash";
+import { Action } from "@/components";
import {
- ProfileEditModal,
anyCutoff,
+ ProfileEditModal,
} from "@/components/forms/ProfileEditForm";
+import SimpleTable from "@/components/tables/SimpleTable";
import { useModals } from "@/modules/modals";
+import { languageProfileKey } from "@/pages/Settings/keys";
+import { useFormActions } from "@/pages/Settings/utilities/FormValues";
import { BuildKey, useArrayAction } from "@/utilities";
-import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
-import { Badge, Button, Group } from "@mantine/core";
-import { cloneDeep } from "lodash";
-import { FunctionComponent, useCallback, useMemo } from "react";
-import { Column } from "react-table";
import { useLatestEnabledLanguages, useLatestProfiles } from ".";
-import { languageProfileKey } from "../keys";
-import { useFormActions } from "../utilities/FormValues";
const Table: FunctionComponent = () => {
const profiles = useLatestProfiles();
@@ -40,6 +41,7 @@ const Table: FunctionComponent = () => {
const updateProfile = useCallback(
(profile: Language.Profile) => {
const list = [...profiles];
+
const idx = list.findIndex((v) => v.profileId === profile.profileId);
if (idx !== -1) {
@@ -57,20 +59,26 @@ const Table: FunctionComponent = () => {
submitProfiles(fn(list));
});
- const columns = useMemo<Column<Language.Profile>[]>(
+ const columns = useMemo<ColumnDef<Language.Profile>[]>(
() => [
{
- Header: "Name",
- accessor: "name",
+ header: "Name",
+ accessorKey: "name",
+ },
+ {
+ header: "Tag",
+ accessorKey: "tag",
},
{
- Header: "Languages",
- accessor: "items",
- Cell: (row) => {
- const items = row.value;
- const cutoff = row.row.original.cutoff;
+ header: "Languages",
+ accessorKey: "items",
+ cell: ({
+ row: {
+ original: { items, cutoff },
+ },
+ }) => {
return (
- <Group spacing="xs" noWrap>
+ <Group gap="xs" wrap="nowrap">
{items.map((v) => {
const isCutoff = v.id === cutoff || cutoff === anyCutoff;
return (
@@ -82,16 +90,19 @@ const Table: FunctionComponent = () => {
},
},
{
- Header: "Must contain",
- accessor: "mustContain",
- Cell: (row) => {
- const items = row.value;
- if (!items) {
+ header: "Must contain",
+ accessorKey: "mustContain",
+ cell: ({
+ row: {
+ original: { mustContain },
+ },
+ }) => {
+ if (!mustContain) {
return null;
}
return (
<>
- {items.map((v, idx) => {
+ {mustContain.map((v, idx) => {
return (
<Badge key={BuildKey(idx, v)} color="gray">
{v}
@@ -103,16 +114,19 @@ const Table: FunctionComponent = () => {
},
},
{
- Header: "Must not contain",
- accessor: "mustNotContain",
- Cell: (row) => {
- const items = row.value;
- if (!items) {
+ header: "Must not contain",
+ accessorKey: "mustNotContain",
+ cell: ({
+ row: {
+ original: { mustNotContain },
+ },
+ }) => {
+ if (!mustNotContain) {
return null;
}
return (
<>
- {items.map((v, idx) => {
+ {mustNotContain.map((v, idx) => {
return (
<Badge key={BuildKey(idx, v)} color="gray">
{v}
@@ -124,14 +138,15 @@ const Table: FunctionComponent = () => {
},
},
{
- accessor: "profileId",
- Cell: ({ row }) => {
+ id: "profileId",
+ cell: ({ row }) => {
const profile = row.original;
return (
- <Group spacing="xs" noWrap>
+ <Group gap="xs" wrap="nowrap">
<Action
label="Edit Profile"
icon={faWrench}
+ c="gray"
onClick={() => {
modals.openContextModal(ProfileEditModal, {
languages,
@@ -143,7 +158,7 @@ const Table: FunctionComponent = () => {
<Action
label="Remove"
icon={faTrash}
- color="red"
+ c="red"
onClick={() => action.remove(row.index)}
></Action>
</Group>
@@ -159,15 +174,15 @@ const Table: FunctionComponent = () => {
return (
<>
- <SimpleTable columns={columns} data={profiles}></SimpleTable>
+ <SimpleTable columns={columns} data={[...profiles]}></SimpleTable>
<Button
fullWidth
disabled={!canAdd}
- color="light"
onClick={() => {
const profile = {
profileId: nextProfileId,
name: "",
+ tag: undefined,
items: [],
cutoff: null,
mustContain: [],
diff --git a/frontend/src/pages/Settings/Notifications/components.tsx b/frontend/src/pages/Settings/Notifications/components.tsx
index 1a2b20f65..8fa17abb2 100644
--- a/frontend/src/pages/Settings/Notifications/components.tsx
+++ b/frontend/src/pages/Settings/Notifications/components.tsx
@@ -1,9 +1,4 @@
-import api from "@/apis/raw";
-import { Selector } from "@/components";
-import MutateButton from "@/components/async/MutateButton";
-import { useModals, withModal } from "@/modules/modals";
-import { BuildKey, useSelectorOptions } from "@/utilities";
-import FormUtils from "@/utilities/form";
+import { FunctionComponent, useCallback, useMemo } from "react";
import {
Button,
Divider,
@@ -13,12 +8,20 @@ import {
Textarea,
} from "@mantine/core";
import { useForm } from "@mantine/form";
+import { useMutation } from "@tanstack/react-query";
import { isObject } from "lodash";
-import { FunctionComponent, useCallback, useMemo } from "react";
-import { useMutation } from "react-query";
-import { Card } from "../components";
-import { notificationsKey } from "../keys";
-import { useSettingValue, useUpdateArray } from "../utilities/hooks";
+import api from "@/apis/raw";
+import { Selector } from "@/components";
+import MutateButton from "@/components/async/MutateButton";
+import { useModals, withModal } from "@/modules/modals";
+import { Card } from "@/pages/Settings/components";
+import { notificationsKey } from "@/pages/Settings/keys";
+import {
+ useSettingValue,
+ useUpdateArray,
+} from "@/pages/Settings/utilities/hooks";
+import { BuildKey, useSelectorOptions } from "@/utilities";
+import FormUtils from "@/utilities/form";
const notificationHook = (notifications: Settings.NotificationInfo[]) => {
return notifications.map((info) => JSON.stringify(info));
@@ -60,7 +63,9 @@ const NotificationForm: FunctionComponent<Props> = ({
},
});
- const test = useMutation((url: string) => api.system.testNotification(url));
+ const test = useMutation({
+ mutationFn: (url: string) => api.system.testNotification(url),
+ });
return (
<form
@@ -90,7 +95,7 @@ const NotificationForm: FunctionComponent<Props> = ({
></Textarea>
</div>
<Divider></Divider>
- <Group position="right">
+ <Group justify="right">
<MutateButton mutation={test} args={() => form.values.url}>
Test
</MutateButton>
diff --git a/frontend/src/pages/Settings/Notifications/index.tsx b/frontend/src/pages/Settings/Notifications/index.tsx
index f764bbd61..54ce0d0b8 100644
--- a/frontend/src/pages/Settings/Notifications/index.tsx
+++ b/frontend/src/pages/Settings/Notifications/index.tsx
@@ -1,12 +1,19 @@
-import { Anchor, Blockquote, Text } from "@mantine/core";
+// eslint-disable-next-line simple-import-sort/imports
import { FunctionComponent } from "react";
-import { Check, Layout, Message, Section } from "../components";
+import { Anchor, Blockquote, Text } from "@mantine/core";
+import { Check, Layout, Message, Section } from "@/pages/Settings/components";
import { NotificationView } from "./components";
+import { faQuoteLeftAlt } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const SettingsNotificationsView: FunctionComponent = () => {
return (
<Layout name="Notifications">
- <Blockquote>
+ <Blockquote
+ bg="transparent"
+ mt="xl"
+ icon={<FontAwesomeIcon icon={faQuoteLeftAlt}></FontAwesomeIcon>}
+ >
<Text>
Thanks to caronc for his work on{" "}
<Anchor
diff --git a/frontend/src/pages/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx
index 91076d7de..acae15261 100644
--- a/frontend/src/pages/Settings/Providers/components.tsx
+++ b/frontend/src/pages/Settings/Providers/components.tsx
@@ -1,44 +1,47 @@
-import { Selector } from "@/components";
-import { useModals, withModal } from "@/modules/modals";
-import { BuildKey, useSelectorOptions } from "@/utilities";
-import { ASSERT } from "@/utilities/console";
import {
+ FunctionComponent,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ AutocompleteProps,
Button,
Divider,
Group,
- Text as MantineText,
SimpleGrid,
Stack,
+ Text as MantineText,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { capitalize } from "lodash";
-import {
- FunctionComponent,
- forwardRef,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { Selector } from "@/components";
+import { useModals, withModal } from "@/modules/modals";
import {
Card,
Check,
Chips,
- Selector as GlobalSelector,
Message,
Password,
ProviderTestButton,
+ Selector as GlobalSelector,
Text,
-} from "../components";
+} from "@/pages/Settings/components";
import {
FormContext,
FormValues,
runHooks,
useFormActions,
useStagedValues,
-} from "../utilities/FormValues";
-import { SettingsProvider, useSettings } from "../utilities/SettingsProvider";
-import { useSettingValue } from "../utilities/hooks";
+} from "@/pages/Settings/utilities/FormValues";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
+import {
+ SettingsProvider,
+ useSettings,
+} from "@/pages/Settings/utilities/SettingsProvider";
+import { BuildKey, useSelectorOptions } from "@/utilities";
+import { ASSERT } from "@/utilities/console";
import { ProviderInfo } from "./list";
type SettingsKey =
@@ -50,6 +53,11 @@ interface ProviderViewProps {
settingsKey: SettingsKey;
}
+interface ProviderSelect {
+ value: string;
+ payload: ProviderInfo;
+}
+
export const ProviderView: FunctionComponent<ProviderViewProps> = ({
availableOptions,
settingsKey,
@@ -100,10 +108,12 @@ export const ProviderView: FunctionComponent<ProviderViewProps> = ({
})
.map((v, idx) => (
<Card
+ titleStyles={{ overflow: "hidden", textOverflow: "ellipsis" }}
key={BuildKey(v.key, idx)}
header={v.name ?? capitalize(v.key)}
description={v.description}
onClick={() => select(v)}
+ lineClamp={2}
></Card>
));
} else {
@@ -130,17 +140,16 @@ interface ProviderToolProps {
settingsKey: Readonly<SettingsKey>;
}
-const SelectItem = forwardRef<
- HTMLDivElement,
- { payload: ProviderInfo; label: string }
->(({ payload: { description }, label, ...other }, ref) => {
+const SelectItem: AutocompleteProps["renderOption"] = ({ option }) => {
+ const provider = option as ProviderSelect;
+
return (
- <Stack spacing={1} ref={ref} {...other}>
- <MantineText size="md">{label}</MantineText>
- <MantineText size="xs">{description}</MantineText>
+ <Stack gap={1}>
+ <MantineText size="md">{provider.value}</MantineText>
+ <MantineText size="xs">{provider.payload.description}</MantineText>
</Stack>
);
-});
+};
const ProviderTool: FunctionComponent<ProviderToolProps> = ({
payload,
@@ -298,19 +307,19 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({
}
});
- return <Stack spacing="xs">{elements}</Stack>;
+ return <Stack gap="xs">{elements}</Stack>;
}, [info]);
return (
<SettingsProvider value={settings}>
<FormContext.Provider value={form}>
<Stack>
- <Stack spacing="xs">
+ <Stack gap="xs">
<Selector
data-autofocus
searchable
placeholder="Click to Select a Provider"
- itemComponent={SelectItem}
+ renderOption={SelectItem}
disabled={payload !== null}
{...selectorOptions}
value={info}
@@ -323,7 +332,7 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({
</div>
</Stack>
<Divider></Divider>
- <Group position="right">
+ <Group justify="right">
<Button hidden={!payload} color="red" onClick={deletePayload}>
Delete
</Button>
diff --git a/frontend/src/pages/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx
index ce855c4ee..a179ecda3 100644
--- a/frontend/src/pages/Settings/Providers/index.tsx
+++ b/frontend/src/pages/Settings/Providers/index.tsx
@@ -1,6 +1,5 @@
-import { antiCaptchaOption } from "@/pages/Settings/Providers/options";
-import { Anchor } from "@mantine/core";
import { FunctionComponent } from "react";
+import { Anchor } from "@mantine/core";
import {
CollapseBox,
Layout,
@@ -9,7 +8,8 @@ import {
Section,
Selector,
Text,
-} from "../components";
+} from "@/pages/Settings/components";
+import { antiCaptchaOption } from "@/pages/Settings/Providers/options";
import { ProviderView } from "./components";
import { IntegrationList, ProviderList } from "./list";
diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts
index bb3f4e5a5..8f0e46a56 100644
--- a/frontend/src/pages/Settings/Providers/list.ts
+++ b/frontend/src/pages/Settings/Providers/list.ts
@@ -1,5 +1,5 @@
-import { SelectorOption } from "@/components";
import { ReactText } from "react";
+import { SelectorOption } from "@/components";
type Input<T, N> = {
type: N;
@@ -218,6 +218,35 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
},
],
},
+ {
+ key: "jimaku",
+ name: "Jimaku.cc",
+ description: "Japanese Subtitles Provider",
+ message:
+ "API key required. Subtitles stem from various sources and might have quality/timing issues.",
+ inputs: [
+ {
+ type: "password",
+ key: "api_key",
+ name: "API key",
+ },
+ {
+ type: "switch",
+ key: "enable_name_search_fallback",
+ name: "Search by name if no AniList ID was determined (Less accurate, required for live action)",
+ },
+ {
+ type: "switch",
+ key: "enable_archives_download",
+ name: "Also consider archives alongside uncompressed subtitles",
+ },
+ {
+ type: "switch",
+ key: "enable_ai_subs",
+ name: "Download AI generated subtitles",
+ },
+ ],
+ },
{ key: "hosszupuska", description: "Hungarian Subtitles Provider" },
{
key: "karagarga",
@@ -276,6 +305,21 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
{ type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" },
],
},
+ {
+ key: "legendasnet",
+ name: "Legendas.net",
+ description: "Brazilian Subtitles Provider",
+ inputs: [
+ {
+ type: "text",
+ key: "username",
+ },
+ {
+ type: "password",
+ key: "password",
+ },
+ ],
+ },
{ key: "napiprojekt", description: "Polish Subtitles Provider" },
{
key: "napisy24",
@@ -372,6 +416,15 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
},
{ key: "subdivx", description: "LATAM Spanish / Spanish Subtitles Provider" },
{
+ key: "subdl",
+ inputs: [
+ {
+ type: "text",
+ key: "api_key",
+ },
+ ],
+ },
+ {
key: "subf2m",
name: "subf2m.co",
inputs: [
diff --git a/frontend/src/pages/Settings/Radarr/index.tsx b/frontend/src/pages/Settings/Radarr/index.tsx
index 8cd038ab8..264c78924 100644
--- a/frontend/src/pages/Settings/Radarr/index.tsx
+++ b/frontend/src/pages/Settings/Radarr/index.tsx
@@ -1,5 +1,5 @@
-import { Code } from "@mantine/core";
import { FunctionComponent } from "react";
+import { Code } from "@mantine/core";
import {
Check,
Chips,
@@ -13,8 +13,8 @@ import {
Slider,
Text,
URLTestButton,
-} from "../components";
-import { moviesEnabledKey } from "../keys";
+} from "@/pages/Settings/components";
+import { moviesEnabledKey } from "@/pages/Settings/keys";
import { timeoutOptions } from "./options";
const SettingsRadarrView: FunctionComponent = () => {
@@ -30,7 +30,7 @@ const SettingsRadarrView: FunctionComponent = () => {
<Number label="Port" settingKey="settings-radarr-port"></Number>
<Text
label="Base URL"
- icon="/"
+ leftSection="/"
settingKey="settings-radarr-base_url"
settingOptions={{
onLoaded: (s) => s.radarr.base_url?.slice(1) ?? "",
@@ -54,6 +54,11 @@ const SettingsRadarrView: FunctionComponent = () => {
<Chips
label="Excluded Tags"
settingKey="settings-radarr-excluded_tags"
+ sanitizeFn={(values: string[] | null) =>
+ values?.map((item) =>
+ item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
+ )
+ }
></Chips>
<Message>
Movies with those tags (case sensitive) in Radarr will be excluded
diff --git a/frontend/src/pages/Settings/Scheduler/index.tsx b/frontend/src/pages/Settings/Scheduler/index.tsx
index a6cd2ca74..df88725b2 100644
--- a/frontend/src/pages/Settings/Scheduler/index.tsx
+++ b/frontend/src/pages/Settings/Scheduler/index.tsx
@@ -1,5 +1,5 @@
-import { SelectorOption } from "@/components";
import { FunctionComponent, useMemo } from "react";
+import { SelectorOption } from "@/components";
import {
Check,
CollapseBox,
@@ -7,7 +7,7 @@ import {
Message,
Section,
Selector,
-} from "../components";
+} from "@/pages/Settings/components";
import {
backupOptions,
dayOptions,
diff --git a/frontend/src/pages/Settings/Sonarr/index.tsx b/frontend/src/pages/Settings/Sonarr/index.tsx
index 1d2125568..ff4ac6ca2 100644
--- a/frontend/src/pages/Settings/Sonarr/index.tsx
+++ b/frontend/src/pages/Settings/Sonarr/index.tsx
@@ -1,5 +1,5 @@
-import { Code } from "@mantine/core";
import { FunctionComponent } from "react";
+import { Code } from "@mantine/core";
import {
Check,
Chips,
@@ -14,9 +14,9 @@ import {
Slider,
Text,
URLTestButton,
-} from "../components";
-import { seriesEnabledKey } from "../keys";
-import { seriesTypeOptions } from "../options";
+} from "@/pages/Settings/components";
+import { seriesEnabledKey } from "@/pages/Settings/keys";
+import { seriesTypeOptions } from "@/pages/Settings/options";
import { timeoutOptions } from "./options";
const SettingsSonarrView: FunctionComponent = () => {
@@ -32,7 +32,7 @@ const SettingsSonarrView: FunctionComponent = () => {
<Number label="Port" settingKey="settings-sonarr-port"></Number>
<Text
label="Base URL"
- icon="/"
+ leftSection="/"
settingKey="settings-sonarr-base_url"
settingOptions={{
onLoaded: (s) => s.sonarr.base_url?.slice(1) ?? "",
@@ -56,6 +56,11 @@ const SettingsSonarrView: FunctionComponent = () => {
<Chips
label="Excluded Tags"
settingKey="settings-sonarr-excluded_tags"
+ sanitizeFn={(values: string[] | null) =>
+ values?.map((item) =>
+ item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
+ )
+ }
></Chips>
<Message>
Episodes from series with those tags (case sensitive) in Sonarr will
diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx
index f2dec69ac..a2e05a5c5 100644
--- a/frontend/src/pages/Settings/Subtitles/index.tsx
+++ b/frontend/src/pages/Settings/Subtitles/index.tsx
@@ -1,5 +1,5 @@
-import { Code, Space, Table } from "@mantine/core";
-import { FunctionComponent } from "react";
+import React, { FunctionComponent } from "react";
+import { Code, Space, Table, Text as MantineText } from "@mantine/core";
import {
Check,
CollapseBox,
@@ -10,11 +10,11 @@ import {
Selector,
Slider,
Text,
-} from "../components";
+} from "@/pages/Settings/components";
import {
SubzeroColorModification,
SubzeroModification,
-} from "../utilities/modifications";
+} from "@/pages/Settings/utilities/modifications";
import {
adaptiveSearchingDelayOption,
adaptiveSearchingDeltaOption,
@@ -115,14 +115,16 @@ const commandOptions: CommandOption[] = [
},
];
-const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => (
- <tr key={idx}>
- <td>
- <Code>{op.option}</Code>
- </td>
- <td>{op.description}</td>
- </tr>
-));
+const commandOptionElements: React.JSX.Element[] = commandOptions.map(
+ (op, idx) => (
+ <tr key={idx}>
+ <td>
+ <Code>{op.option}</Code>
+ </td>
+ <td>{op.description}</td>
+ </tr>
+ ),
+);
const SettingsSubtitlesView: FunctionComponent = () => {
return (
@@ -436,8 +438,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_threshold"></Slider>
<Space />
<Message>
- Only series subtitles with scores <b>below</b> this value will be
- automatically synchronized.
+ Only series subtitles with scores{" "}
+ <MantineText fw={700} span>
+ below
+ </MantineText>{" "}
+ this value will be automatically synchronized.
</Message>
</CollapseBox>
<Check
@@ -451,8 +456,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
<Space />
<Message>
- Only movie subtitles with scores <b>below</b> this value will be
- automatically synchronized.
+ Only movie subtitles with scores{" "}
+ <MantineText fw={700} span>
+ below
+ </MantineText>{" "}
+ this value will be automatically synchronized.
</Message>
</CollapseBox>
</CollapseBox>
@@ -478,8 +486,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold"></Slider>
<Space />
<Message>
- Only series subtitles with scores <b>below</b> this value will be
- automatically post-processed.
+ Only series subtitles with scores{" "}
+ <MantineText fw={700} span>
+ below
+ </MantineText>{" "}
+ this value will be automatically post-processed.
</Message>
</CollapseBox>
<Check
@@ -493,15 +504,18 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold_movie"></Slider>
<Space />
<Message>
- Only movie subtitles with scores <b>below</b> this value will be
- automatically post-processed.
+ Only movie subtitles with scores{" "}
+ <MantineText fw={700} span>
+ below
+ </MantineText>{" "}
+ this value will be automatically post-processed.
</Message>
</CollapseBox>
<Text
label="Command"
settingKey="settings-general-postprocessing_cmd"
></Text>
- <Table highlightOnHover fontSize="sm">
+ <Table highlightOnHover fs="sm">
<tbody>{commandOptionElements}</tbody>
</Table>
</CollapseBox>
diff --git a/frontend/src/pages/Settings/Subtitles/options.ts b/frontend/src/pages/Settings/Subtitles/options.ts
index 75fc4b027..b14d88f44 100644
--- a/frontend/src/pages/Settings/Subtitles/options.ts
+++ b/frontend/src/pages/Settings/Subtitles/options.ts
@@ -1,5 +1,5 @@
import { SelectorOption } from "@/components";
-import { ProviderList } from "../Providers/list";
+import { ProviderList } from "@/pages/Settings/Providers/list";
export const hiExtensionOptions: SelectorOption<string>[] = [
{
diff --git a/frontend/src/pages/Settings/UI/index.tsx b/frontend/src/pages/Settings/UI/index.tsx
index c7b6ada1b..a4410f0ba 100644
--- a/frontend/src/pages/Settings/UI/index.tsx
+++ b/frontend/src/pages/Settings/UI/index.tsx
@@ -1,6 +1,6 @@
-import { uiPageSizeKey } from "@/utilities/storage";
import { FunctionComponent } from "react";
-import { Layout, Section, Selector } from "../components";
+import { Layout, Section, Selector } from "@/pages/Settings/components";
+import { uiPageSizeKey } from "@/utilities/storage";
import { colorSchemeOptions, pageSizeOptions } from "./options";
const SettingsUIView: FunctionComponent = () => {
diff --git a/frontend/src/pages/Settings/components/Card.module.scss b/frontend/src/pages/Settings/components/Card.module.scss
new file mode 100644
index 000000000..746e55e65
--- /dev/null
+++ b/frontend/src/pages/Settings/components/Card.module.scss
@@ -0,0 +1,9 @@
+.card {
+ border-radius: var(--mantine-radius-sm);
+ border: 1px solid var(--mantine-color-gray-7);
+
+ &:hover {
+ box-shadow: var(--mantine-shadow-md);
+ border: 1px solid $color-brand-5;
+ }
+}
diff --git a/frontend/src/pages/Settings/components/Card.tsx b/frontend/src/pages/Settings/components/Card.tsx
index 4f3bd4fbf..a8a33eec3 100644
--- a/frontend/src/pages/Settings/components/Card.tsx
+++ b/frontend/src/pages/Settings/components/Card.tsx
@@ -1,36 +1,23 @@
-import { faPlus } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { FunctionComponent } from "react";
import {
Center,
- createStyles,
+ MantineStyleProp,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
-import { FunctionComponent } from "react";
-
-const useCardStyles = createStyles((theme) => {
- return {
- card: {
- borderRadius: theme.radius.sm,
- border: `1px solid ${theme.colors.gray[7]}`,
-
- "&:hover": {
- boxShadow: theme.shadows.md,
- border: `1px solid ${theme.colors.brand[5]}`,
- },
- },
- stack: {
- height: "100%",
- },
- };
-});
+import { faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import TextPopover from "@/components/TextPopover";
+import styles from "./Card.module.scss";
interface CardProps {
- header?: string;
description?: string;
- plus?: boolean;
+ header?: string;
+ lineClamp?: number | undefined;
onClick?: () => void;
+ plus?: boolean;
+ titleStyles?: MantineStyleProp | undefined;
}
export const Card: FunctionComponent<CardProps> = ({
@@ -38,18 +25,25 @@ export const Card: FunctionComponent<CardProps> = ({
description,
plus,
onClick,
+ lineClamp,
+ titleStyles,
}) => {
- const { classes } = useCardStyles();
return (
- <UnstyledButton p="lg" onClick={onClick} className={classes.card}>
+ <UnstyledButton p="lg" onClick={onClick} className={styles.card}>
{plus ? (
<Center>
<FontAwesomeIcon size="2x" icon={faPlus}></FontAwesomeIcon>
</Center>
) : (
- <Stack className={classes.stack} spacing={0} align="flex-start">
- <Text weight="bold">{header}</Text>
- <Text hidden={description === undefined}>{description}</Text>
+ <Stack h="100%" gap={0}>
+ <Text fw="bold" style={titleStyles}>
+ {header}
+ </Text>
+ <TextPopover text={description}>
+ <Text hidden={description === undefined} lineClamp={lineClamp}>
+ {description}
+ </Text>
+ </TextPopover>
</Stack>
)}
</UnstyledButton>
diff --git a/frontend/src/pages/Settings/components/Layout.test.tsx b/frontend/src/pages/Settings/components/Layout.test.tsx
index 512d0310c..a890bc277 100644
--- a/frontend/src/pages/Settings/components/Layout.test.tsx
+++ b/frontend/src/pages/Settings/components/Layout.test.tsx
@@ -1,6 +1,6 @@
-import { render, screen } from "@/tests";
import { Text } from "@mantine/core";
import { describe, it } from "vitest";
+import { render, screen } from "@/tests";
import Layout from "./Layout";
describe("Settings layout", () => {
diff --git a/frontend/src/pages/Settings/components/Layout.tsx b/frontend/src/pages/Settings/components/Layout.tsx
index b20c8092b..da72818fa 100644
--- a/frontend/src/pages/Settings/components/Layout.tsx
+++ b/frontend/src/pages/Settings/components/Layout.tsx
@@ -1,16 +1,20 @@
+import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
+import { Badge, Container, Group, LoadingOverlay } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faSave } from "@fortawesome/free-solid-svg-icons";
import { useSettingsMutation, useSystemSettings } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { LoadingProvider } from "@/contexts";
+import {
+ FormContext,
+ FormValues,
+ runHooks,
+} from "@/pages/Settings/utilities/FormValues";
+import { SettingsProvider } from "@/pages/Settings/utilities/SettingsProvider";
import { useOnValueChange } from "@/utilities";
import { LOG } from "@/utilities/console";
import { usePrompt } from "@/utilities/routers";
-import { faSave } from "@fortawesome/free-solid-svg-icons";
-import { Badge, Container, Group, LoadingOverlay } from "@mantine/core";
-import { useForm } from "@mantine/form";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
-import { FormContext, FormValues, runHooks } from "../utilities/FormValues";
-import { SettingsProvider } from "../utilities/SettingsProvider";
interface Props {
name: string;
@@ -21,7 +25,7 @@ const Layout: FunctionComponent<Props> = (props) => {
const { children, name } = props;
const { data: settings, isLoading, isRefetching } = useSystemSettings();
- const { mutate, isLoading: isMutating } = useSettingsMutation();
+ const { mutate, isPending: isMutating } = useSettingsMutation();
const form = useForm<FormValues>({
initialValues: {
@@ -73,7 +77,7 @@ const Layout: FunctionComponent<Props> = (props) => {
icon={faSave}
loading={isMutating}
disabled={totalStagedCount === 0}
- rightIcon={
+ rightSection={
<Badge size="xs" radius="sm" hidden={totalStagedCount === 0}>
{totalStagedCount}
</Badge>
diff --git a/frontend/src/pages/Settings/components/LayoutModal.tsx b/frontend/src/pages/Settings/components/LayoutModal.tsx
index cb4d5a1b5..9702ad96e 100644
--- a/frontend/src/pages/Settings/components/LayoutModal.tsx
+++ b/frontend/src/pages/Settings/components/LayoutModal.tsx
@@ -1,7 +1,4 @@
-import { useSettingsMutation, useSystemSettings } from "@/apis/hooks";
-import { LoadingProvider } from "@/contexts";
-import { useOnValueChange } from "@/utilities";
-import { LOG } from "@/utilities/console";
+import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
import {
Button,
Container,
@@ -11,9 +8,16 @@ import {
Space,
} from "@mantine/core";
import { useForm } from "@mantine/form";
-import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
-import { FormContext, FormValues, runHooks } from "../utilities/FormValues";
-import { SettingsProvider } from "../utilities/SettingsProvider";
+import { useSettingsMutation, useSystemSettings } from "@/apis/hooks";
+import { LoadingProvider } from "@/contexts";
+import {
+ FormContext,
+ FormValues,
+ runHooks,
+} from "@/pages/Settings/utilities/FormValues";
+import { SettingsProvider } from "@/pages/Settings/utilities/SettingsProvider";
+import { useOnValueChange } from "@/utilities";
+import { LOG } from "@/utilities/console";
interface Props {
children: ReactNode;
@@ -24,7 +28,7 @@ const LayoutModal: FunctionComponent<Props> = (props) => {
const { children, callbackModal } = props;
const { data: settings, isLoading, isRefetching } = useSystemSettings();
- const { mutate, isLoading: isMutating } = useSettingsMutation();
+ const { mutate, isPending: isMutating } = useSettingsMutation();
const form = useForm<FormValues>({
initialValues: {
@@ -74,7 +78,7 @@ const LayoutModal: FunctionComponent<Props> = (props) => {
<Space h="md" />
<Divider></Divider>
<Space h="md" />
- <Group position="right">
+ <Group justify="right">
<Button
type="submit"
disabled={totalStagedCount === 0}
diff --git a/frontend/src/pages/Settings/components/Message.tsx b/frontend/src/pages/Settings/components/Message.tsx
index 301df7bab..67f519485 100644
--- a/frontend/src/pages/Settings/components/Message.tsx
+++ b/frontend/src/pages/Settings/components/Message.tsx
@@ -1,5 +1,5 @@
-import { Text } from "@mantine/core";
import { FunctionComponent, PropsWithChildren } from "react";
+import { Text } from "@mantine/core";
interface MessageProps {
type?: "warning" | "info";
@@ -12,7 +12,7 @@ export const Message: FunctionComponent<Props> = ({
children,
}) => {
return (
- <Text size="sm" color={type === "info" ? "dimmed" : "yellow"} my={0}>
+ <Text size="sm" c={type === "info" ? "dimmed" : "yellow"} my={0}>
{children}
</Text>
);
diff --git a/frontend/src/pages/Settings/components/Section.test.tsx b/frontend/src/pages/Settings/components/Section.test.tsx
index e7f270e0d..535bd8be2 100644
--- a/frontend/src/pages/Settings/components/Section.test.tsx
+++ b/frontend/src/pages/Settings/components/Section.test.tsx
@@ -1,12 +1,12 @@
-import { rawRender, screen } from "@/tests";
import { Text } from "@mantine/core";
import { describe, it } from "vitest";
+import { render, screen } from "@/tests";
import { Section } from "./Section";
describe("Settings section", () => {
const header = "Section Header";
it("should show header", () => {
- rawRender(<Section header="Section Header"></Section>);
+ render(<Section header="Section Header"></Section>);
expect(screen.getByText(header)).toBeDefined();
expect(screen.getByRole("separator")).toBeDefined();
@@ -14,7 +14,7 @@ describe("Settings section", () => {
it("should show children", () => {
const text = "Section Child";
- rawRender(
+ render(
<Section header="Section Header">
<Text>{text}</Text>
</Section>,
@@ -26,7 +26,7 @@ describe("Settings section", () => {
it("should work with hidden", () => {
const text = "Section Child";
- rawRender(
+ render(
<Section header="Section Header" hidden>
<Text>{text}</Text>
</Section>,
diff --git a/frontend/src/pages/Settings/components/Section.tsx b/frontend/src/pages/Settings/components/Section.tsx
index 36f56ff8d..1e6a2e0b8 100644
--- a/frontend/src/pages/Settings/components/Section.tsx
+++ b/frontend/src/pages/Settings/components/Section.tsx
@@ -1,5 +1,5 @@
-import { Divider, Stack, Title } from "@mantine/core";
import { FunctionComponent, PropsWithChildren } from "react";
+import { Divider, Stack, Title } from "@mantine/core";
interface SectionProps {
header: string;
@@ -14,7 +14,7 @@ export const Section: FunctionComponent<Props> = ({
children,
}) => {
return (
- <Stack hidden={hidden} spacing="xs" my="lg">
+ <Stack hidden={hidden} gap="xs" my="lg">
<Title order={4}>{header}</Title>
<Divider></Divider>
{children}
diff --git a/frontend/src/pages/Settings/components/collapse.tsx b/frontend/src/pages/Settings/components/collapse.tsx
index 1dcffbd97..d502ecc69 100644
--- a/frontend/src/pages/Settings/components/collapse.tsx
+++ b/frontend/src/pages/Settings/components/collapse.tsx
@@ -1,6 +1,6 @@
-import { Collapse, Stack } from "@mantine/core";
import { FunctionComponent, PropsWithChildren, useMemo, useRef } from "react";
-import { useSettingValue } from "../utilities/hooks";
+import { Collapse, Stack } from "@mantine/core";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
interface ContentProps {
settingKey: string;
@@ -31,7 +31,7 @@ const CollapseBox: FunctionComponent<Props> = ({
return (
<Collapse in={open} pl={indent ? "md" : undefined}>
- <Stack spacing="xs">{children}</Stack>
+ <Stack gap="xs">{children}</Stack>
</Collapse>
);
};
diff --git a/frontend/src/pages/Settings/components/forms.test.tsx b/frontend/src/pages/Settings/components/forms.test.tsx
index 19c66ade0..4ec60699b 100644
--- a/frontend/src/pages/Settings/components/forms.test.tsx
+++ b/frontend/src/pages/Settings/components/forms.test.tsx
@@ -1,8 +1,8 @@
-import { rawRender, RenderOptions, screen } from "@/tests";
-import { useForm } from "@mantine/form";
import { FunctionComponent, PropsWithChildren, ReactElement } from "react";
+import { useForm } from "@mantine/form";
import { describe, it } from "vitest";
-import { FormContext, FormValues } from "../utilities/FormValues";
+import { FormContext, FormValues } from "@/pages/Settings/utilities/FormValues";
+import { render, screen } from "@/tests";
import { Number, Text } from "./forms";
const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
@@ -15,10 +15,8 @@ const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
};
-const formRender = (
- ui: ReactElement,
- options?: Omit<RenderOptions, "wrapper">,
-) => rawRender(ui, { wrapper: FormSupport, ...options });
+const formRender = (ui: ReactElement) =>
+ render(<FormSupport>{ui}</FormSupport>);
describe("Settings form", () => {
describe("number component", () => {
diff --git a/frontend/src/pages/Settings/components/forms.tsx b/frontend/src/pages/Settings/components/forms.tsx
index 3e1d3f12f..43b559736 100644
--- a/frontend/src/pages/Settings/components/forms.tsx
+++ b/frontend/src/pages/Settings/components/forms.tsx
@@ -1,7 +1,20 @@
+import { FunctionComponent, ReactNode } from "react";
import {
+ Input,
+ NumberInput,
+ NumberInputProps,
+ PasswordInput,
+ PasswordInputProps,
+ Slider as MantineSlider,
+ SliderProps as MantineSliderProps,
+ Switch,
+ TextInput,
+ TextInputProps,
+} from "@mantine/core";
+import {
+ Action as GlobalAction,
FileBrowser,
FileBrowserProps,
- Action as GlobalAction,
MultiSelector as GlobalMultiSelector,
MultiSelectorProps as GlobalMultiSelectorProps,
Selector as GlobalSelector,
@@ -9,21 +22,8 @@ import {
} from "@/components";
import { ActionProps as GlobalActionProps } from "@/components/inputs/Action";
import ChipInput, { ChipInputProps } from "@/components/inputs/ChipInput";
+import { BaseInput, useBaseInput } from "@/pages/Settings/utilities/hooks";
import { useSliderMarks } from "@/utilities";
-import {
- Input,
- Slider as MantineSlider,
- SliderProps as MantineSliderProps,
- NumberInput,
- NumberInputProps,
- PasswordInput,
- PasswordInputProps,
- Switch,
- TextInput,
- TextInputProps,
-} from "@mantine/core";
-import { FunctionComponent, ReactNode, ReactText } from "react";
-import { BaseInput, useBaseInput } from "../utilities/hooks";
export type NumberProps = BaseInput<number> & NumberInputProps;
@@ -38,13 +38,18 @@ export const Number: FunctionComponent<NumberProps> = (props) => {
if (val === "") {
val = 0;
}
+
+ if (typeof val === "string") {
+ return update(+val);
+ }
+
update(val);
}}
></NumberInput>
);
};
-export type TextProps = BaseInput<ReactText> & TextInputProps;
+export type TextProps = BaseInput<string | number> & TextInputProps;
export const Text: FunctionComponent<TextProps> = (props) => {
const { value, update, rest } = useBaseInput(props);
@@ -81,11 +86,7 @@ export interface CheckProps extends BaseInput<boolean> {
inline?: boolean;
}
-export const Check: FunctionComponent<CheckProps> = ({
- label,
- inline,
- ...props
-}) => {
+export const Check: FunctionComponent<CheckProps> = ({ label, ...props }) => {
const { value, update, rest } = useBaseInput(props);
return (
@@ -155,13 +156,25 @@ export const Slider: FunctionComponent<SliderProps> = (props) => {
};
type ChipsProp = BaseInput<string[]> &
- Omit<ChipInputProps, "onChange" | "data">;
+ Omit<ChipInputProps, "onChange" | "data"> & {
+ sanitizeFn?: (values: string[] | null) => string[] | undefined;
+ };
export const Chips: FunctionComponent<ChipsProp> = (props) => {
const { value, update, rest } = useBaseInput(props);
+ const handleChange = (value: string[] | null) => {
+ const sanitizedValues = props.sanitizeFn?.(value) ?? value;
+
+ update(sanitizedValues || null);
+ };
+
return (
- <ChipInput {...rest} value={value ?? []} onChange={update}></ChipInput>
+ <ChipInput
+ {...rest}
+ value={value ?? []}
+ onChange={handleChange}
+ ></ChipInput>
);
};
diff --git a/frontend/src/pages/Settings/components/index.tsx b/frontend/src/pages/Settings/components/index.tsx
index 99d1658bc..5e7882bbc 100644
--- a/frontend/src/pages/Settings/components/index.tsx
+++ b/frontend/src/pages/Settings/components/index.tsx
@@ -1,7 +1,7 @@
-import api from "@/apis/raw";
-import { Button } from "@mantine/core";
import { FunctionComponent, useCallback, useEffect, useState } from "react";
-import { useSettingValue } from "../utilities/hooks";
+import { Button } from "@mantine/core";
+import api from "@/apis/raw";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
export const URLTestButton: FunctionComponent<{
category: "sonarr" | "radarr";
@@ -56,7 +56,7 @@ export const URLTestButton: FunctionComponent<{
}, [address, port, url, apikey, ssl]);
return (
- <Button onClick={click} color={color} title={title}>
+ <Button autoContrast onClick={click} variant={color} title={title}>
{title}
</Button>
);
@@ -107,7 +107,7 @@ export const ProviderTestButton: FunctionComponent<{
}, [testUrl]);
return (
- <Button onClick={click} color={color} title={title}>
+ <Button onClick={click} variant={color} title={title}>
{title}
</Button>
);
diff --git a/frontend/src/pages/Settings/components/pathMapper.tsx b/frontend/src/pages/Settings/components/pathMapper.tsx
index 8bb3514b7..6b2c7baa2 100644
--- a/frontend/src/pages/Settings/components/pathMapper.tsx
+++ b/frontend/src/pages/Settings/components/pathMapper.tsx
@@ -1,19 +1,20 @@
-import { Action, FileBrowser, SimpleTable } from "@/components";
-import { useArrayAction } from "@/utilities";
+import { FunctionComponent, useCallback, useMemo } from "react";
+import { Button } from "@mantine/core";
import { faArrowCircleRight, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Button } from "@mantine/core";
+import { ColumnDef } from "@tanstack/react-table";
import { capitalize } from "lodash";
-import { FunctionComponent, useCallback, useMemo } from "react";
-import { Column } from "react-table";
+import { Action, FileBrowser } from "@/components";
+import SimpleTable from "@/components/tables/SimpleTable";
import {
moviesEnabledKey,
pathMappingsKey,
pathMappingsMovieKey,
seriesEnabledKey,
-} from "../keys";
-import { useFormActions } from "../utilities/FormValues";
-import { useSettingValue } from "../utilities/hooks";
+} from "@/pages/Settings/keys";
+import { useFormActions } from "@/pages/Settings/utilities/FormValues";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
+import { useArrayAction } from "@/utilities";
import { Message } from "./Message";
type SupportType = "sonarr" | "radarr";
@@ -78,16 +79,16 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
updateRow(fn(data));
});
- const columns = useMemo<Column<PathMappingItem>[]>(
+ const columns = useMemo<ColumnDef<PathMappingItem>[]>(
() => [
{
- Header: capitalize(type),
- accessor: "from",
- Cell: ({ value, row: { original, index } }) => {
+ header: capitalize(type),
+ accessorKey: "from",
+ cell: ({ row: { original, index } }) => {
return (
<FileBrowser
type={type}
- defaultValue={value}
+ defaultValue={original.from}
onChange={(path) => {
action.mutate(index, { ...original, from: path });
}}
@@ -97,17 +98,17 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
},
{
id: "arrow",
- Cell: () => (
+ cell: () => (
<FontAwesomeIcon icon={faArrowCircleRight}></FontAwesomeIcon>
),
},
{
- Header: "Bazarr",
- accessor: "to",
- Cell: ({ value, row: { original, index } }) => {
+ header: "Bazarr",
+ accessorKey: "to",
+ cell: ({ row: { original, index } }) => {
return (
<FileBrowser
- defaultValue={value}
+ defaultValue={original.to}
type="bazarr"
onChange={(path) => {
action.mutate(index, { ...original, to: path });
@@ -118,8 +119,8 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
},
{
id: "action",
- accessor: "to",
- Cell: ({ row: { index } }) => {
+ accessorKey: "to",
+ cell: ({ row: { index } }) => {
return (
<Action
label="Remove"
@@ -141,7 +142,7 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
columns={columns}
data={data}
></SimpleTable>
- <Button fullWidth color="light" onClick={addRow}>
+ <Button fullWidth onClick={addRow}>
Add
</Button>
</>
diff --git a/frontend/src/pages/Settings/utilities/FormValues.ts b/frontend/src/pages/Settings/utilities/FormValues.ts
index d5d1774f5..32e6af226 100644
--- a/frontend/src/pages/Settings/utilities/FormValues.ts
+++ b/frontend/src/pages/Settings/utilities/FormValues.ts
@@ -1,6 +1,6 @@
-import { LOG } from "@/utilities/console";
-import type { UseFormReturnType } from "@mantine/form";
import { createContext, useCallback, useContext, useRef } from "react";
+import type { UseFormReturnType } from "@mantine/form";
+import { LOG } from "@/utilities/console";
export const FormContext = createContext<UseFormReturnType<FormValues> | null>(
null,
diff --git a/frontend/src/pages/Settings/utilities/hooks.ts b/frontend/src/pages/Settings/utilities/hooks.ts
index da874314e..00c8b9bef 100644
--- a/frontend/src/pages/Settings/utilities/hooks.ts
+++ b/frontend/src/pages/Settings/utilities/hooks.ts
@@ -1,12 +1,12 @@
-import { LOG } from "@/utilities/console";
-import { get, isNull, isUndefined, uniqBy } from "lodash";
import { useCallback, useMemo, useRef } from "react";
+import { get, isNull, isUndefined, uniqBy } from "lodash";
import {
HookType,
useFormActions,
useStagedValues,
-} from "../utilities/FormValues";
-import { useSettings } from "../utilities/SettingsProvider";
+} from "@/pages/Settings/utilities/FormValues";
+import { useSettings } from "@/pages/Settings/utilities/SettingsProvider";
+import { LOG } from "@/utilities/console";
export interface BaseInput<T> {
disabled?: boolean;
diff --git a/frontend/src/pages/System/Announcements/index.tsx b/frontend/src/pages/System/Announcements/index.tsx
index 4e204431e..de9cdea3b 100644
--- a/frontend/src/pages/System/Announcements/index.tsx
+++ b/frontend/src/pages/System/Announcements/index.tsx
@@ -1,8 +1,8 @@
-import { useSystemAnnouncements } from "@/apis/hooks";
-import { QueryOverlay } from "@/components/async";
+import { FunctionComponent } from "react";
import { Container } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
+import { useSystemAnnouncements } from "@/apis/hooks";
+import { QueryOverlay } from "@/components/async";
import Table from "./table";
const SystemAnnouncementsView: FunctionComponent = () => {
diff --git a/frontend/src/pages/System/Announcements/table.tsx b/frontend/src/pages/System/Announcements/table.tsx
index 74a160190..910fb4bd5 100644
--- a/frontend/src/pages/System/Announcements/table.tsx
+++ b/frontend/src/pages/System/Announcements/table.tsx
@@ -1,68 +1,82 @@
+import { FunctionComponent, useMemo } from "react";
+import { Anchor, Text } from "@mantine/core";
+import { faWindowClose } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { useSystemAnnouncementsAddDismiss } from "@/apis/hooks";
-import { SimpleTable } from "@/components";
import { MutateAction } from "@/components/async";
-import { useTableStyles } from "@/styles";
-import { faWindowClose } from "@fortawesome/free-solid-svg-icons";
-import { Anchor, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
+import SimpleTable from "@/components/tables/SimpleTable";
interface Props {
- announcements: readonly System.Announcements[];
+ announcements: System.Announcements[];
}
const Table: FunctionComponent<Props> = ({ announcements }) => {
- const columns: Column<System.Announcements>[] = useMemo<
- Column<System.Announcements>[]
+ const addDismiss = useSystemAnnouncementsAddDismiss();
+
+ const columns: ColumnDef<System.Announcements>[] = useMemo<
+ ColumnDef<System.Announcements>[]
>(
() => [
{
- Header: "Since",
- accessor: "timestamp",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.primary}>{value}</Text>;
+ header: "Since",
+ accessorKey: "timestamp",
+ cell: ({
+ row: {
+ original: { timestamp },
+ },
+ }) => {
+ return <Text className="table-primary">{timestamp}</Text>;
},
},
{
- Header: "Announcement",
- accessor: "text",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.primary}>{value}</Text>;
+ header: "Announcement",
+ accessorKey: "text",
+ cell: ({
+ row: {
+ original: { text },
+ },
+ }) => {
+ return <Text className="table-primary">{text}</Text>;
},
},
{
- Header: "More Info",
- accessor: "link",
- Cell: ({ value }) => {
- if (value) {
- return <Label link={value}>Link</Label>;
+ header: "More Info",
+ accessorKey: "link",
+ cell: ({
+ row: {
+ original: { link },
+ },
+ }) => {
+ if (link) {
+ return <Label link={link}>Link</Label>;
} else {
return <Text>n/a</Text>;
}
},
},
{
- Header: "Dismiss",
- accessor: "hash",
- Cell: ({ row, value }) => {
- const add = useSystemAnnouncementsAddDismiss();
+ header: "Dismiss",
+ accessorKey: "hash",
+ cell: ({
+ row: {
+ original: { dismissible, hash },
+ },
+ }) => {
return (
<MutateAction
label="Dismiss announcement"
- disabled={!row.original.dismissible}
+ disabled={!dismissible}
icon={faWindowClose}
- mutation={add}
+ mutation={addDismiss}
args={() => ({
- hash: value,
+ hash: hash,
})}
></MutateAction>
);
},
},
],
- [],
+ [addDismiss],
);
return (
diff --git a/frontend/src/pages/System/Backups/index.tsx b/frontend/src/pages/System/Backups/index.tsx
index 0a19f2a9a..1057623d1 100644
--- a/frontend/src/pages/System/Backups/index.tsx
+++ b/frontend/src/pages/System/Backups/index.tsx
@@ -1,16 +1,16 @@
+import { FunctionComponent } from "react";
+import { Container } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faFileArchive } from "@fortawesome/free-solid-svg-icons";
import { useCreateBackups, useSystemBackups } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
-import { faFileArchive } from "@fortawesome/free-solid-svg-icons";
-import { Container } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
import Table from "./table";
const SystemBackupsView: FunctionComponent = () => {
const backups = useSystemBackups();
- const { mutate: backup, isLoading: isResetting } = useCreateBackups();
+ const { mutate: backup, isPending: isResetting } = useCreateBackups();
useDocumentTitle("Backups - Bazarr (System)");
diff --git a/frontend/src/pages/System/Backups/table.tsx b/frontend/src/pages/System/Backups/table.tsx
index 4f9eeae44..5c9a97f1f 100644
--- a/frontend/src/pages/System/Backups/table.tsx
+++ b/frontend/src/pages/System/Backups/table.tsx
@@ -1,56 +1,74 @@
+import { FunctionComponent, useMemo } from "react";
+import { Anchor, Text } from "@mantine/core";
+import { faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { useDeleteBackups, useRestoreBackups } from "@/apis/hooks";
-import { Action, PageTable } from "@/components";
+import { Action } from "@/components";
+import PageTable from "@/components/tables/PageTable";
import { useModals } from "@/modules/modals";
-import { useTableStyles } from "@/styles";
import { Environment } from "@/utilities";
-import { faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Anchor, Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
interface Props {
- backups: readonly System.Backups[];
+ backups: System.Backups[];
}
const Table: FunctionComponent<Props> = ({ backups }) => {
- const columns: Column<System.Backups>[] = useMemo<Column<System.Backups>[]>(
+ const modals = useModals();
+
+ const restore = useRestoreBackups();
+
+ const remove = useDeleteBackups();
+
+ const columns = useMemo<ColumnDef<System.Backups>[]>(
() => [
{
- Header: "Name",
- accessor: "filename",
- Cell: ({ value }) => {
+ header: "Name",
+ accessorKey: "filename",
+ cell: ({
+ row: {
+ original: { filename },
+ },
+ }) => {
return (
<Anchor
- href={`${Environment.baseUrl}/system/backup/download/${value}`}
+ href={`${Environment.baseUrl}/system/backup/download/${filename}`}
>
- {value}
+ {filename}
</Anchor>
);
},
},
{
- Header: "Size",
- accessor: "size",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}</Text>;
+ header: "Size",
+ accessorKey: "size",
+ cell: ({
+ row: {
+ original: { size },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{size}</Text>;
},
},
{
- Header: "Time",
- accessor: "date",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}</Text>;
+ header: "Time",
+ accessorKey: "date",
+ cell: ({
+ row: {
+ original: { date },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{date}</Text>;
},
},
{
id: "restore",
- Header: "Restore",
- accessor: "filename",
- Cell: ({ value }) => {
- const modals = useModals();
- const restore = useRestoreBackups();
+ header: "Restore",
+ accessorKey: "filename",
+ cell: ({
+ row: {
+ original: { filename },
+ },
+ }) => {
return (
<Action
label="Restore"
@@ -59,14 +77,14 @@ const Table: FunctionComponent<Props> = ({ backups }) => {
title: "Restore Backup",
children: (
<Text size="sm">
- Are you sure you want to restore the backup ({value})?
+ Are you sure you want to restore the backup ({filename})?
Bazarr will automatically restart and reload the UI during
the restore process.
</Text>
),
labels: { confirm: "Restore", cancel: "Cancel" },
confirmProps: { color: "red" },
- onConfirm: () => restore.mutate(value),
+ onConfirm: () => restore.mutate(filename),
})
}
icon={faHistory}
@@ -75,27 +93,29 @@ const Table: FunctionComponent<Props> = ({ backups }) => {
},
},
{
- id: "delet4",
- Header: "Delete",
- accessor: "filename",
- Cell: ({ value }) => {
- const modals = useModals();
- const remove = useDeleteBackups();
+ id: "delete",
+ header: "Delete",
+ accessorKey: "filename",
+ cell: ({
+ row: {
+ original: { filename },
+ },
+ }) => {
return (
<Action
label="Delete"
- color="red"
+ c="red"
onClick={() =>
modals.openConfirmModal({
title: "Delete Backup",
children: (
<Text size="sm">
- Are you sure you want to delete the backup ({value})?
+ Are you sure you want to delete the backup ({filename})?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
- onConfirm: () => remove.mutate(value),
+ onConfirm: () => remove.mutate(filename),
})
}
icon={faTrash}
@@ -104,7 +124,7 @@ const Table: FunctionComponent<Props> = ({ backups }) => {
},
},
],
- [],
+ [modals, remove, restore],
);
return <PageTable columns={columns} data={backups}></PageTable>;
diff --git a/frontend/src/pages/System/Logs/index.tsx b/frontend/src/pages/System/Logs/index.tsx
index d77e102d8..cb984a192 100644
--- a/frontend/src/pages/System/Logs/index.tsx
+++ b/frontend/src/pages/System/Logs/index.tsx
@@ -1,25 +1,25 @@
-import { useDeleteLogs, useSystemLogs, useSystemSettings } from "@/apis/hooks";
-import { Toolbox } from "@/components";
-import { QueryOverlay } from "@/components/async";
-import { Check, LayoutModal, Message, Text } from "@/pages/Settings/components";
-import { Environment } from "@/utilities";
+import { FunctionComponent, useCallback } from "react";
+import { Badge, Container, Group, Stack } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { useModals } from "@mantine/modals";
import {
faDownload,
faFilter,
faSync,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
-import { Badge, Container, Group, Stack } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { useModals } from "@mantine/modals";
-import { FunctionComponent, useCallback } from "react";
+import { useDeleteLogs, useSystemLogs, useSystemSettings } from "@/apis/hooks";
+import { Toolbox } from "@/components";
+import { QueryOverlay } from "@/components/async";
+import { Check, LayoutModal, Message, Text } from "@/pages/Settings/components";
+import { Environment } from "@/utilities";
import Table from "./table";
const SystemLogsView: FunctionComponent = () => {
const logs = useSystemLogs();
const { isFetching, data, refetch } = logs;
- const { mutate, isLoading } = useDeleteLogs();
+ const { mutate, isPending } = useDeleteLogs();
const download = useCallback(() => {
window.open(`${Environment.baseUrl}/bazarr.log`);
@@ -86,7 +86,7 @@ const SystemLogsView: FunctionComponent = () => {
<Container fluid px={0}>
<QueryOverlay result={logs}>
<Toolbox>
- <Group spacing="xs">
+ <Group gap="xs">
<Toolbox.Button
loading={isFetching}
icon={faSync}
@@ -98,17 +98,17 @@ const SystemLogsView: FunctionComponent = () => {
Download
</Toolbox.Button>
<Toolbox.Button
- loading={isLoading}
+ loading={isPending}
icon={faTrash}
onClick={() => mutate()}
>
Empty
</Toolbox.Button>
<Toolbox.Button
- loading={isLoading}
+ loading={isPending}
icon={faFilter}
onClick={openFilterModal}
- rightIcon={
+ rightSection={
suffix() !== "" ? (
<Badge size="xs" radius="sm">
{suffix()}
diff --git a/frontend/src/pages/System/Logs/modal.tsx b/frontend/src/pages/System/Logs/modal.tsx
index 297909757..efd687ac0 100644
--- a/frontend/src/pages/System/Logs/modal.tsx
+++ b/frontend/src/pages/System/Logs/modal.tsx
@@ -1,6 +1,6 @@
-import { withModal } from "@/modules/modals";
-import { Code, Text } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
+import { Code, Text } from "@mantine/core";
+import { withModal } from "@/modules/modals";
interface Props {
stack: string;
diff --git a/frontend/src/pages/System/Logs/table.tsx b/frontend/src/pages/System/Logs/table.tsx
index 5a36f0f2b..0b1397c97 100644
--- a/frontend/src/pages/System/Logs/table.tsx
+++ b/frontend/src/pages/System/Logs/table.tsx
@@ -1,5 +1,4 @@
-import { Action, PageTable } from "@/components";
-import { useModals } from "@/modules/modals";
+import { FunctionComponent, useMemo } from "react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faBug,
@@ -10,12 +9,14 @@ import {
faQuestion,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
+import { ColumnDef } from "@tanstack/react-table";
+import { Action } from "@/components";
+import PageTable from "@/components/tables/PageTable";
+import { useModals } from "@/modules/modals";
import SystemLogModal from "./modal";
interface Props {
- logs: readonly System.Log[];
+ logs: System.Log[];
}
function mapTypeToIcon(type: System.LogType): IconDefinition {
@@ -34,33 +35,40 @@ function mapTypeToIcon(type: System.LogType): IconDefinition {
}
const Table: FunctionComponent<Props> = ({ logs }) => {
- const columns: Column<System.Log>[] = useMemo<Column<System.Log>[]>(
+ const modals = useModals();
+
+ const columns = useMemo<ColumnDef<System.Log>[]>(
() => [
{
- accessor: "type",
- Cell: (row) => (
- <FontAwesomeIcon icon={mapTypeToIcon(row.value)}></FontAwesomeIcon>
- ),
+ accessorKey: "type",
+ cell: ({
+ row: {
+ original: { type },
+ },
+ }) => <FontAwesomeIcon icon={mapTypeToIcon(type)}></FontAwesomeIcon>,
},
{
Header: "Message",
- accessor: "message",
+ accessorKey: "message",
},
{
Header: "Date",
- accessor: "timestamp",
+ accessorKey: "timestamp",
},
{
- accessor: "exception",
- Cell: ({ value }) => {
- const modals = useModals();
- if (value) {
+ accessorKey: "exception",
+ cell: ({
+ row: {
+ original: { exception },
+ },
+ }) => {
+ if (exception) {
return (
<Action
label="Detail"
icon={faLayerGroup}
onClick={() =>
- modals.openContextModal(SystemLogModal, { stack: value })
+ modals.openContextModal(SystemLogModal, { stack: exception })
}
></Action>
);
@@ -70,7 +78,7 @@ const Table: FunctionComponent<Props> = ({ logs }) => {
},
},
],
- [],
+ [modals],
);
return (
diff --git a/frontend/src/pages/System/Providers/index.tsx b/frontend/src/pages/System/Providers/index.tsx
index cd7086221..8b73d53b0 100644
--- a/frontend/src/pages/System/Providers/index.tsx
+++ b/frontend/src/pages/System/Providers/index.tsx
@@ -1,10 +1,10 @@
+import { FunctionComponent } from "react";
+import { Container, Group } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useResetProvider, useSystemProviders } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
-import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
-import { Container, Group } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
import Table from "./table";
const SystemProvidersView: FunctionComponent = () => {
@@ -12,7 +12,7 @@ const SystemProvidersView: FunctionComponent = () => {
const { isFetching, data, refetch } = providers;
- const { mutate: reset, isLoading: isResetting } = useResetProvider();
+ const { mutate: reset, isPending: isResetting } = useResetProvider();
useDocumentTitle("Providers - Bazarr (System)");
diff --git a/frontend/src/pages/System/Providers/table.tsx b/frontend/src/pages/System/Providers/table.tsx
index 961da65fb..8e3ff7b89 100644
--- a/frontend/src/pages/System/Providers/table.tsx
+++ b/frontend/src/pages/System/Providers/table.tsx
@@ -1,25 +1,25 @@
-import { SimpleTable } from "@/components";
import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
+import { ColumnDef } from "@tanstack/react-table";
+import SimpleTable from "@/components/tables/SimpleTable";
interface Props {
- providers: readonly System.Provider[];
+ providers: System.Provider[];
}
const Table: FunctionComponent<Props> = (props) => {
- const columns: Column<System.Provider>[] = useMemo<Column<System.Provider>[]>(
+ const columns = useMemo<ColumnDef<System.Provider>[]>(
() => [
{
- Header: "Name",
- accessor: "name",
+ header: "Name",
+ accessorKey: "name",
},
{
- Header: "Status",
- accessor: "status",
+ header: "Status",
+ accessorKey: "status",
},
{
- Header: "Next Retry",
- accessor: "retry",
+ header: "Next Retry",
+ accessorKey: "retry",
},
],
[],
diff --git a/frontend/src/pages/System/Releases/index.tsx b/frontend/src/pages/System/Releases/index.tsx
index f205da086..908e5ba5c 100644
--- a/frontend/src/pages/System/Releases/index.tsx
+++ b/frontend/src/pages/System/Releases/index.tsx
@@ -1,6 +1,4 @@
-import { useSystemReleases } from "@/apis/hooks";
-import { QueryOverlay } from "@/components/async";
-import { BuildKey } from "@/utilities";
+import { FunctionComponent, useMemo } from "react";
import {
Badge,
Card,
@@ -12,7 +10,9 @@ import {
Text,
} from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent, useMemo } from "react";
+import { useSystemReleases } from "@/apis/hooks";
+import { QueryOverlay } from "@/components/async";
+import { BuildKey } from "@/utilities";
const SystemReleasesView: FunctionComponent = () => {
const releases = useSystemReleases();
@@ -21,9 +21,9 @@ const SystemReleasesView: FunctionComponent = () => {
useDocumentTitle("Releases - Bazarr (System)");
return (
- <Container size={600} py={12}>
+ <Container size="md" py={12}>
<QueryOverlay result={releases}>
- <Stack spacing="lg">
+ <Stack gap="lg">
{data?.map((v, idx) => (
<ReleaseCard key={BuildKey(idx, v.date)} {...v}></ReleaseCard>
))}
@@ -47,7 +47,7 @@ const ReleaseCard: FunctionComponent<ReleaseInfo> = ({
return (
<Card shadow="md" p="lg">
<Group>
- <Text weight="bold">{name}</Text>
+ <Text fw="bold">{name}</Text>
<Badge color="blue">{date}</Badge>
<Badge color={prerelease ? "yellow" : "green"}>
{prerelease ? "Development" : "Master"}
diff --git a/frontend/src/pages/System/Status/index.tsx b/frontend/src/pages/System/Status/index.tsx
index 49c88ccd4..bcd0e175d 100644
--- a/frontend/src/pages/System/Status/index.tsx
+++ b/frontend/src/pages/System/Status/index.tsx
@@ -1,15 +1,10 @@
-import { useSystemHealth, useSystemStatus } from "@/apis/hooks";
-import { QueryOverlay } from "@/components/async";
-import { GithubRepoRoot } from "@/constants";
-import { Environment, useInterval } from "@/utilities";
-import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import {
- faDiscord,
- faGithub,
- faWikipediaW,
-} from "@fortawesome/free-brands-svg-icons";
-import { faCode, faPaperPlane } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+ FunctionComponent,
+ PropsWithChildren,
+ ReactNode,
+ useCallback,
+ useState,
+} from "react";
import {
Anchor,
Container,
@@ -20,13 +15,18 @@ import {
Text,
} from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
+import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import {
- FunctionComponent,
- PropsWithChildren,
- ReactNode,
- useCallback,
- useState,
-} from "react";
+ faDiscord,
+ faGithub,
+ faWikipediaW,
+} from "@fortawesome/free-brands-svg-icons";
+import { faCode, faPaperPlane } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useSystemHealth, useSystemStatus } from "@/apis/hooks";
+import { QueryOverlay } from "@/components/async";
+import { GithubRepoRoot } from "@/constants";
+import { Environment, useInterval } from "@/utilities";
import {
divisorDay,
divisorHour,
@@ -46,7 +46,7 @@ function Row(props: InfoProps): JSX.Element {
return (
<Grid columns={10}>
<Grid.Col span={2}>
- <Text size="sm" align="right" weight="bold">
+ <Text size="sm" ta="right" fw="bold">
{title}
</Text>
</Grid.Col>
@@ -85,9 +85,12 @@ const InfoContainer: FunctionComponent<
return (
<Stack>
<Divider
- labelProps={{ size: "medium", weight: "bold" }}
labelPosition="left"
- label={title}
+ label={
+ <Text size="md" fw="bold">
+ {title}
+ </Text>
+ }
></Divider>
{children}
<Space />
diff --git a/frontend/src/pages/System/Status/table.tsx b/frontend/src/pages/System/Status/table.tsx
index 3b8a87e8a..7dad6757f 100644
--- a/frontend/src/pages/System/Status/table.tsx
+++ b/frontend/src/pages/System/Status/table.tsx
@@ -1,30 +1,35 @@
-import { SimpleTable } from "@/components";
-import { useTableStyles } from "@/styles";
-import { Text } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
-import { Column } from "react-table";
+import { Text } from "@mantine/core";
+import { ColumnDef } from "@tanstack/react-table";
+import SimpleTable from "@/components/tables/SimpleTable";
interface Props {
- health: readonly System.Health[];
+ health: System.Health[];
}
const Table: FunctionComponent<Props> = ({ health }) => {
- const columns: Column<System.Health>[] = useMemo<Column<System.Health>[]>(
+ const columns = useMemo<ColumnDef<System.Health>[]>(
() => [
{
- Header: "Object",
- accessor: "object",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}</Text>;
+ header: "Object",
+ accessorKey: "object",
+ cell: ({
+ row: {
+ original: { object },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{object}</Text>;
},
},
{
- Header: "Issue",
- accessor: "issue",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.primary}>{value}</Text>;
+ header: "Issue",
+ accessorKey: "issue",
+ cell: ({
+ row: {
+ original: { issue },
+ },
+ }) => {
+ return <Text className="table-primary">{issue}</Text>;
},
},
],
diff --git a/frontend/src/pages/System/Tasks/index.tsx b/frontend/src/pages/System/Tasks/index.tsx
index 17e429152..b384ea460 100644
--- a/frontend/src/pages/System/Tasks/index.tsx
+++ b/frontend/src/pages/System/Tasks/index.tsx
@@ -1,10 +1,10 @@
+import { FunctionComponent } from "react";
+import { Container } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faSync } from "@fortawesome/free-solid-svg-icons";
import { useSystemTasks } from "@/apis/hooks";
import { Toolbox } from "@/components";
import { QueryOverlay } from "@/components/async";
-import { faSync } from "@fortawesome/free-solid-svg-icons";
-import { Container } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { FunctionComponent } from "react";
import Table from "./table";
const SystemTasksView: FunctionComponent = () => {
diff --git a/frontend/src/pages/System/Tasks/table.tsx b/frontend/src/pages/System/Tasks/table.tsx
index ea45af49d..5e1b045bd 100644
--- a/frontend/src/pages/System/Tasks/table.tsx
+++ b/frontend/src/pages/System/Tasks/table.tsx
@@ -1,51 +1,59 @@
+import { FunctionComponent, useMemo } from "react";
+import { Text } from "@mantine/core";
+import { faPlay } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef, getSortedRowModel } from "@tanstack/react-table";
import { useRunTask } from "@/apis/hooks";
-import { SimpleTable } from "@/components";
import MutateAction from "@/components/async/MutateAction";
-import { useTableStyles } from "@/styles";
-import { faPlay } from "@fortawesome/free-solid-svg-icons";
-import { Text } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Column, useSortBy } from "react-table";
+import SimpleTable from "@/components/tables/SimpleTable";
interface Props {
- tasks: readonly System.Task[];
+ tasks: System.Task[];
}
const Table: FunctionComponent<Props> = ({ tasks }) => {
- const columns: Column<System.Task>[] = useMemo<Column<System.Task>[]>(
+ const runTask = useRunTask();
+
+ const columns: ColumnDef<System.Task>[] = useMemo<ColumnDef<System.Task>[]>(
() => [
{
- Header: "Name",
- accessor: "name",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.primary}>{value}</Text>;
+ header: "Name",
+ accessorKey: "name",
+ cell: ({
+ row: {
+ original: { name },
+ },
+ }) => {
+ return <Text className="table-primary">{name}</Text>;
},
},
{
- Header: "Interval",
- accessor: "interval",
- Cell: ({ value }) => {
- const { classes } = useTableStyles();
- return <Text className={classes.noWrap}>{value}</Text>;
+ header: "Interval",
+ accessorKey: "interval",
+ cell: ({
+ row: {
+ original: { interval },
+ },
+ }) => {
+ return <Text className="table-no-wrap">{interval}</Text>;
},
},
{
- Header: "Next Execution",
- accessor: "next_run_in",
+ header: "Next Execution",
+ accessorKey: "next_run_in",
},
{
- Header: "Run",
- accessor: "job_running",
- Cell: ({ row, value }) => {
- const { job_id: jobId } = row.original;
- const runTask = useRunTask();
-
+ header: "Run",
+ accessorKey: "job_running",
+ cell: ({
+ row: {
+ original: { job_id: jobId, job_running: jobRunning },
+ },
+ }) => {
return (
<MutateAction
label="Run Job"
icon={faPlay}
- iconProps={{ spin: value }}
+ iconProps={{ spin: jobRunning }}
mutation={runTask}
args={() => jobId}
></MutateAction>
@@ -53,15 +61,16 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
},
},
],
- [],
+ [runTask],
);
return (
<SimpleTable
- initialState={{ sortBy: [{ id: "name", desc: false }] }}
+ initialState={{ sorting: [{ id: "name", desc: false }] }}
columns={columns}
data={tasks}
- plugins={[useSortBy]}
+ enableSorting
+ getSortedRowModel={getSortedRowModel()}
></SimpleTable>
);
};
diff --git a/frontend/src/pages/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx
index 57fb6d6ed..c05cfb7c3 100644
--- a/frontend/src/pages/Wanted/Movies/index.tsx
+++ b/frontend/src/pages/Wanted/Movies/index.tsx
@@ -1,48 +1,53 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Badge, Group } from "@mantine/core";
+import { faSearch } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import {
useMovieAction,
useMovieSubtitleModification,
useMovieWantedPagination,
} from "@/apis/hooks";
import Language from "@/components/bazarr/Language";
-import { TaskGroup, task } from "@/modules/task";
+import { task, TaskGroup } from "@/modules/task";
import WantedView from "@/pages/views/WantedView";
import { BuildKey } from "@/utilities";
-import { faSearch } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Badge, Group } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const WantedMoviesView: FunctionComponent = () => {
- const columns: Column<Wanted.Movie>[] = useMemo<Column<Wanted.Movie>[]>(
+ const { download } = useMovieSubtitleModification();
+
+ const columns = useMemo<ColumnDef<Wanted.Movie>[]>(
() => [
{
- Header: "Name",
- accessor: "title",
- Cell: (row) => {
- const target = `/movies/${row.row.original.radarrId}`;
+ header: "Name",
+ accessorKey: "title",
+ cell: ({
+ row: {
+ original: { title, radarrId },
+ },
+ }) => {
+ const target = `/movies/${radarrId}`;
return (
<Anchor component={Link} to={target}>
- {row.value}
+ {title}
</Anchor>
);
},
},
{
- Header: "Missing",
- accessor: "missing_subtitles",
- Cell: ({ row, value }) => {
- const wanted = row.original;
- const { radarrId } = wanted;
-
- const { download } = useMovieSubtitleModification();
-
+ header: "Missing",
+ accessorKey: "missing_subtitles",
+ cell: ({
+ row: {
+ original: { radarrId, missing_subtitles: missingSubtitles },
+ },
+ }) => {
return (
- <Group spacing="sm">
- {value.map((item, idx) => (
+ <Group gap="sm">
+ {missingSubtitles.map((item, idx) => (
<Badge
- color={download.isLoading ? "gray" : undefined}
+ color={download.isPending ? "gray" : undefined}
leftSection={<FontAwesomeIcon icon={faSearch} />}
key={BuildKey(idx, item.code2)}
style={{ cursor: "pointer" }}
@@ -70,7 +75,7 @@ const WantedMoviesView: FunctionComponent = () => {
},
},
],
- [],
+ [download],
);
const { mutateAsync } = useMovieAction();
diff --git a/frontend/src/pages/Wanted/Series/index.tsx b/frontend/src/pages/Wanted/Series/index.tsx
index 96507bccd..0501ecef5 100644
--- a/frontend/src/pages/Wanted/Series/index.tsx
+++ b/frontend/src/pages/Wanted/Series/index.tsx
@@ -1,58 +1,67 @@
+import { FunctionComponent, useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Anchor, Badge, Group } from "@mantine/core";
+import { faSearch } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ColumnDef } from "@tanstack/react-table";
import {
useEpisodeSubtitleModification,
useEpisodeWantedPagination,
useSeriesAction,
} from "@/apis/hooks";
import Language from "@/components/bazarr/Language";
-import { TaskGroup, task } from "@/modules/task";
+import { task, TaskGroup } from "@/modules/task";
import WantedView from "@/pages/views/WantedView";
-import { useTableStyles } from "@/styles";
import { BuildKey } from "@/utilities";
-import { faSearch } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Anchor, Badge, Group } from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { Column } from "react-table";
const WantedSeriesView: FunctionComponent = () => {
- const columns: Column<Wanted.Episode>[] = useMemo<Column<Wanted.Episode>[]>(
+ const { download } = useEpisodeSubtitleModification();
+
+ const columns = useMemo<ColumnDef<Wanted.Episode>[]>(
() => [
{
- Header: "Name",
- accessor: "seriesTitle",
- Cell: (row) => {
- const target = `/series/${row.row.original.sonarrSeriesId}`;
- const { classes } = useTableStyles();
+ header: "Name",
+ accessorKey: "seriesTitle",
+ cell: ({
+ row: {
+ original: { sonarrSeriesId, seriesTitle },
+ },
+ }) => {
+ const target = `/series/${sonarrSeriesId}`;
return (
- <Anchor className={classes.primary} component={Link} to={target}>
- {row.value}
+ <Anchor className="table-primary" component={Link} to={target}>
+ {seriesTitle}
</Anchor>
);
},
},
{
- Header: "Episode",
- accessor: "episode_number",
+ header: "Episode",
+ accessorKey: "episode_number",
},
{
- accessor: "episodeTitle",
+ accessorKey: "episodeTitle",
},
{
- Header: "Missing",
- accessor: "missing_subtitles",
- Cell: ({ row, value }) => {
- const wanted = row.original;
- const seriesId = wanted.sonarrSeriesId;
- const episodeId = wanted.sonarrEpisodeId;
-
- const { download } = useEpisodeSubtitleModification();
+ header: "Missing",
+ accessorKey: "missing_subtitles",
+ cell: ({
+ row: {
+ original: {
+ sonarrSeriesId,
+ sonarrEpisodeId,
+ missing_subtitles: missingSubtitles,
+ },
+ },
+ }) => {
+ const seriesId = sonarrSeriesId;
+ const episodeId = sonarrEpisodeId;
return (
- <Group spacing="sm">
- {value.map((item, idx) => (
+ <Group gap="sm">
+ {missingSubtitles.map((item, idx) => (
<Badge
- color={download.isLoading ? "gray" : undefined}
+ color={download.isPending ? "gray" : undefined}
leftSection={<FontAwesomeIcon icon={faSearch} />}
key={BuildKey(idx, item.code2)}
style={{ cursor: "pointer" }}
@@ -81,7 +90,7 @@ const WantedSeriesView: FunctionComponent = () => {
},
},
],
- [],
+ [download],
);
const { mutateAsync } = useSeriesAction();
diff --git a/frontend/src/pages/errors/CriticalError.tsx b/frontend/src/pages/errors/CriticalError.tsx
index 2c8d0202b..22070c2a7 100644
--- a/frontend/src/pages/errors/CriticalError.tsx
+++ b/frontend/src/pages/errors/CriticalError.tsx
@@ -1,8 +1,8 @@
-import { Reload } from "@/utilities";
+import { FunctionComponent } from "react";
+import { Alert, Container, Text } from "@mantine/core";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Alert, Container, Text } from "@mantine/core";
-import { FunctionComponent } from "react";
+import { Reload } from "@/utilities";
interface Props {
message: string;
diff --git a/frontend/src/pages/errors/NotFound.tsx b/frontend/src/pages/errors/NotFound.tsx
index d81c31d7f..da4ba8229 100644
--- a/frontend/src/pages/errors/NotFound.tsx
+++ b/frontend/src/pages/errors/NotFound.tsx
@@ -1,7 +1,7 @@
+import { FunctionComponent } from "react";
+import { Box, Center, Container, Text, Title } from "@mantine/core";
import { faEyeSlash as fasEyeSlash } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Box, Center, Container, Text, Title } from "@mantine/core";
-import { FunctionComponent } from "react";
const NotFound: FunctionComponent = () => {
return (
diff --git a/frontend/src/pages/errors/UIError.tsx b/frontend/src/pages/errors/UIError.tsx
index 4f26d0d0c..030f6ba11 100644
--- a/frontend/src/pages/errors/UIError.tsx
+++ b/frontend/src/pages/errors/UIError.tsx
@@ -1,7 +1,4 @@
-import { GithubRepoRoot } from "@/constants";
-import { Reload } from "@/utilities";
-import { faDizzy } from "@fortawesome/free-regular-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { FunctionComponent, useMemo } from "react";
import {
Anchor,
Box,
@@ -13,7 +10,10 @@ import {
Text,
Title,
} from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
+import { faDizzy } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { GithubRepoRoot } from "@/constants";
+import { Reload } from "@/utilities";
const Placeholder = "********";
@@ -45,13 +45,11 @@ const UIError: FunctionComponent<Props> = ({ error }) => {
<Center my="xl">
<Code>{stack}</Code>
</Center>
- <Group position="center">
+ <Group justify="center">
<Anchor href={`${GithubRepoRoot}/issues/new/choose`} target="_blank">
<Button color="yellow">Report Issue</Button>
</Anchor>
- <Button onClick={Reload} color="light">
- Reload Page
- </Button>
+ <Button onClick={Reload}>Reload Page</Button>
</Group>
</Container>
);
diff --git a/frontend/src/pages/views/HistoryView.tsx b/frontend/src/pages/views/HistoryView.tsx
index 2ecc74afb..f9fe8a27f 100644
--- a/frontend/src/pages/views/HistoryView.tsx
+++ b/frontend/src/pages/views/HistoryView.tsx
@@ -1,13 +1,13 @@
-import { UsePaginationQueryResult } from "@/apis/queries/hooks";
-import { QueryPageTable } from "@/components";
import { Container } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
-import { Column } from "react-table";
+import { ColumnDef } from "@tanstack/react-table";
+import { UsePaginationQueryResult } from "@/apis/queries/hooks";
+import { QueryPageTable } from "@/components";
interface Props<T extends History.Base> {
name: string;
query: UsePaginationQueryResult<T>;
- columns: Column<T>[];
+ columns: ColumnDef<T>[];
}
function HistoryView<T extends History.Base = History.Base>({
diff --git a/frontend/src/pages/views/ItemOverview.tsx b/frontend/src/pages/views/ItemOverview.tsx
index d95944db3..36d296850 100644
--- a/frontend/src/pages/views/ItemOverview.tsx
+++ b/frontend/src/pages/views/ItemOverview.tsx
@@ -1,23 +1,4 @@
-import { Language } from "@/components/bazarr";
-import { BuildKey } from "@/utilities";
-import {
- useLanguageProfileBy,
- useProfileItemsToLanguages,
-} from "@/utilities/languages";
-import {
- faFolder,
- faBookmark as farBookmark,
-} from "@fortawesome/free-regular-svg-icons";
-import {
- IconDefinition,
- faBookmark,
- faClone,
- faLanguage,
- faMusic,
- faStream,
- faTags,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { FunctionComponent, useMemo } from "react";
import {
BackgroundImage,
Badge,
@@ -28,45 +9,54 @@ import {
HoverCard,
Image,
List,
- MediaQuery,
Stack,
Text,
Title,
- createStyles,
+ Tooltip,
} from "@mantine/core";
-import { FunctionComponent, useMemo } from "react";
+import {
+ faBookmark as farBookmark,
+ faFolder,
+} from "@fortawesome/free-regular-svg-icons";
+import {
+ faBookmark,
+ faClone,
+ faLanguage,
+ faMusic,
+ faStream,
+ faTags,
+ IconDefinition,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { Language } from "@/components/bazarr";
+import { BuildKey } from "@/utilities";
+import {
+ normalizeAudioLanguage,
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "@/utilities/languages";
interface Props {
item: Item.Base | null;
details?: { icon: IconDefinition; text: string }[];
}
-const useStyles = createStyles((theme) => {
- return {
- poster: {
- maxWidth: "250px",
- },
- col: {
- maxWidth: "100%",
- },
- group: {
- maxWidth: "100%",
- },
- };
-});
-
const ItemOverview: FunctionComponent<Props> = (props) => {
const { item, details } = props;
- const { classes } = useStyles();
-
const detailBadges = useMemo(() => {
- const badges: (JSX.Element | null)[] = [];
+ const badges: (React.JSX.Element | null)[] = [];
if (item) {
badges.push(
<ItemBadge key="file-path" icon={faFolder} title="File Path">
- {item.path}
+ <Tooltip
+ label={item.path}
+ multiline
+ style={{ overflowWrap: "anywhere" }}
+ >
+ <span>{item.path}</span>
+ </Tooltip>
</ItemBadge>,
);
@@ -98,7 +88,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
icon={faMusic}
title="Audio Language"
>
- {v.name}
+ {normalizeAudioLanguage(v.name)}
</ItemBadge>
)) ?? [],
[item?.audio_language],
@@ -108,7 +98,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
const profileItems = useProfileItemsToLanguages(profile);
const languageBadges = useMemo(() => {
- const badges: (JSX.Element | null)[] = [];
+ const badges: (React.JSX.Element | null)[] = [];
if (profile) {
badges.push(
@@ -147,24 +137,19 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
m={0}
style={{
backgroundColor: "rgba(0,0,0,0.7)",
- flexWrap: "nowrap",
+ }}
+ styles={{
+ inner: { flexWrap: "nowrap" },
}}
>
- <MediaQuery smallerThan="sm" styles={{ display: "none" }}>
- <Grid.Col span={3}>
- <Image
- src={item?.poster}
- mx="auto"
- className={classes.poster}
- withPlaceholder
- ></Image>
- </Grid.Col>
- </MediaQuery>
- <Grid.Col span={8} className={classes.col}>
- <Stack align="flex-start" spacing="xs" mx={6}>
- <Group align="flex-start" noWrap className={classes.group}>
+ <Grid.Col span={3} visibleFrom="sm">
+ <Image src={item?.poster} mx="auto" maw="250px"></Image>
+ </Grid.Col>
+ <Grid.Col span={8} maw="100%" style={{ overflow: "hidden" }}>
+ <Stack align="flex-start" gap="xs" mx={6}>
+ <Group align="flex-start" wrap="nowrap" maw="100%">
<Title my={0}>
- <Text inherit color="white">
+ <Text inherit c="white">
<Box component="span" mr={12}>
<FontAwesomeIcon
title={item?.monitored ? "monitored" : "unmonitored"}
@@ -176,10 +161,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
</Title>
<HoverCard position="bottom" withArrow>
<HoverCard.Target>
- <Text
- hidden={item?.alternativeTitles.length === 0}
- color="white"
- >
+ <Text hidden={item?.alternativeTitles.length === 0} c="white">
<FontAwesomeIcon icon={faClone} />
</Text>
</HoverCard.Target>
@@ -192,16 +174,16 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
</HoverCard.Dropdown>
</HoverCard>
</Group>
- <Group spacing="xs" className={classes.group}>
+ <Group gap="xs" maw="100%">
{detailBadges}
</Group>
- <Group spacing="xs" className={classes.group}>
+ <Group gap="xs" maw="100%">
{audioBadges}
</Group>
- <Group spacing="xs" className={classes.group}>
+ <Group gap="xs" maw="100%">
{languageBadges}
</Group>
- <Text size="sm" color="white">
+ <Text size="sm" c="white">
{item?.overview}
</Text>
</Stack>
@@ -223,8 +205,8 @@ const ItemBadge: FunctionComponent<ItemBadgeProps> = ({
}) => (
<Badge
leftSection={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
+ variant="light"
radius="sm"
- color="dark"
size="sm"
style={{ textTransform: "none" }}
aria-label={title}
diff --git a/frontend/src/pages/views/ItemView.tsx b/frontend/src/pages/views/ItemView.tsx
index 8fdaf83c8..c4ff250ea 100644
--- a/frontend/src/pages/views/ItemView.tsx
+++ b/frontend/src/pages/views/ItemView.tsx
@@ -1,12 +1,12 @@
+import { useNavigate } from "react-router-dom";
+import { faList } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { QueryPageTable, Toolbox } from "@/components";
-import { faList } from "@fortawesome/free-solid-svg-icons";
-import { useNavigate } from "react-router-dom";
-import { Column } from "react-table";
interface Props<T extends Item.Base = Item.Base> {
query: UsePaginationQueryResult<T>;
- columns: Column<T>[];
+ columns: ColumnDef<T>[];
}
function ItemView<T extends Item.Base>({ query, columns }: Props<T>) {
diff --git a/frontend/src/pages/views/MassEditor.tsx b/frontend/src/pages/views/MassEditor.tsx
index b15a55e83..48068d1c6 100644
--- a/frontend/src/pages/views/MassEditor.tsx
+++ b/frontend/src/pages/views/MassEditor.tsx
@@ -1,18 +1,17 @@
-import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
-import { SimpleTable, Toolbox } from "@/components";
-import { Selector, SelectorOption } from "@/components/inputs";
-import { useCustomSelection } from "@/components/tables/plugins";
-import { GetItemId, useSelectorOptions } from "@/utilities";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Box, Container, useCombobox } from "@mantine/core";
import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
-import { Box, Container } from "@mantine/core";
+import { UseMutationResult } from "@tanstack/react-query";
+import { ColumnDef, Table } from "@tanstack/react-table";
import { uniqBy } from "lodash";
-import { useCallback, useMemo, useState } from "react";
-import { UseMutationResult } from "react-query";
-import { useNavigate } from "react-router-dom";
-import { Column, useRowSelect } from "react-table";
+import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
+import { GroupedSelector, GroupedSelectorOptions, Toolbox } from "@/components";
+import SimpleTable from "@/components/tables/SimpleTable";
+import { GetItemId, useSelectorOptions } from "@/utilities";
interface MassEditorProps<T extends Item.Base = Item.Base> {
- columns: Column<T>[];
+ columns: ColumnDef<T>[];
data: T[];
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
}
@@ -24,6 +23,7 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
const [dirties, setDirties] = useState<T[]>([]);
const hasTask = useIsAnyMutationRunning();
const { data: profiles } = useLanguageProfiles();
+ const tableRef = useRef<Table<T>>(null);
const navigate = useNavigate();
@@ -37,14 +37,25 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
const profileOptions = useSelectorOptions(profiles ?? [], (v) => v.name);
const profileOptionsWithAction = useMemo<
- SelectorOption<Language.Profile | null>[]
- >(
- () => [
- { label: "Clear", value: null, group: "Action" },
- ...profileOptions.options,
- ],
- [profileOptions.options],
- );
+ GroupedSelectorOptions<string>[]
+ >(() => {
+ return [
+ {
+ group: "Actions",
+ items: [{ label: "Clear", value: "", profileId: null }],
+ },
+ {
+ group: "Profiles",
+ items: profileOptions.options.map((a) => {
+ return {
+ value: a.value.profileId.toString(),
+ label: a.label,
+ profileId: a.value.profileId,
+ };
+ }),
+ },
+ ];
+ }, [profileOptions.options]);
const getKey = useCallback((value: Language.Profile | null) => {
if (value) {
@@ -56,11 +67,20 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
const { mutateAsync } = mutation;
+ /**
+ * Submit the form that contains the series id and the respective profile id set in chunks to prevent payloads too
+ * large when we have a high amount of series or movies being applied the profile. The chunks are executed in order
+ * since there are no much benefit on executing in parallel, also parallelism could result in high load on the server
+ * side if not throttled properly.
+ */
const save = useCallback(() => {
+ const chunkSize = 1000;
+
const form: FormType.ModifyItem = {
id: [],
profileid: [],
};
+
dirties.forEach((v) => {
const id = GetItemId(v);
if (id) {
@@ -68,32 +88,63 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
form.profileid.push(v.profileId);
}
});
- return mutateAsync(form);
+
+ const mutateInChunks = async (
+ ids: number[],
+ profileIds: (number | null)[],
+ ) => {
+ if (ids.length === 0) return;
+
+ const chunkIds = ids.slice(0, chunkSize);
+ const chunkProfileIds = profileIds.slice(0, chunkSize);
+
+ await mutateAsync({
+ id: chunkIds,
+ profileid: chunkProfileIds,
+ });
+
+ await mutateInChunks(ids.slice(chunkSize), profileIds.slice(chunkSize));
+ };
+
+ return mutateInChunks(form.id, form.profileid);
}, [dirties, mutateAsync]);
const setProfiles = useCallback(
- (profile: Language.Profile | null) => {
- const id = profile?.profileId ?? null;
+ (id: number | null) => {
const newItems = selections.map((v) => ({ ...v, profileId: id }));
setDirties((dirty) => {
return uniqBy([...newItems, ...dirty], GetItemId);
});
+
+ tableRef.current?.toggleAllRowsSelected(false);
},
[selections],
);
+
+ const combobox = useCombobox();
+
return (
<Container fluid px={0}>
<Toolbox>
<Box>
- <Selector
- allowDeselect
+ <GroupedSelector
+ onClick={() => combobox.openDropdown()}
+ onDropdownClose={() => {
+ combobox.resetSelectedOption();
+ }}
placeholder="Change Profile"
+ withCheckIcon={false}
options={profileOptionsWithAction}
getkey={getKey}
disabled={selections.length === 0}
- onChange={setProfiles}
- ></Selector>
+ comboboxProps={{
+ store: combobox,
+ onOptionSubmit: (value) => {
+ setProfiles(value ? +value : null);
+ },
+ }}
+ ></GroupedSelector>
</Box>
<Box>
<Toolbox.Button icon={faUndo} onClick={onEnded}>
@@ -110,10 +161,13 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
</Box>
</Toolbox>
<SimpleTable
+ instanceRef={tableRef}
columns={columns}
data={data}
- onSelect={setSelections}
- plugins={[useRowSelect, useCustomSelection]}
+ enableRowSelection
+ onRowSelectionChanged={(row) => {
+ setSelections(row.map((r) => r.original));
+ }}
></SimpleTable>
</Container>
);
diff --git a/frontend/src/pages/views/WantedView.tsx b/frontend/src/pages/views/WantedView.tsx
index 5605bf337..e04f583b8 100644
--- a/frontend/src/pages/views/WantedView.tsx
+++ b/frontend/src/pages/views/WantedView.tsx
@@ -1,14 +1,14 @@
+import { Container } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
+import { faSearch } from "@fortawesome/free-solid-svg-icons";
+import { ColumnDef } from "@tanstack/react-table";
import { useIsAnyActionRunning } from "@/apis/hooks";
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { QueryPageTable, Toolbox } from "@/components";
-import { faSearch } from "@fortawesome/free-solid-svg-icons";
-import { Container } from "@mantine/core";
-import { useDocumentTitle } from "@mantine/hooks";
-import { Column } from "react-table";
interface Props<T extends Wanted.Base> {
name: string;
- columns: Column<T>[];
+ columns: ColumnDef<T>[];
query: UsePaginationQueryResult<T>;
searchAll: () => Promise<void>;
}
diff --git a/frontend/src/providers.tsx b/frontend/src/providers.tsx
index 97189a23e..52a152600 100644
--- a/frontend/src/providers.tsx
+++ b/frontend/src/providers.tsx
@@ -1,12 +1,12 @@
-import ThemeProvider from "@/App/theme";
+import { FunctionComponent, PropsWithChildren } from "react";
+import { Notifications } from "@mantine/notifications";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import queryClient from "@/apis/queries";
+import ThemeProvider from "@/App/ThemeProvider";
import { ModalsProvider } from "@/modules/modals";
-import "@fontsource/roboto/300.css";
-import { Notifications } from "@mantine/notifications";
-import { FunctionComponent, PropsWithChildren } from "react";
-import { QueryClientProvider } from "react-query";
-import { ReactQueryDevtools } from "react-query/devtools";
import { Environment } from "./utilities";
+import "@fontsource/roboto/300.css";
export const AllProviders: FunctionComponent<PropsWithChildren> = ({
children,
diff --git a/frontend/src/styles/index.ts b/frontend/src/styles/index.ts
deleted file mode 100644
index 0e948df9e..000000000
--- a/frontend/src/styles/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./table";
diff --git a/frontend/src/styles/table.ts b/frontend/src/styles/table.ts
deleted file mode 100644
index 40d533401..000000000
--- a/frontend/src/styles/table.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { createStyles } from "@mantine/core";
-
-export const useTableStyles = createStyles((theme) => ({
- primary: {
- display: "inline-block",
- [theme.fn.smallerThan("sm")]: {
- minWidth: "12rem",
- },
- },
- noWrap: {
- whiteSpace: "nowrap",
- },
- select: {
- display: "inline-block",
- [theme.fn.smallerThan("sm")]: {
- minWidth: "10rem",
- },
- },
-}));
diff --git a/frontend/src/tests/index.tsx b/frontend/src/tests/index.tsx
index d18df7227..8b4d64c64 100644
--- a/frontend/src/tests/index.tsx
+++ b/frontend/src/tests/index.tsx
@@ -1,5 +1,3 @@
-import { AllProviders } from "@/providers";
-import { render, RenderOptions } from "@testing-library/react";
import {
FunctionComponent,
PropsWithChildren,
@@ -11,6 +9,8 @@ import {
RouteObject,
RouterProvider,
} from "react-router-dom";
+import { render, RenderOptions } from "@testing-library/react";
+import { AllProviders } from "@/providers";
const AllProvidersWithStrictMode: FunctionComponent<PropsWithChildren> = ({
children,
diff --git a/frontend/src/tests/setup.ts b/frontend/src/tests/setup.ts
index 2b3bf3672..0f6df68ba 100644
--- a/frontend/src/tests/setup.ts
+++ b/frontend/src/tests/setup.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-empty-function */
-import "@testing-library/jest-dom";
import { vitest } from "vitest";
+import "@testing-library/jest-dom";
// From https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, "matchMedia", {
diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts
index 069be3029..e8bd4483e 100644
--- a/frontend/src/types/api.d.ts
+++ b/frontend/src/types/api.d.ts
@@ -40,6 +40,7 @@ declare namespace Language {
mustContain: string[];
mustNotContain: string[];
originalFormat: boolean | null;
+ tag: string | undefined;
}
}
diff --git a/frontend/src/types/react-table.d.ts b/frontend/src/types/react-table.d.ts
index 6795b50a3..c05bcd9fd 100644
--- a/frontend/src/types/react-table.d.ts
+++ b/frontend/src/types/react-table.d.ts
@@ -31,7 +31,7 @@ import {
UseSortByOptions,
UseSortByState,
} from "react-table";
-import {} from "../components/tables/plugins";
+import {} from "@/components/tables/plugins";
declare module "react-table" {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts
index 9ae6d8454..7b57f10cc 100644
--- a/frontend/src/types/settings.d.ts
+++ b/frontend/src/types/settings.d.ts
@@ -62,6 +62,7 @@ declare namespace Settings {
postprocessing_cmd?: string;
postprocessing_threshold: number;
postprocessing_threshold_movie: number;
+ remove_profile_tags: string[];
single_language: boolean;
subfolder: string;
subfolder_custom?: string;
diff --git a/frontend/src/utilities/hooks.ts b/frontend/src/utilities/hooks.ts
index 4f703877c..6ae8a2366 100644
--- a/frontend/src/utilities/hooks.ts
+++ b/frontend/src/utilities/hooks.ts
@@ -1,5 +1,3 @@
-import { SelectorOption, SelectorProps } from "@/components";
-import { SliderProps } from "@mantine/core";
import {
Dispatch,
useCallback,
@@ -9,6 +7,8 @@ import {
useState,
} from "react";
import { useNavigate } from "react-router-dom";
+import { SliderProps } from "@mantine/core";
+import { SelectorOption, SelectorProps } from "@/components";
export function useGotoHomepage() {
const navigate = useNavigate();
diff --git a/frontend/src/utilities/index.test.ts b/frontend/src/utilities/index.test.ts
new file mode 100644
index 000000000..b2596a005
--- /dev/null
+++ b/frontend/src/utilities/index.test.ts
@@ -0,0 +1,25 @@
+import { fromPython, toPython } from "@/utilities/index";
+
+describe("fromPythonConversion", () => {
+ it("should convert a true value", () => {
+ expect(fromPython("True")).toBe(true);
+ });
+
+ it("should convert a false value", () => {
+ expect(fromPython("False")).toBe(false);
+ });
+
+ it("should convert an undefined value", () => {
+ expect(fromPython(undefined)).toBe(false);
+ });
+});
+
+describe("toPythonConversion", () => {
+ it("should convert a true value", () => {
+ expect(toPython(true)).toBe("True");
+ });
+
+ it("should convert a false value", () => {
+ expect(toPython(false)).toBe("False");
+ });
+});
diff --git a/frontend/src/utilities/index.ts b/frontend/src/utilities/index.ts
index b0555ad31..cde66d894 100644
--- a/frontend/src/utilities/index.ts
+++ b/frontend/src/utilities/index.ts
@@ -1,5 +1,5 @@
-import { difference, differenceWith } from "lodash";
import { Dispatch } from "react";
+import { difference, differenceWith } from "lodash";
import { isEpisode, isMovie, isSeries } from "./validate";
export function toggleState(
@@ -59,6 +59,10 @@ export function filterSubtitleBy(
}
}
+export function fromPython(value: PythonBoolean | undefined): boolean {
+ return value === "True";
+}
+
export function toPython(value: boolean): PythonBoolean {
return value ? "True" : "False";
}
diff --git a/frontend/src/utilities/languages.ts b/frontend/src/utilities/languages.ts
index ca0c5c51a..7885e9667 100644
--- a/frontend/src/utilities/languages.ts
+++ b/frontend/src/utilities/languages.ts
@@ -1,5 +1,5 @@
-import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
import { useMemo } from "react";
+import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
export function useLanguageProfileBy(id: number | null | undefined) {
const { data } = useLanguageProfiles();
@@ -51,3 +51,7 @@ export function useLanguageFromCode3(code3: string) {
[data, code3],
);
}
+
+export const normalizeAudioLanguage = (name: string) => {
+ return name === "Chinese Simplified" ? "Chinese" : name;
+};
diff --git a/frontend/src/utilities/storage.ts b/frontend/src/utilities/storage.ts
index c4d50a829..a7c256cab 100644
--- a/frontend/src/utilities/storage.ts
+++ b/frontend/src/utilities/storage.ts
@@ -1,5 +1,5 @@
-import { useSystemSettings } from "@/apis/hooks";
import { useCallback } from "react";
+import { useSystemSettings } from "@/apis/hooks";
export const uiPageSizeKey = "settings-general-page_size";
diff --git a/frontend/src/utilities/time.test.ts b/frontend/src/utilities/time.test.ts
index a0e936a25..25cc9d72e 100644
--- a/frontend/src/utilities/time.test.ts
+++ b/frontend/src/utilities/time.test.ts
@@ -30,7 +30,7 @@ describe("formatTime", () => {
{ unit: "s", divisor: divisorSecond },
]);
- expect(formattedTime).toBe("581d 25:27:41");
+ expect(formattedTime).toBe("581d 01:27:41");
});
it("should format time day hour minute", () => {
diff --git a/frontend/src/utilities/time.ts b/frontend/src/utilities/time.ts
index 54f93289f..6e1519b62 100644
--- a/frontend/src/utilities/time.ts
+++ b/frontend/src/utilities/time.ts
@@ -11,19 +11,28 @@ export const divisorSecond = 1;
export const formatTime = (
timeInSeconds: number,
formats: TimeFormat[],
-): string =>
- formats.reduce(
- (formattedTime: string, { unit, divisor }: TimeFormat, index: number) => {
- const timeValue: number =
- index === 0
- ? Math.floor(timeInSeconds / divisor)
- : Math.floor(timeInSeconds / divisor) % 60;
- return (
+): string => {
+ return formats.reduce(
+ (
+ { formattedTime, remainingSeconds },
+ { unit, divisor }: TimeFormat,
+ index: number,
+ ) => {
+ const timeValue = Math.floor(remainingSeconds / divisor);
+
+ const seconds = remainingSeconds % divisor;
+
+ const time =
formattedTime +
(index === 0
? `${timeValue}${unit} `
- : `${timeValue.toString().padStart(2, "0")}${index < formats.length - 1 ? ":" : ""}`)
- );
+ : `${timeValue.toString().padStart(2, "0")}${index < formats.length - 1 ? ":" : ""}`);
+
+ return {
+ formattedTime: time,
+ remainingSeconds: seconds,
+ };
},
- "",
- );
+ { formattedTime: "", remainingSeconds: timeInSeconds },
+ ).formattedTime;
+};
diff --git a/frontend/src/utilities/validate.ts b/frontend/src/utilities/validate.ts
index 665e5aa61..2b3f028f6 100644
--- a/frontend/src/utilities/validate.ts
+++ b/frontend/src/utilities/validate.ts
@@ -1,5 +1,5 @@
-import { isNumber, isString } from "lodash";
import { ReactText } from "react";
+import { isNumber, isString } from "lodash";
export function isReactText(v: unknown): v is ReactText {
return isString(v) || isNumber(v);