1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-17 21:26:13 -04:00

Compare commits

...

38 Commits

Author SHA1 Message Date
Weblate
e6bd58453a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translation: Servarr/Sonarr
2024-09-15 10:26:43 -07:00
Sonarr
9603f0b086 Automated API Docs update
ignore-downstream
2024-09-15 10:23:52 -07:00
Mark McDowall
d84c450094 New: Add exception to SSL Certificate validation message
Closes #7198
2024-09-15 10:23:29 -07:00
Mark McDowall
97ebaf2796 New: Use instance name in forms authentication cookie name
Closes #7199
2024-09-15 10:23:22 -07:00
Stevie Robinson
31bf9e313e New: Add rating as option in sort dropdown on series overviews and posters views 2024-09-15 13:23:12 -04:00
Stevie Robinson
6cccacd4d7 Add workflow to close issue when labelled as support 2024-09-15 13:22:28 -04:00
Mark McDowall
3c857135c5 Gotify notification updates
New: Option to include links for Gotify notifications
New: Include images and links for Android
Closes #7190
2024-09-15 10:21:26 -07:00
Mark McDowall
750a9353f8 New: Add additional archive exentions
Closes #7191
2024-09-15 10:21:16 -07:00
Mark McDowall
71a19377d9 New: Add Bluray 576p quality
Closes #6203
2024-09-15 13:21:01 -04:00
Mark McDowall
4b5ff3927d New: Check for available space before grabbing
Closes #7177
2024-09-15 13:20:42 -04:00
Mark McDowall
4d8a443681 Fixed: Replace illegal characters even when renaming is disabled
Closes #7183
2024-09-15 10:20:19 -07:00
Bogdan
6a332b40ac Fixed: Refresh tags after updating autotags 2024-09-15 10:20:19 -07:00
Bogdan
a929548ae3 Fixed: Linking autotags with tag specification to all tags 2024-09-15 10:20:19 -07:00
Mark McDowall
55363f4e3d Fixed: Don't parse language from series title for v2 releases
Closes #7182
2024-09-15 10:20:19 -07:00
Mark McDowall
f20ac9dc34 Fixed: Series links not opening on iOS 2024-09-15 10:20:13 -07:00
somniumV
8b20a9449c New: Minimum Upgrade Score for Custom Formats
Closes #6800
2024-09-15 13:20:03 -04:00
Robert Dailey
24f03fc1e9 Add 'qualitydefinition/limits' endpoint to get size limitations 2024-09-15 13:19:08 -04:00
Weblate
5513d7bc5d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: genoher <genoher@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-09-15 10:17:38 -07:00
Mark McDowall
a9072ac460 Convert Progress Bars to TypeScript 2024-09-03 20:19:47 -07:00
Mark McDowall
55aaaa5c40 New: Add MDBList link to series details
Closes #7162
2024-09-03 23:19:36 -04:00
Mark McDowall
ee99c3895d Convert series images to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
e1e10e195c Convert NoSeries to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
0b9a212f33 Fixed: Links tooltip closing too quickly 2024-09-03 20:19:12 -07:00
Mark McDowall
0e384ee3aa New: Include seasons and episodes in Trakt import lists
Closes #7137
2024-09-03 20:18:59 -07:00
Sonarr
d903529389 Automated API Docs update
ignore-downstream
2024-09-02 13:27:43 -07:00
Mark McDowall
6f51e72d00 Fixed: Respect Quality cutoff if Custom Format cutoff isn't met
Closes #7132
2024-09-02 13:27:21 -07:00
Bogdan
66cead6b48 Cleanup History Details and a typo 2024-09-02 13:27:00 -07:00
Mark McDowall
7f0696c574 Fixed: Failing to import any file for series if one has bad encoding
Closes #7157
2024-09-02 13:26:50 -07:00
Mark McDowall
1584311914 New: Except language option for Language Custom Formats
Closes #7120
2024-09-02 13:26:35 -07:00
amdavie
278c7891a3 New: Scene and Nuked IndexerFlags for Newznab indexers
Closes #6932
2024-09-02 13:25:53 -07:00
Bogdan
0a0e03dca0 Convert Interactive Search to TypeScript 2024-09-02 13:25:05 -07:00
ManiMatter
546e9fd1d0 New: Last Searched column on Wanted screens 2024-09-02 13:24:55 -07:00
Weblate
c80bd81bb9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Nota Inutilis <hugo@notainutilis.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-09-02 13:24:04 -07:00
Mark McDowall
e1cbc4a782 Convert Components to TypeScript 2024-08-30 20:26:38 -07:00
Bogdan
53d8c9ba8d Fixed: Importing files without media info available 2024-08-30 20:26:22 -07:00
Bogdan
9136ee4ad9 Fixed: Forbid empty spaces in Release Profile restrictions 2024-08-30 23:25:32 -04:00
Bogdan
44fab9a96c Fixed: Generating absolute episode file paths in webhook events
Closes #7149
2024-08-30 23:24:08 -04:00
Weblate
66e4b7c819 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: 极染 <poledye@icloud.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-08-30 20:21:48 -07:00
198 changed files with 4654 additions and 3592 deletions

29
.github/workflows/support-requests.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'Support Requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: write
jobs:
action:
runs-on: ubuntu-latest
if: github.repository == 'Sonarr/Sonarr'
steps:
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use one of the support channels:
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
for support/questions.
close-issue: true
issue-close-reason: 'not planned'
lock-issue: false
issue-lock-reason: 'off-topic'

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ src/.idea/
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View File

