diff options
author | github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> | 2024-09-15 19:26:21 +0000 |
---|---|---|
committer | GitHub <[email protected]> | 2024-09-15 19:26:21 +0000 |
commit | ad80ac44537ead820808af481cd549104047b111 (patch) | |
tree | 8d8ef249443a1da1f874f2d7a3b2a80ac4d505f4 /frontend/src | |
parent | 97ea3a81242f604007fcd54c54f4acf17a97fade (diff) | |
parent | ff54bc83a9dbf07e83b563cd01d52acb165441c7 (diff) | |
download | bazarr-ad80ac44537ead820808af481cd549104047b111.tar.gz bazarr-ad80ac44537ead820808af481cd549104047b111.zip |
Merge development into master
Diffstat (limited to 'frontend/src')
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); |