1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-09 15:00:03 -04:00

Compare commits

..

65 Commits

Author SHA1 Message Date
Mark McDowall
d0101bdca1 Don't publish v5 builds yet 2025-02-01 11:58:31 -08:00
Mark McDowall
539f55deae Fix tests for v5 2025-02-01 11:58:31 -08:00
Mark McDowall
0f90544a35 Improve build speed 2025-02-01 11:58:31 -08:00
Mark McDowall
f10b0e51b5 Fix builds with v5 API 2025-01-30 21:15:48 -08:00
Mark McDowall
b5c54575c2 Fix import order after TS 2025-01-29 20:11:50 -08:00
Mark McDowall
f89ee64edc Build and conflict labeler for PRs against v5-develop 2025-01-29 20:11:50 -08:00
Mark McDowall
59800d3675 v5 API docs 2025-01-29 20:11:50 -08:00
Mark McDowall
ed1b17005f Add v5 API 2025-01-26 08:13:54 -08:00
Mark McDowall
ea5231abef Fix signalR 2025-01-25 19:38:34 -08:00
Steel City Phantom
42206b023f Auto-detect building on macOS ARM 2025-01-25 19:38:34 -08:00
Stevie Robinson
bf55bca142 New: Show size in history details
Closes #7594
2025-01-25 19:38:09 -08:00
Bogdan
496eb6fd37 Typing for Interactive Search payload 2025-01-25 19:38:08 -08:00
Bogdan
1a5fa185d1 New: Migrate appdata folder for .NET 8 on OSX 2025-01-25 19:38:08 -08:00
Bogdan
518f1799dc New: Bump to .NET 8 2025-01-25 19:38:08 -08:00
Mark McDowall
9f570d4dbf Convert signalR to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
dd014993f1 Convert Import Series to TypeScript 2025-01-25 19:38:08 -08:00
Mark McDowall
61876e795f Remove pnpm-lock.yaml 2025-01-25 19:38:07 -08:00
Mark McDowall
e721a06291 Remove defaultProps from SeriesIndexFilterMenu 2025-01-25 19:38:07 -08:00
Mark McDowall
37cc66ce66 Convert Add New Series to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
9c196c5fa0 Remove Measure 2025-01-25 19:38:07 -08:00
Mark McDowall
7dcf0808dc Convert Series Details to TypeScript 2025-01-25 19:38:07 -08:00
Mark McDowall
46afe84edc Convert Delete Series Modal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
0ff3101511 Convert Series History to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
17aab235a5 Fixed Import List CleanLibraryLevel Options 2025-01-25 19:38:06 -08:00
Mark McDowall
f1cfef19b2 Convert Monitoring Options to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
d89d1b2f8b Convert MoveSeriesModal to TypeScript 2025-01-25 19:38:06 -08:00
Mark McDowall
f986062d4c Convert FilterBuilder types to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
e2bc322462 Convert Date utilties to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
8e2263d1a1 Convert TableOptionsWrapper to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
307135d3f0 Convert Series Popovers to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
0b58278e15 Convert Table to TypeScript 2025-01-25 19:38:05 -08:00
Mark McDowall
d8a147d234 Convert Messages to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
bba2ab98b6 Remove withCurrentPage 2025-01-25 19:38:04 -08:00
Mark McDowall
4852afcad7 Convert Missing to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
0cb656cdd8 Convert Cutoff Unmet to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
4a5b839d93 Convert Custom Format settings to TypeScript 2025-01-25 19:38:04 -08:00
Mark McDowall
65bae6a7ce Convert Notifications to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
8b0a1b7756 Convert Download Client settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
aad8ba0f9b Improve typings in FormInputGroup 2025-01-25 19:38:03 -08:00
Mark McDowall
ebb0aab2f5 Convert General Settings to TypeScript 2025-01-25 19:38:03 -08:00
Mark McDowall
e62c687d93 Convert ImportLists to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
ac2ecae874 Convert Indexer settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
3991eec5e0 Convert Media Management settings to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
15e4599d31 Convert MetadataSource to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
9f1b0d3a3b Upgrade react-dnd and DnD Components to TypeScript 2025-01-25 19:38:02 -08:00
Mark McDowall
64c1ef85c4 New: Quality limits are part of Quality Profile
Closes #613
2025-01-25 19:38:01 -08:00
Mark McDowall
9ce473d9bb Convert Quality Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
89f584d1b3 Convert Tags to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
405ee7473c Convert MetadataSettings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
e9f8023528 Convert UI Settings to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
86c785ffa0 Convert SettingsToolbar to TypeScript 2025-01-25 19:38:01 -08:00
Mark McDowall
64160866c3 Convert Log FIles to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
1e5932d89a Convert Log Events to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
9276bd7a16 Convert Backup and Restore to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
8a8ea4eb94 Convert Preview Rename to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
6bee95747e Convert SelectSeriesRow to TypeScript 2025-01-25 19:38:00 -08:00
Mark McDowall
dc576d0dd3 Convert TagList components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
5dfb5de863 Convert Menu components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
ac7ac34cc2 Convert ProviderFieldFormGroup to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
24173139f0 Remove defaultProps from TypeScript components 2025-01-25 19:37:59 -08:00
Mark McDowall
3cd8a2a98b Convert Filter components to TypeScript 2025-01-25 19:37:59 -08:00
Mark McDowall
e4f1b2c4ec Convert Spinner button components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
442b3b506f Convert Modal components to TypeScript 2025-01-25 19:37:58 -08:00
Mark McDowall
e8a6ce371d useMeasure instead of Measure in TypeScript components 2025-01-25 19:37:58 -08:00
Mark McDowall
d9e5842f8b Convert Page components to TypeScript 2025-01-25 19:37:58 -08:00
584 changed files with 5230 additions and 14288 deletions

View File