@@ -27,8 +27,6 @@ interface HistoryDetailsProps {
sourceTitle: string;
data: HistoryData;
downloadId?: string;
shortDateFormat: string;
timeFormat: string;
}
function HistoryDetails(props: HistoryDetailsProps) {

View File

@@ -38,8 +38,6 @@ interface HistoryDetailsModalProps {
data: HistoryData;
downloadId?: string;
isMarkingAsFailed: boolean;
shortDateFormat: string;
timeFormat: string;
onMarkAsFailedPress: () => void;
onModalClose: () => void;
}
@@ -52,8 +50,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
data,
downloadId,
isMarkingAsFailed = false,
shortDateFormat,
timeFormat,
onMarkAsFailedPress,
onModalClose,
} = props;
@@ -69,8 +65,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
</ModalBody>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -20,7 +20,6 @@ import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@@ -72,10 +71,6 @@ function HistoryRow(props: HistoryRowProps) {
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const { shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
@@ -260,8 +255,6 @@ function HistoryRow(props: HistoryRowProps) {
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,

View File

@@ -1,5 +1,5 @@
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {

View File

@@ -6,7 +6,9 @@ import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
@@ -69,7 +71,9 @@ interface AppState {
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
parse: ParseAppState;
paths: PathsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;
seriesIndex: SeriesIndexAppState;

View File

@@ -0,0 +1,29 @@
interface BasePath {
name: string;
path: string;
size: number;
lastModified: string;
}
interface File extends BasePath {
type: 'file';
}
interface Folder extends BasePath {
type: 'folder';
}
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
export type Path = File | Folder;
interface PathsAppState {
currentPath: string;
isFetching: boolean;
isPopulated: boolean;
error: Error;
directories: Folder[];
files: File[];
parent: string | null;
}
export default PathsAppState;

View File

@@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Release from 'typings/Release';
interface ReleasesAppState
extends AppSectionState<Release>,
AppSectionFilterState<Release> {}
export default ReleasesAppState;

View File

@@ -3,7 +3,7 @@ import AppSectionState, {
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import { Filter, FilterBuilderProp } from './AppState';

View File

@@ -1,34 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Alert.css';
function Alert(props) {
const { className, kind, children, ...otherProps } = props;
return (
<div
className={classNames(
className,
styles[kind]
)}
{...otherProps}
>
{children}
</div>
);
}
Alert.propTypes = {
className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired
};
Alert.defaultProps = {
className: styles.alert,
kind: kinds.INFO
};
export default Alert;

View File

@@ -0,0 +1,18 @@
import classNames from 'classnames';
import React from 'react';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Alert.css';
interface AlertProps {
className?: string;
kind?: Extract<Kind, keyof typeof styles>;
children: React.ReactNode;
}
function Alert(props: AlertProps) {
const { className = styles.alert, kind = 'info', children } = props;
return <div className={classNames(className, styles[kind])}>{children}</div>;
}
export default Alert;

View File

@@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './Card.css';
class Card extends Component {
//
// Render
render() {
const {
className,
overlayClassName,
overlayContent,
children,
onPress
} = this.props;
if (overlayContent) {
return (
<div className={className}>
<Link
className={styles.underlay}
onPress={onPress}
/>
<div className={overlayClassName}>
{children}
</div>
</div>
);
}
return (
<Link
className={className}
onPress={onPress}
>
{children}
</Link>
);
}
}
Card.propTypes = {
className: PropTypes.string.isRequired,
overlayClassName: PropTypes.string.isRequired,
overlayContent: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onPress: PropTypes.func.isRequired
};
Card.defaultProps = {
className: styles.card,
overlayClassName: styles.overlay,
overlayContent: false
};
export default Card;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import styles from './Card.css';
interface CardProps extends Pick<LinkProps, 'onPress'> {
// TODO: Consider using different properties for classname depending if it's overlaying content or not
className?: string;
overlayClassName?: string;
overlayContent?: boolean;
children: React.ReactNode;
}
function Card(props: CardProps) {
const {
className = styles.card,
overlayClassName = styles.overlay,
overlayContent = false,
children,
onPress,
} = props;
if (overlayContent) {
return (
<div className={className}>
<Link className={styles.underlay} onPress={onPress} />
<div className={overlayClassName}>{children}</div>
</div>
);
}
return (
<Link className={className} onPress={onPress}>
{children}
</Link>
);
}
export default Card;

View File

@@ -1,138 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './CircularProgressBar.css';
class CircularProgressBar extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
progress: 0
};
}
componentDidMount() {
this._progressStep();
}
componentDidUpdate(prevProps) {
const progress = this.props.progress;
if (prevProps.progress !== progress) {
this._cancelProgressStep();
this._progressStep();
}
}
componentWillUnmount() {
this._cancelProgressStep();
}
//
// Control
_progressStep() {
this.requestAnimationFrame = window.requestAnimationFrame(() => {
this.setState({
progress: this.state.progress + 1
}, () => {
if (this.state.progress < this.props.progress) {
this._progressStep();
}
});
});
}
_cancelProgressStep() {
if (this.requestAnimationFrame) {
window.cancelAnimationFrame(this.requestAnimationFrame);
}
}
//
// Render
render() {
const {
className,
containerClassName,
size,
strokeWidth,
strokeColor,
showProgressText
} = this.props;
const progress = this.state.progress;
const center = size / 2;
const radius = center - strokeWidth;
const circumference = Math.PI * (radius * 2);
const sizeInPixels = `${size}px`;
const strokeDashoffset = ((100 - progress) / 100) * circumference;
const progressText = `${Math.round(progress)}%`;
return (
<div
className={containerClassName}
style={{
width: sizeInPixels,
height: sizeInPixels,
lineHeight: sizeInPixels
}}
>
<svg
className={className}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
>
<circle
fill="transparent"
r={radius}
cx={center}
cy={center}
strokeDasharray={circumference}
style={{
stroke: strokeColor,
strokeWidth,
strokeDashoffset
}}
/>
</svg>
{
showProgressText &&
<div className={styles.circularProgressBarText}>
{progressText}
</div>
}
</div>
);
}
}
CircularProgressBar.propTypes = {
className: PropTypes.string,
containerClassName: PropTypes.string,
size: PropTypes.number,
progress: PropTypes.number.isRequired,
strokeWidth: PropTypes.number,
strokeColor: PropTypes.string,
showProgressText: PropTypes.bool
};
CircularProgressBar.defaultProps = {
className: styles.circularProgressBar,
containerClassName: styles.circularProgressBarContainer,
size: 60,
strokeWidth: 5,
strokeColor: '#35c5f4',
showProgressText: false
};
export default CircularProgressBar;

View File

@@ -0,0 +1,99 @@
import React, { useCallback, useEffect, useState } from 'react';
import styles from './CircularProgressBar.css';
interface CircularProgressBarProps {
className?: string;
containerClassName?: string;
size?: number;
progress: number;
strokeWidth?: number;
strokeColor?: string;
showProgressText?: boolean;
}
function CircularProgressBar({
className = styles.circularProgressBar,
containerClassName = styles.circularProgressBarContainer,
size = 60,
strokeWidth = 5,
strokeColor = '#35c5f4',
showProgressText = false,
progress,
}: CircularProgressBarProps) {
const [currentProgress, setCurrentProgress] = useState(0);
const raf = React.useRef<number>(0);
const center = size / 2;
const radius = center - strokeWidth;
const circumference = Math.PI * (radius * 2);
const sizeInPixels = `${size}px`;
const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
const progressText = `${Math.round(currentProgress)}%`;
const handleAnimation = useCallback(
(p: number) => {
setCurrentProgress((prevProgress) => {
if (prevProgress < p) {
return prevProgress + Math.min(1, p - prevProgress);
}
return prevProgress;
});
},
[setCurrentProgress]
);
useEffect(() => {
if (progress > currentProgress) {
cancelAnimationFrame(raf.current);
raf.current = requestAnimationFrame(() => handleAnimation(progress));
}
}, [progress, currentProgress, handleAnimation]);
useEffect(
() => {
return () => cancelAnimationFrame(raf.current);
},
// We only want to run this effect once
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div
className={containerClassName}
style={{
width: sizeInPixels,
height: sizeInPixels,
lineHeight: sizeInPixels,
}}
>
<svg
className={className}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
>
<circle
fill="transparent"
r={radius}
cx={center}
cy={center}
strokeDasharray={circumference}
style={{
stroke: strokeColor,
strokeWidth,
strokeDashoffset,
}}
/>
</svg>
{showProgressText && (
<div className={styles.circularProgressBarText}>{progressText}</div>
)}
</div>
);
}
export default CircularProgressBar;

View File

@@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './DescriptionList.css';
class DescriptionList extends Component {
//
// Render
render() {
const {
className,
children
} = this.props;
return (
<dl className={className}>
{children}
</dl>
);
}
}
DescriptionList.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node
};
DescriptionList.defaultProps = {
className: styles.descriptionList
};
export default DescriptionList;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import styles from './DescriptionList.css';
interface DescriptionListProps {
className?: string;
children?: React.ReactNode;
}
function DescriptionList(props: DescriptionListProps) {
const { className = styles.descriptionList, children } = props;
return <dl className={className}>{children}</dl>;
}
export default DescriptionList;

View File

@@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionListItemDescription from './DescriptionListItemDescription';
import DescriptionListItemTitle from './DescriptionListItemTitle';
class DescriptionListItem extends Component {
//
// Render
render() {
const {
className,
titleClassName,
descriptionClassName,
title,
data
} = this.props;
return (
<div className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription
className={descriptionClassName}
>
{data}
</DescriptionListItemDescription>
</div>
);
}
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
export default DescriptionListItem;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import DescriptionListItemDescription, {
DescriptionListItemDescriptionProps,
} from './DescriptionListItemDescription';
import DescriptionListItemTitle, {
DescriptionListItemTitleProps,
} from './DescriptionListItemTitle';
interface DescriptionListItemProps {
className?: string;
titleClassName?: DescriptionListItemTitleProps['className'];
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
title?: DescriptionListItemTitleProps['children'];
data?: DescriptionListItemDescriptionProps['children'];
}
function DescriptionListItem(props: DescriptionListItemProps) {
const { className, titleClassName, descriptionClassName, title, data } =
props;
return (
<div className={className}>
<DescriptionListItemTitle className={titleClassName}>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription className={descriptionClassName}>
{data}
</DescriptionListItemDescription>
</div>
);
}
export default DescriptionListItem;

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DescriptionListItemDescription.css';
function DescriptionListItemDescription(props) {
const {
className,
children
} = props;
return (
<dd className={className}>
{children}
</dd>
);
}
DescriptionListItemDescription.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
DescriptionListItemDescription.defaultProps = {
className: styles.description
};
export default DescriptionListItemDescription;

View File

@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import styles from './DescriptionListItemDescription.css';
export interface DescriptionListItemDescriptionProps {
className?: string;
children?: ReactNode;
}
function DescriptionListItemDescription(
props: DescriptionListItemDescriptionProps
) {
const { className = styles.description, children } = props;
return <dd className={className}>{children}</dd>;
}
export default DescriptionListItemDescription;

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DescriptionListItemTitle.css';
function DescriptionListItemTitle(props) {
const {
className,
children
} = props;
return (
<dt className={className}>
{children}
</dt>
);
}
DescriptionListItemTitle.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.string
};
DescriptionListItemTitle.defaultProps = {
className: styles.title
};
export default DescriptionListItemTitle;

View File

@@ -0,0 +1,15 @@
import React, { ReactNode } from 'react';
import styles from './DescriptionListItemTitle.css';
export interface DescriptionListItemTitleProps {
className?: string;
children?: ReactNode;
}
function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
const { className = styles.title, children } = props;
return <dt className={className}>{children}</dt>;
}
export default DescriptionListItemTitle;

View File

@@ -1,22 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DragPreviewLayer.css';
function DragPreviewLayer({ children, ...otherProps }) {
return (
<div {...otherProps}>
{children}
</div>
);
}
DragPreviewLayer.propTypes = {
children: PropTypes.node,
className: PropTypes.string
};
DragPreviewLayer.defaultProps = {
className: styles.dragLayer
};
export default DragPreviewLayer;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import styles from './DragPreviewLayer.css';
interface DragPreviewLayerProps {
className?: string;
children?: React.ReactNode;
}
function DragPreviewLayer({
className = styles.dragLayer,
children,
...otherProps
}: DragPreviewLayerProps) {
return (
<div className={className} {...otherProps}>
{children}
</div>
);
}
export default DragPreviewLayer;

View File

@@ -1,62 +0,0 @@
import * as sentry from '@sentry/browser';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
class ErrorBoundary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
error: null,
info: null
};
}
componentDidCatch(error, info) {
this.setState({
error,
info
});
sentry.captureException(error);
}
//
// Render
render() {
const {
children,
errorComponent: ErrorComponent,
...otherProps
} = this.props;
const {
error,
info
} = this.state;
if (error) {
return (
<ErrorComponent
error={error}
info={info}
{...otherProps}
/>
);
}
return children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
errorComponent: PropTypes.elementType.isRequired
};
export default ErrorBoundary;

View File

@@ -0,0 +1,46 @@
import * as sentry from '@sentry/browser';
import React, { Component, ErrorInfo } from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
errorComponent: React.ElementType;
}
interface ErrorBoundaryState {
error: Error | null;
info: ErrorInfo | null;
}
// Class component until componentDidCatch is supported in functional components
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
error: null,
info: null,
};
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({
error,
info,
});
sentry.captureException(error);
}
render() {
const { children, errorComponent: ErrorComponent } = this.props;
const { error, info } = this.state;
if (error) {
return <ErrorComponent error={error} info={info} />;
}
return children;
}
}
export default ErrorBoundary;

View File

@@ -1,41 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import styles from './FieldSet.css';
class FieldSet extends Component {
//
// Render
render() {
const {
size,
legend,
children
} = this.props;
return (
<fieldset className={styles.fieldSet}>
<legend className={classNames(styles.legend, (size === sizes.SMALL) && styles.small)}>
{legend}
</legend>
{children}
</fieldset>
);
}
}
FieldSet.propTypes = {
size: PropTypes.oneOf(sizes.all).isRequired,
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
children: PropTypes.node
};
FieldSet.defaultProps = {
size: sizes.MEDIUM
};
export default FieldSet;

View File

