diff options
author | JayZed <[email protected]> | 2024-07-08 17:33:43 -0400 |
---|---|---|
committer | JayZed <[email protected]> | 2024-07-08 17:33:43 -0400 |
commit | 4cc6806193127f9d6d3f2dab26969471d9bbf159 (patch) | |
tree | bfc90e4b55fa0f48f83e51c4e5947c1f7d7d7a2d /frontend/src/pages | |
parent | d875dc7733c901246881325ee3a84fe5d44b10b9 (diff) | |
parent | 5886c20c9c7929bf46836a99c2d9d4eb834638bd (diff) | |
download | bazarr-4cc6806193127f9d6d3f2dab26969471d9bbf159.tar.gz bazarr-4cc6806193127f9d6d3f2dab26969471d9bbf159.zip |
Merge branch 'development' of https://github.com/morpheus65535/bazarr into development
Diffstat (limited to 'frontend/src/pages')
78 files changed, 1824 insertions, 1470 deletions
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..229082444 100644 --- a/frontend/src/pages/Series/index.tsx +++ b/frontend/src/pages/Series/index.tsx @@ -1,61 +1,68 @@ +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; @@ -70,25 +77,24 @@ const SeriesView: FunctionComponent = () => { } 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={progress} + color={episodeMissingCount === 0 ? "brand" : "yellow"} + > + <Progress.Label>{label}</Progress.Label> + </Progress.Section> + </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 +113,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..312d09d1f 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"; @@ -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..9fe562920 100644 --- a/frontend/src/pages/Settings/Languages/index.tsx +++ b/frontend/src/pages/Settings/Languages/index.tsx @@ -1,6 +1,5 @@ -import { useLanguageProfiles, useLanguages } from "@/apis/hooks"; -import { useEnabledLanguages } from "@/utilities/languages"; import { FunctionComponent } from "react"; +import { useLanguageProfiles, useLanguages } from "@/apis/hooks"; import { Check, CollapseBox, @@ -8,14 +7,15 @@ import { 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"; diff --git a/frontend/src/pages/Settings/Languages/table.tsx b/frontend/src/pages/Settings/Languages/table.tsx index a1ee217e8..c32300628 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,22 @@ 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: "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 +86,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 +110,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 +134,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 +154,7 @@ const Table: FunctionComponent = () => { <Action label="Remove" icon={faTrash} - color="red" + c="red" onClick={() => action.remove(row.index)} ></Action> </Group> @@ -159,11 +170,10 @@ 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, 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 87abe7571..72e2c3b1f 100644 --- a/frontend/src/pages/Settings/Providers/components.tsx +++ b/frontend/src/pages/Settings/Providers/components.tsx @@ -1,52 +1,70 @@ -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"; -import { ProviderInfo, ProviderList } from "./list"; +} 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"; -const ProviderKey = "settings-general-enabled_providers"; +type SettingsKey = + | "settings-general-enabled_providers" + | "settings-general-enabled_integrations"; -export const ProviderView: FunctionComponent = () => { +interface ProviderViewProps { + availableOptions: Readonly<ProviderInfo[]>; + settingsKey: SettingsKey; +} + +interface ProviderSelect { + value: string; + payload: ProviderInfo; +} + +export const ProviderView: FunctionComponent<ProviderViewProps> = ({ + availableOptions, + settingsKey, +}) => { const settings = useSettings(); const staged = useStagedValues(); - const providers = useSettingValue<string[]>(ProviderKey); + const providers = useSettingValue<string[]>(settingsKey); const { update } = useFormActions(); @@ -61,17 +79,27 @@ export const ProviderView: FunctionComponent = () => { staged, settings, onChange: update, + availableOptions: availableOptions, + settingsKey: settingsKey, }); } }, - [modals, providers, settings, staged, update], + [ + modals, + providers, + settings, + staged, + update, + availableOptions, + settingsKey, + ], ); const cards = useMemo(() => { if (providers) { return providers .flatMap((v) => { - const item = ProviderList.find((inn) => inn.key === v); + const item = availableOptions.find((inn) => inn.key === v); if (item) { return item; } else { @@ -89,7 +117,7 @@ export const ProviderView: FunctionComponent = () => { } else { return []; } - }, [providers, select]); + }, [providers, select, availableOptions]); return ( <SimpleGrid cols={3}> @@ -106,19 +134,20 @@ interface ProviderToolProps { staged: LooseObject; settings: Settings; onChange: (v: LooseObject) => void; + availableOptions: Readonly<ProviderInfo[]>; + 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, @@ -126,6 +155,8 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({ staged, settings, onChange, + availableOptions, + settingsKey, }) => { const modals = useModals(); @@ -147,11 +178,11 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({ if (idx !== -1) { const newProviders = [...enabledProviders]; newProviders.splice(idx, 1); - onChangeRef.current({ [ProviderKey]: newProviders }); + onChangeRef.current({ [settingsKey]: newProviders }); modals.closeAll(); } } - }, [payload, enabledProviders, modals]); + }, [payload, enabledProviders, modals, settingsKey]); const submit = useCallback( (values: FormValues) => { @@ -161,8 +192,7 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({ // Add this provider if not exist if (enabledProviders.find((v) => v === info.key) === undefined) { - const newProviders = [...enabledProviders, info.key]; - changes[ProviderKey] = newProviders; + changes[settingsKey] = [...enabledProviders, info.key]; } // Apply submit hooks @@ -172,7 +202,7 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({ modals.closeAll(); } }, - [info, enabledProviders, modals], + [info, enabledProviders, modals, settingsKey], ); const canSave = info !== null; @@ -188,18 +218,18 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({ } }, []); - const availableOptions = useMemo( + const options = useMemo( () => - ProviderList.filter( + availableOptions.filter( (v) => enabledProviders?.find((p) => p === v.key && p !== info?.key) === undefined, ), - [info?.key, enabledProviders], + [info?.key, enabledProviders, availableOptions], ); - const options = useSelectorOptions( - availableOptions, + const selectorOptions = useSelectorOptions( + options, (v) => v.name ?? capitalize(v.key), ); @@ -275,21 +305,21 @@ 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} - {...options} + {...selectorOptions} value={info} onChange={onSelect} ></Selector> @@ -300,7 +330,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 bd8b648ff..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,14 +8,19 @@ 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"; const SettingsProvidersView: FunctionComponent = () => { return ( <Layout name="Providers"> <Section header="Providers"> - <ProviderView></ProviderView> + <ProviderView + availableOptions={ProviderList} + settingsKey="settings-general-enabled_providers" + ></ProviderView> </Section> <Section header="Anti-Captcha Options"> <Selector @@ -58,6 +62,12 @@ const SettingsProvidersView: FunctionComponent = () => { <Message>Link to subscribe</Message> </CollapseBox> </Section> + <Section header="Integrations"> + <ProviderView + availableOptions={IntegrationList} + settingsKey="settings-general-enabled_integrations" + ></ProviderView> + </Section> </Layout> ); }; diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index 967526187..b2f9a33c7 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; @@ -65,6 +65,21 @@ export const ProviderList: Readonly<ProviderInfo[]> = [ ], }, { + key: "animetosho", + name: "Anime Tosho", + description: + "Anime Tosho is a free, completely automated service which mirrors most torrents posted on TokyoTosho's anime category, Nyaa.si's English translated anime category and AniDex's anime category.", + inputs: [ + { + type: "text", + key: "search_threshold", + defaultValue: 6, + name: "Search Threshold. Increase if you often cannot find subtitles for your Anime. Note that increasing the value will decrease the performance of the search for each Episode.", + }, + ], + message: "Requires AniDB Integration.", + }, + { key: "argenteam_dump", name: "Argenteam Dump", description: "Subtitles dump of the now extinct Argenteam", @@ -357,9 +372,17 @@ 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", - description: "Subscene Alternative Provider", inputs: [ { type: "switch", @@ -391,20 +414,6 @@ export const ProviderList: Readonly<ProviderInfo[]> = [ description: "Greek Subtitles Provider.\nRequires anti-captcha provider to solve captchas for each download.", }, - { - key: "subscene", - inputs: [ - { - type: "text", - key: "username", - }, - { - type: "password", - key: "password", - }, - ], - description: "Broken, may not work for some. Use subf2m instead.", - }, { key: "subscenter", description: "Hebrew Subtitles Provider" }, { key: "subsunacs", @@ -538,3 +547,24 @@ export const ProviderList: Readonly<ProviderInfo[]> = [ description: "Chinese Subtitles Provider. Anti-captcha required.", }, ]; + +export const IntegrationList: Readonly<ProviderInfo[]> = [ + { + key: "anidb", + name: "AniDB", + description: + "AniDB is non-profit database of anime information that is freely open to the public.", + inputs: [ + { + type: "text", + key: "api_client", + name: "API Client", + }, + { + type: "text", + key: "api_client_ver", + name: "API Client Version", + }, + ], + }, +]; diff --git a/frontend/src/pages/Settings/Radarr/index.tsx b/frontend/src/pages/Settings/Radarr/index.tsx index 8cd038ab8..b2e858178 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) ?? "", 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..ed66ef679 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) ?? "", diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index f6e0cae37..a2250e5a9 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 { Code, Space, Table } 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, @@ -409,8 +409,7 @@ const SettingsSubtitlesView: FunctionComponent = () => { settingKey="settings-subsync-use_subsync" ></Check> <Message> - Enable automatic subtitles synchronization after downloading a - subtitle. + Enable automatic synchronization after downloading subtitles. </Message> <CollapseBox indent settingKey="settings-subsync-use_subsync"> <MultiSelector @@ -502,7 +501,7 @@ const SettingsSubtitlesView: FunctionComponent = () => { 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..69df15636 100644 --- a/frontend/src/pages/Settings/components/Card.tsx +++ b/frontend/src/pages/Settings/components/Card.tsx @@ -1,30 +1,8 @@ +import { FunctionComponent } from "react"; +import { Center, Stack, Text, UnstyledButton } from "@mantine/core"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Center, - createStyles, - 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 styles from "./Card.module.scss"; interface CardProps { header?: string; @@ -39,16 +17,15 @@ export const Card: FunctionComponent<CardProps> = ({ plus, onClick, }) => { - 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> + <Stack h="100%" gap={0} align="flex-start"> + <Text fw="bold">{header}</Text> <Text hidden={description === undefined}>{description}</Text> </Stack> )} 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..a88d2bec7 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, RenderOptions, screen } from "@/tests"; import { Number, Text } from "./forms"; const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => { @@ -18,7 +18,7 @@ const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => { const formRender = ( ui: ReactElement, options?: Omit<RenderOptions, "wrapper">, -) => rawRender(ui, { wrapper: FormSupport, ...options }); +) => 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..95134db92 100644 --- a/frontend/src/pages/Settings/components/forms.tsx +++ b/frontend/src/pages/Settings/components/forms.tsx @@ -1,7 +1,20 @@ +import { FunctionComponent, ReactNode, ReactText } 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,6 +38,11 @@ export const Number: FunctionComponent<NumberProps> = (props) => { if (val === "") { val = 0; } + + if (typeof val === "string") { + return update(+val); + } + update(val); }} ></NumberInput> 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..febb32fa1 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", + header: "Since", accessor: "timestamp", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.primary}>{value}</Text>; + cell: ({ + row: { + original: { timestamp }, + }, + }) => { + return <Text className="table-primary">{timestamp}</Text>; }, }, { - Header: "Announcement", + header: "Announcement", accessor: "text", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.primary}>{value}</Text>; + cell: ({ + row: { + original: { text }, + }, + }) => { + return <Text className="table-primary">{text}</Text>; }, }, { - Header: "More Info", + header: "More Info", accessor: "link", - Cell: ({ value }) => { - if (value) { - return <Label link={value}>Link</Label>; + cell: ({ + row: { + original: { link }, + }, + }) => { + if (link) { + return <Label link={link}>Link</Label>; } else { return <Text>n/a</Text>; } }, }, { - Header: "Dismiss", + header: "Dismiss", accessor: "hash", - Cell: ({ row, value }) => { - const add = useSystemAnnouncementsAddDismiss(); + 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 80757c4c7..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,14 +15,25 @@ import { Text, } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; -import moment from "moment"; +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, + divisorMinute, + divisorSecond, + formatTime, +} from "@/utilities/time"; import Table from "./table"; interface InfoProps { @@ -40,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> @@ -79,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 /> @@ -98,15 +107,19 @@ const SystemStatusView: FunctionComponent = () => { const update = useCallback(() => { const startTime = status?.start_time; if (startTime) { - const duration = moment.duration( - moment().utc().unix() - startTime, - "seconds", - ), - days = duration.days(), - hours = duration.hours().toString().padStart(2, "0"), - minutes = duration.minutes().toString().padStart(2, "0"), - seconds = duration.seconds().toString().padStart(2, "0"); - setUptime(days + "d " + hours + ":" + minutes + ":" + seconds); + // Current time in seconds + const currentTime = Math.floor(Date.now() / 1000); + + const uptimeInSeconds = currentTime - startTime; + + const uptime: string = formatTime(uptimeInSeconds, [ + { unit: "d", divisor: divisorDay }, + { unit: "h", divisor: divisorHour }, + { unit: "m", divisor: divisorMinute }, + { unit: "s", divisor: divisorSecond }, + ]); + + setUptime(uptime); } }, [status?.start_time]); 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..ed3248b6f 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", + header: "Name", accessor: "name", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.primary}>{value}</Text>; + cell: ({ + row: { + original: { name }, + }, + }) => { + return <Text className="table-primary">{name}</Text>; }, }, { - Header: "Interval", + header: "Interval", accessor: "interval", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.noWrap}>{value}</Text>; + cell: ({ + row: { + original: { interval }, + }, + }) => { + return <Text className="table-no-wrap">{interval}</Text>; }, }, { - Header: "Next Execution", + header: "Next Execution", accessor: "next_run_in", }, { - Header: "Run", + header: "Run", accessor: "job_running", - Cell: ({ row, value }) => { - const { job_id: jobId } = row.original; - const runTask = useRunTask(); - + 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..7c497f799 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", + header: "Name", accessor: "title", - Cell: (row) => { - const target = `/movies/${row.row.original.radarrId}`; + cell: ({ + row: { + original: { title, radarrId }, + }, + }) => { + const target = `/movies/${radarrId}`; return ( <Anchor component={Link} to={target}> - {row.value} + {title} </Anchor> ); }, }, { - Header: "Missing", + header: "Missing", accessor: "missing_subtitles", - Cell: ({ row, value }) => { - const wanted = row.original; - const { radarrId } = wanted; - - const { download } = useMovieSubtitleModification(); - + 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..15b43aab1 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,53 @@ 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 { + 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>, ); @@ -108,7 +97,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 +136,24 @@ 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" + fallbackSrc="https://placehold.co/250x250?text=Placeholder" + ></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 +165,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 +178,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 +209,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>; } |