@@ -170,7 +170,7 @@ runs:
framework="${{ inputs.framework }}"
runtime="${{ inputs.runtime }}"
cp scripts/test.sh "_tests/$framework/$runtime/publish"
cp test.sh "_tests/$framework/$runtime/publish"
rm -f _tests/$framework/$runtime/*.log.config

View File

@@ -33,7 +33,7 @@ jobs:
id: setup-dotnet
- name: Create openapi.json
run: ./scripts/docs.sh Linux x64
run: ./docs.sh Linux x64
- name: Commit API Docs Change
continue-on-error: true
@@ -50,16 +50,3 @@ jobs:
else
echo "No changes since last run"
fi
- name: Notify
if: failure()
uses: tsickert/discord-webhook@v6.0.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: "GitHub Actions"
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
embed-title: "${{ github.workflow }}: Failure"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
Failed to update API docs
embed-color: "15158332"

View File

@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2025
- Copyright 2010-2024

103
distribution/debian/install.sh Executable file → Normal file
View File

@@ -6,7 +6,6 @@
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
### Boilerplate Warning
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
@@ -17,8 +16,8 @@
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
scriptversion="1.0.4"
scriptdate="2025-04-05"
scriptversion="1.0.3"
scriptdate="2024-01-06"
set -euo pipefail
@@ -50,106 +49,18 @@ if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindi
exit
fi
show_help() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--user <name> What user will $app run under?
User will be created if it doesn't already exist.
--group <name> What group will $app run under?
Group will be created if it doesn't already exist.
-u Unattended mode
The installer will not prompt or pause, making it suitable for automated installations.
This option requires the use of --user and --group to supply those inputs for the script.
-h, --help Show this help message and exit
EOF
}
# Default values for command-line arguments
arg_user=""
arg_group=""
arg_unattended=false
# Parse command-line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--user=*)
arg_user="${1#*=}"
shift
;;
--user)
if [[ -n "$2" && "$2" != -* ]]; then
arg_user="$2"
shift 2
else
echo "Error: --user requires a value." >&2
exit 1
fi
;;
--group=*)
arg_group="${1#*=}"
shift
;;
--group)
if [[ -n "$2" && "$2" != -* ]]; then
arg_group="$2"
shift 2
else
echo "Error: --group requires a value." >&2
exit 1
fi
;;
-u)
arg_unattended=true
shift
;;
-h|--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
echo "Use --help to see valid options." >&2
exit 1
;;
esac
done
# If unattended mode is requested, require user and group
if $arg_unattended; then
if [[ -z "$arg_user" || -z "$arg_group" ]]; then
echo "Error: --user and --group are required when using -u (unattended mode)." >&2
exit 1
fi
fi
# Prompt User if necessary
if [ -n "$arg_user" ]; then
app_uid="$arg_user"
else
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
fi
# Prompt User
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
app_uid=$(echo "$app_uid" | tr -d ' ')
app_uid=${app_uid:-$app}
# Prompt Group if necessary
if [ -n "$arg_group" ]; then
app_guid="$arg_group"
else
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
fi
# Prompt Group
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
if ! $arg_unattended; then
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
fi
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed
if [ "$app_guid" != "$app_uid" ]; then

View File

@@ -7,9 +7,9 @@ cd /data/test
runTest()
{
bash scripts/test.sh Linux $1
bash test.sh Linux $1
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
}
runTest Integration
runTest Unit
runTest Unit

View File

@@ -23,7 +23,7 @@ rm -rf $outputFolder
rm -rf $testPackageFolder
slnFile=src/Sonarr.sln
outputFile=src/Sonarr.Api.V5/openapi.json
platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
@@ -38,10 +38,7 @@ dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 8.0.0 Swashbuckle.AspNetCore.Cli
# Remove the openapi.json file so we can check if it was created
rm $outputFile
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Sonarr.Api.V5/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v5 &
@@ -49,9 +46,4 @@ sleep 45
kill %1
if [ ! -f $outputFile ]; then
echo "$outputFile not found, check logs for errors"
exit 1
fi
exit 0

View File

@@ -65,7 +65,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: 'auto',
publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
sourceMapFilename: '[file].map'
},
@@ -176,7 +176,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.42'
corejs: '3.39'
}
]
]

View File

@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFailed') {
const { message, indexer } = data as DownloadFailedHistory;
const { message } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -188,10 +188,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}

View File

@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
anchor={progressBar!}
title={`${state} - ${progress.toFixed(1)}%`}
body={<div>{title}</div>}
position="bottom-start"
position={tooltipPositions.LEFT}
/>
);
}

View File

@@ -1,10 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React, { useCallback, useMemo, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
@@ -12,8 +9,6 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css';
@@ -35,6 +30,12 @@ interface RemoveQueueItemModalProps {
onModalClose: () => void;
}
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
@@ -47,13 +48,12 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
onModalClose,
} = props;
const dispatch = useDispatch();
const multipleSelected = selectedCount && selectedCount > 1;
const { removalMethod, blocklistMethod } = useSelector(
(state: AppState) => state.queue.removalOptions
);
const [removalMethod, setRemovalMethod] =
useState<RemovalMethod>('removeFromClient');
const [blocklistMethod, setBlocklistMethod] =
useState<BlocklistMethod>('doNotBlocklist');
const { title, message } = useMemo(() => {
if (!selectedCount) {
@@ -79,7 +79,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [sourceTitle, selectedCount]);
const removalMethodOptions = useMemo(() => {
const options: EnhancedSelectInputValue<string>[] = [
return [
{
key: 'removeFromClient',
value: translate('RemoveFromDownloadClient'),
@@ -106,12 +106,10 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('IgnoreDownloadHint'),
},
];
return options;
}, [canChangeCategory, canIgnore, multipleSelected]);
const blocklistMethodOptions = useMemo(() => {
const options: EnhancedSelectInputValue<string>[] = [
return [
{
key: 'doNotBlocklist',
value: translate('DoNotBlocklist'),
@@ -133,15 +131,20 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('BlocklistOnlyHint'),
},
];
return options;
}, [isPending, multipleSelected]);
const handleRemovalOptionInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setQueueRemovalOption({ [name]: value }));
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {
setRemovalMethod(value);
},
[dispatch]
[setRemovalMethod]
);
const handleBlocklistMethodChange = useCallback(
({ value }: { value: BlocklistMethod }) => {
setBlocklistMethod(value);
},
[setBlocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
@@ -151,11 +154,23 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
}, [removalMethod, blocklistMethod, onRemovePress]);
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
}, [
removalMethod,
blocklistMethod,
setRemovalMethod,
setBlocklistMethod,
onRemovePress,
]);
const handleModalClose = useCallback(() => {
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
onModalClose();
}, [onModalClose]);
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
@@ -178,7 +193,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
onChange={handleRemovalOptionInputChange}
onChange={handleRemovalMethodChange}
/>
</FormGroup>
)}
@@ -196,7 +211,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
onChange={handleRemovalOptionInputChange}
onChange={handleBlocklistMethodChange}
/>
</FormGroup>
</ModalBody>

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
@@ -9,6 +10,7 @@ import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useQueryParams from 'Helpers/Hooks/useQueryParams';
import { icons, kinds } from 'Helpers/Props';
@@ -16,7 +18,6 @@ import { InputChanged } from 'typings/inputs';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
import { useLookupSeries } from './useAddSeries';
import styles from './AddNewSeries.css';
function AddNewSeries() {
@@ -47,7 +48,12 @@ function AddNewSeries() {
isFetching: isFetchingApi,
error,
data = [],
} = useLookupSeries(query);
} = useApiQuery<AddSeries[]>({
path: `/series/lookup?term=${query}`,
queryOptions: {
enabled: !!query,
},
});
useEffect(() => {
setIsFetching(isFetchingApi);
@@ -97,9 +103,7 @@ function AddNewSeries() {
{!isFetching && !error && !!data.length ? (
<div className={styles.searchResults}>
{data.map((item) => {
return (
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
);
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
})}
</div>
) : null}

View File

@@ -1,13 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import { AddSeries } from 'App/State/AddSeriesAppState';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@@ -21,43 +17,46 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { useAddSeries } from './useAddSeries';
import styles from './AddNewSeriesModalContent.css';
export interface AddNewSeriesModalContentProps {
series: AddSeries;
initialSeriesType: SeriesType;
export interface AddNewSeriesModalContentProps
extends Pick<
AddSeries,
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
> {
initialSeriesType: string;
onModalClose: () => void;
}
function AddNewSeriesModalContent({
series,
tvdbId,
title,
year,
overview,
images,
folder,
initialSeriesType,
onModalClose,
}: AddNewSeriesModalContentProps) {
const { title, year, overview, images, folder } = series;
const options = useAddSeriesOptions();
const dispatch = useDispatch();
const { isAdding, addError, defaults } = useSelector(
(state: AppState) => state.addSeries
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const {
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
}, [options, addError]);
return selectSettings(defaults, {}, addError);
}, [defaults, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(
const [seriesType, setSeriesType] = useState(
initialSeriesType === 'standard'
? settings.seriesType.value
: initialSeriesType
@@ -75,33 +74,35 @@ function AddNewSeriesModalContent({
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
setAddSeriesOption(name as keyof AddSeriesOptions, value);
({ name, value }: InputChanged) => {
dispatch(setAddSeriesDefault({ [name]: value }));
},
[]
[dispatch]
);
const handleQualityProfileIdChange = useCallback(
({ value }: InputChanged<string | number>) => {
setAddSeriesOption('qualityProfileId', value as number);
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
},
[]
[dispatch]
);
const handleAddSeriesPress = useCallback(() => {
addSeries({
...series,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
});
dispatch(
addSeries({
tvdbId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
seriesType,
seasonFolder: seasonFolder.value,
searchForMissingEpisodes: searchForMissingEpisodes.value,
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
tags: tags.value,
})
);
}, [
series,
tvdbId,
seriesType,
rootFolderPath,
monitor,
@@ -110,7 +111,7 @@ function AddNewSeriesModalContent({
searchForMissingEpisodes,
searchForCutoffUnmetEpisodes,
tags,
addSeries,
dispatch,
]);
useEffect(() => {

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { AddSeries } from 'App/State/AddSeriesAppState';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@@ -16,27 +16,24 @@ import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
interface AddNewSeriesSearchResultProps {
series: AddSeries;
}
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
const {
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
overview,
seriesType,
images,
} = series;
type AddNewSeriesSearchResultProps = AddSeries;
function AddNewSeriesSearchResult({
tvdbId,
titleSlug,
title,
year,
network,
originalLanguage,
genres = [],
status,
statistics = {} as Statistics,
ratings,
folder,
overview,
seriesType,
images,
}: AddNewSeriesSearchResultProps) {
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
const { isSmallScreen } = useSelector(createDimensionsSelector());
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
@@ -171,8 +168,13 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
<AddNewSeriesModal
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
series={series}
tvdbId={tvdbId}
title={title}
year={year}
overview={overview}
folder={folder}
initialSeriesType={seriesType}
images={images}
onModalClose={handleAddSeriesModalClose}
/>
</div>

View File

@@ -1,43 +0,0 @@
import { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import AddSeries from 'AddSeries/AddSeries';
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Series from 'Series/Series';
import { updateItem } from 'Store/Actions/baseActions';
type AddSeriesPayload = AddSeries & AddSeriesOptions;
export const useLookupSeries = (query: string) => {
return useApiQuery<AddSeries[]>({
path: '/series/lookup',
queryParams: {
term: query,
},
queryOptions: {
enabled: !!query,
// Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false,
},
});
};
export const useAddSeries = () => {
const dispatch = useDispatch();
const onAddSuccess = useCallback(
(data: Series) => {
dispatch(updateItem({ section: 'series', ...data }));
},
[dispatch]
);
return useApiMutation<Series, AddSeriesPayload>({
path: '/series',
method: 'POST',
mutationOptions: {
onSuccess: onAddSuccess,
},
});
};

View File

@@ -1,7 +0,0 @@
import Series from 'Series/Series';
interface AddSeries extends Series {
folder: string;
}
export default AddSeries;

View File

@@ -1,10 +1,6 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import {
setAddSeriesOption,
useAddSeriesOption,
} from 'AddSeries/addSeriesOptionsStore';
import { SelectProvider } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
@@ -12,6 +8,7 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import translate from 'Utilities/String/translate';
@@ -51,7 +48,9 @@ function ImportSeries() {
(state: AppState) => state.settings.qualityProfiles.items
);
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
const defaultQualityProfileId = useSelector(
(state: AppState) => state.addSeries.defaults.qualityProfileId
);
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -77,7 +76,9 @@ function ImportSeries() {
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
dispatch(
setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id })
);
}
}, [defaultQualityProfileId, qualityProfiles, dispatch]);

View File

@@ -1,10 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
AddSeriesOptions,
setAddSeriesOption,
useAddSeriesOptions,
} from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import CheckInput from 'Components/Form/CheckInput';
@@ -17,6 +12,7 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesMonitor, SeriesType } from 'Series/Series';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import {
cancelLookupSeries,
importSeries,
@@ -37,7 +33,7 @@ function ImportSeriesFooter() {
qualityProfileId: defaultQualityProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder,
} = useAddSeriesOptions();
} = useSelector((state: AppState) => state.addSeries.defaults);
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
(state: AppState) => state.importSeries
@@ -114,7 +110,7 @@ function ImportSeriesFooter() {
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
({ name, value }: InputChanged) => {
if (name === 'monitor') {
setMonitor(value as SeriesMonitor);
} else if (name === 'qualityProfileId') {
@@ -125,7 +121,7 @@ function ImportSeriesFooter() {
setSeasonFolder(value as boolean);
}
setAddSeriesOption(name as keyof AddSeriesOptions, value);
dispatch(setAddSeriesDefault({ [name]: value }));
selectedIds.forEach((id) => {
dispatch(

View File

@@ -75,6 +75,11 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
[selectDispatch]
);
console.info(
'\x1b[36m[MarkTest] is selected\x1b[0m',
selectState.selectedState[id]
);
return (
<>
<VirtualTableSelectCell

View File

@@ -1,7 +1,6 @@
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import { ImportSeries } from 'App/State/ImportSeriesAppState';
@@ -60,8 +59,9 @@ function ImportSeriesTable({
}: ImportSeriesTableProps) {
const dispatch = useDispatch();
const { monitor, qualityProfileId, seriesType, seasonFolder } =
useAddSeriesOptions();
const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector(
(state: AppState) => state.addSeries.defaults
);
const items = useSelector((state: AppState) => state.importSeries.items);
const { isSmallScreen } = useSelector(createDimensionsSelector());

View File

@@ -1,13 +1,5 @@
import {
autoUpdate,
flip,
FloatingPortal,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormInputButton from 'Components/Form/FormInputButton';
@@ -15,6 +7,7 @@ import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Portal from 'Components/Portal';
import { icons, kinds } from 'Helpers/Props';
import {
queueLookupSeries,
@@ -54,6 +47,9 @@ function ImportSeriesSelectSeries({
// @ts-expect-error - ignoring this for now
} = useSelector(createImportSeriesItemSelector(id, { id }));
const buttonId = useId();
const contentId = useId();
const updater = useRef<(() => void) | null>(null);
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
const [term, setTerm] = useState('');
@@ -61,6 +57,37 @@ function ImportSeriesSelectSeries({
const errorMessage = getErrorMessage(error);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const content = document.getElementById(contentId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected) {
return;
}
if (
!button.contains(eventTarget) &&
content &&
!content.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isOpen, buttonId, contentId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
}, []);
@@ -120,139 +147,157 @@ function ImportSeriesSelectSeries({
[id, items, dispatch, onInputChange]
);
useEffect(() => {
if (updater.current) {
updater.current();
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
useEffect(() => {
setTerm(itemTerm);
}, [itemTerm]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
],
open: isOpen,
placement: 'bottom',
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
<Link className={styles.button} component="div" onPress={handlePress}>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<Link
// ref={ref}
className={styles.button}
component="div"
onPress={handlePress}
>
{isLookingUpSeries && isQueued && !isPopulated ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries && isExistingSeries ? (
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && selectedSeries ? (
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
) : null}
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('NoMatchFound')}
</div>
) : null}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
</Link>
</div>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.contentContainer}
style={floatingStyles}
{...getFloatingProps()}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
{isPopulated && !selectedSeries ? (
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
{translate('NoMatchFound')}
</div>
) : null}
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
{!isFetching && !!error ? (
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
{translate('SearchFailedError')}
</div>
) : null}
<div className={styles.dropdownArrowContainer}>
<Icon name={icons.CARET_DOWN} />
</div>
) : null}
</Link>
</div>
</FloatingPortal>
) : null}
</>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport',
},
}}
>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={ref}
id={contentId}
className={styles.contentContainer}
style={style}
>
{isOpen ? (
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={term}
onChange={handleSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={handleRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{items.map((item) => {
return (
<ImportSeriesSearchResult
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={handleSeriesSelect}
/>
);
})}
</div>
</div>
) : null}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}

View File

@@ -1,49 +0,0 @@
import { createPersist } from 'Helpers/createPersist';
import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
tags: number[];
}
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
'add_series_options',
() => {
return {
rootFolderPath: '',
monitor: 'all',
qualityProfileId: 0,
seriesType: 'standard',
seasonFolder: true,
searchForMissingEpisodes: false,
searchForCutoffUnmetEpisodes: false,
tags: [],
};
}
);
export const useAddSeriesOptions = () => {
return addSeriesOptionsStore((state) => state);
};
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};

View File

@@ -11,7 +11,6 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { fetchUpdates } from 'Store/Actions/systemActions';
import UpdateChanges from 'System/Updates/UpdateChanges';
import useUpdates from 'System/Updates/useUpdates';
import Update from 'typings/Update';
import translate from 'Utilities/String/translate';
import AppState from './State/AppState';
@@ -66,12 +65,14 @@ interface AppUpdatedModalContentProps {
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
const dispatch = useDispatch();
const { version, prevVersion } = useSelector((state: AppState) => state.app);
const { isFetched, error, data } = useUpdates();
const { isPopulated, error, items } = useSelector(
(state: AppState) => state.system.updates
);
const previousVersion = usePrevious(version);
const { onModalClose } = props;
const update = mergeUpdates(data, version, prevVersion);
const update = mergeUpdates(items, version, prevVersion);
const handleSeeChangesPress = useCallback(() => {
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
@@ -99,7 +100,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
/>
</div>
{isFetched && !error && !!update ? (
{isPopulated && !error && !!update ? (
<div>
{update.changes ? (
<div className={styles.maintenance}>
@@ -125,7 +126,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
</div>
) : null}
{!isFetched && !error ? <LoadingIndicator /> : null}
{!isPopulated && !error ? <LoadingIndicator /> : null}
</ModalBody>
<ModalFooter>

View File

@@ -1,9 +1,20 @@
import { useCallback, useEffect } from 'react';
import useTheme from 'Helpers/Hooks/useTheme';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme() {
const theme = useTheme();
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {

View File

@@ -0,0 +1,25 @@
import AppSectionState, { Error } from 'App/State/AppSectionState';
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeries extends Series {
folder: string;
}
interface AddSeriesAppState extends AppSectionState<AddSeries> {
isAdding: boolean;
isAdded: boolean;
addError: Error | undefined;
defaults: {
rootFolderPath: string;
monitor: SeriesMonitor;
qualityProfileId: number;
seriesType: SeriesType;
seasonFolder: boolean;
tags: number[];
searchForMissingEpisodes: boolean;
searchForCutoffUnmetEpisodes: boolean;
};
}
export default AddSeriesAppState;

View File

@@ -1,6 +1,7 @@
import ModelBase from 'App/ModelBase';
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import AddSeriesAppState from './AddSeriesAppState';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
@@ -49,6 +50,7 @@ export interface PropertyFilter {
export interface Filter {
key: string;
label: string | (() => string);
type: string;
filters: PropertyFilter[];
}
@@ -81,6 +83,7 @@ export interface AppSectionState {
}
interface AppState {
addSeries: AddSeriesAppState;
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;

View File

@@ -0,0 +1,14 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import LogEvent from 'typings/LogEvent';
interface LogsAppState
extends AppSectionState<LogEvent>,
AppSectionFilterState<LogEvent>,
PagedAppSectionState,
TableAppSectionState {}
export default LogsAppState;

View File

@@ -32,17 +32,6 @@ export interface QueuePagedAppState
removeError: Error;
}
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
export type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
interface RemovalOptions {
removalMethod: RemovalMethod;
blocklistMethod: BlocklistMethod;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
@@ -50,7 +39,6 @@ interface QueueAppState {
options: {
includeUnknownSeriesItems: boolean;
};
removalOptions: RemovalOptions;
}
export default QueueAppState;

View File

@@ -3,23 +3,28 @@ import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import BackupAppState from './BackupAppState';
import LogsAppState from './LogsAppState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
backups: BackupAppState;
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
logs: LogsAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -22,9 +22,9 @@ function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some(
(item) => item.episodeId && episodeIds.includes(item.episodeId)
);
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
@@ -61,10 +61,10 @@ function CalendarEventGroup({
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
useMemo(() => {
let files = 0;
let grabbed = 0;
let queued = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
@@ -73,8 +73,8 @@ function CalendarEventGroup({
files++;
}
if (event.grabbed) {
grabbed++;
if (event.queued) {
queued++;
}
if (series.monitored && event.monitored) {
@@ -88,13 +88,13 @@ function CalendarEventGroup({
return {
allDownloaded: files === events.length,
anyGrabbed: grabbed > 0,
anyQueued: queued > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
};
}, [series, events]);
const anyDownloading = isDownloading || anyGrabbed;
const anyDownloading = isDownloading || anyQueued;
const statusStyle = getStatusStyle(
allDownloaded,

View File

@@ -22,12 +22,7 @@ interface CalendarLinkModalContentProps {
function CalendarLinkModalContent({
onModalClose,
}: CalendarLinkModalContentProps) {
const [state, setState] = useState<{
unmonitored: boolean;
premieresOnly: boolean;
asAllDay: boolean;
tags: number[];
}>({
const [state, setState] = useState({
unmonitored: false,
premieresOnly: false,
asAllDay: false,

View File

@@ -1,4 +1,3 @@
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react-dom';
import classNames from 'classnames';
import React, {
FocusEvent,
@@ -20,6 +19,8 @@ import Autosuggest, {
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
@@ -36,6 +37,7 @@ interface AutoSuggestInputProps<T>
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
@@ -68,6 +70,7 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
@@ -86,60 +89,95 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const { refs, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(0, availableHeight)}px`,
});
},
}),
],
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
}
data.styles.width = width;
return data;
},
[enforceMaxHeight, maxHeight]
);
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, refs.setReference);
}
return (
<div ref={refs.setReference}>
<input {...inputProps} />
</div>
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[refs.setReference, renderInputComponent]
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<div
ref={refs.setFloating}
style={floatingStyles}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: enforceMaxHeight ? maxHeight : undefined,
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{children}
</div>
</div>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[enforceMaxHeight, floatingStyles, maxHeight, refs.setFloating]
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
@@ -198,21 +236,23 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
}, [suggestions, previousSuggestions]);
return (
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}

View File

@@ -139,11 +139,11 @@ type PickProps<V, C extends InputType> = C extends 'text'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
EnhancedSelectInputProps<any, V>
: C extends 'seriesTag'
? SeriesTagInputProps<V>
? SeriesTagInputProps
: C extends 'seriesTypeSelect'
? SeriesTypeSelectInputProps
: C extends 'tag'
? SeriesTagInputProps<V>
? SeriesTagInputProps
: C extends 'tagSelect'
? TagSelectInputProps
: C extends 'text'
@@ -222,7 +222,7 @@ function FormInputGroup<T, C extends InputType>(
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
{/* @ts-expect-error - types are validated already */}
{/* @ts-expect-error - tpyes are validated already */}
<InputComponent
className={inputClassName}
helpText={helpText}

View File

@@ -120,7 +120,7 @@ function ProviderFieldFormGroup<T>({
helpTextWarning={helpTextWarning}
helpLink={helpLink}
placeholder={placeholder}
// @ts-expect-error - this isn't available on all types
// @ts-expect-error - this isn;'t available on all types
selectOptionsProviderAction={selectOptionsProviderAction}
value={value}
values={selectValues}

View File

@@ -42,10 +42,15 @@
color: var(--disabledInputColor);
}
.optionsContainer {
z-index: $popperZIndex;
max-height: vh(50);
width: auto;
}
.options {
composes: scroller from '~Components/Scroller/Scroller.css';
z-index: $popperZIndex;
border: 1px solid var(--inputBorderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);

View File

@@ -13,6 +13,7 @@ interface CssExports {
'mobileCloseButton': string;
'mobileCloseButtonContainer': string;
'options': string;
'optionsContainer': string;
'optionsInnerModalBody': string;
'optionsModal': string;
'optionsModalBody': string;

View File

@@ -1,13 +1,3 @@
import {
autoUpdate,
flip,
FloatingPortal,
size,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import classNames from 'classnames';
import React, {
ElementType,
@@ -16,24 +6,31 @@ import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { icons } from 'Helpers/Props';
import ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import TextInput from '../TextInput';
import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 30;
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -165,6 +162,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
onOpen,
} = props;
const [measureRef, { width }] = useMeasure();
const updater = useRef<(() => void) | null>(null);
const buttonId = useMemo(() => getUniqueElementId(), []);
const optionsId = useMemo(() => getUniqueElementId(), []);
const [selectedIndex, setSelectedIndex] = useState(
getSelectedIndex(value, values)
);
@@ -174,38 +175,6 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const isMultiSelect = Array.isArray(value);
const selectedOption = getSelectedOption(selectedIndex, values);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
size({
apply({ availableHeight, elements, rects }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(
0,
Math.min(window.innerHeight / 2, availableHeight)
)}px`,
});
},
}),
],
open: isOpen,
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
]);
const selectedValue = useMemo(() => {
if (values.length) {
return value;
@@ -220,10 +189,60 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
return '';
}, [value, values, isMultiSelect]);
const handlePress = useCallback(() => {
setIsOpen((prevIsOpen) => !prevIsOpen);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleComputeMaxHeight = useCallback((data: any) => {
const { top, bottom } = data.offsets.reference;
const windowHeight = window.innerHeight;
if (/^bottom/.test(data.placement)) {
data.styles.maxHeight =
windowHeight - bottom - MINIMUM_DISTANCE_FROM_EDGE;
} else {
data.styles.maxHeight = top - MINIMUM_DISTANCE_FROM_EDGE;
}
return data;
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const button = document.getElementById(buttonId);
const options = document.getElementById(optionsId);
const eventTarget = event.target as HTMLElement;
if (!button || !eventTarget.isConnected || isMobile) {
return;
}
if (
!button.contains(eventTarget) &&
options &&
!options.contains(eventTarget) &&
isOpen
) {
setIsOpen(false);
window.removeEventListener('click', handleWindowClick);
}
},
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
);
const addListener = useCallback(() => {
window.addEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const removeListener = useCallback(() => {
window.removeEventListener('click', handleWindowClick);
}, [handleWindowClick]);
const handlePress = useCallback(() => {
if (!isOpen && onOpen) {
onOpen();
}
setIsOpen(!isOpen);
}, [isOpen, setIsOpen, onOpen]);
const handleSelect = useCallback(
(newValue: ArrayElement<V>) => {
const additionalProperties = values.find(
@@ -279,9 +298,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
const handleFocus = useCallback(() => {
if (isOpen) {
removeListener();
setIsOpen(false);
}
}, [isOpen, setIsOpen]);
}, [isOpen, setIsOpen, removeListener]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
@@ -376,121 +396,171 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
);
useEffect(() => {
if (isOpen) {
onOpen?.();
if (updater.current) {
updater.current();
}
}, [isOpen, onOpen]);
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
return (
<>
<div ref={refs.setReference} {...getReferenceProps()}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<div ref={measureRef}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleEditChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
)}
onPress={handlePress}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</Link>
</div>
) : (
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPress={handlePress}
>
<SelectedValueComponent
values={values}
{...selectedValueOptions}
selectedValue={selectedValue}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : selectedValue}
</SelectedValueComponent>
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
<div
className={
isDisabled
? styles.dropdownArrowContainerDisabled
: styles.dropdownArrowContainer
}
>
{isFetching ? (
<LoadingIndicator
className={styles.loading}
size={20}
/>
) : null}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
</div>
</Link>
)}
</div>
</div>
</Link>
)}
</div>
{!isMobile && isOpen ? (
<FloatingPortal id="portal-root">
<Scroller
ref={refs.setFloating}
className={styles.options}
style={floatingStyles}
{...getFloatingProps()}
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
}}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
<div
ref={ref}
id={optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width,
}}
>
{v.value}
</OptionComponent>
{isOpen && !isMobile ? (
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight,
}}
>
{values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected =
v.parentKey !== undefined &&
Array.isArray(value) &&
value.includes(v.parentKey);
const { key, ...other } = v;
return (
<OptionComponent
key={v.key}
id={v.key}
depth={depth}
isSelected={isSelectedItem(index, value, values)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...other}
isMobile={false}
onSelect={handleSelect}
>
{v.value}
</OptionComponent>
);
})}
</Scroller>
) : null}
</div>
);
})}
</Scroller>
</FloatingPortal>
) : null}
}}
</Popper>
</Portal>
</Manager>
{isMobile ? (
<Modal
@@ -545,7 +615,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
</ModalBody>
</Modal>
) : null}
</>
</div>
);
}

View File

@@ -19,6 +19,7 @@ function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
hint,
depth,
isSelected = false,
isMultiSelect,
isMobile,
...otherProps
} = props;

View File

@@ -88,10 +88,13 @@ function QualityProfileSelectInput({
);
const handleChange = useCallback(
({ value }: EnhancedSelectInputChanged<string | number>) => {
onChange({ name, value });
({ value: newValue }: EnhancedSelectInputChanged<string | number>) => {
onChange({
name,
value: newValue === 'noChange' ? value : newValue,
});
},
[name, onChange]
[name, value, onChange]
);
useEffect(() => {

View File

@@ -21,7 +21,6 @@ const ADD_NEW_KEY = 'addNew';
export interface RootFolderSelectInputValue
extends EnhancedSelectInputValue<string> {
freeSpace?: number;
isMissing?: boolean;
}
@@ -43,58 +42,66 @@ function createRootFolderOptionsSelector(
includeNoChange: boolean,
includeNoChangeDisabled: boolean
) {
return createSelector(createRootFoldersSelector(), (rootFolders) => {
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
(rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace,
return createSelector(
createRootFoldersSelector(),
(rootFolders) => {
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
(rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace,
isMissing: false,
};
}
);
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled,
isMissing: false,
};
});
}
);
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: includeNoChangeDisabled,
isMissing: false,
});
}
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true,
});
}
if (
includeMissingValue &&
value &&
!values.find((v) => v.key === value)
) {
values.push({
key: value,
value,
isMissing: true,
isDisabled: true,
});
}
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true,
key: ADD_NEW_KEY,
value: translate('AddANewPath'),
});
return {
values,
isSaving: rootFolders.isSaving,
saveError: rootFolders.saveError,
};
}
if (includeMissingValue && value && !values.find((v) => v.key === value)) {
values.push({
key: value,
value,
isMissing: true,
isDisabled: true,
});
}
values.push({
key: ADD_NEW_KEY,
value: translate('AddANewPath'),
});
return {
values,
isSaving: rootFolders.isSaving,
saveError: rootFolders.saveError,
};
});
);
}
function RootFolderSelectInput({

View File

@@ -18,16 +18,18 @@ interface RootFolderSelectInputOptionProps
isWindows?: boolean;
}
function RootFolderSelectInputOption({
id,
value,
freeSpace,
isMissing,
seriesFolder,
isMobile,
isWindows,
...otherProps
}: RootFolderSelectInputOptionProps) {
function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
const {
id,
value,
freeSpace,
isMissing,
seriesFolder,
isMobile,
isWindows,
...otherProps
} = props;
const slashCharacter = isWindows ? '\\' : '/';
return (

View File

@@ -30,11 +30,3 @@
text-align: right;
font-size: $smallFontSize;
}
.isMissing {
flex: 0 0 auto;
margin-left: 15px;
color: var(--dangerColor);
text-align: right;
font-size: $smallFontSize;
}

View File

@@ -2,7 +2,6 @@
// Please do not change this file!
interface CssExports {
'freeSpace': string;
'isMissing': string;
'path': string;
'pathContainer': string;
'selectedValue': string;

View File

@@ -8,23 +8,27 @@ import styles from './RootFolderSelectInputSelectedValue.css';
interface RootFolderSelectInputSelectedValueProps {
selectedValue: string;
values: RootFolderSelectInputValue[];
freeSpace?: number;
seriesFolder?: string;
isWindows?: boolean;
includeFreeSpace?: boolean;
}
function RootFolderSelectInputSelectedValue({
selectedValue,
values,
seriesFolder,
includeFreeSpace = true,
isWindows,
...otherProps
}: RootFolderSelectInputSelectedValueProps) {
function RootFolderSelectInputSelectedValue(
props: RootFolderSelectInputSelectedValueProps
) {
const {
selectedValue,
values,
freeSpace,
seriesFolder,
includeFreeSpace = true,
isWindows,
...otherProps
} = props;
const slashCharacter = isWindows ? '\\' : '/';
const { value, freeSpace, isMissing } =
values.find((v) => v.key === selectedValue) ||
({} as RootFolderSelectInputValue);
const value = values.find((v) => v.key === selectedValue)?.value;
return (
<EnhancedSelectInputSelectedValue
@@ -49,10 +53,6 @@ function RootFolderSelectInputSelectedValue({
})}
</div>
) : null}
{isMissing ? (
<div className={styles.isMissing}>{translate('Missing')}</div>
) : null}
</EnhancedSelectInputSelectedValue>
);
}

View File

@@ -2,12 +2,10 @@
import React, { SyntheticEvent } from 'react';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput, {
EnhancedSelectInputValue,
} from './EnhancedSelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
import styles from './UMaskInput.css';
const umaskOptions: EnhancedSelectInputValue<string>[] = [
const umaskOptions = [
{
key: '755',
get value() {

View File

@@ -1,15 +1,9 @@
import classNames from 'classnames';
import React, {
ChangeEvent,
ComponentProps,
SyntheticEvent,
useCallback,
} from 'react';
import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react';
import { InputChanged } from 'typings/inputs';
import styles from './SelectInput.css';
export interface SelectInputOption
extends Pick<ComponentProps<'option'>, 'disabled'> {
interface SelectInputOption {
key: string | number;
value: string | number | (() => string | number);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { addTag } from 'Store/Actions/tagActions';
@@ -12,10 +12,10 @@ interface SeriesTag extends TagBase {
name: string;
}
export interface SeriesTagInputProps<V> {
export interface SeriesTagInputProps {
name: string;
value: V;
onChange: (change: InputChanged<V>) => void;
value: number[];
onChange: (change: InputChanged<number[]>) => void;
}
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
@@ -59,48 +59,28 @@ function createSeriesTagsSelector(tags: number[]) {
});
}
export default function SeriesTagInput<V extends number | number[]>({
export default function SeriesTagInput({
name,
value,
onChange,
}: SeriesTagInputProps<V>) {
}: SeriesTagInputProps) {
const dispatch = useDispatch();
const isArray = Array.isArray(value);
const arrayValue = useMemo(() => {
if (isArray) {
return value as number[];
}
return value === 0 ? [] : [value as number];
}, [isArray, value]);
const { tags, tagList, allTags } = useSelector(
createSeriesTagsSelector(arrayValue)
createSeriesTagsSelector(value)
);
const handleTagCreated = useCallback(
(tag: SeriesTag) => {
if (isArray) {
onChange({ name, value: [...value, tag.id] as V });
} else {
onChange({
name,
value: tag.id as V,
});
}
onChange({ name, value: [...value, tag.id] });
},
[name, value, isArray, onChange]
[name, value, onChange]
);
const handleTagAdd = useCallback(
(newTag: SeriesTag) => {
if (newTag.id) {
if (isArray) {
onChange({ name, value: [...value, newTag.id] as V });
} else {
onChange({ name, value: newTag.id as V });
}
onChange({ name, value: [...value, newTag.id] });
return;
}
@@ -116,21 +96,17 @@ export default function SeriesTagInput<V extends number | number[]>({
);
}
},
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
[name, value, allTags, handleTagCreated, onChange, dispatch]
);
const handleTagDelete = useCallback(
({ index }: { index: number }) => {
if (isArray) {
const newValue = value.slice();
newValue.splice(index, 1);
const newValue = value.slice();
newValue.splice(index, 1);
onChange({ name, value: newValue as V });
} else {
onChange({ name, value: 0 as V });
}
onChange({ name, value: newValue });
},
[name, value, isArray, onChange]
[name, value, onChange]
);
return (

View File

@@ -14,7 +14,7 @@ import {
RenderSuggestion,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { useDebouncedCallback } from 'use-debounce';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import { Kind } from 'Helpers/Props/kinds';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from '../AutoSuggestInput';

View File

@@ -1,22 +1,41 @@
import {
autoUpdate,
flip,
FloatingPortal,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react';
import React, {
ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './Menu.css';
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0,
},
flip: {
padding: 0,
},
},
};
const popperOptions: {
right: Partial<PopperProps>;
left: Partial<PopperProps>;
} = {
right: {
...sharedPopperOptions,
placement: 'bottom-end',
},
left: {
...sharedPopperOptions,
placement: 'bottom-start',
},
};
interface MenuProps {
className?: string;
children: React.ReactNode;
@@ -30,7 +49,9 @@ function Menu({
alignMenu = 'left',
enforceMaxHeight = true,
}: MenuProps) {
const updater = useRef<(() => void) | null>(null);
const menuButtonId = useId();
const menuContentId = useId();
const [maxHeight, setMaxHeight] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -49,14 +70,45 @@ function Menu({
setMaxHeight(height);
}, [menuButtonId]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const menuButton = document.getElementById(menuButtonId);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
if (!menuButton) {
return;
}
if (!menuButton.contains(event.target as Node)) {
setIsMenuOpen(false);
}
},
[menuButtonId]
);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const menuButton = document.getElementById(menuButtonId);
const menuContent = document.getElementById(menuContentId);
if (!menuButton || !menuContent) {
return;
}
if (event.targetTouches.length !== 1) {
return;
}
const target = event.targetTouches[0].target;
if (
!menuButton.contains(target as Node) &&
!menuContent.contains(target as Node)
) {
setIsMenuOpen(false);
}
},
[menuButtonId, menuContentId]
);
const handleWindowResize = useCallback(() => {
updateMaxHeight();
@@ -68,15 +120,32 @@ function Menu({
}
}, [isMenuOpen, updateMaxHeight]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
useEffect(() => {
if (enforceMaxHeight) {
updateMaxHeight();
}
}, [enforceMaxHeight, updateMaxHeight]);
useEffect(() => {
if (updater.current && isMenuOpen) {
updater.current();
}
}, [isMenuOpen]);
useEffect(() => {
// Listen to resize events on the window and scroll events
// on all elements to ensure the menu is the best size possible.
// Listen for click events on the window to support closing the
// menu on clicks outside.
if (!isMenuOpen) {
return;
@@ -84,88 +153,52 @@ function Menu({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleWindowScroll, { capture: true });
window.addEventListener('click', handleWindowClick);
window.addEventListener('touchstart', handleTouchStart);
return () => {
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleWindowScroll, {
capture: true,
});
window.removeEventListener('click', handleWindowClick);
window.removeEventListener('touchstart', handleTouchStart);
};
}, [isMenuOpen, handleWindowResize, handleWindowScroll]);
const { refs, context, floatingStyles } = useFloating({
middleware: [
flip({
crossAxis: false,
mainAxis: true,
}),
// offset({ mainAxis: 10 }),
shift(),
],
open: isMenuOpen,
placement: alignMenu === 'left' ? 'bottom-start' : 'bottom-end',
whileElementsMounted: autoUpdate,
onOpenChange: setIsMenuOpen,
});
const handleFloaterPress = useCallback(
(event: MouseEvent) => {
if (
refs.reference &&
(refs.reference.current as HTMLElement).contains(
event.target as HTMLElement
)
) {
return false;
}
// TODO: Menu items should handle closing when they are clicked.
// This is handled before the menu item click event is handled, so wait 100ms before closing.
setTimeout(() => {
setIsMenuOpen(false);
}, 100);
return true;
},
[refs.reference]
);
const click = useClick(context);
const dismiss = useDismiss(context, {
outsidePressEvent: 'click',
outsidePress: handleFloaterPress,
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
}, [
isMenuOpen,
handleWindowResize,
handleWindowScroll,
handleWindowClick,
handleTouchStart,
]);
return (
<>
<div
ref={refs.setReference}
{...getReferenceProps()}
id={menuButtonId}
className={className}
>
{button}
</div>
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={menuButtonId} className={className}>
{button}
</div>
)}
</Reference>
{isMenuOpen ? (
<FloatingPortal id="portal-root">
{React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: refs.setFloating,
style: {
maxHeight,
...floatingStyles,
},
isOpen: isMenuOpen,
...getFloatingProps(),
})}
</FloatingPortal>
) : null}
</>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: ref,
style: {
...style,
maxHeight,
},
isOpen: isMenuOpen,
});
}}
</Popper>
</Portal>
</Manager>
);
}

View File

@@ -88,6 +88,13 @@
}
@media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer {
position: fixed;
}

View File

@@ -13,10 +13,10 @@ import React, {
import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useDebouncedCallback } from 'use-debounce';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import Series from 'Series/Series';
@@ -316,7 +316,7 @@ function SeriesSearchInput() {
return;
}
// If a suggestion is not selected go to the first series,
// If an suggestion is not selected go to the first series,
// otherwise go to the selected series.
const selectedSuggestion =

View File

@@ -359,37 +359,34 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
});
}, []);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
const handleTouchEnd = useCallback((event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStartX.current) {
return;
}
if (!touchStartX.current) {
return;
}
if (currentTouch > touchStartX.current && currentTouch > 50) {
setSidebarTransform({
transition: 'none',
transform: 0,
});
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
setSidebarTransform({
transition: 'transform 50ms ease-in-out',
transform: SIDEBAR_WIDTH * -1,
});
} else {
setSidebarTransform({
transition: 'none',
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
});
}
if (currentTouch > touchStartX.current && currentTouch > 50) {
setSidebarTransform({
transition: 'none',
transform: 0,
});
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
setSidebarTransform({
transition: 'transform 50ms ease-in-out',
transform: SIDEBAR_WIDTH * -1,
});
} else {
setSidebarTransform({
transition: 'none',
transform: 0,
});
}
touchStartX.current = null;
touchStartY.current = null;
},
[isSidebarVisible]
);
touchStartX.current = null;
touchStartY.current = null;
}, []);
const handleTouchCancel = useCallback(() => {
touchStartX.current = null;

View File

@@ -24,7 +24,6 @@
composes: link;
padding: 10px 24px;
padding-left: 35px;
}
.isActiveLink {
@@ -42,6 +41,10 @@
text-align: center;
}
.noIcon {
margin-left: 25px;
}
.status {
float: right;
}

View File

@@ -8,6 +8,7 @@ interface CssExports {
'isActiveParentLink': string;
'item': string;
'link': string;
'noIcon': string;
'status': string;
}
export const cssExports: CssExports;

View File

@@ -54,7 +54,9 @@ function PageSidebarItem({
</span>
)}
{typeof title === 'function' ? title() : title}
<span className={isChildItem ? styles.noIcon : undefined}>
{typeof title === 'function' ? title() : title}
</span>
{!!StatusComponent && (
<span className={styles.status}>

View File

@@ -22,14 +22,11 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
height: 24px;
}
.label {
padding: 0 3px;
max-width: 100%;
max-height: 100%;
color: var(--toolbarLabelColor);
font-size: $extraSmallFontSize;
line-height: calc($extraSmallFontSize + 1px);

View File

@@ -31,7 +31,6 @@ function PageToolbarButton({
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon

View File

@@ -80,12 +80,8 @@ function PageToolbarSection({
if (buttonCount - 1 === maxButtons) {
const overflowItems: PageToolbarButtonProps[] = [];
const buttonsWithoutSeparators = validChildren.filter(
(child) => Object.keys(child.props).length > 0
);
return {
buttons: buttonsWithoutSeparators,
buttons: validChildren,
buttonCount,
overflowItems,
};

View File

@@ -4,7 +4,7 @@
line-height: 1.52857143;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View File

@@ -7,7 +7,7 @@
white-space: nowrap;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View File

@@ -8,7 +8,7 @@ interface Column {
name: string;
label: string | PropertyFunction<string> | React.ReactNode;
className?: string;
columnLabel?: string | PropertyFunction<string>;
columnLabel?: string;
isSortable?: boolean;
fixedSortDirection?: SortDirection;
isVisible: boolean;

View File

@@ -10,7 +10,7 @@
border-collapse: collapse;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.tableContainer {
min-width: 100%;
width: fit-content;

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View File

@@ -60,7 +60,7 @@
height: 25px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.pager {
flex-wrap: wrap;
}

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import React, { useCallback, useMemo, useState } from 'react';
import SelectInput, { SelectInputOption } from 'Components/Form/SelectInput';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -14,10 +14,10 @@ interface TablePagerProps {
totalPages?: number;
totalRecords?: number;
isFetching?: boolean;
onFirstPagePress?: () => void;
onPreviousPagePress?: () => void;
onNextPagePress?: () => void;
onLastPagePress?: () => void;
onFirstPagePress: () => void;
onPreviousPagePress: () => void;
onNextPagePress: () => void;
onLastPagePress: () => void;
onPageSelect: (page: number) => void;
}
@@ -26,6 +26,10 @@ function TablePager({
totalPages,
totalRecords = 0,
isFetching,
onFirstPagePress,
onPreviousPagePress,
onNextPagePress,
onLastPagePress,
onPageSelect,
}: TablePagerProps) {
const [isShowingPageSelect, setIsShowingPageSelect] = useState(false);
@@ -34,7 +38,7 @@ function TablePager({
const isLastPage = page === totalPages;
const pages = useMemo(() => {
return Array.from(new Array(totalPages), (_x, i): SelectInputOption => {
return Array.from(new Array(totalPages), (_x, i) => {
const pageNumber = i + 1;
return {
@@ -60,34 +64,6 @@ function TablePager({
setIsShowingPageSelect(false);
}, []);
const handleFirstPagePress = useCallback(() => {
onPageSelect(1);
}, [onPageSelect]);
const onPreviousPagePress = useCallback(() => {
if (!page) {
return;
}
onPageSelect(page - 1);
}, [onPageSelect, page]);
const onNextPagePress = useCallback(() => {
if (!page) {
return;
}
onPageSelect(page + 1);
}, [onPageSelect, page]);
const onLastPagePress = useCallback(() => {
if (!totalPages) {
return;
}
onPageSelect(totalPages);
}, [onPageSelect, totalPages]);
if (!page) {
return null;
}
@@ -108,7 +84,7 @@ function TablePager({
isFirstPage && styles.disabledPageButton
)}
isDisabled={isFirstPage}
onPress={handleFirstPagePress}
onPress={onFirstPagePress}
>
<Icon name={icons.PAGE_FIRST} />
</Link>

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View File

@@ -1,5 +1,6 @@
.tooltipContainer {
z-index: $popperZIndex;
margin: 10px;
}
.tooltip {
@@ -17,24 +18,174 @@
}
}
.arrow,
.arrow::after {
position: absolute;
display: block;
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: transparent;
}
.arrowDisabled {
display: none;
}
.arrow::after {
border-width: 10px;
content: '';
}
.top {
bottom: -11px;
margin-left: -11px;
border-bottom-width: 0;
&::after {
bottom: 1px;
margin-left: -10px;
border-bottom-width: 0;
content: ' ';
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-top-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-top-color: var(--popoverArrowBorderInverseColor);
}
}
.right {
left: -11px;
margin-top: -11px;
border-left-width: 0;
&::after {
bottom: -10px;
left: 1px;
border-left-width: 0;
content: ' ';
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-right-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-right-color: var(--popoverArrowBorderInverseColor);
}
}
.bottom {
top: -11px;
margin-left: -11px;
border-top-width: 0;
&::after {
top: 1px;
margin-left: -10px;
border-top-width: 0;
content: ' ';
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-bottom-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-bottom-color: var(--popoverArrowBorderInverseColor);
}
}
.left {
right: -11px;
margin-top: -11px;
border-right-width: 0;
&::after {
right: 1px;
bottom: -10px;
border-right-width: 0;
content: ' ';
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
&.default {
border-left-color: var(--popoverArrowBorderColor);
}
&.inverse {
border-left-color: var(--popoverArrowBorderInverseColor);
}
}
.body {
padding: 5px;
}
.verticalContainer {
max-height: 300px;
}
.horizontalContainer {
max-width: calc($breakpointExtraSmall - 20px);
}
@media only screen and (min-width: $breakpointExtraSmall) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointSmall * 0.8);
}
}
@media only screen and (min-width: $breakpointSmall) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointMedium * 0.8);
}
}
@media only screen and (min-width: $breakpointMedium) {
.tooltip {
.horizontalContainer {
max-width: calc($breakpointLarge * 0.8);
}
}
/* @media only screen and (max-width: $breakpointLarge) {
.horizontalContainer {
max-width: calc($breakpointLarge * 0.8);
}
} */

View File

@@ -1,11 +1,19 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'arrow': string;
'arrowDisabled': string;
'body': string;
'bottom': string;
'default': string;
'horizontalContainer': string;
'inverse': string;
'left': string;
'right': string;
'tooltip': string;
'tooltipContainer': string;
'top': string;
'verticalContainer': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,25 +1,17 @@
import {
arrow,
autoUpdate,
flip,
FloatingArrow,
FloatingPortal,
offset,
Placement,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useHover,
useInteractions,
} from '@floating-ui/react';
import classNames from 'classnames';
import React, { useRef, useState } from 'react';
import { useThemeColor } from 'Helpers/Hooks/useTheme';
import { kinds } from 'Helpers/Props';
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 { isMobile } from 'Utilities/browser';
import dimensions from 'Styles/Variables/dimensions';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import styles from './Tooltip.css';
export interface TooltipProps {
@@ -27,8 +19,8 @@ export interface TooltipProps {
bodyClassName?: string;
anchor: React.ReactNode;
tooltip: string | React.ReactNode;
kind?: Extract<Kind, 'default' | 'inverse'>;
position?: Placement;
kind?: Extract<Kind, keyof typeof styles>;
position?: (typeof tooltipPositions.all)[number];
canFlip?: boolean;
}
function Tooltip(props: TooltipProps) {
@@ -38,76 +30,196 @@ function Tooltip(props: TooltipProps) {
anchor,
tooltip,
kind = kinds.DEFAULT,
position,
canFlip = true,
position = tooltipPositions.TOP,
canFlip = false,
} = props;
const arrowColor = useThemeColor(
kind === 'inverse'
? 'popoverArrowBorderInverseColor'
: 'popoverArrowBorderColor'
);
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
const updater = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useState(false);
const arrowRef = useRef(null);
const handleClick = useCallback(() => {
if (!isMobileUtil()) {
return;
}
const { refs, context, floatingStyles } = useFloating({
middleware: [
arrow({
element: arrowRef,
}),
flip({
crossAxis: canFlip,
mainAxis: canFlip,
}),
offset({ mainAxis: 10 }),
shift(),
],
open: isOpen,
placement: position,
whileElementsMounted: autoUpdate,
onOpenChange: setIsOpen,
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();
}
});
const click = useClick(context, {
enabled: isMobile(),
});
const dismiss = useDismiss(context);
const hover = useHover(context, {
handleClose: safePolygon(),
});
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
hover,
]);
useEffect(() => {
return () => {
if (closeTimeout.current) {
clearTimeout(closeTimeout.current);
}
};
}, []);
return (
<>
<span
ref={refs.setReference}
{...getReferenceProps()}
className={className}
>
{anchor}
</span>
{isOpen ? (
<FloatingPortal id="portal-root">
<div
ref={refs.setFloating}
className={styles.tooltipContainer}
style={floatingStyles}
{...getFloatingProps()}
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
className={className}
onClick={handleClick}
onMouseEnter={handleMouseEnterAnchor}
onMouseLeave={handleMouseLeave}
>
<FloatingArrow ref={arrowRef} context={context} fill={arrowColor} />
<div className={classNames(styles.tooltip, styles[kind])}>
<div className={bodyClassName}>{tooltip}</div>
</div>
</div>
</FloatingPortal>
) : null}
</>
{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>
);
}

View File

@@ -22,6 +22,10 @@ interface Episode extends ModelBase {
monitored: boolean;
grabbed?: boolean;
unverifiedSceneNumbering: boolean;
endTime?: string;
grabDate?: string;
seriesTitle?: string;
queued?: boolean;
series?: Series;
finaleType?: string;
}

View File

@@ -11,7 +11,7 @@ import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import {
deleteEpisodeFile,
fetchEpisodeFile,
@@ -128,7 +128,7 @@ function EpisodeSummary(props: EpisodeSummaryProps) {
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
<Label kind={kinds.PRIMARY} size={sizes.MEDIUM}>
<QualityProfileName qualityProfileId={qualityProfileId} />
<QualityProfileNameConnector qualityProfileId={qualityProfileId} />
</Label>
</div>

View File

@@ -2,7 +2,6 @@ import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import MediaInfoProps from 'typings/MediaInfo';
import formatBitrate from 'Utilities/Number/formatBitrate';
import getEntries from 'Utilities/Object/getEntries';
function MediaInfo(props: MediaInfoProps) {
@@ -17,19 +16,9 @@ function MediaInfo(props: MediaInfoProps) {
return null;
}
if (key === 'audioBitrate' || key === 'videoBitrate') {
return (
<DescriptionListItem
key={key}
title={title}
data={
<span title={value.toString()}>{formatBitrate(value)}</span>
}
/>
);
}
return <DescriptionListItem key={key} title={title} data={value} />;
return (
<DescriptionListItem key={key} title={title} data={props[key]} />
);
})}
</DescriptionList>
);

View File

@@ -1,34 +0,0 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState';
import fetchJson, {
apiRoot,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
}
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
const requestOptions = useMemo(() => {
return {
...options,
path: apiRoot + options.path,
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
};
}, [options]);
return useMutation<T, Error, TData>({
...options.mutationOptions,
mutationFn: async (data: TData) =>
fetchJson<T, TData>({ ...requestOptions, body: data }),
});
}
export default useApiMutation;

View File

@@ -1,26 +1,46 @@
import { UndefinedInitialDataOptions, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import fetchJson, {
ApiError,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
queryParams?: QueryParams;
interface ApiErrorResponse {
message: string;
details: string;
}
export class ApiError extends Error {
public statusCode: number;
public statusText: string;
public statusBody?: ApiErrorResponse;
public constructor(
path: string,
statusCode: number,
statusText: string,
statusBody?: ApiErrorResponse
) {
super(`Request Error: (${statusCode}) ${path}`);
this.statusCode = statusCode;
this.statusText = statusText;
this.statusBody = statusBody;
Object.setPrototypeOf(this, new.target.prototype);
}
}
interface QueryOptions<T> {
path: string;
headers?: HeadersInit;
queryOptions?:
| Omit<UndefinedInitialDataOptions<T, ApiError>, 'queryKey' | 'queryFn'>
| undefined;
}
const useApiQuery = <T>(options: QueryOptions<T>) => {
const requestOptions = useMemo(() => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
function useApiQuery<T>(options: QueryOptions<T>) {
const { path, headers } = useMemo(() => {
return {
...otherOptions,
path: getQueryPath(path) + getQueryString(queryParams),
path: apiRoot + options.path,
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
@@ -30,10 +50,29 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }),
queryKey: [path, headers],
queryFn: async ({ signal }) => {
const response = await fetch(path, {
headers,
signal,
});
if (!response.ok) {
// eslint-disable-next-line init-declarations
let body;
try {
body = (await response.json()) as ApiErrorResponse;
} catch {
throw new ApiError(path, response.status, response.statusText);
}
throw new ApiError(path, response.status, response.statusText, body);
}
return response.json() as T;
},
});
};
}
export default useApiQuery;

View File

@@ -0,0 +1,16 @@
import { debounce, DebouncedFunc, DebounceSettings } from 'lodash';
import { useCallback } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useDebouncedCallback<T extends (...args: any) => any>(
callback: T,
delay: number,
options?: DebounceSettings
): DebouncedFunc<T> {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useCallback(debounce(callback, delay, options), [
callback,
delay,
options,
]);
}

View File

@@ -1,36 +0,0 @@
import { useEffect } from 'react';
import { useHistory } from 'react-router';
import { create } from 'zustand';
interface PageStore {
events: number;
}
const pageStore = create<PageStore>(() => ({
events: 1,
}));
const usePage = (kind: keyof PageStore) => {
const { action } = useHistory();
const goToPage = (page: number) => {
pageStore.setState({ [kind]: page });
};
useEffect(() => {
if (action === 'POP') {
pageStore.setState({ [kind]: 1 });
}
}, [action, kind]);
return {
page: pageStore((state) => state[kind]),
goToPage,
};
};
export default usePage;
export const resetPage = (kind: keyof PageStore) => {
pageStore.setState({ [kind]: 1 });
};

View File

@@ -1,81 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { PropertyFilter } from 'App/State/AppState';
import { SortDirection } from 'Helpers/Props/sortDirections';
import fetchJson from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString from 'Utilities/Fetch/getQueryString';
import { QueryOptions } from './useApiQuery';
interface PagedQueryOptions<T> extends QueryOptions<PagedQueryResponse<T>> {
page: number;
pageSize: number;
sortKey?: string;
sortDirection?: SortDirection;
filters?: PropertyFilter[];
}
interface PagedQueryResponse<T> {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
totalPages: number;
records: T[];
}
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const requestOptions = useMemo(() => {
const {
path,
page,
pageSize,
sortKey,
sortDirection,
filters,
queryParams,
queryOptions,
...otherOptions
} = options;
return {
...otherOptions,
path:
getQueryPath(path) +
getQueryString({
...queryParams,
page,
pageSize,
sortKey,
sortDirection,
filters,
}),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
};
}, [options]);
return useQuery({
...options.queryOptions,
queryKey: [requestOptions.path],
queryFn: async ({ signal }) => {
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
...requestOptions,
signal,
});
return {
...response,
totalPages: Math.max(
Math.ceil(response.totalRecords / options.pageSize),
1
),
};
},
});
};
export default usePagedApiQuery;

View File

@@ -1,27 +0,0 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import themes from 'Styles/Themes';
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
(theme) => {
return theme;
}
);
}
const useTheme = () => {
return useSelector(createThemeSelector());
};
export default useTheme;
export const useThemeColor = (color: string) => {
const theme = useTheme();
const themeVariables = themes[theme];
// @ts-expect-error - themeVariables is a string indexable type
return themeVariables[color];
};

View File

@@ -1,74 +0,0 @@
import { create, type StateCreator } from 'zustand';
import { persist, type PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
export const createPersist = <T>(
name: string,
state: StateCreator<T>,
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
) => {
const instanceName =
window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') ?? 'sonarr';
const finalName = `${instanceName}_${name}`;
return create(
persist<T>(state, {
...options,
name: finalName,
})
);
};
export const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};

View File

@@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import * as commandNames from 'Commands/commandNames';
import SelectInput, { SelectInputOption } from 'Components/Form/SelectInput';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
@@ -164,7 +164,7 @@ const COLUMNS = [
},
];
const importModeOptions: SelectInputOption[] = [
const importModeOptions = [
{
key: 'chooseImportMode',
value: () => translate('ChooseImportMode'),
@@ -343,7 +343,7 @@ function InteractiveImportModalContent(
}
);
const options: SelectInputOption[] = [
const options = [
{
key: 'select',
value: translate('SelectDropdown'),

View File

@@ -7,7 +7,6 @@ import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
@@ -70,7 +69,7 @@ function SelectQualityModalContent(props: SelectQualityModalContentProps) {
);
const qualityOptions = useMemo(() => {
return items.map(({ id, name }): EnhancedSelectInputValue<number> => {
return items.map(({ id, name }) => {
return {
key: id,
value: name,

View File

@@ -82,9 +82,9 @@ function RootFolderRow(props: RootFolderRowProps) {
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('RemoveRootFolder')}
message={translate('RemoveRootFolderWithSeriesMessageText', { path })}
confirmLabel={translate('Remove')}
title={translate('DeleteRootFolder')}
message={translate('DeleteRootFolderMessageText', { path })}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>

View File

@@ -50,9 +50,7 @@ function DeleteSeriesModalContent({
dispatch(
deleteSeries({ id: seriesId, deleteFiles, addImportListExclusion })
);
onModalClose();
}, [seriesId, addImportListExclusion, deleteFiles, dispatch, onModalClose]);
}, [seriesId, addImportListExclusion, deleteFiles, dispatch]);
const handleDeleteOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {

View File

@@ -5,6 +5,7 @@
.header {
position: relative;
width: 100%;
height: 425px;
}
.backdrop {
@@ -29,18 +30,20 @@
width: 100%;
height: 100%;
color: var(--white);
gap: 35px;
}
.poster {
flex-shrink: 0;
margin-right: 35px;
width: 250px;
height: 368px;
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
width: 100%;
}
.titleRow {
@@ -56,13 +59,10 @@
}
.title {
overflow: auto;
max-height: calc(3 * 50px);
text-wrap: balance;
font-weight: 300;
font-size: 50px;
line-height: 50px;
line-clamp: 3;
}
.toggleMonitoredContainer {
@@ -153,13 +153,6 @@
padding: 20px;
}
.seriesProgressLabel {
composes: label from '~Components/Label.css';
margin: 0;
font-size: 17px;
}
@media only screen and (max-width: $breakpointSmall) {
.contentContainer {
padding: 20px 0;
@@ -170,8 +163,6 @@
}
.title {
overflow: hidden;
max-height: calc(3 * 30px);
font-weight: 300;
font-size: 30px;
line-height: 30px;

View File

@@ -24,7 +24,6 @@ interface CssExports {
'runtime': string;
'seriesNavigationButton': string;
'seriesNavigationButtons': string;
'seriesProgressLabel': string;
'sizeOnDisk': string;
'statusName': string;
'tags': string;

View File

@@ -1,6 +1,7 @@
import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TextTruncate from 'react-text-truncate';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
@@ -20,6 +21,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
align,
@@ -39,8 +41,7 @@ import { Image, Statistics } from 'Series/Series';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import useSeries from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
@@ -54,6 +55,7 @@ import {
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import fonts from 'Styles/Variables/fonts';
import sortByProp from 'Utilities/Array/sortByProp';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -68,10 +70,12 @@ import toggleSelected from 'Utilities/Table/toggleSelected';
import SeriesAlternateTitles from './SeriesAlternateTitles';
import SeriesDetailsLinks from './SeriesDetailsLinks';
import SeriesDetailsSeason from './SeriesDetailsSeason';
import SeriesProgressLabel from './SeriesProgressLabel';
import SeriesTags from './SeriesTags';
import styles from './SeriesDetails.css';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images: Image[]) {
return images.find((image) => image.coverType === 'fanart')?.url;
}
@@ -120,6 +124,40 @@ function createEpisodeFilesSelector() {
);
}
function createSeriesSelector(seriesId: number) {
return createSelector(createAllSeriesSelector(), (allSeries) => {
const sortedSeries = [...allSeries].sort(sortByProp('sortTitle'));
const seriesIndex = sortedSeries.findIndex(
(series) => series.id === seriesId
);
if (seriesIndex === -1) {
return {
series: undefined,
nextSeries: undefined,
previousSeries: undefined,
};
}
const series = sortedSeries[seriesIndex];
const nextSeries = sortedSeries[seriesIndex + 1] ?? sortedSeries[0];
const previousSeries =
sortedSeries[seriesIndex - 1] ?? sortedSeries[sortedSeries.length - 1];
return {
series,
nextSeries: {
title: nextSeries.title,
titleSlug: nextSeries.titleSlug,
},
previousSeries: {
title: previousSeries.title,
titleSlug: previousSeries.titleSlug,
},
};
});
}
interface ExpandedState {
allExpanded: boolean;
allCollapsed: boolean;
@@ -132,10 +170,9 @@ interface SeriesDetailsProps {
function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const dispatch = useDispatch();
const series = useSeries(seriesId);
const allSeries = useSelector(createAllSeriesSelector());
const { series, nextSeries, previousSeries } = useSelector(
createSeriesSelector(seriesId)
);
const {
isEpisodesFetching,
isEpisodesPopulated,
@@ -151,23 +188,22 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
} = useSelector(createEpisodeFilesSelector());
const commands = useSelector(createCommandsSelector());
const isSaving = useSelector((state: AppState) => state.series.isSaving);
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
const isSeriesRefreshing = isCommandExecuting(
findCommand(commands, {
name: commandNames.REFRESH_SERIES,
seriesId,
})
);
const seriesRefreshingCommand = findCommand(commands, {
name: commandNames.REFRESH_SERIES,
});
const isSeriesRefreshingCommandExecuting = isCommandExecuting(
seriesRefreshingCommand
);
const allSeriesRefreshing =
isSeriesRefreshingCommandExecuting &&
!seriesRefreshingCommand?.body.seriesIds?.length;
const isSeriesRefreshing =
isSeriesRefreshingCommandExecuting &&
seriesRefreshingCommand?.body.seriesIds?.includes(seriesId);
isCommandExecuting(seriesRefreshingCommand) &&
!seriesRefreshingCommand?.body.seriesId;
const isSearchingExecuting = isCommandExecuting(
findCommand(commands, {
@@ -198,35 +234,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
};
}, [seriesId, commands]);
const { nextSeries, previousSeries } = useMemo(() => {
const sortedSeries = [...allSeries].sort(sortByProp('sortTitle'));
const seriesIndex = sortedSeries.findIndex(
(series) => series.id === seriesId
);
if (seriesIndex === -1) {
return {
nextSeries: undefined,
previousSeries: undefined,
};
}
const nextSeries = sortedSeries[seriesIndex + 1] ?? sortedSeries[0];
const previousSeries =
sortedSeries[seriesIndex - 1] ?? sortedSeries[sortedSeries.length - 1];
return {
nextSeries: {
title: nextSeries.title,
titleSlug: nextSeries.titleSlug,
},
previousSeries: {
title: previousSeries.title,
titleSlug: previousSeries.titleSlug,
},
};
}, [seriesId, allSeries]);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isManageEpisodesOpen, setIsManageEpisodesOpen] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
@@ -240,6 +247,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
allCollapsed: false,
seasons: {},
});
const [overviewRef, { height: overviewHeight }] = useMeasure();
const wasRefreshing = usePrevious(isRefreshing);
const wasRenaming = usePrevious(isRenaming);
@@ -388,7 +396,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
}, [populate]);
useEffect(() => {
registerPagePopulator(populate, ['seriesUpdated']);
registerPagePopulator(populate);
return () => {
unregisterPagePopulator(populate);
@@ -429,15 +437,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
genres,
tags,
year,
isSaving = false,
} = series;
const {
episodeCount = 0,
episodeFileCount = 0,
sizeOnDisk = 0,
lastAired,
} = statistics;
const { episodeFileCount = 0, sizeOnDisk = 0, lastAired } = statistics;
const statusDetails = getSeriesStatusDetails(status);
const runningYears =
@@ -516,7 +518,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
<PageToolbarSeparator />
<PageToolbarButton
label={translate('EpisodeMonitoring')}
label={translate('SeriesMonitoring')}
iconName={icons.MONITORED}
onPress={handleMonitorOptionsPress}
/>
@@ -600,29 +602,25 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
</div>
<div className={styles.seriesNavigationButtons}>
{previousSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
) : null}
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
{nextSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
) : null}
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
</div>
</div>
@@ -681,7 +679,9 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
<div>
<Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</span>
</div>
</Label>
@@ -779,17 +779,18 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
position={tooltipPositions.BOTTOM}
/>
) : null}
<SeriesProgressLabel
className={styles.seriesProgressLabel}
seriesId={seriesId}
monitored={monitored}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
/>
</div>
<div className={styles.overview}>{overview}</div>
<div ref={overviewRef} className={styles.overview}>
<TextTruncate
line={
Math.floor(
overviewHeight / (defaultFontSize * lineHeight)
) - 1
}
text={overview}
/>
</div>
<MetadataAttribution />
</div>

View File

@@ -19,11 +19,7 @@ function SeriesDetailsPage() {
const previousIndex = usePrevious(seriesIndex);
useEffect(() => {
if (
seriesIndex === -1 &&
previousIndex !== -1 &&
previousIndex !== undefined
) {
if (seriesIndex === -1 && previousIndex !== -1) {
history.push(`${window.Sonarr.urlBase}/`);
}
}, [seriesIndex, previousIndex, history]);

View File

@@ -562,7 +562,6 @@ function SeriesDetailsSeason({
<SeasonInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
episodeCount={totalEpisodeCount}
seriesId={seriesId}
seasonNumber={seasonNumber}
onModalClose={handleInteractiveSearchModalClose}

View File

@@ -1,70 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind(
monitored: boolean,
episodeFileCount: number,
episodeCount: number,
isDownloading: boolean
) {
if (isDownloading) {
return kinds.PURPLE;
}
if (episodeFileCount === episodeCount && episodeCount > 0) {
return kinds.SUCCESS;
}
if (!monitored) {
return kinds.WARNING;
}
return kinds.DANGER;
}
interface SeriesProgressLabelProps {
className: string;
seriesId: number;
monitored: boolean;
episodeCount: number;
episodeFileCount: number;
}
function SeriesProgressLabel({
className,
seriesId,
monitored,
episodeCount,
episodeFileCount,
}: SeriesProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector(
createSeriesQueueItemsDetailsSelector(seriesId)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads
? `${episodeFileCount} + ${newDownloads} / ${episodeCount}`
: `${episodeFileCount} / ${episodeCount}`;
return (
<Label
className={className}
kind={getEpisodeCountKind(
monitored,
episodeFileCount,
episodeCount,
queueDetails.count > 0
)}
size={sizes.LARGE}
>
<span>{text}</span>
</Label>
);
}
export default SeriesProgressLabel;

View File

@@ -4,7 +4,6 @@ import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
@@ -15,7 +14,7 @@ import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions';
import translate from 'Utilities/String/translate';
import selectOverviewOptions from '../selectOverviewOptions';
const posterSizeOptions: EnhancedSelectInputValue<string>[] = [
const posterSizeOptions = [
{
key: 'small',
get value() {

View File

@@ -92,7 +92,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -0,0 +1,11 @@
.grid {
flex: 1 0 auto;
}
.container {
&:hover {
.content {
background-color: var(--tableRowHoverBackgroundColor);
}
}
}

View File

@@ -80,17 +80,17 @@ function SeriesIndexOverviews(props: SeriesIndexOverviewsProps) {
const [size, setSize] = useState({ width: 0, height: 0 });
const posterWidth = useMemo(() => {
const maximumPosterWidth = isSmallScreen ? 152 : 162;
const maxiumPosterWidth = isSmallScreen ? 152 : 162;
if (posterSize === 'large') {
return maximumPosterWidth;
return maxiumPosterWidth;
}
if (posterSize === 'medium') {
return Math.floor(maximumPosterWidth * 0.75);
return Math.floor(maxiumPosterWidth * 0.75);
}
return Math.floor(maximumPosterWidth * 0.5);
return Math.floor(maxiumPosterWidth * 0.5);
}, [posterSize, isSmallScreen]);
const posterHeight = useMemo(() => {

View File

@@ -4,7 +4,6 @@ import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
@@ -15,7 +14,7 @@ import selectPosterOptions from 'Series/Index/Posters/selectPosterOptions';
import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions';
import translate from 'Utilities/String/translate';
const posterSizeOptions: EnhancedSelectInputValue<string>[] = [
const posterSizeOptions = [
{
key: 'small',
get value() {

View File

@@ -83,7 +83,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
@@ -32,7 +31,7 @@ interface EditSeriesModalContentProps {
const NO_CHANGE = 'noChange';
const monitoredOptions: EnhancedSelectInputValue<string>[] = [
const monitoredOptions = [
{
key: NO_CHANGE,
get value() {
@@ -54,7 +53,7 @@ const monitoredOptions: EnhancedSelectInputValue<string>[] = [
},
];
const seasonFolderOptions: EnhancedSelectInputValue<string>[] = [
const seasonFolderOptions = [
{
key: NO_CHANGE,
get value() {

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useState } from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -12,7 +11,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { icons, inputTypes, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ChangeMonitoringModalContent.css';
@@ -47,12 +46,9 @@ function ChangeMonitoringModalContent(
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('MonitorEpisodes')}</ModalHeader>
<ModalHeader>{translate('MonitorSeries')}</ModalHeader>
<ModalBody>
<Alert kind={kinds.INFO}>
<div>{translate('MonitorEpisodesModalInfo')}</div>
</Alert>
<Form {...otherProps}>
<FormGroup>
<FormLabel>

View File

@@ -6,7 +6,6 @@ import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
@@ -67,7 +66,7 @@ function TagsModalContent(props: TagsModalContentProps) {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions: EnhancedSelectInputValue<string>[] = [
const applyTagsOptions = [
{
key: 'add',
value: translate('Add'),

View File

@@ -97,7 +97,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesIds: [seriesId],
seriesId,
})
);
}, [seriesId, dispatch]);

View File

@@ -15,8 +15,7 @@ function createSeriesIndexItemSelector(seriesId: number) {
(series: Series, qualityProfile, executingCommands: Command[]) => {
const isRefreshingSeries = executingCommands.some((command) => {
return (
command.name === REFRESH_SERIES &&
command.body.seriesIds?.includes(series.id)
command.name === REFRESH_SERIES && command.body.seriesId === seriesId
);
});

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -16,7 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { icons, tooltipPositions } from 'Helpers/Props';
import { updateSeriesMonitor } from 'Store/Actions/seriesActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@@ -67,12 +66,9 @@ function MonitoringOptionsModalContent({
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('MonitorEpisodes')}</ModalHeader>
<ModalHeader>{translate('MonitorSeries')}</ModalHeader>
<ModalBody>
<Alert kind={kinds.INFO}>
<div>{translate('MonitorEpisodesModalInfo')}</div>
</Alert>
<Form>
<FormGroup>
<FormLabel>

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