@@ -0,0 +1,29 @@
import classNames from 'classnames';
import React, { ComponentProps } from 'react';
import { sizes } from 'Helpers/Props';
import { Size } from 'Helpers/Props/sizes';
import styles from './FieldSet.css';
interface FieldSetProps {
size?: Size;
legend?: ComponentProps<'legend'>['children'];
children?: React.ReactNode;
}
function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
return (
<fieldset className={styles.fieldSet}>
<legend
className={classNames(
styles.legend,
size === sizes.SMALL && styles.small
)}
>
{legend}
</legend>
{children}
</fieldset>
);
}
export default FieldSet;

View File

@@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
import styles from './FileBrowserModal.css';
class FileBrowserModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
className={styles.modal}
isOpen={isOpen}
onModalClose={onModalClose}
>
<FileBrowserModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
FileBrowserModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FileBrowserModal;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import FileBrowserModalContent, {
FileBrowserModalContentProps,
} from './FileBrowserModalContent';
import styles from './FileBrowserModal.css';
interface FileBrowserModalProps extends FileBrowserModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
function FileBrowserModal(props: FileBrowserModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal className={styles.modal} isOpen={isOpen} onModalClose={onModalClose}>
<FileBrowserModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default FileBrowserModal;

View File

@@ -1,246 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds, scrollDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FileBrowserRow from './FileBrowserRow';
import styles from './FileBrowserModalContent.css';
const columns = [
{
name: 'type',
label: () => translate('Type'),
isVisible: true
},
{
name: 'name',
label: () => translate('Name'),
isVisible: true
}
];
class FileBrowserModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scrollerRef = React.createRef();
this.state = {
isFileBrowserModalOpen: false,
currentPath: props.value
};
}
componentDidUpdate(prevProps, prevState) {
const {
currentPath
} = this.props;
if (
currentPath !== this.state.currentPath &&
currentPath !== prevState.currentPath
) {
this.setState({ currentPath });
this._scrollerRef.current.scrollTop = 0;
}
}
//
// Listeners
onPathInputChange = ({ value }) => {
this.setState({ currentPath: value });
};
onRowPress = (path) => {
this.props.onFetchPaths(path);
};
onOkPress = () => {
this.props.onChange({
name: this.props.name,
value: this.state.currentPath
});
this.props.onClearPaths();
this.props.onModalClose();
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
parent,
directories,
files,
isWindowsService,
onModalClose,
...otherProps
} = this.props;
const emptyParent = parent === '';
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
{translate('FileBrowser')}
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
{
isWindowsService &&
<Alert
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
</Alert>
}
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}
{...otherProps}
value={this.state.currentPath}
onChange={this.onPathInputChange}
/>
<Scroller
ref={this._scrollerRef}
className={styles.scroller}
scrollDirection={scrollDirections.BOTH}
>
{
!!error &&
<div>{translate('ErrorLoadingContents')}</div>
}
{
isPopulated && !error &&
<Table
horizontalScroll={false}
columns={columns}
>
<TableBody>
{
emptyParent &&
<FileBrowserRow
type="computer"
name={translate('MyComputer')}
path={parent}
onPress={this.onRowPress}
/>
}
{
!emptyParent && parent &&
<FileBrowserRow
type="parent"
name="..."
path={parent}
onPress={this.onRowPress}
/>
}
{
directories.map((directory) => {
return (
<FileBrowserRow
key={directory.path}
type={directory.type}
name={directory.name}
path={directory.path}
onPress={this.onRowPress}
/>
);
})
}
{
files.map((file) => {
return (
<FileBrowserRow
key={file.path}
type={file.type}
name={file.name}
path={file.path}
onPress={this.onRowPress}
/>
);
})
}
</TableBody>
</Table>
}
</Scroller>
</ModalBody>
<ModalFooter>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<Button
onPress={this.onOkPress}
>
{translate('Ok')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
FileBrowserModalContent.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
parent: PropTypes.string,
currentPath: PropTypes.string.isRequired,
directories: PropTypes.arrayOf(PropTypes.object).isRequired,
files: PropTypes.arrayOf(PropTypes.object).isRequired,
isWindowsService: PropTypes.bool.isRequired,
onFetchPaths: PropTypes.func.isRequired,
onClearPaths: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default FileBrowserModalContent;

View File

@@ -0,0 +1,237 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds, scrollDirections } from 'Helpers/Props';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import createPathsSelector from './createPathsSelector';
import FileBrowserRow from './FileBrowserRow';
import styles from './FileBrowserModalContent.css';
const columns: Column[] = [
{
name: 'type',
label: () => translate('Type'),
isVisible: true,
},
{
name: 'name',
label: () => translate('Name'),
isVisible: true,
},
];
const handleClearPaths = () => {};
export interface FileBrowserModalContentProps {
name: string;
value: string;
includeFiles?: boolean;
onChange: (args: InputChanged<string>) => unknown;
onModalClose: () => void;
}
function FileBrowserModalContent(props: FileBrowserModalContentProps) {
const { name, value, includeFiles = true, onChange, onModalClose } = props;
const dispatch = useDispatch();
const { isWindows, mode } = useSelector(createSystemStatusSelector());
const { isFetching, isPopulated, error, parent, directories, files, paths } =
useSelector(createPathsSelector());
const [currentPath, setCurrentPath] = useState(value);
const scrollerRef = useRef(null);
const previousValue = usePrevious(value);
const emptyParent = parent === '';
const isWindowsService = isWindows && mode === 'service';
const handlePathInputChange = useCallback(
({ value }: InputChanged<string>) => {
setCurrentPath(value);
},
[]
);
const handleRowPress = useCallback(
(path: string) => {
setCurrentPath(path);
dispatch(
fetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
},
[includeFiles, dispatch, setCurrentPath]
);
const handleOkPress = useCallback(() => {
onChange({
name,
value: currentPath,
});
dispatch(clearPaths());
onModalClose();
}, [name, currentPath, dispatch, onChange, onModalClose]);
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(
fetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
},
[includeFiles, dispatch]
);
useEffect(() => {
if (value !== previousValue && value !== currentPath) {
setCurrentPath(value);
}
}, [value, previousValue, currentPath, setCurrentPath]);
useEffect(
() => {
dispatch(
fetchPaths({
path: currentPath,
allowFoldersWithoutTrailingSlashes: true,
includeFiles,
})
);
return () => {
dispatch(clearPaths());
};
},
// This should only run once when the component mounts,
// so we don't need to include the other dependencies.
// eslint-disable-next-line react-hooks/exhaustive-deps
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
{isWindowsService ? (
<Alert className={styles.mappedDrivesWarning} kind={kinds.WARNING}>
<InlineMarkdown
data={translate('MappedNetworkDrivesWindowsService', {
url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server',
})}
/>
</Alert>
) : null}
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}
includeFiles={includeFiles}
paths={paths}
name={name}
value={currentPath}
onChange={handlePathInputChange}
onFetchPaths={handleFetchPaths}
onClearPaths={handleClearPaths}
/>
<Scroller
ref={scrollerRef}
className={styles.scroller}
scrollDirection="both"
>
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
{isPopulated && !error ? (
<Table horizontalScroll={false} columns={columns}>
<TableBody>
{emptyParent ? (
<FileBrowserRow
type="computer"
name={translate('MyComputer')}
path={parent}
onPress={handleRowPress}
/>
) : null}
{!emptyParent && parent ? (
<FileBrowserRow
type="parent"
name="..."
path={parent}
onPress={handleRowPress}
/>
) : null}
{directories.map((directory) => {
return (
<FileBrowserRow
key={directory.path}
type={directory.type}
name={directory.name}
path={directory.path}
onPress={handleRowPress}
/>
);
})}
{files.map((file) => {
return (
<FileBrowserRow
key={file.path}
type={file.type}
name={file.name}
path={file.path}
onPress={handleRowPress}
/>
);
})}
</TableBody>
</Table>
) : null}
</Scroller>
</ModalBody>
<ModalFooter>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={handleOkPress}>{translate('Ok')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default FileBrowserModalContent;

View File

@@ -1,119 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import FileBrowserModalContent from './FileBrowserModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.paths,
createSystemStatusSelector(),
(paths, systemStatus) => {
const {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files
} = paths;
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
paths: filteredPaths,
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
};
}
);
}
const mapDispatchToProps = {
dispatchFetchPaths: fetchPaths,
dispatchClearPaths: clearPaths
};
class FileBrowserModalContentConnector extends Component {
// Lifecycle
componentDidMount() {
const {
value,
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path: value,
allowFoldersWithoutTrailingSlashes: true,
includeFiles
});
}
//
// Listeners
onFetchPaths = (path) => {
const {
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path,
allowFoldersWithoutTrailingSlashes: true,
includeFiles
});
};
onClearPaths = () => {
// this.props.dispatchClearPaths();
};
onModalClose = () => {
this.props.dispatchClearPaths();
this.props.onModalClose();
};
//
// Render
render() {
return (
<FileBrowserModalContent
onFetchPaths={this.onFetchPaths}
onClearPaths={this.onClearPaths}
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
FileBrowserModalContentConnector.propTypes = {
value: PropTypes.string,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
FileBrowserModalContentConnector.defaultProps = {
includeFiles: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);

View File

@@ -1,62 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
import styles from './FileBrowserRow.css';
function getIconName(type) {
switch (type) {
case 'computer':
return icons.COMPUTER;
case 'drive':
return icons.DRIVE;
case 'file':
return icons.FILE;
case 'parent':
return icons.PARENT;
default:
return icons.FOLDER;
}
}
class FileBrowserRow extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.path);
};
//
// Render
render() {
const {
type,
name
} = this.props;
return (
<TableRowButton onPress={this.onPress}>
<TableRowCell className={styles.type}>
<Icon name={getIconName(type)} />
</TableRowCell>
<TableRowCell>{name}</TableRowCell>
</TableRowButton>
);
}
}
FileBrowserRow.propTypes = {
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default FileBrowserRow;

View File

@@ -0,0 +1,49 @@
import React, { useCallback } from 'react';
import { PathType } from 'App/State/PathsAppState';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
import styles from './FileBrowserRow.css';
function getIconName(type: PathType) {
switch (type) {
case 'computer':
return icons.COMPUTER;
case 'drive':
return icons.DRIVE;
case 'file':
return icons.FILE;
case 'parent':
return icons.PARENT;
default:
return icons.FOLDER;
}
}
interface FileBrowserRowProps {
type: PathType;
name: string;
path: string;
onPress: (path: string) => void;
}
function FileBrowserRow(props: FileBrowserRowProps) {
const { type, name, path, onPress } = props;
const handlePress = useCallback(() => {
onPress(path);
}, [path, onPress]);
return (
<TableRowButton onPress={handlePress}>
<TableRowCell className={styles.type}>
<Icon name={getIconName(type)} />
</TableRowCell>
<TableRowCell>{name}</TableRowCell>
</TableRowButton>
);
}
export default FileBrowserRow;

View File

@@ -0,0 +1,36 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createPathsSelector() {
return createSelector(
(state: AppState) => state.paths,
(paths) => {
const {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
} = paths;
const filteredPaths = [...directories, ...files].filter(({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
isFetching,
isPopulated,
error,
parent,
currentPath,
directories,
files,
paths: filteredPaths,
};
}
);
}
export default createPathsSelector;

View File

@@ -49,7 +49,11 @@ class TextTagInputConnector extends Component {
const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
newTags.forEach((newTag) => {
newValue.push(newTag.trim());
const newTagValue = newTag.trim();
if (newTagValue) {
newValue.push(newTagValue);
}
});
onChange({ name, value: newValue });
@@ -80,7 +84,12 @@ class TextTagInputConnector extends Component {
const newValue = [...valueArray];
newValue.splice(tagToReplace.index, 1);
newValue.push(newTag.name.trim());
const newTagValue = newTag.name.trim();
if (newTagValue) {
newValue.push(newTagValue);
}
onChange({ name, value: newValue });
};

View File

@@ -1,22 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './HeartRating.css';
function HeartRating({ rating, votes, iconSize }) {
interface HeartRatingProps {
rating: number;
votes?: number;
iconSize?: IconProps['size'];
}
function HeartRating({ rating, votes = 0, iconSize = 14 }: HeartRatingProps) {
return (
<Tooltip
anchor={
<span className={styles.rating}>
<Icon
className={styles.heart}
name={icons.HEART}
size={iconSize}
/>
<Icon className={styles.heart} name={icons.HEART} size={iconSize} />
{rating * 10}%
</span>
}
@@ -27,15 +27,4 @@ function HeartRating({ rating, votes, iconSize }) {
);
}
HeartRating.propTypes = {
rating: PropTypes.number.isRequired,
votes: PropTypes.number.isRequired,
iconSize: PropTypes.number.isRequired
};
HeartRating.defaultProps = {
votes: 0,
iconSize: 14
};
export default HeartRating;

View File

@@ -5,6 +5,7 @@ import {
import classNames from 'classnames';
import React, { ComponentProps } from 'react';
import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Icon.css';
export interface IconProps
@@ -14,7 +15,7 @@ export interface IconProps
> {
containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon'];
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
kind?: Extract<Kind, keyof typeof styles>;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);

View File

@@ -1,11 +1,13 @@
import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react';
import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import styles from './Label.css';
export interface LabelProps extends ComponentProps<'span'> {
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
outline?: boolean;
children: ReactNode;
}

View File

@@ -1,6 +1,8 @@
import classNames from 'classnames';
import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import Link, { LinkProps } from './Link';
import styles from './Button.css';
@@ -9,8 +11,8 @@ export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
(typeof align.all)[number],
keyof typeof styles
>;
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
children: Required<LinkProps['children']>;
}

View File

@@ -1,50 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './LoadingIndicator.css';
function LoadingIndicator({ className, rippleClassName, size }) {
const sizeInPx = `${size}px`;
const width = sizeInPx;
const height = sizeInPx;
return (
<div
className={className}
style={{ height }}
>
<div
className={styles.rippleContainer}
style={{ width, height }}
>
<div
className={rippleClassName}
style={{ width, height }}
/>
<div
className={rippleClassName}
style={{ width, height }}
/>
<div
className={rippleClassName}
style={{ width, height }}
/>
</div>
</div>
);
}
LoadingIndicator.propTypes = {
className: PropTypes.string,
rippleClassName: PropTypes.string,
size: PropTypes.number
};
LoadingIndicator.defaultProps = {
className: styles.loading,
rippleClassName: styles.ripple,
size: 50
};
export default LoadingIndicator;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import styles from './LoadingIndicator.css';
interface LoadingIndicatorProps {
className?: string;
rippleClassName?: string;
size?: number;
}
function LoadingIndicator({
className = styles.loading,
rippleClassName = styles.ripple,
size = 50,
}: LoadingIndicatorProps) {
const sizeInPx = `${size}px`;
const width = sizeInPx;
const height = sizeInPx;
return (
<div className={className} style={{ height }}>
<div className={styles.rippleContainer} style={{ width, height }}>
<div className={rippleClassName} style={{ width, height }} />
<div className={rippleClassName} style={{ width, height }} />
<div className={rippleClassName} style={{ width, height }} />
</div>
</div>
);
}
export default LoadingIndicator;

View File

@@ -8,21 +8,21 @@ const messages = [
'Bleep Bloop.',
'Locating the required gigapixels to render...',
'Spinning up the hamster wheel...',
'At least you\'re not on hold',
"At least you're not on hold",
'Hum something loud while others stare',
'Loading humorous message... Please Wait',
'I could\'ve been faster in Python',
'Don\'t forget to rewind your episodes',
"I could've been faster in Python",
"Don't forget to rewind your episodes",
'Congratulations! You are the 1000th visitor.',
'HELP! I\'m being held hostage and forced to write these stupid lines!',
"HELP! I'm being held hostage and forced to write these stupid lines!",
'RE-calibrating the internet...',
'I\'ll be here all week',
'Don\'t forget to tip your waitress',
"I'll be here all week",
"Don't forget to tip your waitress",
'Apply directly to the forehead',
'Loading Battlestation'
'Loading Battlestation',
];
let message = null;
let message: string | null = null;
function LoadingMessage() {
if (!message) {
@@ -30,11 +30,7 @@ function LoadingMessage() {
message = messages[index];
}
return (
<div className={styles.loadingMessage}>
{message}
</div>
);
return <div className={styles.loadingMessage}>{message}</div>;
}
export default LoadingMessage;

View File

@@ -1,74 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
class InlineMarkdown extends Component {
//
// Render
render() {
const {
className,
data,
blockClassName
} = this.props;
// For now only replace links or code blocks (not both)
const markdownBlocks = [];
if (data) {
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
let endIndex = 0;
let match = null;
while ((match = linkRegex.exec(data)) !== null) {
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(<Link key={match.index} to={match[2]}>{match[1]}</Link>);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
endIndex = 0;
match = null;
let matchedCode = false;
while ((match = codeRegex.exec(data)) !== null) {
matchedCode = true;
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
if (markdownBlocks.length === 0) {
markdownBlocks.push(data);
}
}
return <span className={className}>{markdownBlocks}</span>;
}
}
InlineMarkdown.propTypes = {
className: PropTypes.string,
data: PropTypes.string,
blockClassName: PropTypes.string
};
export default InlineMarkdown;

View File

@@ -0,0 +1,75 @@
import React, { ReactElement } from 'react';
import Link from 'Components/Link/Link';
interface InlineMarkdownProps {
className?: string;
data?: string;
blockClassName?: string;
}
function InlineMarkdown(props: InlineMarkdownProps) {
const { className, data, blockClassName } = props;
// For now only replace links or code blocks (not both)
const markdownBlocks: (ReactElement | string)[] = [];
if (data) {
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
let endIndex = 0;
let match = null;
while ((match = linkRegex.exec(data)) !== null) {
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(
<Link key={match.index} to={match[2]}>
{match[1]}
</Link>
);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
endIndex = 0;
match = null;
let matchedCode = false;
while ((match = codeRegex.exec(data)) !== null) {
matchedCode = true;
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
markdownBlocks.push(
<code
key={`code-${match.index}`}
className={blockClassName ?? undefined}
>
{match[0].substring(1, match[0].length - 1)}
</code>
);
endIndex = match.index + match[0].length;
}
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
if (markdownBlocks.length === 0) {
markdownBlocks.push(data);
}
}
return <span className={className}>{markdownBlocks}</span>;
}
export default InlineMarkdown;

View File

@@ -6,10 +6,7 @@ import styles from './MetadataAttribution.css';
export default function MetadataAttribution() {
return (
<div className={styles.container}>
<Link
className={styles.attribution}
to="/settings/metadatasource"
>
<Link className={styles.attribution} to="/settings/metadatasource">
{translate('MetadataProvidedBy', { provider: 'TheTVDB' })}
</Link>
</div>

View File

@@ -1,80 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './MonitorToggleButton.css';
function getTooltip(monitored, isDisabled) {
if (isDisabled) {
return translate('ToggleMonitoredSeriesUnmonitored ');
}
if (monitored) {
return translate('ToggleMonitoredToUnmonitored');
}
return translate('ToggleUnmonitoredToMonitored');
}
class MonitorToggleButton extends Component {
//
// Listeners
onPress = (event) => {
const shiftKey = event.nativeEvent.shiftKey;
this.props.onPress(!this.props.monitored, { shiftKey });
};
//
// Render
render() {
const {
className,
monitored,
isDisabled,
isSaving,
size,
...otherProps
} = this.props;
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
return (
<SpinnerIconButton
className={classNames(
className,
isDisabled && styles.isDisabled
)}
name={iconName}
size={size}
title={getTooltip(monitored, isDisabled)}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
onPress={this.onPress}
/>
);
}
}
MonitorToggleButton.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
size: PropTypes.number,
isDisabled: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
MonitorToggleButton.defaultProps = {
className: styles.toggleButton,
isDisabled: false,
isSaving: false
};
export default MonitorToggleButton;

View File

@@ -0,0 +1,65 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useMemo } from 'react';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './MonitorToggleButton.css';
interface MonitorToggleButtonProps {
className?: string;
monitored: boolean;
size?: number;
isDisabled?: boolean;
isSaving?: boolean;
onPress: (value: boolean, options: { shiftKey: boolean }) => unknown;
}
function MonitorToggleButton(props: MonitorToggleButtonProps) {
const {
className = styles.toggleButton,
monitored,
isDisabled = false,
isSaving = false,
size,
onPress,
...otherProps
} = props;
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
const title = useMemo(() => {
if (isDisabled) {
return translate('ToggleMonitoredSeriesUnmonitored');
}
if (monitored) {
return translate('ToggleMonitoredToUnmonitored');
}
return translate('ToggleUnmonitoredToMonitored');
}, [monitored, isDisabled]);
const handlePress = useCallback(
(event: SyntheticEvent<HTMLLinkElement, MouseEvent>) => {
const shiftKey = event.nativeEvent.shiftKey;
onPress(!monitored, { shiftKey });
},
[monitored, onPress]
);
return (
<SpinnerIconButton
className={classNames(className, isDisabled && styles.isDisabled)}
name={iconName}
size={size}
title={title}
isDisabled={isDisabled}
isSpinning={isSaving}
{...otherProps}
onPress={handlePress}
/>
);
}
export default MonitorToggleButton;

View File

@@ -1,18 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import translate from 'Utilities/String/translate';
import styles from './NotFound.css';
function NotFound(props) {
interface NotFoundProps {
message?: string;
}
function NotFound(props: NotFoundProps) {
const { message = translate('DefaultNotFoundMessage') } = props;
return (
<PageContent title="MIA">
<div className={styles.container}>
<div className={styles.message}>
{message}
</div>
<div className={styles.message}>{message}</div>
<img
className={styles.image}
@@ -23,8 +24,4 @@ function NotFound(props) {
);
}
NotFound.propTypes = {
message: PropTypes.string
};
export default NotFound;

View File

@@ -1,6 +1,5 @@
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
@@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
ref={ref}
{...otherProps}
className={className}
scrollDirection={ScrollDirection.Vertical}
scrollDirection="vertical"
onScroll={onScrollWrapper}
>
<div className={innerClassName}>{children}</div>

View File

@@ -1,18 +0,0 @@
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
function Portal(props) {
const { children, target } = props;
return ReactDOM.createPortal(children, target);
}
Portal.propTypes = {
children: PropTypes.node.isRequired,
target: PropTypes.object.isRequired
};
Portal.defaultProps = {
target: document.getElementById('portal-root')
};
export default Portal;

View File

@@ -0,0 +1,20 @@
import ReactDOM from 'react-dom';
interface PortalProps {
children: Parameters<typeof ReactDOM.createPortal>[0];
target?: Parameters<typeof ReactDOM.createPortal>[1];
}
const defaultTarget = document.getElementById('portal-root');
function Portal(props: PortalProps) {
const { children, target = defaultTarget } = props;
if (!target) {
return null;
}
return ReactDOM.createPortal(children, target);
}
export default Portal;

View File

@@ -1,114 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import { kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ProgressBar.css';
function ProgressBar(props) {
const {
className,
containerClassName,
title,
progress,
precision,
showText,
text,
kind,
size,
width
} = props;
const progressPercent = `${progress.toFixed(precision)}%`;
const progressText = text || progressPercent;
const actualWidth = width ? `${width}px` : '100%';
return (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div
className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
{
showText && width ?
<div
className={classNames(styles.backTextContainer, styles[kind])}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div> :
null
}
<div
className={classNames(
className,
styles[kind],
enableColorImpairedMode && 'colorImpaired'
)}
role="meter"
aria-label={translate('ProgressBarProgress', { progress: progress.toFixed(0) })}
aria-valuenow={progress.toFixed(0)}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
/>
{
showText ?
<div
className={classNames(styles.frontTextContainer, styles[kind])}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>
{progressText}
</div>
</div>
</div> :
null
}
</div>
);
}}
</ColorImpairedConsumer>
);
}
ProgressBar.propTypes = {
className: PropTypes.string,
containerClassName: PropTypes.string,
title: PropTypes.string,
progress: PropTypes.number.isRequired,
precision: PropTypes.number.isRequired,
showText: PropTypes.bool.isRequired,
text: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
width: PropTypes.number
};
ProgressBar.defaultProps = {
className: styles.progressBar,
containerClassName: styles.container,
precision: 1,
showText: false,
kind: kinds.PRIMARY,
size: sizes.MEDIUM
};
export default ProgressBar;

View File

@@ -0,0 +1,94 @@
import classNames from 'classnames';
import React from 'react';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import translate from 'Utilities/String/translate';
import styles from './ProgressBar.css';
interface ProgressBarProps {
className?: string;
containerClassName?: string;
title?: string;
progress: number;
precision?: number;
showText?: boolean;
text?: string;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
width?: number;
}
function ProgressBar({
className = styles.progressBar,
containerClassName = styles.container,
title,
progress,
precision = 1,
showText = false,
text,
kind = 'primary',
size = 'medium',
width,
}: ProgressBarProps) {
const progressPercent = `${progress.toFixed(precision)}%`;
const progressText = text || progressPercent;
const actualWidth = width ? `${width}px` : '100%';
return (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div
className={classNames(containerClassName, styles[size])}
title={title}
style={{ width: actualWidth }}
>
{showText && width ? (
<div
className={classNames(styles.backTextContainer, styles[kind])}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>{progressText}</div>
</div>
</div>
) : null}
<div
className={classNames(
className,
styles[kind],
enableColorImpairedMode && 'colorImpaired'
)}
role="meter"
aria-label={translate('ProgressBarProgress', {
progress: progress.toFixed(0),
})}
aria-valuenow={Math.floor(progress)}
aria-valuemin={0}
aria-valuemax={100}
style={{ width: progressPercent }}
/>
{showText ? (
<div
className={classNames(styles.frontTextContainer, styles[kind])}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>{progressText}</div>
</div>
</div>
) : null}
</div>
);
}}
</ColorImpairedConsumer>
);
}
export default ProgressBar;

View File

@@ -1,44 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Switch as RouterSwitch } from 'react-router-dom';
import { map } from 'Helpers/elementChildren';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
class Switch extends Component {
//
// Render
render() {
const {
children
} = this.props;
return (
<RouterSwitch>
{
map(children, (child) => {
const {
path: childPath,
addUrlBase = true
} = child.props;
if (!childPath) {
return child;
}
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
return React.cloneElement(child, { path });
})
}
</RouterSwitch>
);
}
}
Switch.propTypes = {
children: PropTypes.node.isRequired
};
export default Switch;

View File

@@ -0,0 +1,38 @@
import React, { Children, ReactElement, ReactNode } from 'react';
import { Switch as RouterSwitch } from 'react-router-dom';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
interface ExtendedRoute {
path: string;
addUrlBase?: boolean;
}
interface SwitchProps {
children: ReactNode;
}
function Switch({ children }: SwitchProps) {
return (
<RouterSwitch>
{Children.map(children, (child) => {
if (!React.isValidElement<ExtendedRoute>(child)) {
return child;
}
const elementChild: ReactElement<ExtendedRoute> = child;
const { path: childPath, addUrlBase = true } = elementChild.props;
if (!childPath) {
return child;
}
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
return React.cloneElement(child, { path });
})}
</RouterSwitch>
);
}
export default Switch;

View File

@@ -1,179 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Scrollbars } from 'react-custom-scrollbars-2';
import { scrollDirections } from 'Helpers/Props';
import styles from './OverlayScroller.css';
const SCROLLBAR_SIZE = 10;
class OverlayScroller extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scroller = null;
this._isScrolling = false;
}
componentDidUpdate(prevProps) {
const {
scrollTop
} = this.props;
if (
!this._isScrolling &&
scrollTop != null &&
scrollTop !== prevProps.scrollTop
) {
this._scroller.scrollTop(scrollTop);
}
}
//
// Control
_setScrollRef = (ref) => {
this._scroller = ref;
if (ref) {
this.props.registerScroller(ref.view);
}
};
_renderThumb = (props) => {
return (
<div
className={this.props.trackClassName}
{...props}
/>
);
};
_renderTrackHorizontal = ({ style, props }) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
left: 2,
borderRadius: 3,
height: SCROLLBAR_SIZE
};
return (
<div
className={styles.track}
style={finalStyle}
{...props}
/>
);
};
_renderTrackVertical = ({ style, props }) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
top: 2,
borderRadius: 3,
width: SCROLLBAR_SIZE
};
return (
<div
className={styles.track}
style={finalStyle}
{...props}
/>
);
};
_renderView = (props) => {
return (
<div
className={this.props.className}
{...props}
/>
);
};
//
// Listers
onScrollStart = () => {
this._isScrolling = true;
};
onScrollStop = () => {
this._isScrolling = false;
};
onScroll = (event) => {
const {
scrollTop,
scrollLeft
} = event.currentTarget;
this._isScrolling = true;
const onScroll = this.props.onScroll;
if (onScroll) {
onScroll({ scrollTop, scrollLeft });
}
};
//
// Render
render() {
const {
autoHide,
autoScroll,
children
} = this.props;
return (
<Scrollbars
ref={this._setScrollRef}
autoHide={autoHide}
hideTracksWhenNotNeeded={autoScroll}
renderTrackHorizontal={this._renderTrackHorizontal}
renderTrackVertical={this._renderTrackVertical}
renderThumbHorizontal={this._renderThumb}
renderThumbVertical={this._renderThumb}
renderView={this._renderView}
onScrollStart={this.onScrollStart}
onScrollStop={this.onScrollStop}
onScroll={this.onScroll}
>
{children}
</Scrollbars>
);
}
}
OverlayScroller.propTypes = {
className: PropTypes.string,
trackClassName: PropTypes.string,
scrollTop: PropTypes.number,
scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
autoHide: PropTypes.bool.isRequired,
autoScroll: PropTypes.bool.isRequired,
children: PropTypes.node,
onScroll: PropTypes.func,
registerScroller: PropTypes.func
};
OverlayScroller.defaultProps = {
className: styles.scroller,
trackClassName: styles.thumb,
scrollDirection: scrollDirections.VERTICAL,
autoHide: false,
autoScroll: true,
registerScroller: () => { /* no-op */ }
};
export default OverlayScroller;

View File

@@ -0,0 +1,127 @@
import React, { ComponentPropsWithoutRef, useCallback, useRef } from 'react';
import { Scrollbars } from 'react-custom-scrollbars-2';
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import { OnScroll } from './Scroller';
import styles from './OverlayScroller.css';
const SCROLLBAR_SIZE = 10;
interface OverlayScrollerProps {
className?: string;
trackClassName?: string;
scrollTop?: number;
scrollDirection: ScrollDirection;
autoHide: boolean;
autoScroll: boolean;
children?: React.ReactNode;
onScroll?: (payload: OnScroll) => void;
}
interface ScrollbarTrackProps {
style: React.CSSProperties;
props: ComponentPropsWithoutRef<'div'>;
}
function OverlayScroller(props: OverlayScrollerProps) {
const {
autoHide = false,
autoScroll = true,
className = styles.scroller,
trackClassName = styles.thumb,
children,
onScroll,
} = props;
const scrollBarRef = useRef<Scrollbars>(null);
const isScrolling = useRef(false);
const handleScrollStart = useCallback(() => {
isScrolling.current = true;
}, []);
const handleScrollStop = useCallback(() => {
isScrolling.current = false;
}, []);
const handleScroll = useCallback(() => {
if (!scrollBarRef.current) {
return;
}
const { scrollTop, scrollLeft } = scrollBarRef.current.getValues();
isScrolling.current = true;
if (onScroll) {
onScroll({ scrollTop, scrollLeft });
}
}, [onScroll]);
const renderThumb = useCallback(
(props: ComponentPropsWithoutRef<'div'>) => {
return <div className={trackClassName} {...props} />;
},
[trackClassName]
);
const renderTrackHorizontal = useCallback(
({ style, props: trackProps }: ScrollbarTrackProps) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
left: 2,
borderRadius: 3,
height: SCROLLBAR_SIZE,
};
return (
<div className={styles.track} style={finalStyle} {...trackProps} />
);
},
[]
);
const renderTrackVertical = useCallback(
({ style, props: trackProps }: ScrollbarTrackProps) => {
const finalStyle = {
...style,
right: 2,
bottom: 2,
top: 2,
borderRadius: 3,
width: SCROLLBAR_SIZE,
};
return (
<div className={styles.track} style={finalStyle} {...trackProps} />
);
},
[]
);
const renderView = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any) => {
return <div className={className} {...props} />;
},
[className]
);
return (
<Scrollbars
ref={scrollBarRef}
autoHide={autoHide}
hideTracksWhenNotNeeded={autoScroll}
renderTrackHorizontal={renderTrackHorizontal}
renderTrackVertical={renderTrackVertical}
renderThumbHorizontal={renderThumb}
renderThumbVertical={renderThumb}
renderView={renderView}
onScrollStart={handleScrollStart}
onScrollStop={handleScrollStop}
onScroll={handleScroll}
>
{children}
</Scrollbars>
);
}
export default OverlayScroller;

View File

@@ -8,7 +8,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
import styles from './Scroller.css';
export interface OnScroll {
@@ -33,7 +33,7 @@ const Scroller = forwardRef(
className,
autoFocus = false,
autoScroll = true,
scrollDirection = ScrollDirection.Vertical,
scrollDirection = 'vertical',
children,
scrollTop,
initialScrollTop,
@@ -59,7 +59,7 @@ const Scroller = forwardRef(
currentRef.current.scrollTop = scrollTop;
}
if (autoFocus && scrollDirection !== ScrollDirection.None) {
if (autoFocus && scrollDirection !== 'none') {
currentRef.current.focus({ preventScroll: true });
}
}, [autoFocus, currentRef, scrollDirection, scrollTop]);

View File

@@ -10,11 +10,13 @@ export interface SpinnerIconProps extends IconProps {
export default function SpinnerIcon({
name,
spinningName = icons.SPINNER,
isSpinning,
...otherProps
}: SpinnerIconProps) {
return (
<Icon
name={(otherProps.isSpinning && spinningName) || name}
name={(isSpinning && spinningName) || name}
isSpinning={isSpinning}
{...otherProps}
/>
);

View File

@@ -1,11 +1,11 @@
import React, { ComponentPropsWithoutRef } from 'react';
import styles from './TableRowCell.css';
export interface TableRowCellprops extends ComponentPropsWithoutRef<'td'> {}
export interface TableRowCellProps extends ComponentPropsWithoutRef<'td'> {}
export default function TableRowCell({
className = styles.cell,
...tdProps
}: TableRowCellprops) {
}: TableRowCellProps) {
return <td className={className} {...tdProps} />;
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { SortDirection } from 'Helpers/Props/sortDirections';
type PropertyFunction<T> = () => T;
@@ -9,6 +10,7 @@ interface Column {
className?: string;
columnLabel?: string;
isSortable?: boolean;
fixedSortDirection?: SortDirection;
isVisible: boolean;
isModifiable?: boolean;
}

View File

@@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { tooltipPositions } from 'Helpers/Props';
import Tooltip from './Tooltip';
import styles from './Popover.css';
function Popover(props) {
const {
title,
body,
...otherProps
} = props;
return (
<Tooltip
{...otherProps}
bodyClassName={styles.tooltipBody}
tooltip={
<div>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
</div>
}
/>
);
}
Popover.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string,
anchor: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool
};
export default Popover;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Tooltip, { TooltipProps } from './Tooltip';
import styles from './Popover.css';
interface PopoverProps extends Omit<TooltipProps, 'tooltip' | 'bodyClassName'> {
title: string;
body: React.ReactNode;
}
function Popover({ title, body, ...otherProps }: PopoverProps) {
return (
<Tooltip
{...otherProps}
bodyClassName={styles.tooltipBody}
tooltip={
<div>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{body}</div>
</div>
}
/>
);
}
export default Popover;

View File

@@ -1,235 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { kinds, tooltipPositions } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
let maxWidth = null;
function getMaxWidth() {
const windowWidth = window.innerWidth;
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
maxWidth = 800;
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
maxWidth = 650;
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
maxWidth = 500;
} else {
maxWidth = 450;
}
return maxWidth;
}
class Tooltip extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
this._closeTimeout = null;
this._maxWidth = maxWidth || getMaxWidth();
this.state = {
isOpen: false
};
}
componentDidUpdate() {
if (this._scheduleUpdate && this.state.isOpen) {
this._scheduleUpdate();
}
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
// Control
computeMaxSize = (data) => {
const {
top,
right,
bottom,
left
} = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if ((/^top/).test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if ((/^bottom/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if ((/^right/).test(data.placement)) {
data.styles.maxWidth = Math.min(this._maxWidth, windowWidth - right - 20);
data.styles.maxHeight = top - 20;
} else {
data.styles.maxWidth = Math.min(this._maxWidth, left - 20);
data.styles.maxHeight = top - 20;
}
return data;
};
//
// Listeners
onMeasure = ({ width }) => {
this.setState({ width });
};
onClick = () => {
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
};
onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
};
onMouseLeave = () => {
this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
};
//
// Render
render() {
const {
className,
bodyClassName,
anchor,
tooltip,
kind,
position,
canFlip
} = this.props;
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{anchor}
</span>
)}
</Reference>
<Portal>
<Popper
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.computeMaxSize
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: false
},
flip: {
enabled: canFlip
}
}}
>
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
const popperPlacement = placement ? placement.split('-')[0] : position;
const vertical = popperPlacement === 'top' || popperPlacement === 'bottom';
return (
<div
ref={ref}
className={classNames(
styles.tooltipContainer,
vertical ? styles.verticalContainer : styles.horizontalContainer
)}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div
className={this.state.isOpen ? classNames(
styles.arrow,
styles[kind],
styles[popperPlacement]
) : styles.arrowDisabled}
ref={arrowProps.ref}
style={arrowProps.style}
/>
{
this.state.isOpen ?
<div
className={classNames(
styles.tooltip,
styles[kind]
)}
>
<div
className={bodyClassName}
>
{tooltip}
</div>
</div> :
null
}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
Tooltip.propTypes = {
className: PropTypes.string,
bodyClassName: PropTypes.string.isRequired,
anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool.isRequired
};
Tooltip.defaultProps = {
bodyClassName: styles.body,
kind: kinds.DEFAULT,
position: tooltipPositions.TOP,
canFlip: false
};
export default Tooltip;

View File

@@ -0,0 +1,226 @@
import classNames from 'classnames';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { kinds, tooltipPositions } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
export interface TooltipProps {
className?: string;
bodyClassName?: string;
anchor: React.ReactNode;
tooltip: string | React.ReactNode;
kind?: Extract<Kind, keyof typeof styles>;
position?: (typeof tooltipPositions.all)[number];
canFlip?: boolean;
}
function Tooltip(props: TooltipProps) {
const {
className,
bodyClassName = styles.body,
anchor,
tooltip,
kind = kinds.DEFAULT,
position = tooltipPositions.TOP,
canFlip = false,
} = props;
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
const updater = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
setIsOpen((isOpen) => {
return !isOpen;
});
}, [setIsOpen]);
const handleMouseEnterAnchor = useCallback(() => {
// Mobile will fire mouse enter and click events rapidly,
// this causes the tooltip not to open on the first press.
// Ignore the mouse enter event on mobile.
if (isMobileUtil()) {
return;
}
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseEnterTooltip = useCallback(() => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
setIsOpen(true);
}, [setIsOpen]);
const handleMouseLeave = useCallback(() => {
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
clearTimeout(closeTimeout.current);
closeTimeout.current = setTimeout(() => {
setIsOpen(false);
}, 100);
}, [setIsOpen]);
const maxWidth = useMemo(() => {
const windowWidth = window.innerWidth;
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
return 800;
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
return 650;
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
return 500;
}
return 450;
}, []);
const computeMaxSize = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, right, bottom, left } = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (/^top/.test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if (/^bottom/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if (/^right/.test(data.placement)) {
data.styles.maxWidth = Math.min(maxWidth, windowWidth - right - 20);
data.styles.maxHeight = top - 20;
} else {
data.styles.maxWidth = Math.min(maxWidth, left - 20);
data.styles.maxHeight = top - 20;
}
return data;
},
[maxWidth]
);
useEffect(() => {
if (updater.current && isOpen) {
updater.current();
}
});
useEffect(() => {
return () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
}, []);
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnterAnchor}
onMouseLeave={handleMouseLeave}
>
{anchor}
</span>
)}
</Reference>
<Portal>
<Popper
// @ts-expect-error - PopperJS types are not in sync with our position types.
placement={position}
// Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
eventsEnabled={false}
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: computeMaxSize,
},
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: false,
},
flip: {
enabled: canFlip,
},
}}
>
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
updater.current = scheduleUpdate;
const popperPlacement = placement
? placement.split('-')[0]
: position;
const vertical =
popperPlacement === 'top' || popperPlacement === 'bottom';
return (
<div
ref={ref}
className={classNames(
styles.tooltipContainer,
vertical
? styles.verticalContainer
: styles.horizontalContainer
)}
style={style}
onMouseEnter={handleMouseEnterTooltip}
onMouseLeave={handleMouseLeave}
>
<div
ref={arrowProps.ref}
className={
isOpen
? classNames(
styles.arrow,
styles[kind],
// @ts-expect-error - is a string that may not exist in styles
styles[popperPlacement]
)
: styles.arrowDisabled
}
style={arrowProps.style}
/>
{isOpen ? (
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
export default Tooltip;

View File

@@ -9,6 +9,7 @@ interface Episode extends ModelBase {
episodeNumber: number;
airDate: string;
airDateUtc?: string;
lastSearchTime?: string;
runtime: number;
absoluteEpisodeNumber?: number;
sceneSeasonNumber?: number;

View File

@@ -111,7 +111,6 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
<MonitorToggleButton
id={episodeId}
monitored={monitored}
size={18}
isDisabled={!seriesMonitored}

View File

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
import InteractiveSearch from 'InteractiveSearch/InteractiveSearch';
import { executeCommand } from 'Store/Actions/commandActions';
import EpisodeSearch from './EpisodeSearch';
@@ -65,7 +65,7 @@ class EpisodeSearchConnector extends Component {
if (this.state.isInteractiveSearchOpen) {
return (
<InteractiveSearchConnector
<InteractiveSearch
type="episode"
searchPayload={{ episodeId }}
/>

View File

@@ -1,8 +0,0 @@
enum ScrollDirection {
Horizontal = 'horizontal',
Vertical = 'vertical',
None = 'none',
Both = 'both',
}
export default ScrollDirection;

View File

@@ -1,6 +0,0 @@
enum SortDirection {
Ascending = 'ascending',
Descending = 'descending',
}
export default SortDirection;

View File

@@ -1,3 +0,0 @@
type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
export default TooltipPosition;

View File

@@ -3,3 +3,5 @@ export const CENTER = 'center';
export const RIGHT = 'right';
export const all = [LEFT, CENTER, RIGHT];
export type Align = 'left' | 'center' | 'right';

View File

@@ -21,3 +21,15 @@ export const all = [
SUCCESS,
WARNING,
] as const;
export type Kind =
| 'danger'
| 'default'
| 'disabled'
| 'info'
| 'inverse'
| 'pink'
| 'primary'
| 'purple'
| 'success'
| 'warning';

View File

@@ -4,3 +4,5 @@ export const HORIZONTAL = 'horizontal';
export const VERTICAL = 'vertical';
export const all = [NONE, HORIZONTAL, VERTICAL, BOTH];
export type ScrollDirection = 'none' | 'both' | 'horizontal' | 'vertical';

View File

@@ -13,3 +13,11 @@ export const all = [
EXTRA_LARGE,
EXTRA_EXTRA_LARGE,
] as const;
export type Size =
| 'extraSmall'
| 'small'
| 'medium'
| 'large'
| 'extraLarge'
| 'extraExtraLarge';

View File

@@ -2,3 +2,5 @@ export const ASCENDING = 'ascending';
export const DESCENDING = 'descending';
export const all = [ASCENDING, DESCENDING];
export type SortDirection = 'ascending' | 'descending';

View File

@@ -3,9 +3,6 @@ export const RIGHT = 'right';
export const BOTTOM = 'bottom';
export const LEFT = 'left';
export const all = [
TOP,
RIGHT,
BOTTOM,
LEFT
];
export const all = [TOP, RIGHT, BOTTOM, LEFT];
export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';

View File

@@ -15,7 +15,7 @@ import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds, scrollDirections } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
clearEpisodes,
fetchEpisodes,

View File

@@ -1,234 +0,0 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css';
const columns = [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true
},
{
name: 'languageWeight',
label: () => translate('Languages'),
isSortable: true,
isVisible: true
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'indexerFlags',
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags')
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections')
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
}
];
function InteractiveSearch(props) {
const {
searchPayload,
isFetching,
isPopulated,
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
type,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress
} = props;
const errorMessage = getErrorMessage(error);
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
filterModalConnectorComponentProps={{ type }}
onFilterSelect={onFilterSelect}
/>
</div>
{
isFetching ? <LoadingIndicator /> : null
}
{
!isFetching && error ?
<div>
{
errorMessage ?
<Fragment>
{translate('InteractiveSearchResultsSeriesFailedErrorMessage', { message: errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1) })}
</Fragment> :
translate('EpisodeSearchResultsLoadError')
}
</div> :
null
}
{
!isFetching && isPopulated && !totalReleasesCount ?
<Alert kind={kinds.INFO}>
{translate('NoResultsFound')}
</Alert> :
null
}
{
!!totalReleasesCount && isPopulated && !items.length ?
<Alert kind={kinds.WARNING}>
{translate('AllResultsAreHiddenByTheAppliedFilter')}
</Alert> :
null
}
{
isPopulated && !!items.length ?
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveSearchRow
key={`${item.indexerId}-${item.guid}`}
{...item}
searchPayload={searchPayload}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table> :
null
}
{
totalReleasesCount !== items.length && !!items.length ?
<div className={styles.filteredMessage}>
{translate('SomeResultsAreHiddenByTheAppliedFilter')}
</div> :
null
}
</div>
);
}
InteractiveSearch.propTypes = {
searchPayload: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalReleasesCount: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
type: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired
};
export default InteractiveSearch;

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import ReleasesAppState from 'App/State/ReleasesAppState';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import InteractiveSearchType from 'InteractiveSearch/InteractiveSearchType';
import {
fetchReleases,
grabRelease,
setEpisodeReleasesFilter,
setReleasesSort,
setSeasonReleasesFilter,
} from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModal from './InteractiveSearchFilterModal';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css';
const columns: Column[] = [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true,
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true,
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true,
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true,
},
{
name: 'languageWeight',
label: () => translate('Languages'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'indexerFlags',
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections'),
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
];
interface InteractiveSearchProps {
type: InteractiveSearchType;
searchPayload: object;
}
function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
const {
isFetching,
isPopulated,
error,
items,
totalItems,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
}: ReleasesAppState & ClientSideCollectionAppState = useSelector(
createClientSideCollectionSelector('releases', `releases.${type}`)
);
const dispatch = useDispatch();
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
const action =
type === 'episode' ? setEpisodeReleasesFilter : setSeasonReleasesFilter;
dispatch(action({ selectedFilterKey }));
},
[type, dispatch]
);
const handleSortPress = useCallback(
(sortKey: string, sortDirection: SortDirection) => {
dispatch(setReleasesSort({ sortKey, sortDirection }));
},
[dispatch]
);
const handleGrabPress = useCallback(
(payload: object) => {
dispatch(grabRelease(payload));
},
[dispatch]
);
useEffect(() => {
// If search results are not yet isPopulated fetch them,
// otherwise re-show the existing props.
if (!isPopulated) {
dispatch(fetchReleases(searchPayload));
}
}, [isPopulated, searchPayload, dispatch]);
const errorMessage = getErrorMessage(error);
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModal}
filterModalConnectorComponentProps={{ type }}
onFilterSelect={handleFilterSelect}
/>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<div>
{errorMessage ? (
<>
{translate('InteractiveSearchResultsSeriesFailedErrorMessage', {
message:
errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1),
})}
</>
) : (
translate('EpisodeSearchResultsLoadError')
)}
</div>
) : null}
{!isFetching && isPopulated && !totalItems ? (
<Alert kind={kinds.INFO}>{translate('NoResultsFound')}</Alert>
) : null}
{!!totalItems && isPopulated && !items.length ? (
<Alert kind={kinds.WARNING}>
{translate('AllResultsAreHiddenByTheAppliedFilter')}
</Alert>
) : null}
{isPopulated && !!items.length ? (
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
return (
<InteractiveSearchRow
key={`${item.indexerId}-${item.guid}`}
{...item}
searchPayload={searchPayload}
onGrabPress={handleGrabPress}
/>
);
})}
</TableBody>
</Table>
) : null}
{totalItems !== items.length && !!items.length ? (
<div className={styles.filteredMessage}>
{translate('SomeResultsAreHiddenByTheAppliedFilter')}
</div>
) : null}
</div>
);
}
export default InteractiveSearch;

View File

@@ -1,95 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import InteractiveSearch from './InteractiveSearch';
function createMapStateToProps(appState, { type }) {
return createSelector(
(state) => state.releases.items.length,
createClientSideCollectionSelector('releases', `releases.${type}`),
createUISettingsSelector(),
(totalReleasesCount, releases, uiSettings) => {
return {
totalReleasesCount,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
...releases
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchReleases(payload) {
dispatch(releaseActions.fetchReleases(payload));
},
onSortPress(sortKey, sortDirection) {
dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection }));
},
onFilterSelect(selectedFilterKey) {
const action = props.type === 'episode' ?
releaseActions.setEpisodeReleasesFilter :
releaseActions.setSeasonReleasesFilter;
dispatch(action({ selectedFilterKey }));
},
onGrabPress(payload) {
dispatch(releaseActions.grabRelease(payload));
}
};
}
class InteractiveSearchConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
searchPayload,
isPopulated,
dispatchFetchReleases
} = this.props;
// If search results are not yet isPopulated fetch them,
// otherwise re-show the existing props.
if (!isPopulated) {
dispatchFetchReleases(searchPayload);
}
}
//
// Render
render() {
const {
dispatchFetchReleases,
...otherProps
} = this.props;
return (
<InteractiveSearch
{...otherProps}
/>
);
}
}
InteractiveSearchConnector.propTypes = {
type: PropTypes.string.isRequired,
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool,
dispatchFetchReleases: PropTypes.func
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);

View File

@@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import InteractiveSearchType from 'InteractiveSearch/InteractiveSearchType';
import {
setEpisodeReleasesFilter,
setSeasonReleasesFilter,
} from 'Store/Actions/releaseActions';
function createReleasesSelector() {
return createSelector(
(state: AppState) => state.releases.items,
(releases) => {
return releases;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.releases.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface InteractiveSearchFilterModalProps {
isOpen: boolean;
type: InteractiveSearchType;
}
export default function InteractiveSearchFilterModal({
type,
...otherProps
}: InteractiveSearchFilterModalProps) {
const sectionItems = useSelector(createReleasesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'releases';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
const action =
type === 'episode' ? setEpisodeReleasesFilter : setSeasonReleasesFilter;
dispatch(action(payload));
},
[type, dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...otherProps}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@@ -1,32 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterModal from 'Components/Filter/FilterModal';
import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions';
function createMapStateToProps() {
return createSelector(
(state) => state.releases.items,
(state) => state.releases.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'releases'
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchSetFilter(payload) {
const action = props.type === 'episode' ?
setEpisodeReleasesFilter:
setSeasonReleasesFilter;
dispatch(action(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
@@ -8,15 +9,13 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import IndexerFlags from 'Episode/IndexerFlags';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Release from 'typings/Release';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -24,7 +23,6 @@ import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
import Peers from './Peers';
import ReleaseEpisode from './ReleaseEpisode';
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
import styles from './InteractiveSearchRow.css';
@@ -72,43 +70,7 @@ function getDownloadTooltip(
return translate('AddToDownloadQueue');
}
interface InteractiveSearchRowProps {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
infoUrl: string;
indexerId: number;
indexer: string;
size: number;
seeders?: number;
leechers?: number;
quality: QualityModel;
languages: Language[];
customFormats: CustomFormat[];
customFormatScore: number;
sceneMapping?: object;
seasonNumber?: number;
episodeNumbers?: number[];
absoluteEpisodeNumbers?: number[];
mappedSeriesId?: number;
mappedSeasonNumber?: number;
mappedEpisodeNumbers?: number[];
mappedAbsoluteEpisodeNumbers?: number[];
mappedEpisodeInfo: ReleaseEpisode[];
indexerFlags: number;
rejections: string[];
episodeRequested: boolean;
downloadAllowed: boolean;
isDaily: boolean;
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
longDateFormat: string;
timeFormat: string;
interface InteractiveSearchRowProps extends Release {
searchPayload: object;
onGrabPress(...args: unknown[]): void;
}
@@ -148,13 +110,15 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
isDaily,
isGrabbing = false,
isGrabbed = false,
longDateFormat,
timeFormat,
grabError,
searchPayload,
onGrabPress,
} = props;
const { longDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);

View File

@@ -0,0 +1,3 @@
type InteractiveSearchType = 'episode' | 'season';
export default InteractiveSearchType;

View File

@@ -2,9 +2,9 @@ import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import { ReleaseEpisode } from 'typings/Release';
import OverrideMatchModalContent from './OverrideMatchModalContent';
interface OverrideMatchModalProps {

View File

@@ -18,7 +18,6 @@ import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import ReleaseEpisode from 'InteractiveSearch/ReleaseEpisode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
@@ -26,6 +25,7 @@ import { grabRelease } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import { createSeriesSelectorForHook } from 'Store/Selectors/createSeriesSelector';
import { ReleaseEpisode } from 'typings/Release';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';

View File

@@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function getKind(seeders) {
function getKind(seeders: number = 0) {
if (seeders > 50) {
return kinds.PRIMARY;
}
@@ -19,7 +18,7 @@ function getKind(seeders) {
return kinds.DANGER;
}
function getPeersTooltipPart(peers, peersUnit) {
function getPeersTooltipPart(peersUnit: string, peers?: number) {
if (peers == null) {
return `Unknown ${peersUnit}s`;
}
@@ -31,27 +30,27 @@ function getPeersTooltipPart(peers, peersUnit) {
return `${peers} ${peersUnit}s`;
}
function Peers(props) {
const {
seeders,
leechers
} = props;
interface PeersProps {
seeders?: number;
leechers?: number;
}
function Peers(props: PeersProps) {
const { seeders, leechers } = props;
const kind = getKind(seeders);
return (
<Label
kind={kind}
title={`${getPeersTooltipPart(seeders, 'seeder')}, ${getPeersTooltipPart(leechers, 'leecher')}`}
title={`${getPeersTooltipPart('seeder', seeders)}, ${getPeersTooltipPart(
'leecher',
leechers
)}`}
>
{seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
</Label>
);
}
Peers.propTypes = {
seeders: PropTypes.number,
leechers: PropTypes.number
};
export default Peers;

View File

@@ -1,10 +0,0 @@
interface ReleaseEpisode {
id: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
title: string;
}
export default ReleaseEpisode;

View File

@@ -11,3 +11,10 @@
cursor: pointer;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.links {
display: flex;
flex-flow: column wrap;
}
}

View File

@@ -1,23 +1,23 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import { kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import styles from './SeriesDetailsLinks.css';
function SeriesDetailsLinks(props) {
const {
tvdbId,
tvMazeId,
imdbId,
tmdbId
} = props;
type SeriesDetailsLinksProps = Pick<
Series,
'tvdbId' | 'tvMazeId' | 'imdbId' | 'tmdbId'
>;
function SeriesDetailsLinks(props: SeriesDetailsLinksProps) {
const { tvdbId, tvMazeId, imdbId, tmdbId } = props;
return (
<div className={styles.links}>
<Link
className={styles.link}
to={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
>
<Label
className={styles.linkLabel}
@@ -30,7 +30,7 @@ function SeriesDetailsLinks(props) {
<Link
className={styles.link}
to={`http://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
to={`https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
>
<Label
className={styles.linkLabel}
@@ -41,27 +41,26 @@ function SeriesDetailsLinks(props) {
</Label>
</Link>
{
!!tvMazeId &&
<Link
className={styles.link}
to={`http://www.tvmaze.com/shows/${tvMazeId}/_`}
{tvMazeId ? (
<Link
className={styles.link}
to={`https://www.tvmaze.com/shows/${tvMazeId}/_`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
TV Maze
</Label>
</Link>
}
TV Maze
</Label>
</Link>
) : null}
{
!!imdbId &&
{imdbId ? (
<>
<Link
className={styles.link}
to={`http://imdb.com/title/${imdbId}/`}
to={`https://imdb.com/title/${imdbId}/`}
>
<Label
className={styles.linkLabel}
@@ -71,32 +70,38 @@ function SeriesDetailsLinks(props) {
IMDB
</Label>
</Link>
}
{
!!tmdbId &&
<Link
className={styles.link}
to={`https://www.themoviedb.org/tv/${tmdbId}`}
to={`http://mdblist.com/show/${imdbId}`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
TMDB
MDBList
</Label>
</Link>
}
</>
) : null}
{tmdbId ? (
<Link
className={styles.link}
to={`https://www.themoviedb.org/tv/${tmdbId}`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
TMDB
</Label>
</Link>
) : null}
</div>
);
}
SeriesDetailsLinks.propTypes = {
tvdbId: PropTypes.number.isRequired,
tvMazeId: PropTypes.number,
imdbId: PropTypes.string,
tmdbId: PropTypes.number
};
export default SeriesDetailsLinks;

View File

@@ -3,7 +3,7 @@ import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
interface SeriesIndexSortMenuProps {
@@ -153,6 +153,15 @@ function SeriesIndexSortMenu(props: SeriesIndexSortMenuProps) {
>
{translate('Tags')}
</SortMenuItem>
<SortMenuItem
name="ratings"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Rating')}
</SortMenuItem>
</MenuContent>
</SortMenu>
);

View File

@@ -5,7 +5,7 @@ import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import useMeasure from 'Helpers/Hooks/useMeasure';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import SeriesIndexPoster from 'Series/Index/Posters/SeriesIndexPoster';
import Series from 'Series/Series';
import dimensions from 'Styles/Variables/dimensions';

View File

@@ -22,7 +22,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions';
@@ -201,7 +201,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const order = Object.keys(characters).sort();
// Reverse if sorting descending
if (sortDirection === SortDirection.Descending) {
if (sortDirection === DESCENDING) {
order.reverse();
}

View File

@@ -7,8 +7,7 @@ import AppState from 'App/State/AppState';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
@@ -172,10 +171,7 @@ function SeriesIndexTable(props: SeriesIndexTableProps) {
return (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<Scroller className={styles.tableScroller} scrollDirection="horizontal">
<SeriesIndexTableHeader
showBanners={showBanners}
columns={columns}

Some files were not shown because too many files have changed in this diff Show More