Compare commits

..

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

581 changed files with 7500 additions and 17165 deletions

View file

@ -1,13 +0,0 @@
// This file is used to open the backend and frontend in the same workspace, which is necessary as
// the frontend has vscode settings that are distinct from the backend
{
"folders": [
{
"path": ".."
},
{
"path": "../frontend"
}
],
"settings": {}
}

View file

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

View file

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

View file

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

View file

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

35
.gitignore vendored
View file

@ -121,7 +121,6 @@ _artifacts
_rawPackage/ _rawPackage/
_dotTrace* _dotTrace*
_tests/ _tests/
_temp*
*.Result.xml *.Result.xml
coverage*.xml coverage*.xml
coverage*.json coverage*.json
@ -140,6 +139,12 @@ project.fragment.lock.json
artifacts/ artifacts/
**/Properties/launchSettings.json **/Properties/launchSettings.json
#VS outout folders
bin
obj
output/*
# macOS metadata files # macOS metadata files
._* ._*
.DS_Store .DS_Store
@ -158,12 +163,34 @@ Thumbs.db
/tools/Addins/* /tools/Addins/*
packages.config.md5sum 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 # ignore node_modules symlink
node_modules node_modules
node_modules.nosync node_modules.nosync
# API doc generation # API doc generation
.config/ .config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View file

@ -1,7 +1,6 @@
# Lidarr # 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) [![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) [![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) ![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg)
[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) [![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers)

View file

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

View file

@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
app_guid=${app_guid:-media} app_guid=${app_guid:-media}
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" 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 read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
# Create User / Group as needed # Create User / Group as needed
@ -114,7 +114,7 @@ case "$ARCH" in
esac esac
echo "" echo ""
echo "Removing previous tarballs" 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 rm -f "${app^}".*.tar.gz
echo "" echo ""
echo "Downloading..." echo "Downloading..."

23
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1 PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE" RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE" RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE" RUNTIME="osx-x64"
else 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 exit 1
fi fi
@ -26,21 +21,15 @@ slnFile=src/Lidarr.sln
platform=Posix platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
application=Lidarr.Console.dll
else
application=Lidarr.dll
fi
dotnet clean $slnFile -c Debug dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli dotnet tool install --version 6.5.0 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/lidarr.console.dll" v1 &
sleep 45 sleep 45

View file

@ -28,8 +28,7 @@ module.exports = {
globals: { globals: {
expect: false, expect: false,
chai: false, chai: false,
sinon: false, sinon: false
JSX: true
}, },
parserOptions: { parserOptions: {

View file

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

View file

@ -26,7 +26,6 @@ module.exports = (env) => {
const config = { const config = {
mode: isProduction ? 'production' : 'development', mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map', devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: { stats: {
children: false children: false
@ -68,7 +67,7 @@ module.exports = (env) => {
output: { output: {
path: distFolder, path: distFolder,
publicPath: '/', publicPath: '/',
filename: isProduction ? '[name]-[contenthash].js' : '[name].js', filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map' sourceMapFilename: '[file].map'
}, },
@ -93,7 +92,7 @@ module.exports = (env) => {
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'Content/styles.css', filename: 'Content/styles.css',
chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' chunkFilename: 'Content/[id]-[chunkhash].css'
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
@ -135,12 +134,6 @@ module.exports = (env) => {
{ {
source: 'frontend/src/Content/robots.txt', source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, '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, loose: true,
debug: false, debug: false,
useBuiltIns: 'entry', useBuiltIns: 'entry',
corejs: '3.41' corejs: 3
} }
] ]
] ]
@ -209,7 +202,7 @@ module.exports = (env) => {
options: { options: {
importLoaders: 1, importLoaders: 1,
modules: { 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 = { module.exports = {
plugins: [ plugins: [
'autoprefixer',
['postcss-mixins', { ['postcss-mixins', {
mixinsFiles mixinsFiles
}], }],

View file

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

View file

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

View file

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

View file

@ -118,7 +118,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
{ {
key: 'blocklistAndSearch', key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'), value: translate('BlocklistAndSearch'),
isDisabled: isPending,
hint: multipleSelected hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint') ? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'), : translate('BlocklistAndSearchHint'),
@ -131,7 +130,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
: translate('BlocklistOnlyHint'), : translate('BlocklistOnlyHint'),
}, },
]; ];
}, [isPending, multipleSelected]); }, [multipleSelected]);
const handleRemovalMethodChange = useCallback( const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => { ({ value }: { value: RemovalMethod }) => {

View file

@ -10,7 +10,6 @@ export interface Statistics {
} }
interface Album extends ModelBase { interface Album extends ModelBase {
artistId: number;
artist: Artist; artist: Artist;
foreignAlbumId: string; foreignAlbumId: string;
title: string; title: string;
@ -20,7 +19,6 @@ interface Album extends ModelBase {
monitored: boolean; monitored: boolean;
releaseDate: string; releaseDate: string;
statistics: Statistics; statistics: Statistics;
lastSearchTime?: string;
isSaving?: boolean; isSaving?: boolean;
} }

View file

@ -4,11 +4,10 @@ import Link from 'Components/Link/Link';
function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) { function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) {
const link = `/album/${foreignAlbumId}`; const link = `/album/${foreignAlbumId}`;
const albumTitle = `${title}${disambiguation ? ` (${disambiguation})` : ''}`;
return ( return (
<Link to={link} title={albumTitle}> <Link to={link}>
{albumTitle} {title}{disambiguation ? ` (${disambiguation})` : ''}
</Link> </Link>
); );
} }

View file

@ -53,7 +53,7 @@ class DeleteAlbumModalContent extends Component {
render() { render() {
const { const {
title, title,
statistics = {}, statistics,
onModalClose onModalClose
} = this.props; } = this.props;

View file

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

View file

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

View file

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

View file

@ -12,13 +12,16 @@ import TrackRowConnector from './TrackRowConnector';
import styles from './AlbumDetailsMedium.css'; import styles from './AlbumDetailsMedium.css';
function getMediumStatistics(tracks) { function getMediumStatistics(tracks) {
const trackCount = tracks.length; let trackCount = 0;
let trackFileCount = 0; let trackFileCount = 0;
let totalTrackCount = 0; let totalTrackCount = 0;
tracks.forEach((track) => { tracks.forEach((track) => {
if (track.trackFileId) { if (track.trackFileId) {
trackCount++;
trackFileCount++; trackFileCount++;
} else {
trackCount++;
} }
totalTrackCount++; totalTrackCount++;

View file

@ -35,7 +35,7 @@ class EditAlbumModalContent extends Component {
title, title,
artistName, artistName,
albumType, albumType,
statistics = {}, statistics,
item, item,
isSaving, isSaving,
onInputChange, onInputChange,

View file

@ -7,7 +7,6 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { scrollDirections } from 'Helpers/Props'; import { scrollDirections } from 'Helpers/Props';
import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
import translate from 'Utilities/String/translate';
function AlbumInteractiveSearchModalContent(props) { function AlbumInteractiveSearchModalContent(props) {
const { const {
@ -19,10 +18,7 @@ function AlbumInteractiveSearchModalContent(props) {
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
{albumTitle === undefined ? Interactive Search {albumId != null && `- ${albumTitle}`}
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderTitle', { title: albumTitle })
}
</ModalHeader> </ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}> <ModalBody scrollDirection={scrollDirections.BOTH}>
@ -36,7 +32,7 @@ function AlbumInteractiveSearchModalContent(props) {
<ModalFooter> <ModalFooter>
<Button onPress={onModalClose}> <Button onPress={onModalClose}>
{translate('Close')} Close
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View file

@ -12,10 +12,11 @@ function App({ store, history }) {
<DocumentTitle title={window.Lidarr.instanceName}> <DocumentTitle title={window.Lidarr.instanceName}>
<Provider store={store}> <Provider store={store}>
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<ApplyTheme /> <ApplyTheme>
<PageConnector> <PageConnector>
<AppRoutes app={App} /> <AppRoutes app={App} />
</PageConnector> </PageConnector>
</ApplyTheme>
</ConnectedRouter> </ConnectedRouter>
</Provider> </Provider>
</DocumentTitle> </DocumentTitle>

View file

@ -11,7 +11,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound'; import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch'; import Switch from 'Components/Router/Switch';
import AddNewItemConnector from 'Search/AddNewItemConnector'; import AddNewItemConnector from 'Search/AddNewItemConnector';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -29,7 +29,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs'; import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status'; import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks'; import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
@ -184,7 +184,7 @@ function AppRoutes(props) {
<Route <Route
path="/settings/customformats" path="/settings/customformats"
component={CustomFormatSettingsPage} component={CustomFormatSettingsConnector}
/> />
<Route <Route
@ -248,7 +248,7 @@ function AppRoutes(props) {
<Route <Route
path="/system/updates" path="/system/updates"
component={Updates} component={UpdatesConnector}
/> />
<Route <Route

View file

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React, { Fragment, useCallback, useEffect } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.ui.item.theme || window.Lidarr.theme,
(
theme
) => {
return {
theme
};
}
);
}
function ApplyTheme({ theme, children }) {
// Update the CSS Variables
const updateCSSVariables = useCallback(() => {
const arrayOfVariableKeys = Object.keys(themes[theme]);
const arrayOfVariableValues = Object.values(themes[theme]);
// Loop through each array key and set the CSS Variables
arrayOfVariableKeys.forEach((cssVariableKey, index) => {
// Based on our snippet from MDN
document.documentElement.style.setProperty(
`--${cssVariableKey}`,
arrayOfVariableValues[index]
);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables(theme);
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
ApplyTheme.propTypes = {
theme: PropTypes.string.isRequired,
children: PropTypes.object.isRequired
};
export default connect(createMapStateToProps)(ApplyTheme);

View file

@ -1,37 +0,0 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Lidarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme({ children }: ApplyThemeProps) {
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
export default ApplyTheme;

View file

@ -1,4 +1,3 @@
import ParseAppState from 'App/State/ParseAppState';
import AlbumAppState from './AlbumAppState'; import AlbumAppState from './AlbumAppState';
import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
@ -6,7 +5,6 @@ import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState'; import HistoryAppState from './HistoryAppState';
import QueueAppState from './QueueAppState'; import QueueAppState from './QueueAppState';
import SettingsAppState from './SettingsAppState'; import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState'; import TagsAppState from './TagsAppState';
import TrackFilesAppState from './TrackFilesAppState'; import TrackFilesAppState from './TrackFilesAppState';
import TracksAppState from './TracksAppState'; import TracksAppState from './TracksAppState';
@ -44,7 +42,6 @@ export interface CustomFilter {
} }
export interface AppSectionState { export interface AppSectionState {
version: string;
dimensions: { dimensions: {
isSmallScreen: boolean; isSmallScreen: boolean;
width: number; width: number;
@ -60,13 +57,11 @@ interface AppState {
calendar: CalendarAppState; calendar: CalendarAppState;
commands: CommandAppState; commands: CommandAppState;
history: HistoryAppState; history: HistoryAppState;
parse: ParseAppState;
queue: QueueAppState; queue: QueueAppState;
settings: SettingsAppState; settings: SettingsAppState;
tags: TagsAppState; tags: TagsAppState;
trackFiles: TrackFilesAppState; trackFiles: TrackFilesAppState;
tracksSelection: TracksAppState; tracksSelection: TracksAppState;
system: SystemAppState;
} }
export default AppState; export default AppState;

View file

@ -1,35 +0,0 @@
import Album from 'Album/Album';
import ModelBase from 'App/ModelBase';
import { AppSectionItemState } from 'App/State/AppSectionState';
import Artist from 'Artist/Artist';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
export interface ArtistTitleInfo {
title: string;
}
export interface ParsedAlbumInfo {
albumTitle: string;
artistName: string;
artistTitleInfo: ArtistTitleInfo;
discography: boolean;
quality: QualityModel;
releaseGroup?: string;
releaseHash: string;
releaseTitle: string;
releaseTokens: string;
}
export interface ParseModel extends ModelBase {
title: string;
parsedAlbumInfo: ParsedAlbumInfo;
artist?: Artist;
albums: Album[];
customFormats?: CustomFormat[];
customFormatScore?: number;
}
type ParseAppState = AppSectionItemState<ParseModel>;
export default ParseAppState;

View file

@ -1,10 +1,8 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionItemState,
AppSectionSaveState, AppSectionSaveState,
AppSectionSchemaState, AppSectionSchemaState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient'; import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList'; import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer'; import Indexer from 'typings/Indexer';
@ -13,16 +11,13 @@ import MetadataProfile from 'typings/MetadataProfile';
import Notification from 'typings/Notification'; import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile'; import QualityProfile from 'typings/QualityProfile';
import RootFolder from 'typings/RootFolder'; import RootFolder from 'typings/RootFolder';
import General from 'typings/Settings/General'; import { UiSettings } from 'typings/UiSettings';
import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState export interface DownloadClientAppState
extends AppSectionState<DownloadClient>, extends AppSectionState<DownloadClient>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {}
export type GeneralAppState = AppSectionItemState<General>;
export interface ImportListAppState export interface ImportListAppState
extends AppSectionState<ImportList>, extends AppSectionState<ImportList>,
AppSectionDeleteState, AppSectionDeleteState,
@ -45,24 +40,16 @@ export interface MetadataProfilesAppState
extends AppSectionState<MetadataProfile>, extends AppSectionState<MetadataProfile>,
AppSectionSchemaState<MetadataProfile> {} AppSectionSchemaState<MetadataProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface RootFolderAppState export interface RootFolderAppState
extends AppSectionState<RootFolder>, extends AppSectionState<RootFolder>,
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState {} AppSectionSaveState {}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>; export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>; export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
general: GeneralAppState;
importLists: ImportListAppState; importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState; indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState; indexers: IndexerAppState;
@ -70,7 +57,7 @@ interface SettingsAppState {
notifications: NotificationAppState; notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState; qualityProfiles: QualityProfilesAppState;
rootFolders: RootFolderAppState; rootFolders: RootFolderAppState;
ui: UiSettingsAppState; uiSettings: UiSettingsAppState;
} }
export default SettingsAppState; export default SettingsAppState;

View file

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

View file

@ -1,32 +1,12 @@
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
export interface Tag extends ModelBase { export interface Tag extends ModelBase {
label: string; label: string;
} }
export interface TagDetail extends ModelBase { interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
artistIds: number[];
}
export interface TagDetailAppState
extends AppSectionState<TagDetail>,
AppSectionDeleteState,
AppSectionSaveState {}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
details: TagDetailAppState;
}
export default TagsAppState; export default TagsAppState;

View file

@ -23,6 +23,7 @@ export interface Ratings {
interface Artist extends ModelBase { interface Artist extends ModelBase {
added: string; added: string;
artistMetadataId: string;
foreignArtistId: string; foreignArtistId: string;
cleanName: string; cleanName: string;
ended: boolean; ended: boolean;

View file

@ -135,14 +135,14 @@ class DeleteArtistModalContent extends Component {
<ModalFooter> <ModalFooter>
<Button onPress={onModalClose}> <Button onPress={onModalClose}>
{translate('Close')} Close
</Button> </Button>
<Button <Button
kind={kinds.DANGER} kind={kinds.DANGER}
onPress={this.onDeleteArtistConfirmed} onPress={this.onDeleteArtistConfirmed}
> >
{translate('Delete')} Delete
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
@ -161,7 +161,9 @@ DeleteArtistModalContent.propTypes = {
}; };
DeleteArtistModalContent.defaultProps = { DeleteArtistModalContent.defaultProps = {
statistics: {} statistics: {
trackFileCount: 0
}
}; };
export default DeleteArtistModalContent; export default DeleteArtistModalContent;

View file

@ -10,7 +10,6 @@ function AlbumGroupInfo(props) {
const { const {
totalAlbumCount, totalAlbumCount,
monitoredAlbumCount, monitoredAlbumCount,
albumFileCount,
trackFileCount, trackFileCount,
sizeOnDisk sizeOnDisk
} = props; } = props;
@ -31,13 +30,6 @@ function AlbumGroupInfo(props) {
data={monitoredAlbumCount} data={monitoredAlbumCount}
/> />
<DescriptionListItem
titleClassName={styles.title}
descriptionClassName={styles.description}
title={translate('WithFiles')}
data={albumFileCount}
/>
<DescriptionListItem <DescriptionListItem
titleClassName={styles.title} titleClassName={styles.title}
descriptionClassName={styles.description} descriptionClassName={styles.description}
@ -58,7 +50,6 @@ function AlbumGroupInfo(props) {
AlbumGroupInfo.propTypes = { AlbumGroupInfo.propTypes = {
totalAlbumCount: PropTypes.number.isRequired, totalAlbumCount: PropTypes.number.isRequired,
monitoredAlbumCount: PropTypes.number.isRequired, monitoredAlbumCount: PropTypes.number.isRequired,
albumFileCount: PropTypes.number.isRequired,
trackFileCount: PropTypes.number.isRequired, trackFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number.isRequired sizeOnDisk: PropTypes.number.isRequired
}; };

View file

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

View file

@ -192,7 +192,7 @@ class ArtistDetails extends Component {
artistName, artistName,
ratings, ratings,
path, path,
statistics = {}, statistics,
qualityProfileId, qualityProfileId,
monitored, monitored,
genres, genres,

View file

@ -22,43 +22,32 @@ import styles from './ArtistDetailsSeason.css';
function getAlbumStatistics(albums) { function getAlbumStatistics(albums) {
let albumCount = 0; let albumCount = 0;
let albumFileCount = 0;
let trackFileCount = 0; let trackFileCount = 0;
let totalAlbumCount = 0; let totalAlbumCount = 0;
let monitoredAlbumCount = 0; let monitoredAlbumCount = 0;
let hasMonitoredAlbums = false; let hasMonitoredAlbums = false;
let sizeOnDisk = 0; let sizeOnDisk = 0;
albums.forEach(({ monitored, releaseDate, statistics = {} }) => { albums.forEach((album) => {
const { if (album.statistics) {
trackFileCount: albumTrackFileCount = 0, sizeOnDisk = sizeOnDisk + album.statistics.sizeOnDisk;
totalTrackCount: albumTotalTrackCount = 0, trackFileCount = trackFileCount + album.statistics.trackFileCount;
sizeOnDisk: albumSizeOnDisk = 0
} = statistics;
const hasFiles = albumTrackFileCount > 0 && albumTrackFileCount === albumTotalTrackCount; if (album.statistics.trackFileCount === album.statistics.totalTrackCount || (album.monitored && isBefore(album.airDateUtc))) {
if (hasFiles || (monitored && isBefore(releaseDate))) {
albumCount++; albumCount++;
} }
if (hasFiles) {
albumFileCount++;
} }
if (monitored) { if (album.monitored) {
monitoredAlbumCount++; monitoredAlbumCount++;
hasMonitoredAlbums = true; hasMonitoredAlbums = true;
} }
totalAlbumCount++; totalAlbumCount++;
trackFileCount = trackFileCount + albumTrackFileCount;
sizeOnDisk = sizeOnDisk + albumSizeOnDisk;
}); });
return { return {
albumCount, albumCount,
albumFileCount,
totalAlbumCount, totalAlbumCount,
trackFileCount, trackFileCount,
monitoredAlbumCount, monitoredAlbumCount,
@ -67,8 +56,8 @@ function getAlbumStatistics(albums) {
}; };
} }
function getAlbumCountKind(monitored, albumCount, albumFileCount) { function getAlbumCountKind(monitored, albumCount, monitoredAlbumCount) {
if (albumCount === albumFileCount && albumFileCount > 0) { if (albumCount === monitoredAlbumCount && monitoredAlbumCount > 0) {
return kinds.SUCCESS; return kinds.SUCCESS;
} }
@ -203,7 +192,6 @@ class ArtistDetailsSeason extends Component {
const { const {
albumCount, albumCount,
albumFileCount,
totalAlbumCount, totalAlbumCount,
trackFileCount, trackFileCount,
monitoredAlbumCount, monitoredAlbumCount,
@ -238,9 +226,9 @@ class ArtistDetailsSeason extends Component {
anchor={ anchor={
<Label <Label
size={sizes.LARGE} size={sizes.LARGE}
kind={getAlbumCountKind(hasMonitoredAlbums, albumCount, albumFileCount)} kind={getAlbumCountKind(hasMonitoredAlbums, albumCount, monitoredAlbumCount)}
> >
<span>{albumFileCount} / {albumCount}</span> <span>{albumCount} / {monitoredAlbumCount}</span>
</Label> </Label>
} }
title={translate('GroupInformation')} title={translate('GroupInformation')}
@ -249,7 +237,6 @@ class ArtistDetailsSeason extends Component {
<AlbumGroupInfo <AlbumGroupInfo
totalAlbumCount={totalAlbumCount} totalAlbumCount={totalAlbumCount}
monitoredAlbumCount={monitoredAlbumCount} monitoredAlbumCount={monitoredAlbumCount}
albumFileCount={albumFileCount}
trackFileCount={trackFileCount} trackFileCount={trackFileCount}
sizeOnDisk={sizeOnDisk} sizeOnDisk={sizeOnDisk}
/> />

View file

@ -2,7 +2,6 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import ArtistTags from './ArtistTags'; import ArtistTags from './ArtistTags';
function createMapStateToProps() { function createMapStateToProps() {
@ -13,8 +12,8 @@ function createMapStateToProps() {
const tags = artist.tags const tags = artist.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId)) .map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag) .filter((tag) => !!tag)
.sort(sortByProp('label')) .map((tag) => tag.label)
.map((tag) => tag.label); .sort((a, b) => a.localeCompare(b));
return { return {
tags tags

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Statistics } from 'Album/Album';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
@ -57,7 +56,7 @@ function AlbumDetails(props: AlbumDetailsProps) {
disambiguation, disambiguation,
albumType, albumType,
monitored, monitored,
statistics = {} as Statistics, statistics,
isSaving = false, isSaving = false,
} = album; } = album;

View file

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

View file

@ -3,8 +3,8 @@ import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput'; import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning} className={styles.mappedDrivesWarning}
kind={kinds.WARNING} 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> </Alert>
} }

View file

@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import Artist from 'Artist/Artist'; import Artist from 'Artist/Artist';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
@ -11,7 +11,7 @@ function ArtistFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
const tagList = allArtists const tagList = allArtists
.map((artist) => ({ id: artist.id, name: artist.artistName })) .map((artist) => ({ id: artist.id, name: artist.artistName }))
.sort(sortByProp('name')); .sort(sortByName);
return <FilterBuilderRowValue {...props} tagList={tagList} />; return <FilterBuilderRowValue {...props} tagList={tagList} />;
} }

View file

@ -3,7 +3,6 @@ import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput'; import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue'; import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue';
import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue'; import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
@ -11,11 +10,11 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import MetadataProfileFilterBuilderRowValue from './MetadataProfileFilterBuilderRowValue'; import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector';
import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue'; import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
import styles from './FilterBuilderRow.css'; import styles from './FilterBuilderRow.css';
@ -68,7 +67,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return IndexerFilterBuilderRowValueConnector; return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.METADATA_PROFILE: case filterBuilderValueTypes.METADATA_PROFILE:
return MetadataProfileFilterBuilderRowValue; return MetadataProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.MONITOR_NEW_ITEMS: case filterBuilderValueTypes.MONITOR_NEW_ITEMS:
return MonitorNewItemsFilterBuilderRowValue; return MonitorNewItemsFilterBuilderRowValue;
@ -80,7 +79,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector; return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE: case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue; return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.ARTIST: case filterBuilderValueTypes.ARTIST:
return ArtistFilterBuilderRowValue; return ArtistFilterBuilderRowValue;
@ -225,7 +224,7 @@ class FilterBuilderRow extends Component {
key: name, key: name,
value: typeof label === 'function' ? label() : label value: typeof label === 'function' ? label() : label
}; };
}).sort(sortByProp('value')); }).sort((a, b) => a.value.localeCompare(b.value));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);

View file

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props'; import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes'; import * as filterTypes from 'Helpers/Props/filterTypes';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() { function createTagListSelector() {
@ -38,7 +38,7 @@ function createTagListSelector() {
} }
return acc; return acc;
}, []).sort(sortByProp('name')); }, []).sort(sortByName);
} }
return _.uniqBy(items, 'id'); return _.uniqBy(items, 'id');

View file

@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [
{ {
id: 7, id: 7,
get name() { 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

@ -5,7 +5,6 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import CustomFilter from './CustomFilter'; import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css'; import styles from './CustomFiltersModalContent.css';
@ -32,7 +31,7 @@ function CustomFiltersModalContent(props) {
<ModalBody> <ModalBody>
{ {
customFilters customFilters
.sort((a, b) => sortByProp(a, b, 'label')) .sort((a, b) => a.label.localeCompare(b.label))
.map((customFilter) => { .map((customFilter) => {
return ( return (
<CustomFilter <CustomFilter

View file

@ -4,8 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchDownloadClients } from 'Store/Actions/settingsActions'; import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
@ -23,7 +22,7 @@ function createMapStateToProps() {
const filteredItems = items.filter((item) => item.protocol === protocolFilter); const filteredItems = items.filter((item) => item.protocol === protocolFilter);
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => { const values = _.map(filteredItems.sort(sortByName), (downloadClient) => {
return { return {
key: downloadClient.id, key: downloadClient.id,
value: downloadClient.name, value: downloadClient.name,
@ -34,7 +33,7 @@ function createMapStateToProps() {
if (includeAny) { if (includeAny) {
values.unshift({ values.unshift({
key: 0, key: 0,
value: `(${translate('Any')})` value: '(Any)'
}); });
} }

View file

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

View file

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

View file

@ -4,8 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions'; import { fetchIndexers } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
@ -20,7 +19,7 @@ function createMapStateToProps() {
items items
} = indexers; } = indexers;
const values = _.map(items.sort(sortByProp('name')), (indexer) => { const values = _.map(items.sort(sortByName), (indexer) => {
return { return {
key: indexer.id, key: indexer.id,
value: indexer.name value: indexer.name
@ -30,7 +29,7 @@ function createMapStateToProps() {
if (includeAny) { if (includeAny) {
values.unshift({ values.unshift({
key: 0, key: 0,
value: `(${translate('Any')})` value: '(Any)'
}); });
} }

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

View file

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

@ -5,13 +5,13 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { metadataProfileNames } from 'Helpers/Props'; import { metadataProfileNames } from 'Helpers/Props';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.metadataProfiles', sortByProp('name')), createSortedSectionSelector('settings.metadataProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,

View file

@ -25,7 +25,7 @@ function MonitorAlbumsSelectInput(props) {
if (includeMixed) { if (includeMixed) {
values.unshift({ values.unshift({
key: 'mixed', key: 'mixed',
value: `(${translate('Mixed')})`, value: '(Mixed)',
isDisabled: true isDisabled: true
}); });
} }

View file

@ -0,0 +1,5 @@
.input {
composes: input from '~Components/Form/TextInput.css';
font-family: $passwordFamily;
}

View file

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

View file

@ -1,5 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import TextInput from './TextInput'; import TextInput from './TextInput';
import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input // Prevent a user from copying (or cutting) the password from the input
function onCopy(e) { function onCopy(e) {
@ -11,14 +13,17 @@ function PasswordInput(props) {
return ( return (
<TextInput <TextInput
{...props} {...props}
type="password"
onCopy={onCopy} onCopy={onCopy}
/> />
); );
} }
PasswordInput.propTypes = { PasswordInput.propTypes = {
...TextInput.props className: PropTypes.string.isRequired
};
PasswordInput.defaultProps = {
className: styles.input
}; };
export default PasswordInput; export default PasswordInput;

View file

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

View file

@ -4,13 +4,13 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), createSortedSectionSelector('settings.qualityProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,

View file

@ -52,7 +52,6 @@ class SelectInput extends Component {
const { const {
key, key,
value: optionValue, value: optionValue,
isDisabled: optionIsDisabled = false,
...otherOptionProps ...otherOptionProps
} = option; } = option;
@ -60,7 +59,6 @@ class SelectInput extends Component {
<option <option
key={key} key={key}
value={key} value={key}
disabled={optionIsDisabled}
{...otherOptionProps} {...otherOptionProps}
> >
{typeof optionValue === 'function' ? optionValue() : optionValue} {typeof optionValue === 'function' ? optionValue() : optionValue}

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import FilterMenuItem from './FilterMenuItem'; import FilterMenuItem from './FilterMenuItem';
import MenuContent from './MenuContent'; import MenuContent from './MenuContent';
@ -48,7 +47,7 @@ class FilterMenuContent extends Component {
{ {
customFilters customFilters
.sort(sortByProp('label')) .sort((a, b) => a.label.localeCompare(b.label))
.map((filter) => { .map((filter) => {
return ( return (
<FilterMenuItem <FilterMenuItem

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import ArtistSearchInputConnector from './ArtistSearchInputConnector'; import ArtistSearchInputConnector from './ArtistSearchInputConnector';
import KeyboardShortcutsModal from './KeyboardShortcutsModal'; import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenu from './PageHeaderActionsMenu'; import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
import styles from './PageHeader.css'; import styles from './PageHeader.css';
class PageHeader extends Component { class PageHeader extends Component {
@ -83,7 +83,6 @@ class PageHeader extends Component {
size={14} size={14}
title={translate('Donate')} title={translate('Donate')}
/> />
<IconButton <IconButton
className={styles.translation} className={styles.translation}
title={translate('SuggestTranslationChange')} title={translate('SuggestTranslationChange')}
@ -91,8 +90,7 @@ class PageHeader extends Component {
to="https://translate.servarr.com/projects/servarr/lidarr/" to="https://translate.servarr.com/projects/servarr/lidarr/"
size={24} size={24}
/> />
<PageHeaderActionsMenuConnector
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal} onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/> />
</div> </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; const status = resource.status;
// Both successful and failed commands need to be // 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') { if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource); this.props.dispatchFinishCommand(resource);
@ -224,58 +224,10 @@ class SignalRConnector extends Component {
repopulatePage('trackFileUpdated'); 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 = () => { handleHealth = () => {
this.props.dispatchFetchHealth(); 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) => { handleArtist = (body) => {
const action = body.action; const action = body.action;
const section = 'artist'; const section = 'artist';
@ -314,7 +266,7 @@ class SignalRConnector extends Component {
handleWantedCutoff = (body) => { handleWantedCutoff = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.dispatchUpdateItem({ this.props.dispatchUpdateItem({
section: 'wanted.cutoffUnmet', section: 'cutoffUnmet',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
}); });
@ -324,7 +276,7 @@ class SignalRConnector extends Component {
handleWantedMissing = (body) => { handleWantedMissing = (body) => {
if (body.action === 'updated') { if (body.action === 'updated') {
this.props.dispatchUpdateItem({ this.props.dispatchUpdateItem({
section: 'wanted.missing', section: 'missing',
updateOnly: true, updateOnly: true,
...body.resource ...body.resource
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import Label from './Label'; import Label from './Label';
import styles from './TagList.css'; import styles from './TagList.css';
@ -9,7 +8,7 @@ function TagList({ tags, tagList }) {
const sortedTags = tags const sortedTags = tags
.map((tagId) => tagList.find((tag) => tag.id === tagId)) .map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag) .filter((tag) => !!tag)
.sort(sortByProp('label')); .sort((a, b) => a.label.localeCompare(b.label));
return ( return (
<div className={styles.tags}> <div className={styles.tags}>

View file

@ -25,3 +25,14 @@
font-family: 'Ubuntu Mono'; font-family: 'Ubuntu Mono';
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
} }
/*
* text-security-disc
*/
@font-face {
font-weight: normal;
font-style: normal;
font-family: 'text-security-disc';
src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
}

Binary file not shown.

Binary file not shown.

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

@ -32,7 +32,6 @@ import {
faBookReader as fasBookReader, faBookReader as fasBookReader,
faBroadcastTower as fasBroadcastTower, faBroadcastTower as fasBroadcastTower,
faBug as fasBug, faBug as fasBug,
faCalculator as fasCalculator,
faCalendarAlt as fasCalendarAlt, faCalendarAlt as fasCalendarAlt,
faCaretDown as fasCaretDown, faCaretDown as fasCaretDown,
faCheck as fasCheck, faCheck as fasCheck,
@ -188,7 +187,6 @@ export const PAGE_PREVIOUS = fasBackward;
export const PAGE_NEXT = fasForward; export const PAGE_NEXT = fasForward;
export const PAGE_LAST = fasFastForward; export const PAGE_LAST = fasFastForward;
export const PARENT = fasLevelUpAlt; export const PARENT = fasLevelUpAlt;
export const PARSE = fasCalculator;
export const PAUSED = fasPause; export const PAUSED = fasPause;
export const PENDING = farClock; export const PENDING = farClock;
export const PROFILE = fasUser; export const PROFILE = fasUser;

View file

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

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