mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-23 22:27:17 -04:00
Merge branch 'refs/heads/development' into non-hi-only
This commit is contained in:
commit
d24ccbacd1
112 changed files with 1238 additions and 1269 deletions
|
@ -165,6 +165,9 @@ def apply_update():
|
|||
parent_dir = os.path.dirname(file_path)
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
if not os.path.isdir(file_path):
|
||||
if os.path.exists(file_path):
|
||||
# remove the file first to handle case-insensitive file systems
|
||||
os.remove(file_path)
|
||||
with open(file_path, 'wb+') as f:
|
||||
f.write(archive.read(file))
|
||||
except Exception:
|
||||
|
@ -229,6 +232,9 @@ def update_cleaner(zipfile, bazarr_dir, config_dir):
|
|||
dir_to_ignore_regex = re.compile(dir_to_ignore_regex_string)
|
||||
|
||||
file_to_ignore = ['nssm.exe', '7za.exe', 'unins000.exe', 'unins000.dat']
|
||||
# prevent deletion of leftover Apprise.py/pyi files after 1.8.0 version that caused issue on case-insensitive
|
||||
# filesystem. This could be removed in a couple of major versions.
|
||||
file_to_ignore += ['Apprise.py', 'Apprise.pyi', 'apprise.py', 'apprise.pyi']
|
||||
logging.debug(f'BAZARR upgrade leftover cleaner will ignore those files: {", ".join(file_to_ignore)}')
|
||||
extension_to_ignore = ['.pyc']
|
||||
logging.debug(
|
||||
|
|
|
@ -496,7 +496,7 @@ def get_throttled_providers():
|
|||
except Exception:
|
||||
# set empty content in throttled_providers.dat
|
||||
logging.error("Invalid content in throttled_providers.dat. Resetting")
|
||||
set_throttled_providers(providers)
|
||||
set_throttled_providers(str(providers))
|
||||
finally:
|
||||
return providers
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# coding=utf-8
|
||||
|
||||
import apprise
|
||||
from apprise import Apprise, AppriseAsset
|
||||
import logging
|
||||
|
||||
from .database import TableSettingsNotifier, TableEpisodes, TableShows, TableMovies, database, insert, delete, select
|
||||
|
@ -8,7 +8,7 @@ from .database import TableSettingsNotifier, TableEpisodes, TableShows, TableMov
|
|||
|
||||
def update_notifier():
|
||||
# define apprise object
|
||||
a = apprise.Apprise()
|
||||
a = Apprise()
|
||||
|
||||
# Retrieve all the details
|
||||
results = a.details()
|
||||
|
@ -70,9 +70,9 @@ def send_notifications(sonarr_series_id, sonarr_episode_id, message):
|
|||
if not episode:
|
||||
return
|
||||
|
||||
asset = apprise.AppriseAsset(async_mode=False)
|
||||
asset = AppriseAsset(async_mode=False)
|
||||
|
||||
apobj = apprise.Apprise(asset=asset)
|
||||
apobj = Apprise(asset=asset)
|
||||
|
||||
for provider in providers:
|
||||
if provider.url is not None:
|
||||
|
@ -101,9 +101,9 @@ def send_notifications_movie(radarr_id, message):
|
|||
else:
|
||||
movie_year = ''
|
||||
|
||||
asset = apprise.AppriseAsset(async_mode=False)
|
||||
asset = AppriseAsset(async_mode=False)
|
||||
|
||||
apobj = apprise.Apprise(asset=asset)
|
||||
apobj = Apprise(asset=asset)
|
||||
|
||||
for provider in providers:
|
||||
if provider.url is not None:
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
# only methods can be specified here that do not cause other moudules to be loaded
|
||||
# for other methods that use settings, etc., use utilities/helper.py
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
@ -53,4 +54,8 @@ def restart_bazarr():
|
|||
except Exception as e:
|
||||
logging.error(f'BAZARR Cannot create restart file: {repr(e)}')
|
||||
logging.info('Bazarr is being restarted...')
|
||||
raise SystemExit(EXIT_NORMAL)
|
||||
|
||||
# Wrap the SystemExit for a graceful restart. The SystemExit still performs the cleanup but the traceback is omitted
|
||||
# preventing to throw the exception to the caller but still terminates the Python process with the desired Exit Code
|
||||
with contextlib.suppress(SystemExit):
|
||||
raise SystemExit(EXIT_NORMAL)
|
||||
|
|
|
@ -946,8 +946,8 @@ def _search_external_subtitles(path, languages=None, only_one=False, match_stric
|
|||
lambda m: "" if str(m.group(1)).lower() in FULL_LANGUAGE_LIST else m.group(0), p_root)
|
||||
|
||||
p_root_lower = p_root_bare.lower()
|
||||
|
||||
filename_matches = p_root_lower == fn_no_ext_lower
|
||||
# comparing to both unicode normalization forms to prevent broking stuff and improve indexing on some platforms.
|
||||
filename_matches = fn_no_ext_lower in [p_root_lower, unicodedata.normalize('NFC', p_root_lower)]
|
||||
filename_contains = p_root_lower in fn_no_ext_lower
|
||||
|
||||
if not filename_matches:
|
||||
|
@ -1193,7 +1193,7 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non
|
|||
must_remove_hi = 'remove_HI' in subtitle.mods
|
||||
|
||||
# check content
|
||||
if subtitle.content is None:
|
||||
if subtitle.content is None or subtitle.text is None:
|
||||
logger.error('Skipping subtitle %r: no content', subtitle)
|
||||
continue
|
||||
|
||||
|
@ -1203,7 +1203,7 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non
|
|||
continue
|
||||
|
||||
# create subtitle path
|
||||
if bool(re.search(HI_REGEX, subtitle.text)):
|
||||
if subtitle.text and bool(re.search(HI_REGEX, subtitle.text)):
|
||||
subtitle.language.hi = True
|
||||
subtitle_path = get_subtitle_path(file_path, None if single else subtitle.language,
|
||||
forced_tag=subtitle.language.forced,
|
||||
|
|
|
@ -126,7 +126,7 @@ class SubdivxSubtitlesProvider(Provider):
|
|||
titles = [video.series if episode else video.title]
|
||||
|
||||
try:
|
||||
titles.extend(video.alternative_titles)
|
||||
titles.extend(video.alternative_series if episode else video.alternative_titles)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
|
@ -138,6 +138,7 @@ class SubdivxSubtitlesProvider(Provider):
|
|||
# TODO: cache pack queries (TV SHOW S01).
|
||||
# Too many redundant server calls.
|
||||
for title in titles:
|
||||
title = _series_sanitizer(title)
|
||||
for query in (
|
||||
f"{title} S{video.season:02}E{video.episode:02}",
|
||||
f"{title} S{video.season:02}",
|
||||
|
@ -297,20 +298,31 @@ def _check_episode(video, title):
|
|||
) and season_num == video.season
|
||||
|
||||
series_title = _SERIES_RE.sub("", title).strip()
|
||||
series_title = _series_sanitizer(series_title)
|
||||
|
||||
distance = abs(len(series_title) - len(video.series))
|
||||
for video_series_title in [video.series] + video.alternative_series:
|
||||
video_series_title = _series_sanitizer(video_series_title)
|
||||
distance = abs(len(series_title) - len(video_series_title))
|
||||
|
||||
series_matched = distance < 4 and ep_matches
|
||||
series_matched = (distance < 4 or video_series_title in series_title) and ep_matches
|
||||
|
||||
logger.debug(
|
||||
"Series matched? %s [%s -> %s] [title distance: %d]",
|
||||
series_matched,
|
||||
video,
|
||||
title,
|
||||
distance,
|
||||
)
|
||||
logger.debug(
|
||||
"Series matched? %s [%s -> %s] [title distance: %d]",
|
||||
series_matched,
|
||||
video_series_title,
|
||||
series_title,
|
||||
distance,
|
||||
)
|
||||
|
||||
return series_matched
|
||||
if series_matched:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _series_sanitizer(title):
|
||||
title = re.sub(r"\'|\.+", '', title) # remove single quote and dot
|
||||
title = re.sub(r"\W+", ' ', title) # replace by a space anything other than a letter, digit or underscore
|
||||
return re.sub(r"([A-Z])\s(?=[A-Z]\b)", '', title).strip() # Marvels Agent of S.H.I.E.L.D
|
||||
|
||||
|
||||
def _check_movie(video, title):
|
||||
|
|
755
frontend/package-lock.json
generated
755
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -13,12 +13,12 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
"@mantine/form": "^6.0.21",
|
||||
"@mantine/hooks": "^6.0.21",
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"@mantine/core": "^7.10.1",
|
||||
"@mantine/dropzone": "^7.10.1",
|
||||
"@mantine/form": "^7.10.1",
|
||||
"@mantine/hooks": "^7.10.1",
|
||||
"@mantine/modals": "^7.10.1",
|
||||
"@mantine/notifications": "^7.10.1",
|
||||
"axios": "^1.6.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
@ -37,7 +37,7 @@
|
|||
"@testing-library/react": "^15.0.5",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/lodash": "^4.17.1",
|
||||
"@types/node": "^20.12.6",
|
||||
"@types/react": "^18.2.75",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
|
@ -53,6 +53,8 @@
|
|||
"husky": "^9.0.11",
|
||||
"jsdom": "^24.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"postcss-preset-mantine": "^1.14.4",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
"pretty-quick": "^4.0.0",
|
||||
|
|
14
frontend/postcss.config.cjs
Normal file
14
frontend/postcss.config.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
"mantine-breakpoint-sm": "48em",
|
||||
"mantine-breakpoint-md": "62em",
|
||||
"mantine-breakpoint-lg": "75em",
|
||||
"mantine-breakpoint-xl": "88em",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
9
frontend/src/App/Header.module.scss
Normal file
9
frontend/src/App/Header.module.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.header {
|
||||
@include light {
|
||||
color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@include dark {
|
||||
color: var(--mantine-color-dark-0);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||
import { Action, Search } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useIsOnline } from "@/contexts/Online";
|
||||
import { Environment, useGotoHomepage } from "@/utilities";
|
||||
|
@ -12,27 +11,16 @@ import {
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Anchor,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Badge,
|
||||
Burger,
|
||||
Divider,
|
||||
Group,
|
||||
Header,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
createStyles,
|
||||
} from "@mantine/core";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
const headerBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[4];
|
||||
return {
|
||||
header: {
|
||||
backgroundColor: headerBackgroundColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
import styles from "./Header.module.scss";
|
||||
|
||||
const AppHeader: FunctionComponent = () => {
|
||||
const { data: settings } = useSystemSettings();
|
||||
|
@ -47,39 +35,28 @@ const AppHeader: FunctionComponent = () => {
|
|||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Header p="md" height={Layout.HEADER_HEIGHT} className={classes.header}>
|
||||
<Group position="apart" noWrap>
|
||||
<Group noWrap>
|
||||
<MediaQuery
|
||||
smallerThan={Layout.MOBILE_BREAKPOINT}
|
||||
styles={{ display: "none" }}
|
||||
>
|
||||
<Anchor onClick={goHome}>
|
||||
<Avatar
|
||||
alt="brand"
|
||||
size={32}
|
||||
src={`${Environment.baseUrl}/images/logo64.png`}
|
||||
></Avatar>
|
||||
</Anchor>
|
||||
</MediaQuery>
|
||||
<MediaQuery
|
||||
largerThan={Layout.MOBILE_BREAKPOINT}
|
||||
styles={{ display: "none" }}
|
||||
>
|
||||
<Burger
|
||||
opened={showed}
|
||||
onClick={() => show(!showed)}
|
||||
size="sm"
|
||||
></Burger>
|
||||
</MediaQuery>
|
||||
<AppShell.Header p="md" className={styles.header}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Anchor onClick={goHome} visibleFrom="sm">
|
||||
<Avatar
|
||||
alt="brand"
|
||||
size={32}
|
||||
src={`${Environment.baseUrl}/images/logo64.png`}
|
||||
></Avatar>
|
||||
</Anchor>
|
||||
<Burger
|
||||
opened={showed}
|
||||
onClick={() => show(!showed)}
|
||||
size="sm"
|
||||
hiddenFrom="sm"
|
||||
></Burger>
|
||||
<Badge size="lg" radius="sm">
|
||||
Bazarr
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group spacing="xs" position="right" noWrap>
|
||||
<Group gap="xs" justify="right" wrap="nowrap">
|
||||
<Search></Search>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
|
@ -95,13 +72,13 @@ const AppHeader: FunctionComponent = () => {
|
|||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
|
||||
leftSection={<FontAwesomeIcon icon={faArrowRotateLeft} />}
|
||||
onClick={() => restart()}
|
||||
>
|
||||
Restart
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faPowerOff} />}
|
||||
leftSection={<FontAwesomeIcon icon={faPowerOff} />}
|
||||
onClick={() => shutdown()}
|
||||
>
|
||||
Shutdown
|
||||
|
@ -114,7 +91,7 @@ const AppHeader: FunctionComponent = () => {
|
|||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Header>
|
||||
</AppShell.Header>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
56
frontend/src/App/Navbar.module.scss
Normal file
56
frontend/src/App/Navbar.module.scss
Normal file
|
@ -0,0 +1,56 @@
|
|||
.anchor {
|
||||
border-color: var(--mantine-color-gray-5);
|
||||
text-decoration: none;
|
||||
|
||||
@include dark {
|
||||
border-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-left: 2px solid $color-brand-4;
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
|
||||
@include dark {
|
||||
border-left: 2px solid $color-brand-8;
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
}
|
||||
}
|
||||
|
||||
&.hover {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
|
||||
@include dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.4rem;
|
||||
margin-right: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.nav {
|
||||
background-color: var(--mantine-color-gray-2);
|
||||
|
||||
@include dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: var(--mantine-color-gray-8);
|
||||
|
||||
@include dark {
|
||||
color: var(--mantine-color-gray-5);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { Action } from "@/components";
|
||||
import { Layout } from "@/constants";
|
||||
import { useNavbar } from "@/contexts/Navbar";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
|
@ -14,19 +13,19 @@ import {
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Anchor,
|
||||
AppShell,
|
||||
Badge,
|
||||
Collapse,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Navbar as MantineNavbar,
|
||||
Stack,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
import React, {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
|
@ -35,6 +34,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { matchPath, NavLink, RouteObject, useLocation } from "react-router-dom";
|
||||
import styles from "./Navbar.module.scss";
|
||||
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
|
@ -97,11 +97,12 @@ function useIsActive(parent: string, route: RouteObject) {
|
|||
}
|
||||
|
||||
const AppNavbar: FunctionComponent = () => {
|
||||
const { showed } = useNavbar();
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme("light");
|
||||
|
||||
const dark = computedColorScheme === "dark";
|
||||
|
||||
const routes = useRouteItems();
|
||||
|
||||
|
@ -111,23 +112,10 @@ const AppNavbar: FunctionComponent = () => {
|
|||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<MantineNavbar
|
||||
p="xs"
|
||||
hiddenBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||
hidden={!showed}
|
||||
width={{ [Layout.MOBILE_BREAKPOINT]: Layout.NAVBAR_WIDTH }}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.gray[2]
|
||||
: theme.colors.dark[6],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<AppShell.Navbar p="xs" className={styles.nav}>
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<MantineNavbar.Section grow>
|
||||
<Stack spacing={0}>
|
||||
<AppShell.Section grow>
|
||||
<Stack gap={0}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
|
@ -136,10 +124,10 @@ const AppNavbar: FunctionComponent = () => {
|
|||
></RouteItem>
|
||||
))}
|
||||
</Stack>
|
||||
</MantineNavbar.Section>
|
||||
</AppShell.Section>
|
||||
<Divider></Divider>
|
||||
<MantineNavbar.Section mt="xs">
|
||||
<Group spacing="xs">
|
||||
<AppShell.Section mt="xs">
|
||||
<Group gap="xs">
|
||||
<Action
|
||||
label="Change Theme"
|
||||
color={dark ? "yellow" : "indigo"}
|
||||
|
@ -159,9 +147,9 @@ const AppNavbar: FunctionComponent = () => {
|
|||
></Action>
|
||||
</Anchor>
|
||||
</Group>
|
||||
</MantineNavbar.Section>
|
||||
</AppShell.Section>
|
||||
</Selection.Provider>
|
||||
</MantineNavbar>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -186,7 +174,7 @@ const RouteItem: FunctionComponent<{
|
|||
|
||||
if (children !== undefined) {
|
||||
const elements = (
|
||||
<Stack spacing={0}>
|
||||
<Stack gap={0}>
|
||||
{children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
|
@ -199,7 +187,7 @@ const RouteItem: FunctionComponent<{
|
|||
|
||||
if (name) {
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
<Stack gap={0}>
|
||||
<NavbarItem
|
||||
primary
|
||||
name={name}
|
||||
|
@ -244,53 +232,6 @@ const RouteItem: FunctionComponent<{
|
|||
}
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
const borderColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[5] : theme.colors.dark[4];
|
||||
|
||||
const activeBorderColor =
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.brand[4]
|
||||
: theme.colors.brand[8];
|
||||
|
||||
const activeBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[1] : theme.colors.dark[8];
|
||||
|
||||
const hoverBackgroundColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
|
||||
|
||||
const textColor =
|
||||
theme.colorScheme === "light" ? theme.colors.gray[8] : theme.colors.gray[5];
|
||||
|
||||
return {
|
||||
text: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
color: textColor,
|
||||
},
|
||||
anchor: {
|
||||
textDecoration: "none",
|
||||
borderLeft: `2px solid ${borderColor}`,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: activeBackgroundColor,
|
||||
borderLeft: `2px solid ${activeBorderColor}`,
|
||||
boxShadow: theme.shadows.xs,
|
||||
},
|
||||
hover: {
|
||||
backgroundColor: hoverBackgroundColor,
|
||||
},
|
||||
icon: { width: "1.4rem", marginRight: theme.spacing.xs },
|
||||
badge: {
|
||||
marginLeft: "auto",
|
||||
textDecoration: "none",
|
||||
boxShadow: theme.shadows.xs,
|
||||
color: textColor,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface NavbarItemProps {
|
||||
name: string;
|
||||
link: string;
|
||||
|
@ -308,8 +249,6 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
|||
onClick,
|
||||
primary = false,
|
||||
}) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { show } = useNavbar();
|
||||
|
||||
const { ref, hovered } = useHover();
|
||||
|
@ -335,9 +274,9 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
|||
}}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
clsx(classes.anchor, {
|
||||
[classes.active]: isActive,
|
||||
[classes.hover]: hovered,
|
||||
clsx(styles.anchor, {
|
||||
[styles.active]: isActive,
|
||||
[styles.hover]: hovered,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
@ -347,18 +286,19 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
|
|||
inline
|
||||
p="xs"
|
||||
size="sm"
|
||||
weight={primary ? "bold" : "normal"}
|
||||
className={classes.text}
|
||||
fw={primary ? "bold" : "normal"}
|
||||
className={styles.text}
|
||||
span
|
||||
>
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
className={classes.icon}
|
||||
className={styles.icon}
|
||||
icon={icon}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
{name}
|
||||
{shouldHideBadge === false && (
|
||||
<Badge className={classes.badge} radius="xs">
|
||||
{!shouldHideBadge && (
|
||||
<Badge className={styles.badge} radius="xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
|
|
39
frontend/src/App/ThemeLoader.tsx
Normal file
39
frontend/src/App/ThemeLoader.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MantineColorScheme, useMantineColorScheme } from "@mantine/core";
|
||||
import { useSystemSettings } from "@/apis/hooks";
|
||||
|
||||
const ThemeProvider = () => {
|
||||
const [localScheme, setLocalScheme] = useState<MantineColorScheme | null>(
|
||||
null,
|
||||
);
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const settings = useSystemSettings();
|
||||
|
||||
const settingsColorScheme = settings.data?.general
|
||||
.theme as MantineColorScheme;
|
||||
|
||||
const setScheme = useCallback(
|
||||
(colorScheme: MantineColorScheme) => {
|
||||
setColorScheme(colorScheme);
|
||||
},
|
||||
[setColorScheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsColorScheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (localScheme === settingsColorScheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheme(settingsColorScheme);
|
||||
setLocalScheme(settingsColorScheme);
|
||||
}, [settingsColorScheme, setScheme, localScheme]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
61
frontend/src/App/ThemeProvider.tsx
Normal file
61
frontend/src/App/ThemeProvider.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Badge,
|
||||
Button,
|
||||
createTheme,
|
||||
MantineProvider,
|
||||
} from "@mantine/core";
|
||||
import { FunctionComponent, PropsWithChildren } from "react";
|
||||
import ThemeLoader from "@/App/ThemeLoader";
|
||||
import "@mantine/core/styles.layer.css";
|
||||
import "@mantine/notifications/styles.layer.css";
|
||||
import styleVars from "@/assets/_variables.module.scss";
|
||||
import buttonClasses from "@/assets/button.module.scss";
|
||||
import actionIconClasses from "@/assets/action_icon.module.scss";
|
||||
import appShellClasses from "@/assets/app_shell.module.scss";
|
||||
import badgeClasses from "@/assets/badge.module.scss";
|
||||
|
||||
const themeProvider = createTheme({
|
||||
fontFamily: "Roboto, open sans, Helvetica Neue, Helvetica, Arial, sans-serif",
|
||||
colors: {
|
||||
brand: [
|
||||
styleVars.colorBrand0,
|
||||
styleVars.colorBrand1,
|
||||
styleVars.colorBrand2,
|
||||
styleVars.colorBrand3,
|
||||
styleVars.colorBrand4,
|
||||
styleVars.colorBrand5,
|
||||
styleVars.colorBrand6,
|
||||
styleVars.colorBrand7,
|
||||
styleVars.colorBrand8,
|
||||
styleVars.colorBrand9,
|
||||
],
|
||||
},
|
||||
primaryColor: "brand",
|
||||
components: {
|
||||
ActionIcon: ActionIcon.extend({
|
||||
classNames: actionIconClasses,
|
||||
}),
|
||||
AppShell: AppShell.extend({
|
||||
classNames: appShellClasses,
|
||||
}),
|
||||
Badge: Badge.extend({
|
||||
classNames: badgeClasses,
|
||||
}),
|
||||
Button: Button.extend({
|
||||
classNames: buttonClasses,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const ThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<MantineProvider theme={themeProvider} defaultColorScheme="auto">
|
||||
<ThemeLoader />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
|
@ -1,7 +1,6 @@
|
|||
import AppNavbar from "@/App/Navbar";
|
||||
import { RouterNames } from "@/Router/RouterNames";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { Layout } from "@/constants";
|
||||
import NavbarProvider from "@/contexts/Navbar";
|
||||
import OnlineProvider from "@/contexts/Online";
|
||||
import { notification } from "@/modules/task";
|
||||
|
@ -13,6 +12,7 @@ import { showNotification } from "@mantine/notifications";
|
|||
import { FunctionComponent, useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import AppHeader from "./Header";
|
||||
import styleVars from "@/assets/_variables.module.scss";
|
||||
|
||||
const App: FunctionComponent = () => {
|
||||
const navigate = useNavigate();
|
||||
|
@ -55,13 +55,19 @@ const App: FunctionComponent = () => {
|
|||
<NavbarProvider value={{ showed: navbar, show: setNavbar }}>
|
||||
<OnlineProvider value={{ online, setOnline }}>
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint={Layout.MOBILE_BREAKPOINT}
|
||||
header={<AppHeader></AppHeader>}
|
||||
navbar={<AppNavbar></AppNavbar>}
|
||||
navbar={{
|
||||
width: styleVars.navBarWidth,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !navbar },
|
||||
}}
|
||||
header={{ height: { base: styleVars.headerHeight } }}
|
||||
padding={0}
|
||||
fixed
|
||||
>
|
||||
<Outlet></Outlet>
|
||||
<AppHeader></AppHeader>
|
||||
<AppNavbar></AppNavbar>
|
||||
<AppShell.Main>
|
||||
<Outlet></Outlet>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
</OnlineProvider>
|
||||
</NavbarProvider>
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
import { useSystemSettings } from "@/apis/hooks";
|
||||
import {
|
||||
ColorScheme,
|
||||
ColorSchemeProvider,
|
||||
createEmotionCache,
|
||||
MantineProvider,
|
||||
MantineThemeOverride,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import {
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const theme: MantineThemeOverride = {
|
||||
fontFamily: "Roboto, open sans, Helvetica Neue, Helvetica, Arial, sans-serif",
|
||||
colors: {
|
||||
brand: [
|
||||
"#F8F0FC",
|
||||
"#F3D9FA",
|
||||
"#EEBEFA",
|
||||
"#E599F7",
|
||||
"#DA77F2",
|
||||
"#CC5DE8",
|
||||
"#BE4BDB",
|
||||
"#AE3EC9",
|
||||
"#9C36B5",
|
||||
"#862E9C",
|
||||
],
|
||||
},
|
||||
primaryColor: "brand",
|
||||
};
|
||||
|
||||
function useAutoColorScheme() {
|
||||
const settings = useSystemSettings();
|
||||
const settingsColorScheme = settings.data?.general.theme;
|
||||
|
||||
let preferredColorScheme: ColorScheme = useColorScheme();
|
||||
switch (settingsColorScheme) {
|
||||
case "light":
|
||||
preferredColorScheme = "light" as ColorScheme;
|
||||
break;
|
||||
case "dark":
|
||||
preferredColorScheme = "dark" as ColorScheme;
|
||||
break;
|
||||
}
|
||||
|
||||
const [colorScheme, setColorScheme] = useState(preferredColorScheme);
|
||||
|
||||
// automatically switch dark/light theme
|
||||
useEffect(() => {
|
||||
setColorScheme(preferredColorScheme);
|
||||
}, [preferredColorScheme]);
|
||||
|
||||
const toggleColorScheme = useCallback((value?: ColorScheme) => {
|
||||
setColorScheme((scheme) => value || (scheme === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
return { colorScheme, setColorScheme, toggleColorScheme };
|
||||
}
|
||||
|
||||
const emotionCache = createEmotionCache({ key: "bazarr" });
|
||||
|
||||
const ThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
|
||||
|
||||
return (
|
||||
<ColorSchemeProvider
|
||||
colorScheme={colorScheme}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
>
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{ colorScheme, ...theme }}
|
||||
emotionCache={emotionCache}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
|
@ -53,7 +53,9 @@ import Redirector from "./Redirector";
|
|||
import { RouterNames } from "./RouterNames";
|
||||
import { CustomRouteObject } from "./type";
|
||||
|
||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||
const HistoryStats = lazy(
|
||||
() => import("@/pages/History/Statistics/HistoryStats"),
|
||||
);
|
||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||
|
||||
function useRoutes(): CustomRouteObject[] {
|
||||
|
|
40
frontend/src/assets/_bazarr.scss
Normal file
40
frontend/src/assets/_bazarr.scss
Normal file
|
@ -0,0 +1,40 @@
|
|||
$color-brand-0: #f8f0fc;
|
||||
$color-brand-1: #f3d9fa;
|
||||
$color-brand-2: #eebefa;
|
||||
$color-brand-3: #e599f7;
|
||||
$color-brand-4: #da77f2;
|
||||
$color-brand-5: #cc5de8;
|
||||
$color-brand-6: #be4bdb;
|
||||
$color-brand-7: #ae3ec9;
|
||||
$color-brand-8: #9c36b5;
|
||||
$color-brand-9: #862e9c;
|
||||
|
||||
$header-height: 64px;
|
||||
|
||||
:global {
|
||||
.table-long-break {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.table-primary {
|
||||
display: inline-block;
|
||||
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
|
||||
@include smaller-than($mantine-breakpoint-sm) {
|
||||
min-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table-no-wrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-select {
|
||||
display: inline-block;
|
||||
|
||||
@include smaller-than($mantine-breakpoint-sm) {
|
||||
min-width: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
61
frontend/src/assets/_mantine.scss
Normal file
61
frontend/src/assets/_mantine.scss
Normal file
|
@ -0,0 +1,61 @@
|
|||
@use "sass:math";
|
||||
|
||||
$mantine-breakpoint-xs: "36em";
|
||||
$mantine-breakpoint-sm: "48em";
|
||||
$mantine-breakpoint-md: "62em";
|
||||
$mantine-breakpoint-lg: "75em";
|
||||
$mantine-breakpoint-xl: "88em";
|
||||
|
||||
@function rem($value) {
|
||||
@return #{math.div(math.div($value, $value * 0 + 1), 16)}rem;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
[data-mantine-color-scheme="light"] & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
[data-mantine-color-scheme="dark"] & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
&:active {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin smaller-than($breakpoint) {
|
||||
@media (max-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin larger-than($breakpoint) {
|
||||
@media (min-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin rtl {
|
||||
[dir="rtl"] & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ltr {
|
||||
[dir="ltr"] & {
|
||||
@content;
|
||||
}
|
||||
}
|
18
frontend/src/assets/_variables.module.scss
Normal file
18
frontend/src/assets/_variables.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
$navbar-width: 200;
|
||||
|
||||
:export {
|
||||
colorBrand0: $color-brand-0;
|
||||
colorBrand1: $color-brand-1;
|
||||
colorBrand2: $color-brand-2;
|
||||
colorBrand3: $color-brand-3;
|
||||
colorBrand4: $color-brand-4;
|
||||
colorBrand5: $color-brand-5;
|
||||
colorBrand6: $color-brand-6;
|
||||
colorBrand7: $color-brand-7;
|
||||
colorBrand8: $color-brand-8;
|
||||
colorBrand9: $color-brand-9;
|
||||
|
||||
headerHeight: $header-height;
|
||||
|
||||
navBarWidth: $navbar-width;
|
||||
}
|
14
frontend/src/assets/action_icon.module.scss
Normal file
14
frontend/src/assets/action_icon.module.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
@layer mantine {
|
||||
.root {
|
||||
&[data-variant="light"] {
|
||||
color: var(--mantine-color-dark-0);
|
||||
}
|
||||
|
||||
@include light {
|
||||
&[data-variant="light"] {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
color: var(--mantine-color-dark-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
5
frontend/src/assets/app_shell.module.scss
Normal file
5
frontend/src/assets/app_shell.module.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
.main {
|
||||
@include dark {
|
||||
background-color: rgb(26, 27, 30);
|
||||
}
|
||||
}
|
8
frontend/src/assets/badge.module.scss
Normal file
8
frontend/src/assets/badge.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
.root {
|
||||
background-color: var(--mantine-color-grape-light);
|
||||
|
||||
@include light {
|
||||
color: var(--mantine-color-dark-filled);
|
||||
background-color: var(--mantine-color-grape-light);
|
||||
}
|
||||
}
|
12
frontend/src/assets/button.module.scss
Normal file
12
frontend/src/assets/button.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
@layer mantine {
|
||||
.root {
|
||||
@include dark {
|
||||
color: var(--mantine-color-dark-0);
|
||||
}
|
||||
|
||||
&[data-variant="danger"] {
|
||||
background-color: var(--mantine-color-red-9);
|
||||
color: var(--mantine-color-red-0);
|
||||
}
|
||||
}
|
||||
}
|
9
frontend/src/components/Search.module.scss
Normal file
9
frontend/src/components/Search.module.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.result {
|
||||
@include light {
|
||||
color: var(--mantine-color-dark-8);
|
||||
}
|
||||
|
||||
@include dark {
|
||||
color: var(--mantine-color-gray-1);
|
||||
}
|
||||
}
|
|
@ -5,11 +5,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import {
|
||||
Anchor,
|
||||
Autocomplete,
|
||||
createStyles,
|
||||
SelectItemProps,
|
||||
ComboboxItem,
|
||||
OptionsFilter,
|
||||
} from "@mantine/core";
|
||||
import { forwardRef, FunctionComponent, useMemo, useState } from "react";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styles from "./Search.module.scss";
|
||||
|
||||
type SearchResultItem = {
|
||||
value: string;
|
||||
|
@ -41,36 +42,35 @@ function useSearch(query: string) {
|
|||
);
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
return {
|
||||
result: {
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.dark[8]
|
||||
: theme.colors.gray[1],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type ResultCompProps = SelectItemProps & SearchResultItem;
|
||||
|
||||
const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>(
|
||||
({ link, value }, ref) => {
|
||||
const styles = useStyles();
|
||||
const optionsFilter: OptionsFilter = ({ options, search }) => {
|
||||
const lowercaseSearch = search.toLowerCase();
|
||||
const trimmedSearch = search.trim();
|
||||
|
||||
return (options as ComboboxItem[]).filter((option) => {
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={link}
|
||||
underline={false}
|
||||
className={styles.classes.result}
|
||||
p="sm"
|
||||
>
|
||||
{value}
|
||||
</Anchor>
|
||||
option.value.toLowerCase().includes(lowercaseSearch) ||
|
||||
option.value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.includes(trimmedSearch)
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const ResultComponent = ({ name, link }: { name: string; link: string }) => {
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={link}
|
||||
underline="never"
|
||||
className={styles.result}
|
||||
p="sm"
|
||||
>
|
||||
{name}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
|
||||
const Search: FunctionComponent = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
@ -79,22 +79,22 @@ const Search: FunctionComponent = () => {
|
|||
|
||||
return (
|
||||
<Autocomplete
|
||||
icon={<FontAwesomeIcon icon={faSearch} />}
|
||||
itemComponent={ResultComponent}
|
||||
leftSection={<FontAwesomeIcon icon={faSearch} />}
|
||||
renderOption={(input) => (
|
||||
<ResultComponent
|
||||
name={input.option.value}
|
||||
link={
|
||||
results.find((a) => a.value === input.option.value)?.link || "/"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
placeholder="Search"
|
||||
size="sm"
|
||||
data={results}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onBlur={() => setQuery("")}
|
||||
filter={(value, item) =>
|
||||
item.value.toLowerCase().includes(value.toLowerCase().trim()) ||
|
||||
item.value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.includes(value.trim())
|
||||
}
|
||||
filter={optionsFilter}
|
||||
></Autocomplete>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -31,7 +31,7 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
|
|||
return <FontAwesomeIcon icon={faListCheck} />;
|
||||
} else {
|
||||
return (
|
||||
<Text color={hasIssues ? "yellow" : "green"}>
|
||||
<Text c={hasIssues ? "yellow" : "green"} span>
|
||||
<FontAwesomeIcon
|
||||
icon={hasIssues ? faExclamationCircle : faCheckCircle}
|
||||
/>
|
||||
|
@ -48,9 +48,9 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
|
|||
</Text>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group position="left" spacing="xl" noWrap grow>
|
||||
<Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
|
||||
<Text color="green">
|
||||
<Group justify="left" gap="xl" wrap="nowrap" grow>
|
||||
<Stack align="flex-start" justify="flex-start" gap="xs" mb="auto">
|
||||
<Text c="green">
|
||||
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
|
||||
</Text>
|
||||
<List>
|
||||
|
@ -59,8 +59,8 @@ const StateIcon: FunctionComponent<StateIconProps> = ({
|
|||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
<Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
|
||||
<Text color="yellow">
|
||||
<Stack align="flex-start" justify="flex-start" gap="xs" mb="auto">
|
||||
<Text c="yellow">
|
||||
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
|
||||
</Text>
|
||||
<List>
|
||||
|
|
|
@ -148,7 +148,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
|
|||
<Menu.Item
|
||||
key={tool.key}
|
||||
disabled={disabledTools}
|
||||
icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
|
||||
leftSection={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
|
||||
onClick={() => {
|
||||
if (tool.modal) {
|
||||
modals.openContextModal(tool.modal, { selections });
|
||||
|
@ -164,7 +164,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
|
|||
<Menu.Label>Actions</Menu.Label>
|
||||
<Menu.Item
|
||||
disabled={selections.length !== 0 || onAction === undefined}
|
||||
icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
|
||||
leftSection={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
|
||||
onClick={() => {
|
||||
onAction?.("search");
|
||||
}}
|
||||
|
@ -174,7 +174,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
|
|||
<Menu.Item
|
||||
disabled={selections.length === 0 || onAction === undefined}
|
||||
color="red"
|
||||
icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
|
||||
leftSection={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: "The following subtitles will be deleted",
|
||||
|
|
|
@ -13,7 +13,7 @@ const AudioList: FunctionComponent<AudioListProps> = ({
|
|||
...group
|
||||
}) => {
|
||||
return (
|
||||
<Group spacing="xs" {...group}>
|
||||
<Group gap="xs" {...group}>
|
||||
{audios.map((audio, idx) => (
|
||||
<Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}>
|
||||
{audio.name}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, screen } from "@/tests";
|
||||
import { render, screen } from "@/tests";
|
||||
import { describe, it } from "vitest";
|
||||
import { Language } from ".";
|
||||
|
||||
|
@ -9,13 +9,13 @@ describe("Language text", () => {
|
|||
};
|
||||
|
||||
it("should show short text", () => {
|
||||
rawRender(<Language.Text value={testLanguage}></Language.Text>);
|
||||
render(<Language.Text value={testLanguage}></Language.Text>);
|
||||
|
||||
expect(screen.getByText(testLanguage.code2)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should show long text", () => {
|
||||
rawRender(<Language.Text value={testLanguage} long></Language.Text>);
|
||||
render(<Language.Text value={testLanguage} long></Language.Text>);
|
||||
|
||||
expect(screen.getByText(testLanguage.name)).toBeDefined();
|
||||
});
|
||||
|
@ -23,7 +23,7 @@ describe("Language text", () => {
|
|||
const testLanguageWithHi: Language.Info = { ...testLanguage, hi: true };
|
||||
|
||||
it("should show short text with HI", () => {
|
||||
rawRender(<Language.Text value={testLanguageWithHi}></Language.Text>);
|
||||
render(<Language.Text value={testLanguageWithHi}></Language.Text>);
|
||||
|
||||
const expectedText = `${testLanguageWithHi.code2}:HI`;
|
||||
|
||||
|
@ -31,7 +31,7 @@ describe("Language text", () => {
|
|||
});
|
||||
|
||||
it("should show long text with HI", () => {
|
||||
rawRender(<Language.Text value={testLanguageWithHi} long></Language.Text>);
|
||||
render(<Language.Text value={testLanguageWithHi} long></Language.Text>);
|
||||
|
||||
const expectedText = `${testLanguageWithHi.name} HI`;
|
||||
|
||||
|
@ -44,7 +44,7 @@ describe("Language text", () => {
|
|||
};
|
||||
|
||||
it("should show short text with Forced", () => {
|
||||
rawRender(<Language.Text value={testLanguageWithForced}></Language.Text>);
|
||||
render(<Language.Text value={testLanguageWithForced}></Language.Text>);
|
||||
|
||||
const expectedText = `${testLanguageWithHi.code2}:Forced`;
|
||||
|
||||
|
@ -52,9 +52,7 @@ describe("Language text", () => {
|
|||
});
|
||||
|
||||
it("should show long text with Forced", () => {
|
||||
rawRender(
|
||||
<Language.Text value={testLanguageWithForced} long></Language.Text>,
|
||||
);
|
||||
render(<Language.Text value={testLanguageWithForced} long></Language.Text>);
|
||||
|
||||
const expectedText = `${testLanguageWithHi.name} Forced`;
|
||||
|
||||
|
@ -75,7 +73,7 @@ describe("Language list", () => {
|
|||
];
|
||||
|
||||
it("should show all languages", () => {
|
||||
rawRender(<Language.List value={elements}></Language.List>);
|
||||
render(<Language.List value={elements}></Language.List>);
|
||||
|
||||
elements.forEach((value) => {
|
||||
expect(screen.getByText(value.name)).toBeDefined();
|
||||
|
|
|
@ -49,7 +49,7 @@ type LanguageListProps = {
|
|||
|
||||
const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => {
|
||||
return (
|
||||
<Group spacing="xs">
|
||||
<Group gap="xs">
|
||||
{value.map((v) => (
|
||||
<Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge>
|
||||
))}
|
||||
|
|
|
@ -55,15 +55,17 @@ const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
|||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Group spacing="xs" grow>
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
placeholder="From"
|
||||
precision={2}
|
||||
decimalScale={2}
|
||||
fixedDecimalScale
|
||||
{...form.getInputProps("from")}
|
||||
></NumberInput>
|
||||
<NumberInput
|
||||
placeholder="To"
|
||||
precision={2}
|
||||
decimalScale={2}
|
||||
fixedDecimalScale
|
||||
{...form.getInputProps("to")}
|
||||
></NumberInput>
|
||||
</Group>
|
||||
|
|
|
@ -80,7 +80,7 @@ const ItemEditForm: FunctionComponent<Props> = ({
|
|||
label="Languages Profile"
|
||||
></Selector>
|
||||
<Divider></Divider>
|
||||
<Group position="right">
|
||||
<Group justify="right">
|
||||
<Button
|
||||
disabled={isOverlayVisible}
|
||||
onClick={() => {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useMovieSubtitleModification } from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { TaskGroup, task } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import FormUtils from "@/utilities/form";
|
||||
import {
|
||||
|
@ -19,7 +18,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
Divider,
|
||||
MantineColor,
|
||||
Stack,
|
||||
|
@ -79,21 +77,12 @@ interface Props {
|
|||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
return {
|
||||
wrapper: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const MovieUploadForm: FunctionComponent<Props> = ({
|
||||
files,
|
||||
movie,
|
||||
onComplete,
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const profile = useLanguageProfileBy(movie.profileId);
|
||||
|
||||
|
@ -187,7 +176,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
|
|||
|
||||
return (
|
||||
<TextPopover text={value?.messages}>
|
||||
<Text color={color} inline>
|
||||
<Text c={color} inline>
|
||||
<FontAwesomeIcon icon={icon}></FontAwesomeIcon>
|
||||
</Text>
|
||||
</TextPopover>
|
||||
|
@ -199,9 +188,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
|
|||
id: "filename",
|
||||
accessor: "file",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
return <Text className={classes.primary}>{value.name}</Text>;
|
||||
return <Text className="table-primary">{value.name}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -236,11 +223,10 @@ const MovieUploadForm: FunctionComponent<Props> = ({
|
|||
Header: "Language",
|
||||
accessor: "language",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
className="table-long-break"
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(index, { ...original, language: item });
|
||||
|
@ -289,7 +275,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
|
|||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack className={classes.wrapper}>
|
||||
<Stack className="table-long-break">
|
||||
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Upload</Button>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.content {
|
||||
@include smaller-than($mantine-breakpoint-md) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { Action, Selector, SelectorOption, SimpleTable } from "@/components";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import FormUtils from "@/utilities/form";
|
||||
|
@ -19,6 +18,7 @@ import { useForm } from "@mantine/form";
|
|||
import { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Column } from "react-table";
|
||||
import ChipInput from "../inputs/ChipInput";
|
||||
import styles from "./ProfileEditForm.module.scss";
|
||||
|
||||
export const anyCutoff = 65535;
|
||||
|
||||
|
@ -166,12 +166,10 @@ const ProfileEditForm: FunctionComponent<Props> = ({
|
|||
[code],
|
||||
);
|
||||
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
className="table-select"
|
||||
value={language}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
|
@ -262,13 +260,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({
|
|||
multiple
|
||||
chevronPosition="right"
|
||||
defaultValue={["Languages"]}
|
||||
styles={(theme) => ({
|
||||
content: {
|
||||
[theme.fn.smallerThan("md")]: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
})}
|
||||
className={styles.content}
|
||||
>
|
||||
<Accordion.Item value="Languages">
|
||||
<Stack>
|
||||
|
@ -277,7 +269,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({
|
|||
columns={columns}
|
||||
data={form.values.items}
|
||||
></SimpleTable>
|
||||
<Button fullWidth color="light" onClick={addItem}>
|
||||
<Button fullWidth onClick={addItem}>
|
||||
Add Language
|
||||
</Button>
|
||||
<Selector
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
} from "@/apis/hooks";
|
||||
import { useModals, withModal } from "@/modules/modals";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { useArrayAction, useSelectorOptions } from "@/utilities";
|
||||
import FormUtils from "@/utilities/form";
|
||||
import {
|
||||
|
@ -23,7 +22,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
Divider,
|
||||
MantineColor,
|
||||
Stack,
|
||||
|
@ -86,21 +84,12 @@ interface Props {
|
|||
onComplete?: VoidFunction;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
return {
|
||||
wrapper: {
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SeriesUploadForm: FunctionComponent<Props> = ({
|
||||
series,
|
||||
files,
|
||||
onComplete,
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const { classes } = useStyles();
|
||||
const episodes = useEpisodesBySeriesId(series.sonarrSeriesId);
|
||||
const episodeOptions = useSelectorOptions(
|
||||
episodes.data ?? [],
|
||||
|
@ -225,8 +214,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
|
|||
id: "filename",
|
||||
accessor: "file",
|
||||
Cell: ({ value: { name } }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.primary}>{name}</Text>;
|
||||
return <Text className="table-primary">{name}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -283,11 +271,10 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
|
|||
),
|
||||
accessor: "language",
|
||||
Cell: ({ row: { original, index }, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...languageOptions}
|
||||
className={classes.select}
|
||||
className="table-select"
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(index, { ...original, language: item });
|
||||
|
@ -301,12 +288,11 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
|
|||
Header: "Episode",
|
||||
accessor: "episode",
|
||||
Cell: ({ value, row }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Selector
|
||||
{...episodeOptions}
|
||||
searchable
|
||||
className={classes.select}
|
||||
className="table-select"
|
||||
value={value}
|
||||
onChange={(item) => {
|
||||
action.mutate(row.index, { ...row.original, episode: item });
|
||||
|
@ -368,7 +354,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
|
|||
modals.closeSelf();
|
||||
})}
|
||||
>
|
||||
<Stack className={classes.wrapper}>
|
||||
<Stack className="table-long-break">
|
||||
<SimpleTable columns={columns} data={form.values.files}></SimpleTable>
|
||||
<Divider></Divider>
|
||||
<Button type="submit">Upload</Button>
|
||||
|
|
|
@ -14,10 +14,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Selector, SelectorOption } from "../inputs";
|
||||
import { GroupedSelector, Selector } from "../inputs";
|
||||
|
||||
const TaskName = "Syncing Subtitle";
|
||||
|
||||
interface SelectOptions {
|
||||
group: string;
|
||||
items: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
function useReferencedSubtitles(
|
||||
mediaType: "episode" | "movie",
|
||||
mediaId: number,
|
||||
|
@ -37,15 +42,21 @@ function useReferencedSubtitles(
|
|||
|
||||
const mediaData = mediaType === "episode" ? episodeData : movieData;
|
||||
|
||||
const subtitles: { group: string; value: string; label: string }[] = [];
|
||||
const subtitles: SelectOptions[] = [];
|
||||
|
||||
if (!mediaData.data) {
|
||||
return [];
|
||||
} else {
|
||||
if (mediaData.data.audio_tracks.length > 0) {
|
||||
const embeddedAudioGroup: SelectOptions = {
|
||||
group: "Embedded audio tracks",
|
||||
items: [],
|
||||
};
|
||||
|
||||
subtitles.push(embeddedAudioGroup);
|
||||
|
||||
mediaData.data.audio_tracks.forEach((item) => {
|
||||
subtitles.push({
|
||||
group: "Embedded audio tracks",
|
||||
embeddedAudioGroup.items.push({
|
||||
value: item.stream,
|
||||
label: `${item.name || item.language} (${item.stream})`,
|
||||
});
|
||||
|
@ -53,9 +64,15 @@ function useReferencedSubtitles(
|
|||
}
|
||||
|
||||
if (mediaData.data.embedded_subtitles_tracks.length > 0) {
|
||||
const embeddedSubtitlesTrackGroup: SelectOptions = {
|
||||
group: "Embedded subtitles tracks",
|
||||
items: [],
|
||||
};
|
||||
|
||||
subtitles.push(embeddedSubtitlesTrackGroup);
|
||||
|
||||
mediaData.data.embedded_subtitles_tracks.forEach((item) => {
|
||||
subtitles.push({
|
||||
group: "Embedded subtitles tracks",
|
||||
embeddedSubtitlesTrackGroup.items.push({
|
||||
value: item.stream,
|
||||
label: `${item.name || item.language} (${item.stream})`,
|
||||
});
|
||||
|
@ -63,10 +80,16 @@ function useReferencedSubtitles(
|
|||
}
|
||||
|
||||
if (mediaData.data.external_subtitles_tracks.length > 0) {
|
||||
const externalSubtitlesFilesGroup: SelectOptions = {
|
||||
group: "External Subtitles files",
|
||||
items: [],
|
||||
};
|
||||
|
||||
subtitles.push(externalSubtitlesFilesGroup);
|
||||
|
||||
mediaData.data.external_subtitles_tracks.forEach((item) => {
|
||||
if (item) {
|
||||
subtitles.push({
|
||||
group: "External Subtitles files",
|
||||
externalSubtitlesFilesGroup.items.push({
|
||||
value: item.path,
|
||||
label: item.name,
|
||||
});
|
||||
|
@ -105,7 +128,7 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({
|
|||
const mediaId = selections[0].id;
|
||||
const subtitlesPath = selections[0].path;
|
||||
|
||||
const subtitles: SelectorOption<string>[] = useReferencedSubtitles(
|
||||
const subtitles: SelectOptions[] = useReferencedSubtitles(
|
||||
mediaType,
|
||||
mediaId,
|
||||
subtitlesPath,
|
||||
|
@ -145,14 +168,14 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({
|
|||
>
|
||||
<Text size="sm">{selections.length} subtitles selected</Text>
|
||||
</Alert>
|
||||
<Selector
|
||||
<GroupedSelector
|
||||
clearable
|
||||
disabled={subtitles.length === 0 || selections.length !== 1}
|
||||
label="Reference"
|
||||
placeholder="Default: choose automatically within video file"
|
||||
options={subtitles}
|
||||
{...form.getInputProps("reference")}
|
||||
></Selector>
|
||||
></GroupedSelector>
|
||||
<Selector
|
||||
clearable
|
||||
label="Max Offset Seconds"
|
||||
|
|
|
@ -70,7 +70,7 @@ const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
|||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Group align="end" spacing="xs" noWrap>
|
||||
<Group align="end" gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
color="gray"
|
||||
variant="filled"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export { default as Search } from "./Search";
|
||||
export * from "./inputs";
|
||||
export * from "./tables";
|
||||
export { default as Toolbox } from "./toolbox";
|
||||
export { default as Toolbox } from "./toolbox/Toolbox";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, screen } from "@/tests";
|
||||
import { render, screen } from "@/tests";
|
||||
import { faStickyNote } from "@fortawesome/free-regular-svg-icons";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vitest } from "vitest";
|
||||
|
@ -9,7 +9,7 @@ const testIcon = faStickyNote;
|
|||
|
||||
describe("Action button", () => {
|
||||
it("should be a button", () => {
|
||||
rawRender(<Action icon={testIcon} label={testLabel}></Action>);
|
||||
render(<Action icon={testIcon} label={testLabel}></Action>);
|
||||
const element = screen.getByRole("button", { name: testLabel });
|
||||
|
||||
expect(element.getAttribute("type")).toEqual("button");
|
||||
|
@ -17,7 +17,7 @@ describe("Action button", () => {
|
|||
});
|
||||
|
||||
it("should show icon", () => {
|
||||
rawRender(<Action icon={testIcon} label={testLabel}></Action>);
|
||||
render(<Action icon={testIcon} label={testLabel}></Action>);
|
||||
// TODO: use getBy...
|
||||
const element = screen.getByRole("img", { hidden: true });
|
||||
|
||||
|
@ -27,7 +27,7 @@ describe("Action button", () => {
|
|||
|
||||
it("should call on-click event when clicked", async () => {
|
||||
const onClickFn = vitest.fn();
|
||||
rawRender(
|
||||
render(
|
||||
<Action icon={testIcon} label={testLabel} onClick={onClickFn}></Action>,
|
||||
);
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, screen } from "@/tests";
|
||||
import { render, screen } from "@/tests";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vitest } from "vitest";
|
||||
import ChipInput from "./ChipInput";
|
||||
|
@ -8,7 +8,7 @@ describe("ChipInput", () => {
|
|||
|
||||
// TODO: Support default value
|
||||
it.skip("should works with default value", () => {
|
||||
rawRender(<ChipInput defaultValue={existedValues}></ChipInput>);
|
||||
render(<ChipInput defaultValue={existedValues}></ChipInput>);
|
||||
|
||||
existedValues.forEach((value) => {
|
||||
expect(screen.getByText(value)).toBeDefined();
|
||||
|
@ -16,7 +16,7 @@ describe("ChipInput", () => {
|
|||
});
|
||||
|
||||
it("should works with value", () => {
|
||||
rawRender(<ChipInput value={existedValues}></ChipInput>);
|
||||
render(<ChipInput value={existedValues}></ChipInput>);
|
||||
|
||||
existedValues.forEach((value) => {
|
||||
expect(screen.getByText(value)).toBeDefined();
|
||||
|
@ -29,9 +29,7 @@ describe("ChipInput", () => {
|
|||
expect(values).toContain(typedValue);
|
||||
});
|
||||
|
||||
rawRender(
|
||||
<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>,
|
||||
);
|
||||
render(<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>);
|
||||
|
||||
const element = screen.getByRole("searchbox");
|
||||
|
||||
|
|
|
@ -1,35 +1,29 @@
|
|||
import { useSelectorOptions } from "@/utilities";
|
||||
import { FunctionComponent } from "react";
|
||||
import { MultiSelector, MultiSelectorProps } from "./Selector";
|
||||
import { TagsInput } from "@mantine/core";
|
||||
|
||||
export type ChipInputProps = Omit<
|
||||
MultiSelectorProps<string>,
|
||||
| "searchable"
|
||||
| "creatable"
|
||||
| "getCreateLabel"
|
||||
| "onCreate"
|
||||
| "options"
|
||||
| "getkey"
|
||||
>;
|
||||
|
||||
const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const options = useSelectorOptions(value ?? [], (v) => v);
|
||||
export interface ChipInputProps {
|
||||
defaultValue?: string[] | undefined;
|
||||
value?: readonly string[] | null;
|
||||
label?: string;
|
||||
onChange?: (value: string[]) => void;
|
||||
}
|
||||
|
||||
const ChipInput: FunctionComponent<ChipInputProps> = ({
|
||||
defaultValue,
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
}: ChipInputProps) => {
|
||||
// TODO: Replace with our own custom implementation instead of just using the
|
||||
// built-in TagsInput. https://mantine.dev/combobox/?e=MultiSelectCreatable
|
||||
return (
|
||||
<MultiSelector
|
||||
{...props}
|
||||
{...options}
|
||||
creatable
|
||||
searchable
|
||||
getCreateLabel={(query) => `Add "${query}"`}
|
||||
onCreate={(query) => {
|
||||
onChange?.([...(value ?? []), query]);
|
||||
return query;
|
||||
}}
|
||||
buildOption={(value) => value}
|
||||
></MultiSelector>
|
||||
<TagsInput
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
value={value ? value?.map((v) => v) : []}
|
||||
onChange={onChange}
|
||||
clearable
|
||||
></TagsInput>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
4
frontend/src/components/inputs/DropContent.module.scss
Normal file
4
frontend/src/components/inputs/DropContent.module.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.container {
|
||||
pointer-events: none;
|
||||
min-height: 220px;
|
||||
}
|
|
@ -4,24 +4,14 @@ import {
|
|||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Group, Stack, Text, createStyles } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
const useStyle = createStyles((theme) => {
|
||||
return {
|
||||
container: {
|
||||
pointerEvents: "none",
|
||||
minHeight: 220,
|
||||
},
|
||||
};
|
||||
});
|
||||
import styles from "./DropContent.module.scss";
|
||||
|
||||
export const DropContent: FunctionComponent = () => {
|
||||
const { classes } = useStyle();
|
||||
|
||||
return (
|
||||
<Group position="center" spacing="xl" className={classes.container}>
|
||||
<Group justify="center" gap="xl" className={styles.container}>
|
||||
<Dropzone.Idle>
|
||||
<FontAwesomeIcon icon={faFileCirclePlus} size="2x" />
|
||||
</Dropzone.Idle>
|
||||
|
@ -31,9 +21,9 @@ export const DropContent: FunctionComponent = () => {
|
|||
<Dropzone.Reject>
|
||||
<FontAwesomeIcon icon={faXmark} size="2x" />
|
||||
</Dropzone.Reject>
|
||||
<Stack spacing={0}>
|
||||
<Stack gap={0}>
|
||||
<Text size="lg">Upload Subtitles</Text>
|
||||
<Text color="dimmed" size="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
Attach as many files as you like, you will need to select file
|
||||
metadata before uploading
|
||||
</Text>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { useFileSystem } from "@/apis/hooks";
|
||||
import { faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Autocomplete, AutocompleteProps } from "@mantine/core";
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteProps,
|
||||
ComboboxItem,
|
||||
OptionsFilter,
|
||||
} from "@mantine/core";
|
||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// TODO: use fortawesome icons
|
||||
|
@ -75,24 +80,28 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
|
|||
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const optionsFilter: OptionsFilter = ({ options, search }) => {
|
||||
return (options as ComboboxItem[]).filter((option) => {
|
||||
if (search === backKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return option.value.includes(search);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
{...props}
|
||||
ref={ref}
|
||||
icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
|
||||
leftSection={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>}
|
||||
placeholder="Click to start"
|
||||
data={data}
|
||||
value={value}
|
||||
// Temporary solution of infinite dropdown items, fix later
|
||||
limit={NaN}
|
||||
maxDropdownHeight={240}
|
||||
filter={(value, item) => {
|
||||
if (item.value === backKey) {
|
||||
return true;
|
||||
} else {
|
||||
return item.value.includes(value);
|
||||
}
|
||||
}}
|
||||
filter={optionsFilter}
|
||||
onChange={(val) => {
|
||||
if (val !== backKey) {
|
||||
setValue(val);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, screen } from "@/tests";
|
||||
import { render, screen } from "@/tests";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vitest } from "vitest";
|
||||
import { Selector, SelectorOption } from "./Selector";
|
||||
|
@ -18,20 +18,17 @@ const testOptions: SelectorOption<string>[] = [
|
|||
describe("Selector", () => {
|
||||
describe("options", () => {
|
||||
it("should work with the SelectorOption", () => {
|
||||
rawRender(
|
||||
<Selector name={selectorName} options={testOptions}></Selector>,
|
||||
);
|
||||
render(<Selector name={selectorName} options={testOptions}></Selector>);
|
||||
|
||||
// TODO: selectorName
|
||||
expect(screen.getByRole("searchbox")).toBeDefined();
|
||||
testOptions.forEach((o) => {
|
||||
expect(screen.getByText(o.label)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display when clicked", async () => {
|
||||
rawRender(
|
||||
<Selector name={selectorName} options={testOptions}></Selector>,
|
||||
);
|
||||
render(<Selector name={selectorName} options={testOptions}></Selector>);
|
||||
|
||||
const element = screen.getByRole("searchbox");
|
||||
const element = screen.getByTestId("input-selector");
|
||||
|
||||
await userEvent.click(element);
|
||||
|
||||
|
@ -44,7 +41,7 @@ describe("Selector", () => {
|
|||
|
||||
it("shouldn't show default value", async () => {
|
||||
const option = testOptions[0];
|
||||
rawRender(
|
||||
render(
|
||||
<Selector
|
||||
name={selectorName}
|
||||
options={testOptions}
|
||||
|
@ -57,7 +54,7 @@ describe("Selector", () => {
|
|||
|
||||
it("shouldn't show value", async () => {
|
||||
const option = testOptions[0];
|
||||
rawRender(
|
||||
render(
|
||||
<Selector
|
||||
name={selectorName}
|
||||
options={testOptions}
|
||||
|
@ -75,7 +72,7 @@ describe("Selector", () => {
|
|||
const mockedFn = vitest.fn((value: string | null) => {
|
||||
expect(value).toEqual(clickedOption.value);
|
||||
});
|
||||
rawRender(
|
||||
render(
|
||||
<Selector
|
||||
name={selectorName}
|
||||
options={testOptions}
|
||||
|
@ -83,13 +80,13 @@ describe("Selector", () => {
|
|||
></Selector>,
|
||||
);
|
||||
|
||||
const element = screen.getByRole("searchbox");
|
||||
const element = screen.getByTestId("input-selector");
|
||||
|
||||
await userEvent.click(element);
|
||||
|
||||
await userEvent.click(screen.getByText(clickedOption.label));
|
||||
|
||||
expect(mockedFn).toBeCalled();
|
||||
expect(mockedFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -115,7 +112,7 @@ describe("Selector", () => {
|
|||
const mockedFn = vitest.fn((value: { name: string } | null) => {
|
||||
expect(value).toEqual(clickedOption.value);
|
||||
});
|
||||
rawRender(
|
||||
render(
|
||||
<Selector
|
||||
name={selectorName}
|
||||
options={objectOptions}
|
||||
|
@ -124,20 +121,20 @@ describe("Selector", () => {
|
|||
></Selector>,
|
||||
);
|
||||
|
||||
const element = screen.getByRole("searchbox");
|
||||
const element = screen.getByTestId("input-selector");
|
||||
|
||||
await userEvent.click(element);
|
||||
|
||||
await userEvent.click(screen.getByText(clickedOption.label));
|
||||
|
||||
expect(mockedFn).toBeCalled();
|
||||
expect(mockedFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeholder", () => {
|
||||
it("should show when no selection", () => {
|
||||
const placeholder = "Empty Selection";
|
||||
rawRender(
|
||||
render(
|
||||
<Selector
|
||||
name={selectorName}
|
||||
options={testOptions}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { LOG } from "@/utilities/console";
|
||||
import {
|
||||
ComboboxItem,
|
||||
ComboboxParsedItemGroup,
|
||||
MultiSelect,
|
||||
MultiSelectProps,
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectProps,
|
||||
} from "@mantine/core";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
|
@ -14,10 +15,10 @@ export type SelectorOption<T> = Override<
|
|||
value: T;
|
||||
label: string;
|
||||
},
|
||||
SelectItem
|
||||
ComboboxItem
|
||||
>;
|
||||
|
||||
type SelectItemWithPayload<T> = SelectItem & {
|
||||
type SelectItemWithPayload<T> = ComboboxItem & {
|
||||
payload: T;
|
||||
};
|
||||
|
||||
|
@ -34,6 +35,30 @@ function DefaultKeyBuilder<T>(value: T) {
|
|||
}
|
||||
}
|
||||
|
||||
export type GroupedSelectorProps<T> = Override<
|
||||
{
|
||||
options: ComboboxParsedItemGroup[];
|
||||
getkey?: (value: T) => string;
|
||||
},
|
||||
Omit<SelectProps, "data">
|
||||
>;
|
||||
|
||||
export function GroupedSelector<T>({
|
||||
value,
|
||||
options,
|
||||
getkey = DefaultKeyBuilder,
|
||||
...select
|
||||
}: GroupedSelectorProps<T>) {
|
||||
return (
|
||||
<Select
|
||||
data-testid="input-selector"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={options}
|
||||
{...select}
|
||||
></Select>
|
||||
);
|
||||
}
|
||||
|
||||
export type SelectorProps<T> = Override<
|
||||
{
|
||||
value?: T | null;
|
||||
|
@ -84,7 +109,7 @@ export function Selector<T>({
|
|||
}, [defaultValue, keyRef]);
|
||||
|
||||
const wrappedOnChange = useCallback(
|
||||
(value: string) => {
|
||||
(value: string | null) => {
|
||||
const payload = data.find((v) => v.value === value)?.payload ?? null;
|
||||
onChange?.(payload);
|
||||
},
|
||||
|
@ -93,7 +118,8 @@ export function Selector<T>({
|
|||
|
||||
return (
|
||||
<Select
|
||||
withinPortal={true}
|
||||
data-testid="input-selector"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={data}
|
||||
defaultValue={wrappedDefaultValue}
|
||||
value={wrappedValue}
|
||||
|
@ -144,6 +170,7 @@ export function MultiSelector<T>({
|
|||
() => value && value.map(labelRef.current),
|
||||
[value],
|
||||
);
|
||||
|
||||
const wrappedDefaultValue = useMemo(
|
||||
() => defaultValue && defaultValue.map(labelRef.current),
|
||||
[defaultValue],
|
||||
|
@ -168,6 +195,7 @@ export function MultiSelector<T>({
|
|||
return (
|
||||
<MultiSelect
|
||||
{...select}
|
||||
hidePickedOptions
|
||||
value={wrappedValue}
|
||||
defaultValue={wrappedDefaultValue}
|
||||
onChange={wrappedOnChange}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { withModal } from "@/modules/modals";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { GetItemId } from "@/utilities";
|
||||
import {
|
||||
faCaretDown,
|
||||
|
@ -31,9 +30,7 @@ type SupportType = Item.Movie | Item.Episode;
|
|||
|
||||
interface Props<T extends SupportType> {
|
||||
download: (item: T, result: SearchResultType) => Promise<void>;
|
||||
query: (
|
||||
id?: number,
|
||||
) => UseQueryResult<SearchResultType[] | undefined, unknown>;
|
||||
query: (id?: number) => UseQueryResult<SearchResultType[] | undefined>;
|
||||
item: T;
|
||||
}
|
||||
|
||||
|
@ -50,7 +47,8 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
|
||||
const search = useCallback(() => {
|
||||
setSearchStarted(true);
|
||||
results.refetch();
|
||||
|
||||
void results.refetch();
|
||||
}, [results]);
|
||||
|
||||
const columns = useMemo<Column<SearchResultType>[]>(
|
||||
|
@ -59,8 +57,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Header: "Score",
|
||||
accessor: "score",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value}%</Text>;
|
||||
return <Text className="table-no-wrap">{value}%</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -84,13 +81,12 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Header: "Provider",
|
||||
accessor: "provider",
|
||||
Cell: (row) => {
|
||||
const { classes } = useTableStyles();
|
||||
const value = row.value;
|
||||
const { url } = row.row.original;
|
||||
if (url) {
|
||||
return (
|
||||
<Anchor
|
||||
className={classes.noWrap}
|
||||
className="table-no-wrap"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
@ -107,7 +103,6 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Header: "Release",
|
||||
accessor: "release_info",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const items = useMemo(
|
||||
|
@ -116,12 +111,12 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
);
|
||||
|
||||
if (value.length === 0) {
|
||||
return <Text color="dimmed">Cannot get release info</Text>;
|
||||
return <Text c="dimmed">Cannot get release info</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={0} onClick={() => setOpen((o) => !o)}>
|
||||
<Text className={classes.primary}>
|
||||
<Stack gap={0} onClick={() => setOpen((o) => !o)}>
|
||||
<Text className="table-primary" span>
|
||||
{value[0]}
|
||||
{value.length > 1 && (
|
||||
<FontAwesomeIcon
|
||||
|
@ -141,8 +136,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
|||
Header: "Uploader",
|
||||
accessor: "uploader",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value ?? "-"}</Text>;
|
||||
return <Text className="table-no-wrap">{value ?? "-"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
9
frontend/src/components/tables/BaseTable.module.scss
Normal file
9
frontend/src/components/tables/BaseTable.module.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.container {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import { useIsLoading } from "@/contexts";
|
||||
import { usePageSize } from "@/utilities/storage";
|
||||
import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core";
|
||||
import { Box, Skeleton, Table, Text } from "@mantine/core";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { HeaderGroup, Row, TableInstance } from "react-table";
|
||||
import styles from "./BaseTable.module.scss";
|
||||
|
||||
export type BaseTableProps<T extends object> = TableInstance<T> & {
|
||||
tableStyles?: TableStyleProps<T>;
|
||||
|
@ -18,37 +19,23 @@ export interface TableStyleProps<T extends object> {
|
|||
rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
},
|
||||
table: {
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
header: {},
|
||||
};
|
||||
});
|
||||
|
||||
function DefaultHeaderRenderer<T extends object>(
|
||||
headers: HeaderGroup<T>[],
|
||||
): JSX.Element[] {
|
||||
return headers.map((col) => (
|
||||
<th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}>
|
||||
<Table.Th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}>
|
||||
{col.render("Header")}
|
||||
</th>
|
||||
</Table.Th>
|
||||
));
|
||||
}
|
||||
|
||||
function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null {
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<Table.Tr {...row.getRowProps()}>
|
||||
{row.cells.map((cell) => (
|
||||
<td {...cell.getCellProps()}>{cell.render("Cell")}</td>
|
||||
<Table.Td {...cell.getCellProps()}>{cell.render("Cell")}</Table.Td>
|
||||
))}
|
||||
</tr>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -66,8 +53,6 @@ export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
|
|||
const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer;
|
||||
const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer;
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
const colCount = useMemo(() => {
|
||||
return headerGroups.reduce(
|
||||
(prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev),
|
||||
|
@ -88,19 +73,19 @@ export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
|
|||
body = Array(tableStyles?.placeholder ?? pageSize)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={colCount}>
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td colSpan={colCount}>
|
||||
<Skeleton height={24}></Skeleton>
|
||||
</td>
|
||||
</tr>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
} else if (empty && tableStyles?.emptyText) {
|
||||
body = (
|
||||
<tr>
|
||||
<td colSpan={colCount}>
|
||||
<Text align="center">{tableStyles.emptyText}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colCount}>
|
||||
<Text ta="center">{tableStyles.emptyText}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
} else {
|
||||
body = rows.map((row) => {
|
||||
|
@ -110,20 +95,20 @@ export default function BaseTable<T extends object>(props: BaseTableProps<T>) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.container}>
|
||||
<Box className={styles.container}>
|
||||
<Table
|
||||
className={classes.table}
|
||||
className={styles.table}
|
||||
striped={tableStyles?.striped ?? true}
|
||||
{...getTableProps()}
|
||||
>
|
||||
<thead className={classes.header} hidden={tableStyles?.hideHeader}>
|
||||
<Table.Thead hidden={tableStyles?.hideHeader}>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
<Table.Tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headersRenderer(headerGroup.headers)}
|
||||
</tr>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>{body}</tbody>
|
||||
</Table.Thead>
|
||||
<Table.Tbody {...getTableBodyProps()}>{body}</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Box, Text, Table } from "@mantine/core";
|
||||
import {
|
||||
Cell,
|
||||
HeaderGroup,
|
||||
|
@ -29,8 +29,8 @@ function renderRow<T extends object>(row: Row<T>) {
|
|||
if (cell) {
|
||||
const rotation = row.isExpanded ? 90 : undefined;
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td {...cell.getCellProps()} colSpan={row.cells.length}>
|
||||
<Table.Tr {...row.getRowProps()}>
|
||||
<Table.Td {...cell.getCellProps()} colSpan={row.cells.length}>
|
||||
<Text {...row.getToggleRowExpandedProps()} p={2}>
|
||||
{cell.render("Cell")}
|
||||
<Box component="span" mx={12}>
|
||||
|
@ -40,21 +40,23 @@ function renderRow<T extends object>(row: Row<T>) {
|
|||
></FontAwesomeIcon>
|
||||
</Box>
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<Table.Tr {...row.getRowProps()}>
|
||||
{row.cells
|
||||
.filter((cell) => !cell.isPlaceholder)
|
||||
.map((cell) => (
|
||||
<td {...cell.getCellProps()}>{renderCell(cell, row)}</td>
|
||||
<Table.Td {...cell.getCellProps()}>
|
||||
{renderCell(cell, row)}
|
||||
</Table.Td>
|
||||
))}
|
||||
</tr>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -64,7 +66,9 @@ function renderHeaders<T extends object>(
|
|||
): JSX.Element[] {
|
||||
return headers
|
||||
.filter((col) => !col.isGrouped)
|
||||
.map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>);
|
||||
.map((col) => (
|
||||
<Table.Th {...col.getHeaderProps()}>{col.render("Header")}</Table.Th>
|
||||
));
|
||||
}
|
||||
|
||||
type Props<T extends object> = Omit<
|
||||
|
|
|
@ -28,7 +28,7 @@ const PageControl: FunctionComponent<Props> = ({
|
|||
}, [total, goto]);
|
||||
|
||||
return (
|
||||
<Group p={16} position="apart">
|
||||
<Group p={16} justify="apart">
|
||||
<Text size="sm">
|
||||
Show {start} to {end} of {total} entries
|
||||
</Text>
|
||||
|
|
|
@ -24,7 +24,7 @@ const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
|
|||
<Button
|
||||
color="dark"
|
||||
variant="subtle"
|
||||
leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
|
||||
leftSection={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>}
|
||||
{...props}
|
||||
>
|
||||
<Text size="xs">{children}</Text>
|
||||
|
|
9
frontend/src/components/toolbox/Toolbox.module.scss
Normal file
9
frontend/src/components/toolbox/Toolbox.module.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.group {
|
||||
@include light {
|
||||
color: var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
@include dark {
|
||||
color: var(--mantine-color-dark-5);
|
||||
}
|
||||
}
|
|
@ -1,15 +1,7 @@
|
|||
import { createStyles, Group } from "@mantine/core";
|
||||
import { Group } from "@mantine/core";
|
||||
import { FunctionComponent, PropsWithChildren } from "react";
|
||||
import ToolboxButton, { ToolboxMutateButton } from "./Button";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
group: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.gray[3]
|
||||
: theme.colors.dark[5],
|
||||
},
|
||||
}));
|
||||
import styles from "./Toolbox.module.scss";
|
||||
|
||||
declare type ToolboxComp = FunctionComponent<PropsWithChildren> & {
|
||||
Button: typeof ToolboxButton;
|
||||
|
@ -17,9 +9,8 @@ declare type ToolboxComp = FunctionComponent<PropsWithChildren> & {
|
|||
};
|
||||
|
||||
const Toolbox: ToolboxComp = ({ children }) => {
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<Group p={12} position="apart" className={classes.group}>
|
||||
<Group p={12} justify="apart" className={styles.group}>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
|
@ -1,9 +1 @@
|
|||
import { MantineNumberSize } from "@mantine/core";
|
||||
|
||||
export const GithubRepoRoot = "https://github.com/morpheus65535/bazarr";
|
||||
|
||||
export const Layout = {
|
||||
NAVBAR_WIDTH: 200,
|
||||
HEADER_HEIGHT: 64,
|
||||
MOBILE_BREAKPOINT: "sm" as MantineNumberSize,
|
||||
};
|
||||
|
|
|
@ -27,7 +27,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
|
|||
update: (msg) => {
|
||||
msg
|
||||
.map((message) => notification.info("Notification", message))
|
||||
.forEach(showNotification);
|
||||
.forEach((data) => showNotification(data));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -133,7 +133,7 @@ class TaskDispatcher {
|
|||
|
||||
public removeProgress(ids: string[]) {
|
||||
setTimeout(
|
||||
() => ids.forEach(hideNotification),
|
||||
() => ids.forEach((id) => hideNotification(id)),
|
||||
notification.PROGRESS_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { NotificationProps } from "@mantine/notifications";
|
||||
import { NotificationData } from "@mantine/notifications";
|
||||
|
||||
export const notification = {
|
||||
info: (title: string, message: string): NotificationProps => {
|
||||
info: (title: string, message: string): NotificationData => {
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
|
@ -9,7 +9,7 @@ export const notification = {
|
|||
};
|
||||
},
|
||||
|
||||
warn: (title: string, message: string): NotificationProps => {
|
||||
warn: (title: string, message: string): NotificationData => {
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
|
@ -18,7 +18,7 @@ export const notification = {
|
|||
};
|
||||
},
|
||||
|
||||
error: (title: string, message: string): NotificationProps => {
|
||||
error: (title: string, message: string): NotificationData => {
|
||||
return {
|
||||
title,
|
||||
message,
|
||||
|
@ -33,7 +33,7 @@ export const notification = {
|
|||
pending: (
|
||||
id: string,
|
||||
header: string,
|
||||
): NotificationProps & { id: string } => {
|
||||
): NotificationData & { id: string } => {
|
||||
return {
|
||||
id,
|
||||
title: header,
|
||||
|
@ -48,7 +48,7 @@ export const notification = {
|
|||
body: string,
|
||||
current: number,
|
||||
total: number,
|
||||
): NotificationProps & { id: string } => {
|
||||
): NotificationData & { id: string } => {
|
||||
return {
|
||||
id,
|
||||
title: header,
|
||||
|
@ -57,7 +57,7 @@ export const notification = {
|
|||
autoClose: false,
|
||||
};
|
||||
},
|
||||
end: (id: string, header: string): NotificationProps & { id: string } => {
|
||||
end: (id: string, header: string): NotificationData & { id: string } => {
|
||||
return {
|
||||
id,
|
||||
title: header,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -3,7 +3,6 @@ import { PageTable } from "@/components";
|
|||
import MutateAction from "@/components/async/MutateAction";
|
||||
import Language from "@/components/bazarr/Language";
|
||||
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";
|
||||
|
@ -22,9 +21,8 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
|
|||
accessor: "title",
|
||||
Cell: (row) => {
|
||||
const target = `/movies/${row.row.original.radarrId}`;
|
||||
const { classes } = useTableStyles();
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{row.value}
|
||||
</Anchor>
|
||||
);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { PageTable } from "@/components";
|
|||
import MutateAction from "@/components/async/MutateAction";
|
||||
import Language from "@/components/bazarr/Language";
|
||||
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";
|
||||
|
@ -21,10 +20,9 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
|
|||
Header: "Series",
|
||||
accessor: "seriesTitle",
|
||||
Cell: (row) => {
|
||||
const { classes } = useTableStyles();
|
||||
const target = `/series/${row.row.original.sonarrSeriesId}`;
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{row.value}
|
||||
</Anchor>
|
||||
);
|
||||
|
|
|
@ -125,7 +125,7 @@ const SeriesEpisodesView: FunctionComponent = () => {
|
|||
<DropContent></DropContent>
|
||||
</Dropzone.FullScreen>
|
||||
<Toolbox>
|
||||
<Group spacing="xs">
|
||||
<Group gap="xs">
|
||||
<Toolbox.Button
|
||||
icon={faSync}
|
||||
disabled={!available || hasTask}
|
||||
|
@ -160,7 +160,7 @@ const SeriesEpisodesView: FunctionComponent = () => {
|
|||
Search
|
||||
</Toolbox.Button>
|
||||
</Group>
|
||||
<Group spacing="xs">
|
||||
<Group gap="xs">
|
||||
<Toolbox.Button
|
||||
disabled={
|
||||
series === undefined ||
|
||||
|
|
|
@ -6,7 +6,6 @@ import { AudioList } from "@/components/bazarr";
|
|||
import { EpisodeHistoryModal } from "@/components/modals";
|
||||
import { EpisodeSearchModal } from "@/components/modals/ManualSearchModal";
|
||||
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";
|
||||
|
@ -92,7 +91,7 @@ const Table: FunctionComponent<Props> = ({
|
|||
{
|
||||
accessor: "season",
|
||||
Cell: (row) => {
|
||||
return <Text>Season {row.value}</Text>;
|
||||
return <Text span>Season {row.value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -103,11 +102,9 @@ const Table: FunctionComponent<Props> = ({
|
|||
Header: "Title",
|
||||
accessor: "title",
|
||||
Cell: ({ value, row }) => {
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
return (
|
||||
<TextPopover text={row.original.sceneName}>
|
||||
<Text className={classes.primary}>{value}</Text>
|
||||
<Text className="table-primary">{value}</Text>
|
||||
</TextPopover>
|
||||
);
|
||||
},
|
||||
|
@ -156,7 +153,7 @@ const Table: FunctionComponent<Props> = ({
|
|||
}, [episode, seriesId]);
|
||||
|
||||
return (
|
||||
<Group spacing="xs" noWrap>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{elements}
|
||||
</Group>
|
||||
);
|
||||
|
@ -168,7 +165,7 @@ const Table: FunctionComponent<Props> = ({
|
|||
Cell: ({ row }) => {
|
||||
const modals = useModals();
|
||||
return (
|
||||
<Group spacing="xs" noWrap>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Action
|
||||
label="Manual Search"
|
||||
disabled={disabled}
|
||||
|
|
|
@ -6,7 +6,6 @@ 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,
|
||||
|
@ -29,10 +28,9 @@ const MoviesHistoryView: FunctionComponent = () => {
|
|||
Header: "Name",
|
||||
accessor: "title",
|
||||
Cell: ({ row, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
const target = `/movies/${row.original.radarrId}`;
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{value}
|
||||
</Anchor>
|
||||
);
|
||||
|
|
|
@ -9,7 +9,6 @@ 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,
|
||||
|
@ -32,11 +31,10 @@ const SeriesHistoryView: FunctionComponent = () => {
|
|||
Header: "Series",
|
||||
accessor: "seriesTitle",
|
||||
Cell: (row) => {
|
||||
const { classes } = useTableStyles();
|
||||
const target = `/series/${row.row.original.sonarrSeriesId}`;
|
||||
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{row.value}
|
||||
</Anchor>
|
||||
);
|
||||
|
@ -50,8 +48,7 @@ const SeriesHistoryView: FunctionComponent = () => {
|
|||
Header: "Title",
|
||||
accessor: "episodeTitle",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value}</Text>;
|
||||
return <Text className="table-no-wrap">{value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - $header-height);
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 90%;
|
||||
}
|
|
@ -5,16 +5,8 @@ import {
|
|||
} 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 { Box, Container, SimpleGrid, useMantineTheme } from "@mantine/core";
|
||||
import { useDocumentTitle } from "@mantine/hooks";
|
||||
import { merge } from "lodash";
|
||||
import { FunctionComponent, useMemo, useState } from "react";
|
||||
|
@ -29,17 +21,7 @@ import {
|
|||
YAxis,
|
||||
} from "recharts";
|
||||
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>
|
|
@ -1,7 +1,7 @@
|
|||
import { renderTest, RenderTestCase } from "@/tests/render";
|
||||
import MoviesHistoryView from "./Movies";
|
||||
import SeriesHistoryView from "./Series";
|
||||
import HistoryStats from "./Statistics";
|
||||
import HistoryStats from "./Statistics/HistoryStats";
|
||||
|
||||
const cases: RenderTestCase[] = [
|
||||
{
|
||||
|
|
|
@ -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}
|
||||
|
@ -205,7 +205,7 @@ const MovieDetailView: FunctionComponent = () => {
|
|||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
icon={<FontAwesomeIcon icon={faToolbox} />}
|
||||
leftSection={<FontAwesomeIcon icon={faToolbox} />}
|
||||
onClick={() => {
|
||||
if (movie) {
|
||||
modals.openContextModal(SubtitleToolsModal, {
|
||||
|
@ -217,7 +217,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 });
|
||||
|
|
|
@ -4,7 +4,6 @@ import { Action, SimpleTable } from "@/components";
|
|||
import Language from "@/components/bazarr/Language";
|
||||
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
|
||||
import { task, TaskGroup } from "@/modules/task";
|
||||
import { useTableStyles } from "@/styles";
|
||||
import { filterSubtitleBy } from "@/utilities";
|
||||
import { useProfileItemsToLanguages } from "@/utilities/languages";
|
||||
import { faEllipsis, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
|
@ -40,17 +39,17 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
|
|||
Header: "Subtitle Path",
|
||||
accessor: "path",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
|
||||
const props: TextProps = {
|
||||
className: classes.primary,
|
||||
className: "table-primary",
|
||||
};
|
||||
|
||||
if (isSubtitleTrack(value)) {
|
||||
return <Text {...props}>Video File Subtitle Track</Text>;
|
||||
return (
|
||||
<Text className="table-primary">Video File Subtitle Track</Text>
|
||||
);
|
||||
} else if (isSubtitleMissing(value)) {
|
||||
return (
|
||||
<Text {...props} color="dimmed">
|
||||
<Text {...props} c="dimmed">
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
|
|
|
@ -6,7 +6,6 @@ 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";
|
||||
|
@ -35,10 +34,9 @@ const MovieView: FunctionComponent = () => {
|
|||
Header: "Name",
|
||||
accessor: "title",
|
||||
Cell: ({ row, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
const target = `/movies/${row.original.radarrId}`;
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{value}
|
||||
</Anchor>
|
||||
);
|
||||
|
|
|
@ -4,7 +4,6 @@ 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";
|
||||
|
@ -34,10 +33,9 @@ const SeriesView: FunctionComponent = () => {
|
|||
Header: "Name",
|
||||
accessor: "title",
|
||||
Cell: ({ row, value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
const target = `/series/${row.original.sonarrSeriesId}`;
|
||||
return (
|
||||
<Anchor className={classes.primary} component={Link} to={target}>
|
||||
<Anchor className="table-primary" component={Link} to={target}>
|
||||
{value}
|
||||
</Anchor>
|
||||
);
|
||||
|
@ -70,13 +68,14 @@ 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
faClipboard,
|
||||
faSync,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Group as MantineGroup, Text as MantineText } from "@mantine/core";
|
||||
import { Box, Group as MantineGroup, Text as MantineText } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { FunctionComponent, useState } from "react";
|
||||
import {
|
||||
|
@ -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,7 +87,7 @@ 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 && (
|
||||
|
@ -204,13 +204,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>
|
||||
|
|
|
@ -355,7 +355,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>
|
||||
</>
|
||||
|
|
|
@ -70,7 +70,7 @@ const Table: FunctionComponent = () => {
|
|||
const items = row.value;
|
||||
const cutoff = row.row.original.cutoff;
|
||||
return (
|
||||
<Group spacing="xs" noWrap>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{items.map((v) => {
|
||||
const isCutoff = v.id === cutoff || cutoff === anyCutoff;
|
||||
return (
|
||||
|
@ -128,7 +128,7 @@ const Table: FunctionComponent = () => {
|
|||
Cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<Group spacing="xs" noWrap>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Action
|
||||
label="Edit Profile"
|
||||
icon={faWrench}
|
||||
|
@ -163,7 +163,6 @@ const Table: FunctionComponent = () => {
|
|||
<Button
|
||||
fullWidth
|
||||
disabled={!canAdd}
|
||||
color="light"
|
||||
onClick={() => {
|
||||
const profile = {
|
||||
profileId: nextProfileId,
|
||||
|
|
|
@ -90,7 +90,7 @@ const NotificationForm: FunctionComponent<Props> = ({
|
|||
></Textarea>
|
||||
</div>
|
||||
<Divider></Divider>
|
||||
<Group position="right">
|
||||
<Group justify="right">
|
||||
<MutateButton mutation={test} args={() => form.values.url}>
|
||||
Test
|
||||
</MutateButton>
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
Text as MantineText,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
AutocompleteProps,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
FunctionComponent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
|
@ -50,6 +50,11 @@ interface ProviderViewProps {
|
|||
settingsKey: SettingsKey;
|
||||
}
|
||||
|
||||
interface ProviderSelect {
|
||||
value: string;
|
||||
payload: ProviderInfo;
|
||||
}
|
||||
|
||||
export const ProviderView: FunctionComponent<ProviderViewProps> = ({
|
||||
availableOptions,
|
||||
settingsKey,
|
||||
|
@ -130,17 +135,16 @@ interface ProviderToolProps {
|
|||
settingsKey: Readonly<SettingsKey>;
|
||||
}
|
||||
|
||||
const SelectItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ payload: ProviderInfo; label: string }
|
||||
>(({ payload: { description }, label, ...other }, ref) => {
|
||||
const SelectItem: AutocompleteProps["renderOption"] = ({ option }) => {
|
||||
const provider = option as ProviderSelect;
|
||||
|
||||
return (
|
||||
<Stack spacing={1} ref={ref} {...other}>
|
||||
<MantineText size="md">{label}</MantineText>
|
||||
<MantineText size="xs">{description}</MantineText>
|
||||
<Stack gap={1}>
|
||||
<MantineText size="md">{provider.value}</MantineText>
|
||||
<MantineText size="xs">{provider.payload.description}</MantineText>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const ProviderTool: FunctionComponent<ProviderToolProps> = ({
|
||||
payload,
|
||||
|
@ -298,19 +302,19 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
return <Stack spacing="xs">{elements}</Stack>;
|
||||
return <Stack gap="xs">{elements}</Stack>;
|
||||
}, [info]);
|
||||
|
||||
return (
|
||||
<SettingsProvider value={settings}>
|
||||
<FormContext.Provider value={form}>
|
||||
<Stack>
|
||||
<Stack spacing="xs">
|
||||
<Stack gap="xs">
|
||||
<Selector
|
||||
data-autofocus
|
||||
searchable
|
||||
placeholder="Click to Select a Provider"
|
||||
itemComponent={SelectItem}
|
||||
renderOption={SelectItem}
|
||||
disabled={payload !== null}
|
||||
{...selectorOptions}
|
||||
value={info}
|
||||
|
@ -323,7 +327,7 @@ const ProviderTool: FunctionComponent<ProviderToolProps> = ({
|
|||
</div>
|
||||
</Stack>
|
||||
<Divider></Divider>
|
||||
<Group position="right">
|
||||
<Group justify="right">
|
||||
<Button hidden={!payload} color="red" onClick={deletePayload}>
|
||||
Delete
|
||||
</Button>
|
||||
|
|
|
@ -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) ?? "",
|
||||
|
|
|
@ -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) ?? "",
|
||||
|
|
|
@ -501,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>
|
||||
|
|
9
frontend/src/pages/Settings/components/Card.module.scss
Normal file
9
frontend/src/pages/Settings/components/Card.module.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,30 +1,8 @@
|
|||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Center,
|
||||
createStyles,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Center, 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>
|
||||
)}
|
||||
|
|
|
@ -73,7 +73,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>
|
||||
|
|
|
@ -74,7 +74,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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, screen } from "@/tests";
|
||||
import { render, screen } from "@/tests";
|
||||
import { Text } from "@mantine/core";
|
||||
import { describe, it } from "vitest";
|
||||
import { Section } from "./Section";
|
||||
|
@ -6,7 +6,7 @@ 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>,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { rawRender, RenderOptions, screen } from "@/tests";
|
||||
import { render, RenderOptions, screen } from "@/tests";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { FunctionComponent, PropsWithChildren, ReactElement } from "react";
|
||||
import { describe, it } from "vitest";
|
||||
|
@ -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", () => {
|
||||
|
|
|
@ -38,6 +38,11 @@ export const Number: FunctionComponent<NumberProps> = (props) => {
|
|||
if (val === "") {
|
||||
val = 0;
|
||||
}
|
||||
|
||||
if (typeof val === "string") {
|
||||
return update(+val);
|
||||
}
|
||||
|
||||
update(val);
|
||||
}}
|
||||
></NumberInput>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -141,7 +141,7 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => {
|
|||
columns={columns}
|
||||
data={data}
|
||||
></SimpleTable>
|
||||
<Button fullWidth color="light" onClick={addRow}>
|
||||
<Button fullWidth onClick={addRow}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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";
|
||||
|
@ -20,16 +19,14 @@ const Table: FunctionComponent<Props> = ({ announcements }) => {
|
|||
Header: "Since",
|
||||
accessor: "timestamp",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.primary}>{value}</Text>;
|
||||
return <Text className="table-primary">{value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Announcement",
|
||||
accessor: "text",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.primary}>{value}</Text>;
|
||||
return <Text className="table-primary">{value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useDeleteBackups, useRestoreBackups } from "@/apis/hooks";
|
||||
import { Action, PageTable } from "@/components";
|
||||
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";
|
||||
|
@ -32,16 +31,14 @@ const Table: FunctionComponent<Props> = ({ backups }) => {
|
|||
Header: "Size",
|
||||
accessor: "size",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value}</Text>;
|
||||
return <Text className="table-no-wrap">{value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Time",
|
||||
accessor: "date",
|
||||
Cell: ({ value }) => {
|
||||
const { classes } = useTableStyles();
|
||||
return <Text className={classes.noWrap}>{value}</Text>;
|
||||
return <Text className="table-no-wrap">{value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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}
|
||||
|
@ -108,7 +108,7 @@ const SystemLogsView: FunctionComponent = () => {
|
|||
loading={isLoading}
|
||||
icon={faFilter}
|
||||
onClick={openFilterModal}
|
||||
rightIcon={
|
||||
rightSection={
|
||||
suffix() !== "" ? (
|
||||
<Badge size="xs" radius="sm">
|
||||
{suffix()}
|
||||
|
|
|
@ -23,7 +23,7 @@ const SystemReleasesView: FunctionComponent = () => {
|
|||
return (
|
||||
<Container size={600} 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"}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue