Compare commits

..

No commits in common. "develop" and "v2.5.0.4277" have entirely different histories.

444 changed files with 5804 additions and 11056 deletions

View file

@ -6,7 +6,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "20",
"version": "16",
"nvmVersion": "latest"
}
},

View file

@ -60,7 +60,6 @@ body:
- Master
- Develop
- Nightly
- Plugins (experimental)
- Other (This issue will be closed)
validations:
required: true

View file

@ -12,6 +12,6 @@ jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/label-actions@v4
- uses: dessant/label-actions@v3
with:
process-only: 'issues'

View file

@ -9,7 +9,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v4
with:
github-token: ${{ github.token }}
issue-inactive-days: '90'

35
.gitignore vendored
View file

@ -121,7 +121,6 @@ _artifacts
_rawPackage/
_dotTrace*
_tests/
_temp*
*.Result.xml
coverage*.xml
coverage*.json
@ -140,6 +139,12 @@ project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
#VS outout folders
bin
obj
output/*
# macOS metadata files
._*
.DS_Store
@ -158,12 +163,34 @@ Thumbs.db
/tools/Addins/*
packages.config.md5sum
# Common IntelliJ Platform excludes
# User specific
**/.idea/**/workspace.xml
**/.idea/**/tasks.xml
**/.idea/shelf/*
**/.idea/dictionaries
**/.idea/.idea.Radarr.Posix
**/.idea/.idea.Radarr.Windows
# Sensitive or high-churn files
**/.idea/**/dataSources/
**/.idea/**/dataSources.ids
**/.idea/**/dataSources.xml
**/.idea/**/dataSources.local.xml
**/.idea/**/sqlDataSources.xml
**/.idea/**/dynamic.xml
# Rider
# Rider auto-generates .iml files, and contentModel.xml
**/.idea/**/*.iml
**/.idea/**/contentModel.xml
**/.idea/**/modules.xml
# ignore node_modules symlink
node_modules
node_modules.nosync
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View file

@ -1,7 +1,6 @@
# Lidarr
[![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop)
[![Translation status](https://translate.servarr.com/widget/servarr/lidarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget)
[![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker)
![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg)
[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers)

View file

@ -9,18 +9,18 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '2.13.1'
majorVersion: '2.5.0'
minorVersion: $[counter('minorVersion', 1076)]
lidarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.427'
dotnetVersion: '6.0.424'
nodeVersion: '20.X'
innoVersion: '6.2.0'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-22.04'
macImage: 'macOS-13'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-12'
trigger:
branches:
@ -1120,19 +1120,19 @@ stages:
vmImage: ${{ variables.windowsImage }}
steps:
- checkout: self # Need history for Sonar analysis
- task: SonarCloudPrepare@3
- task: SonarCloudPrepare@2
env:
SONAR_SCANNER_OPTS: ''
inputs:
SonarCloud: 'SonarCloud'
organization: 'lidarr'
scannerMode: 'cli'
scannerMode: 'CLI'
configMode: 'manual'
cliProjectKey: 'lidarr_Lidarr.UI'
cliProjectName: 'LidarrUI'
cliProjectVersion: '$(lidarrVersion)'
cliSources: './frontend'
- task: SonarCloudAnalyze@3
- task: SonarCloudAnalyze@2
- job: Api_Docs
displayName: API Docs
@ -1208,12 +1208,12 @@ stages:
submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service
- task: SonarCloudPrepare@3
- task: SonarCloudPrepare@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs:
SonarCloud: 'SonarCloud'
organization: 'lidarr'
scannerMode: 'dotnet'
scannerMode: 'MSBuild'
projectKey: 'lidarr_Lidarr'
projectName: 'Lidarr'
projectVersion: '$(lidarrVersion)'
@ -1226,10 +1226,10 @@ stages:
./build.sh --backend -f net6.0 -r win-x64
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
displayName: Coverage Unit Tests
- task: SonarCloudAnalyze@3
- task: SonarCloudAnalyze@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results
- task: reportgenerator@5.3.11
- task: reportgenerator@5
displayName: Generate Coverage Report
inputs:
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'

View file

@ -59,7 +59,7 @@ 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"
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed
@ -114,7 +114,7 @@ case "$ARCH" in
esac
echo ""
echo "Removing previous tarballs"
# -f to Force so we fail if it doesn't exist
# -f to Force so we fail if it doesnt exist
rm -f "${app^}".*.tar.gz
echo ""
echo "Downloading..."

15
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE"
RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE"
RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE"
RUNTIME="osx-x64"
else
echo "Platform must be provided as first argument: Windows, Linux or Mac"
echo "Platform must be provided as first arguement: Windows, Linux or Mac"
exit 1
fi
@ -40,7 +35,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 &
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v1 &
sleep 45

View file

@ -9,7 +9,7 @@
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
"source.fixAll": true
},
"typescript.preferences.quoteStyle": "single",

View file

@ -26,7 +26,6 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@ -68,7 +67,7 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map'
},
@ -93,7 +92,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({
filename: 'Content/styles.css',
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css'
chunkFilename: 'Content/[id]-[chunkhash].css'
}),
new HtmlWebpackPlugin({
@ -135,12 +134,6 @@ module.exports = (env) => {
{
source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, 'Content/robots.txt')
},
// manifest.json and browserconfig.xml
{
source: 'frontend/src/Content/*.(json|xml)',
destination: path.join(distFolder, 'Content')
}
]
}
@ -188,7 +181,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.41'
corejs: 3
}
]
]
@ -209,7 +202,7 @@ module.exports = (env) => {
options: {
importLoaders: 1,
modules: {
localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]'
localIdentName: '[name]/[local]/[hash:base64:5]'
}
}
},

View file

@ -16,7 +16,6 @@ const mixinsFiles = [
module.exports = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View file

@ -172,8 +172,7 @@ function HistoryDetails(props) {
if (eventType === 'downloadFailed') {
const {
message,
indexer
message
} = data;
return (
@ -193,14 +192,6 @@ function HistoryDetails(props) {
null
}
{
indexer ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{
message ?
<DescriptionListItem

View file

@ -26,5 +26,4 @@
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 90px;
text-align: right;
}

View file

@ -57,40 +57,30 @@ function QueueStatusCell(props) {
if (status === 'paused') {
iconName = icons.PAUSED;
title = translate('Paused');
title = 'Paused';
}
if (status === 'queued') {
iconName = icons.QUEUED;
title = translate('Queued');
title = 'Queued';
}
if (status === 'completed') {
iconName = icons.DOWNLOADED;
title = translate('Downloaded');
if (trackedDownloadState === 'importBlocked') {
title += ` - ${translate('UnableToImportAutomatically')}`;
iconKind = kinds.WARNING;
}
if (trackedDownloadState === 'importFailed') {
title += ` - ${translate('ImportFailed', { sourceTitle })}`;
iconKind = kinds.WARNING;
}
title = 'Downloaded';
if (trackedDownloadState === 'importPending') {
title += ` - ${translate('WaitingToImport')}`;
title += ' - Waiting to Import';
iconKind = kinds.PURPLE;
}
if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`;
title += ' - Importing';
iconKind = kinds.PURPLE;
}
if (trackedDownloadState === 'failedPending') {
title += ` - ${translate('WaitingToProcess')}`;
title += ' - Waiting to Process';
iconKind = kinds.DANGER;
}
}
@ -101,38 +91,36 @@ function QueueStatusCell(props) {
if (status === 'delay') {
iconName = icons.PENDING;
title = translate('Pending');
title = 'Pending';
}
if (status === 'downloadClientUnavailable') {
iconName = icons.PENDING;
iconKind = kinds.WARNING;
title = translate('PendingDownloadClientUnavailable');
title = 'Pending - Download client is unavailable';
}
if (status === 'failed') {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = translate('DownloadFailed');
title = 'Download failed';
}
if (status === 'warning') {
iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING;
const warningMessage =
errorMessage || translate('CheckDownloadClientForDetails');
title = translate('DownloadWarning', { warningMessage });
title = `Download warning: ${errorMessage || 'check download client for more details'}`;
}
if (hasError) {
if (status === 'completed') {
iconName = icons.DOWNLOAD;
iconKind = kinds.DANGER;
title = translate('ImportFailed', { sourceTitle });
title = `Import failed: ${sourceTitle}`;
} else {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = translate('DownloadFailed');
title = 'Download failed';
}
}

View file

@ -20,7 +20,6 @@ interface Album extends ModelBase {
monitored: boolean;
releaseDate: string;
statistics: Statistics;
lastSearchTime?: string;
isSaving?: boolean;
}

View file

@ -121,8 +121,6 @@
.releaseDate,
.sizeOnDisk,
.albumType,
.secondaryTypes,
.qualityProfileName,
.links,
.tags {

View file

@ -3,7 +3,6 @@
interface CssExports {
'albumNavigationButton': string;
'albumNavigationButtons': string;
'albumType': string;
'alternateTitlesIconContainer': string;
'backdrop': string;
'backdropOverlay': string;
@ -21,7 +20,6 @@ interface CssExports {
'overview': string;
'qualityProfileName': string;
'releaseDate': string;
'secondaryTypes': string;
'sizeOnDisk': string;
'tags': string;
'title': string;

View file

@ -192,7 +192,6 @@ class AlbumDetails extends Component {
duration,
overview,
albumType,
secondaryTypes,
statistics = {},
monitored,
releaseDate,
@ -205,7 +204,6 @@ class AlbumDetails extends Component {
isFetching,
isPopulated,
albumsError,
tracksError,
trackFilesError,
hasTrackFiles,
shortDateFormat,
@ -398,11 +396,10 @@ class AlbumDetails extends Component {
<div className={styles.details}>
<div>
{
duration ?
!!duration &&
<span className={styles.duration}>
{formatDuration(duration)}
</span> :
null
</span>
}
<HeartRating
@ -421,15 +418,14 @@ class AlbumDetails extends Component {
title={translate('ReleaseDate')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.CALENDAR}
size={17}
/>
<span className={styles.releaseDate}>
{moment(releaseDate).format(shortDateFormat)}
</span>
</div>
<Icon
name={icons.CALENDAR}
size={17}
/>
<span className={styles.releaseDate}>
{moment(releaseDate).format(shortDateFormat)}
</span>
</Label>
<Tooltip
@ -438,15 +434,16 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk || 0)
}
</span>
</Label>
}
tooltip={
@ -462,55 +459,32 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</span>
</div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</span>
</Label>
{
albumType ?
!!albumType &&
<Label
className={styles.detailsLabel}
title={translate('Type')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.INFO}
size={17}
/>
<span className={styles.albumType}>
{albumType}
</span>
</div>
</Label> :
null
}
<Icon
name={icons.INFO}
size={17}
/>
{
secondaryTypes.length ?
<Label
className={styles.detailsLabel}
title={translate('SecondaryTypes')}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.INFO}
size={17}
/>
<span className={styles.secondaryTypes}>
{secondaryTypes.join(', ')}
</span>
</div>
</Label> :
null
<span className={styles.qualityProfileName}>
{albumType}
</span>
</Label>
}
<Tooltip
@ -519,15 +493,14 @@ class AlbumDetails extends Component {
className={styles.detailsLabel}
size={sizes.LARGE}
>
<div>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
{translate('Links')}
</span>
</div>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
{translate('Links')}
</span>
</Label>
}
tooltip={
@ -553,9 +526,8 @@ class AlbumDetails extends Component {
<div className={styles.contentContainer}>
{
!isPopulated && !albumsError && !tracksError && !trackFilesError ?
<LoadingIndicator /> :
null
!isPopulated && !albumsError && !trackFilesError &&
<LoadingIndicator />
}
{
@ -566,14 +538,6 @@ class AlbumDetails extends Component {
null
}
{
!isFetching && tracksError ?
<Alert kind={kinds.DANGER}>
{translate('TracksLoadError')}
</Alert> :
null
}
{
!isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}>
@ -602,14 +566,6 @@ class AlbumDetails extends Component {
</div>
}
{
isPopulated && !media.length ?
<Alert kind={kinds.WARNING}>
{translate('NoMediumInformation')}
</Alert> :
null
}
</div>
<OrganizePreviewModalConnector
@ -676,7 +632,6 @@ AlbumDetails.propTypes = {
duration: PropTypes.number,
overview: PropTypes.string,
albumType: PropTypes.string.isRequired,
secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
statistics: PropTypes.object.isRequired,
releaseDate: PropTypes.string.isRequired,
ratings: PropTypes.object.isRequired,
@ -703,8 +658,6 @@ AlbumDetails.propTypes = {
};
AlbumDetails.defaultProps = {
secondaryTypes: [],
statistics: {},
isSaving: false
};

View file

@ -29,7 +29,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
@ -248,7 +248,7 @@ function AppRoutes(props) {
<Route
path="/system/updates"
component={Updates}
component={UpdatesConnector}
/>
<Route

View file

@ -44,7 +44,6 @@ export interface CustomFilter {
}
export interface AppSectionState {
version: string;
dimensions: {
isSmallScreen: boolean;
width: number;

View file

@ -4,7 +4,6 @@ import AppSectionState, {
AppSectionSaveState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer';
@ -13,16 +12,13 @@ import MetadataProfile from 'typings/MetadataProfile';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import RootFolder from 'typings/RootFolder';
import General from 'typings/Settings/General';
import UiSettings from 'typings/Settings/UiSettings';
import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
export type GeneralAppState = AppSectionItemState<General>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@ -45,11 +41,6 @@ export interface MetadataProfilesAppState
extends AppSectionState<MetadataProfile>,
AppSectionSchemaState<MetadataProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface RootFolderAppState
extends AppSectionState<RootFolder>,
AppSectionDeleteState,
@ -59,10 +50,7 @@ export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;

View file

@ -1,12 +1,9 @@
import SystemStatus from 'typings/SystemStatus';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
import { AppSectionItemState } from './AppSectionState';
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
updates: UpdateAppState;
status: SystemStatusAppState;
}

View file

@ -149,7 +149,9 @@ class AlbumRow extends Component {
if (name === 'secondaryTypes') {
return (
<TableRowCell key={name}>
{secondaryTypes.join(', ')}
{
secondaryTypes
}
</TableRowCell>
);
}

View file

@ -15,7 +15,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, sizes, tooltipPositions } from 'Helpers/Props';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EditArtistModalContent.css';
@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
<ModalBody>
<Form {...otherProps}>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Monitored')}
</FormLabel>
@ -107,10 +107,9 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('MonitorNewItems')}
<Popover
anchor={
<Icon
@ -133,7 +132,7 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('QualityProfile')}
</FormLabel>
@ -147,10 +146,10 @@ class EditArtistModalContent extends Component {
</FormGroup>
{
showMetadataProfile ?
<FormGroup size={sizes.MEDIUM}>
showMetadataProfile &&
<FormGroup>
<FormLabel>
{translate('MetadataProfile')}
Metadata Profile
<Popover
anchor={
@ -174,11 +173,10 @@ class EditArtistModalContent extends Component {
{...metadataProfileId}
onChange={onInputChange}
/>
</FormGroup> :
null
</FormGroup>
}
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Path')}
</FormLabel>
@ -191,7 +189,7 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Tags')}
</FormLabel>
@ -211,7 +209,7 @@ class EditArtistModalContent extends Component {
kind={kinds.DANGER}
onPress={onDeleteArtistPress}
>
{translate('Delete')}
Delete
</Button>
<Button

View file

@ -79,7 +79,7 @@ function ArtistIndexSortMenu(props: SeriesIndexSortMenuProps) {
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('LastAlbum')}
{translate('Last Album')}
</SortMenuItem>
<SortMenuItem

View file

@ -6,7 +6,7 @@ import { icons } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import dimensions from 'Styles/Variables/dimensions';
import QualityProfile from 'typings/QualityProfile';
import UiSettings from 'typings/Settings/UiSettings';
import { UiSettings } from 'typings/UiSettings';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';

View file

@ -164,7 +164,7 @@ class CalendarLinkModalContent extends Component {
type={inputTypes.TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsArtistHelpText')}
helpText={translate('TagsHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>

View file

@ -3,8 +3,8 @@ import React, { Component } from 'react';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/lidarr/faq#why-cant-lidarr-see-my-files-on-a-remote-server' })} />
Mapped network drives are not available when running as a Windows Service, see the <Link className={styles.faqLink} to="https://wiki.servarr.com/lidarr/faq">FAQ</Link> for more information.
</Alert>
}

View file

@ -11,11 +11,11 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import MetadataProfileFilterBuilderRowValue from './MetadataProfileFilterBuilderRowValue';
import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector';
import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css';
@ -68,7 +68,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.METADATA_PROFILE:
return MetadataProfileFilterBuilderRowValue;
return MetadataProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.MONITOR_NEW_ITEMS:
return MonitorNewItemsFilterBuilderRowValue;
@ -80,7 +80,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.ARTIST:
return ArtistFilterBuilderRowValue;

View file

@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [
{
id: 7,
get name() {
return translate('ImportCompleteFailed');
return translate('ImportFailed');
},
},
{

View file

@ -1,30 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMetadataProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.metadataProfiles.items,
(metadataProfiles) => {
return metadataProfiles;
}
);
}
function MetadataProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const metadataProfiles = useSelector(createMetadataProfilesSelector());
const tagList = metadataProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default MetadataProfileFilterBuilderRowValue;

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.metadataProfiles,
(metadataProfiles) => {
const tagList = metadataProfiles.items.map((metadataProfile) => {
const {
id,
name
} = metadataProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -1,30 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createQualityProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles;
}
);
}
function QualityProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const qualityProfiles = useSelector(createQualityProfilesSelector());
const tagList = qualityProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default QualityProfileFilterBuilderRowValue;

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const tagList = qualityProfiles.items.map((qualityProfile) => {
const {
id,
name
} = qualityProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View file

@ -20,8 +20,6 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -139,9 +137,18 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
};
@ -450,10 +457,6 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>

View file

@ -49,12 +49,12 @@ function getComponent(type) {
case inputTypes.DEVICE:
return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.PLAYLIST:
return PlaylistInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput;

View file

@ -0,0 +1,156 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
};
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
onFocus = () => {
this.setState({
isFocused: true
});
};
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
};
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder,
hasError,
hasWarning
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

View file

@ -1,104 +0,0 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { InputOnChange } from 'typings/inputs';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
interface KeyValue {
key: string;
value: string;
}
export interface KeyValueListInputProps {
className?: string;
name: string;
value: KeyValue[];
hasError?: boolean;
hasWarning?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
onChange: InputOnChange<KeyValue[]>;
}
function KeyValueListInput({
className = styles.inputContainer,
name,
value = [],
hasError = false,
hasWarning = false,
keyPlaceholder,
valuePlaceholder,
onChange,
}: KeyValueListInputProps): JSX.Element {
const [isFocused, setIsFocused] = useState(false);
const handleItemChange = useCallback(
(index: number | null, itemValue: KeyValue) => {
const newValue = [...value];
if (index === null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const handleRemoveItem = useCallback(
(index: number) => {
const newValue = [...value];
newValue.splice(index, 1);
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const onFocus = useCallback(() => setIsFocused(true), []);
const onBlur = useCallback(() => {
setIsFocused(false);
const newValue = value.reduce((acc: KeyValue[], v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({ name, value: newValue });
}
}, [value, name, onChange]);
return (
<div
className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{[...value, { key: '', value: '' }].map((v, index) => (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={handleItemChange}
onRemove={handleRemoveItem}
onFocus={onFocus}
onBlur={onBlur}
/>
))}
</div>
);
}
export default KeyValueListInput;

View file

@ -5,19 +5,13 @@
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
}
.keyInputWrapper {
.inputWrapper {
flex: 1 0 0;
}
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper {
flex: 0 0 22px;
}
@ -26,10 +20,6 @@
.valueInput {
width: 100%;
border: none;
background-color: transparent;
background-color: var(--inputBackgroundColor);
color: var(--textColor);
&::placeholder {
color: var(--helpTextColor);
}
}

View file

@ -2,11 +2,10 @@
// Please do not change this file!
interface CssExports {
'buttonWrapper': string;
'inputWrapper': string;
'itemContainer': string;
'keyInput': string;
'keyInputWrapper': string;
'valueInput': string;
'valueInputWrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
};
onFocus = () => {
this.props.onFocus();
};
onBlur = () => {
this.props.onBlur();
};
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<div className={styles.inputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.inputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{
isNew ?
null :
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

View file

@ -1,89 +0,0 @@
import React, { useCallback } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
interface KeyValueListInputItemProps {
index: number;
keyValue: string;
value: string;
keyPlaceholder?: string;
valuePlaceholder?: string;
isNew: boolean;
onChange: (index: number, itemValue: { key: string; value: string }) => void;
onRemove: (index: number) => void;
onFocus: () => void;
onBlur: () => void;
}
function KeyValueListInputItem({
index,
keyValue,
value,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
isNew,
onChange,
onRemove,
onFocus,
onBlur,
}: KeyValueListInputItemProps): JSX.Element {
const handleKeyChange = useCallback(
({ value: keyValue }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, value, onChange]
);
const handleValueChange = useCallback(
({ value }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, keyValue, onChange]
);
const handleRemovePress = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
return (
<div className={styles.itemContainer}>
<div className={styles.keyInputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={handleKeyChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.valueInputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={handleValueChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{isNew ? null : (
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={handleRemovePress}
/>
)}
</div>
</div>
);
}
export default KeyValueListInputItem;

View file

@ -14,8 +14,6 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'playlist':
return inputTypes.PLAYLIST;
case 'password':

View file

@ -83,6 +83,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

@ -3,9 +3,9 @@
padding: 0;
font-size: inherit;
&.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
}
}
.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
}

View file

@ -7,7 +7,7 @@ import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import ArtistSearchInputConnector from './ArtistSearchInputConnector';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
import styles from './PageHeader.css';
class PageHeader extends Component {
@ -83,7 +83,6 @@ class PageHeader extends Component {
size={14}
title={translate('Donate')}
/>
<IconButton
className={styles.translation}
title={translate('SuggestTranslationChange')}
@ -91,8 +90,7 @@ class PageHeader extends Component {
to="https://translate.servarr.com/projects/servarr/lidarr/"
size={24}
/>
<PageHeaderActionsMenu
<PageHeaderActionsMenuConnector
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>

View file

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon
name={icons.INTERACTIVE}
title={translate('Menu')}
/>
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
{translate('KeyboardShortcuts')}
</MenuItem>
<MenuItemSeparator />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
name={icons.RESTART}
/>
{translate('Restart')}
</MenuItem>
<MenuItem onPress={onShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
Shutdown
</MenuItem>
{
formsAuth &&
<div className={styles.separator} />
}
{
formsAuth &&
<MenuItem
to={`${window.Lidarr.urlBase}/logout`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
{translate('Logout')}
</MenuItem>
}
</MenuContent>
</Menu>
</div>
);
}
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired
};
export default PageHeaderActionsMenu;

View file

@ -1,87 +0,0 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import { restart, shutdown } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
interface PageHeaderActionsMenuProps {
onKeyboardShortcutsPress(): void;
}
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
const { onKeyboardShortcutsPress } = props;
const dispatch = useDispatch();
const { authentication, isDocker } = useSelector(
(state: AppState) => state.system.status.item
);
const formsAuth = authentication === 'forms';
const handleRestartPress = useCallback(() => {
dispatch(restart());
}, [dispatch]);
const handleShutdownPress = useCallback(() => {
dispatch(shutdown());
}, [dispatch]);
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
{translate('KeyboardShortcuts')}
</MenuItem>
{isDocker ? null : (
<>
<MenuItemSeparator />
<MenuItem onPress={handleRestartPress}>
<Icon className={styles.itemIcon} name={icons.RESTART} />
{translate('Restart')}
</MenuItem>
<MenuItem onPress={handleShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
</>
)}
{formsAuth ? (
<>
<MenuItemSeparator />
<MenuItem to={`${window.Lidarr.urlBase}/logout`} noRouter={true}>
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
{translate('Logout')}
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
</div>
);
}
export default PageHeaderActionsMenu;

View file

@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { restart, shutdown } from 'Store/Actions/systemActions';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
(status) => {
return {
formsAuth: status.item.authentication === 'forms'
};
}
);
}
const mapDispatchToProps = {
restart,
shutdown
};
class PageHeaderActionsMenuConnector extends Component {
//
// Listeners
onRestartPress = () => {
this.props.restart();
};
onShutdownPress = () => {
this.props.shutdown();
};
//
// Render
render() {
return (
<PageHeaderActionsMenu
{...this.props}
onRestartPress={this.onRestartPress}
onShutdownPress={this.onShutdownPress}
/>
);
}
}
PageHeaderActionsMenuConnector.propTypes = {
restart: PropTypes.func.isRequired,
shutdown: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);

View file

@ -172,7 +172,7 @@ class SignalRConnector extends Component {
const status = resource.status;
// Both successful and failed commands need to be
// completed, otherwise they spin until they time out.
// completed, otherwise they spin until they timeout.
if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource);
@ -224,58 +224,10 @@ class SignalRConnector extends Component {
repopulatePage('trackFileUpdated');
};
handleDownloadclient = ({ action, resource }) => {
const section = 'settings.downloadClients';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleHealth = () => {
this.props.dispatchFetchHealth();
};
handleImportlist = ({ action, resource }) => {
const section = 'settings.importLists';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleIndexer = ({ action, resource }) => {
const section = 'settings.indexers';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleMetadata = ({ action, resource }) => {
const section = 'settings.metadata';
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
}
};
handleNotification = ({ action, resource }) => {
const section = 'settings.notifications';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleArtist = (body) => {
const action = body.action;
const section = 'artist';

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

@ -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

@ -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

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/Content/Images/Icons/mstile-150x150.png"/>
<TileColor>#00ccff</TileColor>
</tile>
</msapplication>
</browserconfig>

View file

@ -0,0 +1,19 @@
{
"name": "Lidarr",
"icons": [
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "../../../../",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="__URL_BASE__/Content/Images/Icons/mstile-150x150.png" />
<TileColor>
#00ccff
</TileColor>
</tile>
</msapplication>
</browserconfig>

View file

@ -1,19 +0,0 @@
{
"name": "__INSTANCE_NAME__",
"icons": [
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "__URL_BASE__/",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View file

@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete';
export const CAPTCHA = 'captcha';
export const CHECK = 'check';
export const DEVICE = 'device';
export const KEY_VALUE_LIST = 'keyValueList';
export const PLAYLIST = 'playlist';
export const KEY_VALUE_LIST = 'keyValueList';
export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
export const FLOAT = 'float';
@ -34,8 +34,8 @@ export const all = [
CAPTCHA,
CHECK,
DEVICE,
KEY_VALUE_LIST,
PLAYLIST,
KEY_VALUE_LIST,
MONITOR_ALBUMS_SELECT,
MONITOR_NEW_ITEMS_SELECT,
FLOAT,

View file

@ -11,7 +11,6 @@ import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { scrollDirections } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import SelectAlbumRow from './SelectAlbumRow';
import styles from './SelectAlbumModalContent.css';
@ -20,7 +19,6 @@ const columns = [
{
name: 'title',
label: () => translate('AlbumTitle'),
isSortable: true,
isVisible: true
},
{
@ -31,7 +29,6 @@ const columns = [
{
name: 'releaseDate',
label: () => translate('ReleaseDate'),
isSortable: true,
isVisible: true
},
{
@ -66,22 +63,16 @@ class SelectAlbumModalContent extends Component {
render() {
const {
isFetching,
isPopulated,
error,
items,
sortKey,
sortDirection,
onSortPress,
onAlbumSelect,
onModalClose
onModalClose,
isFetching,
...otherProps
} = this.props;
const filter = this.state.filter;
const filterLower = filter.toLowerCase();
const errorMessage = getErrorMessage(error, 'Unable to load albums');
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
@ -92,29 +83,27 @@ class SelectAlbumModalContent extends Component {
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
{
isFetching &&
<LoadingIndicator />
}
<TextInput
className={styles.filterInput}
placeholder={translate('FilterAlbumPlaceholder')}
name="filter"
value={filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
<Scroller
className={styles.scroller}
autoFocus={false}
>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
<TextInput
className={styles.filterInput}
placeholder={translate('FilterAlbumPlaceholder')}
name="filter"
value={filter}
autoFocus={true}
onChange={this.onFilterChange}
/>
{isPopulated && !!items.length ? (
{
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
{...otherProps}
>
<TableBody>
{
@ -133,7 +122,7 @@ class SelectAlbumModalContent extends Component {
}
</TableBody>
</Table>
) : null}
}
</Scroller>
</ModalBody>
@ -148,13 +137,8 @@ class SelectAlbumModalContent extends Component {
}
SelectAlbumModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
onSortPress: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
onAlbumSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -3,14 +3,18 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearAlbums, fetchAlbums, setAlbumsSort } from 'Store/Actions/albumSelectionActions';
import { saveInteractiveImportItem, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import {
clearInteractiveImportAlbums,
fetchInteractiveImportAlbums,
saveInteractiveImportItem,
setInteractiveImportAlbumsSort,
updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import SelectAlbumModalContent from './SelectAlbumModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('albumSelection'),
createClientSideCollectionSelector('interactiveImport.albums'),
(albums) => {
return albums;
}
@ -18,9 +22,9 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
fetchAlbums,
setAlbumsSort,
clearAlbums,
fetchInteractiveImportAlbums,
setInteractiveImportAlbumsSort,
clearInteractiveImportAlbums,
updateInteractiveImportItem,
saveInteractiveImportItem
};
@ -35,20 +39,20 @@ class SelectAlbumModalContentConnector extends Component {
artistId
} = this.props;
this.props.fetchAlbums({ artistId });
this.props.fetchInteractiveImportAlbums({ artistId });
}
componentWillUnmount() {
// This clears the albums for the queue and hides the queue
// We'll need another place to store albums for manual import
this.props.clearAlbums();
this.props.clearInteractiveImportAlbums();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.setAlbumsSort({ sortKey, sortDirection });
this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection });
};
onAlbumSelect = (albumId) => {
@ -78,7 +82,6 @@ class SelectAlbumModalContentConnector extends Component {
return (
<SelectAlbumModalContent
{...this.props}
onSortPress={this.onSortPress}
onAlbumSelect={this.onAlbumSelect}
/>
);
@ -89,9 +92,9 @@ SelectAlbumModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
artistId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchAlbums: PropTypes.func.isRequired,
setAlbumsSort: PropTypes.func.isRequired,
clearAlbums: PropTypes.func.isRequired,
fetchInteractiveImportAlbums: PropTypes.func.isRequired,
setInteractiveImportAlbumsSort: PropTypes.func.isRequired,
clearInteractiveImportAlbums: PropTypes.func.isRequired,
saveInteractiveImportItem: PropTypes.func.isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired

View file

@ -18,17 +18,12 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
min-width: 0;
}
.leftButtons {
flex: 0 1 auto;
}
.rightButtons {
justify-content: flex-end;
flex: 1 1 50%;
}
.importMode,
@ -36,7 +31,6 @@
composes: select from '~Components/Form/SelectInput.css';
margin-right: 10px;
max-width: 100%;
width: auto;
}
@ -49,12 +43,10 @@
.leftButtons,
.rightButtons {
flex-direction: column;
gap: 3px;
}
.leftButtons {
align-items: flex-start;
max-width: fit-content;
}
.rightButtons {

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
@ -131,8 +130,7 @@ class AddNewItem extends Component {
<div className={styles.helpText}>
{translate('FailedLoadingSearchResults')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
<div>{getErrorMessage(error)}</div>
</div> : null
}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Fragment } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
@ -8,7 +8,6 @@ import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton';
function CustomFormatSettingsPage() {
return (
@ -18,13 +17,11 @@ function CustomFormatSettingsPage() {
// @ts-ignore
showSave={false}
additionalButtons={
<>
<Fragment>
<PageToolbarSeparator />
<ParseToolbarButton />
<ManageCustomFormatsToolbarButton />
</>
</Fragment>
}
/>

View file

@ -3,7 +3,6 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditCustomFormatModal from './EditCustomFormatModal';
import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector';
function mapStateToProps() {
return {};
@ -37,7 +36,6 @@ class EditCustomFormatModalConnector extends Component {
}
EditCustomFormatModalConnector.propTypes = {
...EditCustomFormatModalContentConnector.propTypes,
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};

View file

@ -1,28 +0,0 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent';
interface ManageCustomFormatsEditModalProps {
isOpen: boolean;
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageCustomFormatsEditModal(
props: ManageCustomFormatsEditModalProps
) {
const { isOpen, customFormatIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsEditModalContent
customFormatIds={customFormatIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageCustomFormatsEditModal;

View file

@ -1,16 +0,0 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View file

@ -1,8 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,125 +0,0 @@
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 Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageCustomFormatsEditModalContent.css';
interface SavePayload {
includeCustomFormatWhenRenaming?: boolean;
}
interface ManageCustomFormatsEditModalContentProps {
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
isDisabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageCustomFormatsEditModalContent(
props: ManageCustomFormatsEditModalContentProps
) {
const { customFormatIds, onSavePress, onModalClose } = props;
const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] =
useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (includeCustomFormatWhenRenaming !== NO_CHANGE) {
hasChanges = true;
payload.includeCustomFormatWhenRenaming =
includeCustomFormatWhenRenaming === 'enabled';
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'includeCustomFormatWhenRenaming':
setIncludeCustomFormatWhenRenaming(value);
break;
default:
console.warn(
`EditCustomFormatsModalContent Unknown Input: '${name}'`
);
}
},
[]
);
const selectedCount = customFormatIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedCustomFormats')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('IncludeCustomFormatWhenRenaming')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="includeCustomFormatWhenRenaming"
value={includeCustomFormatWhenRenaming}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountCustomFormatsSelected', {
count: selectedCount,
})}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageCustomFormatsEditModalContent;

View file

@ -1,20 +0,0 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent';
interface ManageCustomFormatsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageCustomFormatsModal;

View file

@ -1,16 +0,0 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View file

@ -1,9 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'leftButtons': string;
'rightButtons': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,244 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CustomFormatAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
setManageCustomFormatsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal';
import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow';
import styles from './ManageCustomFormatsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
{
name: 'name',
label: () => translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'includeCustomFormatWhenRenaming',
label: () => translate('IncludeCustomFormatWhenRenaming'),
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
}
function ManageCustomFormatsModalContent(
props: ManageCustomFormatsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
sortKey,
sortDirection,
}: CustomFormatAppState = useSelector(
createClientSideCollectionSelector('settings.customFormats')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageCustomFormatsSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteCustomFormats({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditCustomFormats({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load custom formats.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageCustomFormats')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoCustomFormatsFound')}</Alert>
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {
return (
<ManageCustomFormatsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageCustomFormatsEditModal
isOpen={isEditModalOpen}
customFormatIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedCustomFormats')}
message={translate('DeleteSelectedCustomFormatsMessageText', {
count: selectedIds.length,
})}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageCustomFormatsModalContent;

View file

@ -1,12 +0,0 @@
.name,
.includeCustomFormatWhenRenaming {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 40px;
}

View file

@ -1,9 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'actions': string;
'includeCustomFormatWhenRenaming': string;
'name': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -1,126 +0,0 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import { deleteCustomFormat } from 'Store/Actions/settingsActions';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import EditCustomFormatModalConnector from '../EditCustomFormatModalConnector';
import styles from './ManageCustomFormatsModalRow.css';
interface ManageCustomFormatsModalRowProps {
id: number;
name: string;
includeCustomFormatWhenRenaming: boolean;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function isDeletingSelector() {
return createSelector(
(state: AppState) => state.settings.customFormats.isDeleting,
(isDeleting) => {
return isDeleting;
}
);
}
function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
const {
id,
isSelected,
name,
includeCustomFormatWhenRenaming,
onSelectedChange,
} = props;
const dispatch = useDispatch();
const isDeleting = useSelector(isDeletingSelector());
const [isEditCustomFormatModalOpen, setIsEditCustomFormatModalOpen] =
useState(false);
const [isDeleteCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen] =
useState(false);
const handlelectedChange = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
const handleEditCustomFormatModalOpen = useCallback(() => {
setIsEditCustomFormatModalOpen(true);
}, [setIsEditCustomFormatModalOpen]);
const handleEditCustomFormatModalClose = useCallback(() => {
setIsEditCustomFormatModalOpen(false);
}, [setIsEditCustomFormatModalOpen]);
const handleDeleteCustomFormatPress = useCallback(() => {
setIsEditCustomFormatModalOpen(false);
setIsDeleteCustomFormatModalOpen(true);
}, [setIsEditCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen]);
const handleDeleteCustomFormatModalClose = useCallback(() => {
setIsDeleteCustomFormatModalOpen(false);
}, [setIsDeleteCustomFormatModalOpen]);
const handleConfirmDeleteCustomFormat = useCallback(() => {
dispatch(deleteCustomFormat({ id }));
}, [id, dispatch]);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={handlelectedChange}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.includeCustomFormatWhenRenaming}>
{includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.EDIT}
onPress={handleEditCustomFormatModalOpen}
/>
</TableRowCell>
<EditCustomFormatModalConnector
id={id}
isOpen={isEditCustomFormatModalOpen}
onModalClose={handleEditCustomFormatModalClose}
onDeleteCustomFormatPress={handleDeleteCustomFormatPress}
/>
<ConfirmModal
isOpen={isDeleteCustomFormatModalOpen}
kind="danger"
title={translate('DeleteCustomFormat')}
message={translate('DeleteCustomFormatMessageText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={handleConfirmDeleteCustomFormat}
onCancel={handleDeleteCustomFormatModalClose}
/>
</TableRow>
);
}
export default ManageCustomFormatsModalRow;

View file

@ -1,28 +0,0 @@
import React from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import ManageCustomFormatsModal from './ManageCustomFormatsModal';
function ManageCustomFormatsToolbarButton() {
const [isManageModalOpen, openManageModal, closeManageModal] =
useModalOpenState(false);
return (
<>
<PageToolbarButton
label={translate('ManageFormats')}
iconName={icons.MANAGE}
onPress={openManageModal}
/>
<ManageCustomFormatsModal
isOpen={isManageModalOpen}
onModalClose={closeManageModal}
/>
</>
);
}
export default ManageCustomFormatsToolbarButton;

View file

@ -7,8 +7,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@ -52,13 +52,12 @@ function EditSpecificationModalContent(props) {
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />
{'More details'} <Link to="https://www.regular-expressions.info/tutorial.html">{'Here'}</Link>
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
{'Regular expressions can be tested '}
<Link to="http://regexstorm.net/tester">Here</Link>
</div>
</Alert>
}

View file

@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View file

@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@ -82,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(
@ -218,9 +220,9 @@ function ManageDownloadClientsModalContent(
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
) : null}
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View file

@ -156,7 +156,6 @@ class GeneralSettings extends Component {
/>
<LoggingSettings
advancedSettings={advancedSettings}
settings={settings}
onInputChange={onInputChange}
/>

View file

@ -15,14 +15,12 @@ const logLevelOptions = [
function LoggingSettings(props) {
const {
advancedSettings,
settings,
onInputChange
} = props;
const {
logLevel,
logSizeLimit
logLevel
} = settings;
return (
@ -41,30 +39,11 @@ function LoggingSettings(props) {
{...logLevel}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('LogSizeLimit')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="logSizeLimit"
min={1}
max={10}
unit="MB"
helpText={translate('LogSizeLimitHelpText')}
onChange={onInputChange}
{...logSizeLimit}
/>
</FormGroup>
</FieldSet>
);
}
LoggingSettings.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
settings: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired
};

View file

@ -292,7 +292,7 @@ function EditImportListModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('ImportListTagsHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View file

@ -198,9 +198,9 @@ function ManageImportListsModalContent(
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
) : null}
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View file

@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View file

@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteIndexers,
bulkEditIndexers,
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@ -82,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
@ -213,9 +215,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
) : null}
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View file

@ -191,21 +191,26 @@ class MediaManagement extends Component {
<FieldSet
legend={translate('Importing')}
>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('SkipFreeSpaceCheck')}</FormLabel>
{
!isWindows &&
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('SkipFreeSpaceCheck')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipFreeSpaceCheckWhenImporting"
helpText={translate('SkipFreeSpaceCheckHelpText')}
onChange={onInputChange}
{...settings.skipFreeSpaceCheckWhenImporting}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.CHECK}
name="skipFreeSpaceCheckWhenImporting"
helpText={translate('SkipFreeSpaceCheckWhenImportingHelpText')}
onChange={onInputChange}
{...settings.skipFreeSpaceCheckWhenImporting}
/>
</FormGroup>
}
<FormGroup
advancedSettings={advancedSettings}

View file

@ -1,3 +1,4 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@ -14,11 +15,11 @@ function createMapStateToProps() {
(state) => state.settings.advancedSettings,
(state) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, namingExamples, sectionSettings) => {
(advancedSettings, examples, sectionSettings) => {
return {
advancedSettings,
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
examples: examples.item,
examplesPopulated: !_.isEmpty(examples.item),
...sectionSettings
};
}

View file

@ -94,9 +94,9 @@ class RootFolder extends Component {
<ConfirmModal
isOpen={this.state.isDeleteRootFolderModalOpen}
kind={kinds.DANGER}
title={translate('RemoveRootFolder')}
message={translate('RemoveRootFolderArtistsMessageText', { name })}
confirmLabel={translate('Remove')}
title={translate('DeleteRootFolder')}
message={translate('DeleteRootFolderMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteRootFolder}
onCancel={this.onDeleteRootFolderModalClose}
/>

View file

@ -105,7 +105,7 @@ function EditNotificationModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('NotificationsTagsArtistHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -87,9 +87,9 @@ function EditDelayProfileModalContent(props) {
{
!isFetching && !!error ?
<Alert kind={kinds.DANGER}>
{translate('AddDelayProfileError')}
</Alert> :
<div>
{translate('UnableToAddANewQualityProfilePleaseTryAgain')}
</div> :
null
}
@ -186,7 +186,7 @@ function EditDelayProfileModalContent(props) {
{
id === 1 ?
<Alert>
{translate('DefaultDelayProfileArtist')}
{translate('DefaultDelayProfileHelpText')}
</Alert> :
<FormGroup>
@ -196,7 +196,7 @@ function EditDelayProfileModalContent(props) {
type={inputTypes.TAG}
name="tags"
{...tags}
helpText={translate('DelayProfileArtistTagsHelpText')}
helpText={translate('TagsHelpText')}
onChange={onInputChange}
/>
</FormGroup>

View file

@ -119,7 +119,7 @@ function EditReleaseProfileModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('ReleaseProfileTagArtistHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -24,19 +24,19 @@
height: 20px;
}
.track {
.bar {
top: 9px;
margin: 0 5px;
height: 3px;
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(3n + 1) {
&:nth-child(3n+1) {
background-color: #ddd;
}
}
.thumb {
.handle {
top: 1px;
z-index: 0 !important;
width: 18px;

View file

@ -1,6 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'bar': string;
'handle': string;
'kilobitsPerSecond': string;
'quality': string;
'qualityDefinition': string;
@ -8,9 +10,7 @@ interface CssExports {
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'title': string;
'track': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -55,27 +55,6 @@ class QualityDefinition extends Component {
};
}
//
// Control
trackRenderer(props, state) {
return (
<div
{...props}
className={styles.track}
/>
);
}
thumbRenderer(props, state) {
return (
<div
{...props}
className={styles.thumb}
/>
);
}
//
// Listeners
@ -195,7 +174,6 @@ class QualityDefinition extends Component {
<div className={styles.sizeLimit}>
<ReactSlider
className={styles.slider}
min={slider.min}
max={slider.max}
step={slider.step}
@ -204,9 +182,9 @@ class QualityDefinition extends Component {
withTracks={true}
allowCross={false}
snapDragDisabled={true}
pearling={true}
renderThumb={this.thumbRenderer}
renderTrack={this.trackRenderer}
className={styles.slider}
trackClassName={styles.bar}
thumbClassName={styles.handle}
onChange={this.onSliderChange}
onAfterChange={this.onAfterSliderChange}
/>

View file

@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
</div>
</Alert>
}

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