diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbea014f5..464a42aaf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.serva 1. Fork Lidarr 2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github) 3. Install the required Node Packages `yarn install` -4. Start gulp to monitor your dev environment for any changes that need post processing using `yarn start` command. +4. Start webpack to monitor your dev environment for any changes that need post processing using `yarn start` command. 5. Build the project in Visual Studio, Setting startup project to `Lidarr.Console` and framework to `netcoreapp31` 6. Debug the project in Visual Studio 7. Open http://localhost:8686 diff --git a/build.sh b/build.sh index 9fba36736..89167f497 100755 --- a/build.sh +++ b/build.sh @@ -77,11 +77,11 @@ YarnInstall() ProgressEnd 'yarn install' } -RunGulp() +RunWebpack() { - ProgressStart 'Running gulp' - yarn run build --production - ProgressEnd 'Running gulp' + ProgressStart 'Running webpack' + yarn run build --env production + ProgressEnd 'Running webpack' } PackageFiles() @@ -326,7 +326,7 @@ fi if [ "$FRONTEND" = "YES" ]; then YarnInstall - RunGulp + RunWebpack fi if [ "$LINT" = "YES" ]; diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 028dd1061..0b6d15b2e 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -6,8 +6,10 @@ const dirs = fs .map((dirent) => dirent.name) .join('|'); +const frontendFolder = __dirname; + module.exports = { - parser: 'babel-eslint', + parser: '@babel/eslint-parser', env: { browser: true, @@ -25,6 +27,9 @@ module.exports = { parserOptions: { ecmaVersion: 6, sourceType: 'module', + babelOptions: { + configFile: `${frontendFolder}/babel.config.js`, + }, ecmaFeatures: { modules: true, impliedStrict: true @@ -271,7 +276,7 @@ module.exports = { // ImportSort - 'simple-import-sort/sort': 'error', + 'simple-import-sort/imports': 'error', 'import/newline-after-import': 'error', // React @@ -309,7 +314,7 @@ module.exports = { { files: ['*.js'], rules: { - 'simple-import-sort/sort': [ + 'simple-import-sort/imports': [ 'error', { groups: [ diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js new file mode 100644 index 000000000..6f324ac95 --- /dev/null +++ b/frontend/build/webpack.config.js @@ -0,0 +1,270 @@ +const path = require('path'); +const webpack = require('webpack'); +const FileManagerPlugin = require('filemanager-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const LiveReloadPlugin = require('webpack-livereload-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); + +module.exports = (env) => { + const uiFolder = 'UI'; + const frontendFolder = path.join(__dirname, '..'); + const srcFolder = path.join(frontendFolder, 'src'); + const isProduction = !!env.production; + const isProfiling = isProduction && !!env.profile; + const inlineWebWorkers = 'no-fallback'; + + const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); + + console.log('Source Folder:', srcFolder); + console.log('Output Folder:', distFolder); + console.log('isProduction:', isProduction); + console.log('isProfiling:', isProfiling); + + const config = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'eval-source-map', + + stats: { + children: false + }, + + watchOptions: { + ignored: /node_modules/ + }, + + entry: { + index: 'index.js' + }, + + resolve: { + modules: [ + srcFolder, + path.join(srcFolder, 'Shims'), + 'node_modules' + ], + alias: { + jquery: 'jquery/src/jquery' + }, + fallback: { + buffer: false, + http: false, + https: false, + url: false, + util: false, + net: false + } + }, + + output: { + path: distFolder, + publicPath: '/', + filename: '[name].js', + sourceMapFilename: '[file].map' + }, + + optimization: { + moduleIds: 'deterministic', + chunkIds: 'named', + splitChunks: { + chunks: 'initial', + name: 'vendors' + } + }, + + performance: { + hints: false + }, + + plugins: [ + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }), + + new MiniCssExtractPlugin({ + filename: 'Content/styles.css' + }), + + new HtmlWebpackPlugin({ + template: 'frontend/src/index.ejs', + filename: 'index.html', + publicPath: '/' + }), + + new FileManagerPlugin({ + events: { + onEnd: { + copy: [ + // HTML + { + source: 'frontend/src/*.html', + destination: distFolder + }, + + // Fonts + { + source: 'frontend/src/Content/Fonts/*.*', + destination: path.join(distFolder, 'Content/Fonts') + }, + + // Icon Images + { + source: 'frontend/src/Content/Images/Icons/*.*', + destination: path.join(distFolder, 'Content/Images/Icons') + }, + + // Images + { + source: 'frontend/src/Content/Images/*.*', + destination: path.join(distFolder, 'Content/Images') + }, + + // Robots + { + source: 'frontend/src/Content/robots.txt', + destination: path.join(distFolder, 'Content/robots.txt') + } + ] + } + } + }), + + new LiveReloadPlugin() + ], + + resolveLoader: { + modules: [ + 'node_modules', + 'frontend/build/webpack/' + ] + }, + + module: { + rules: [ + { + test: /\.worker\.js$/, + use: { + loader: 'worker-loader', + options: { + filename: '[name].js', + inline: inlineWebWorkers + } + } + }, + { + test: /\.js?$/, + exclude: /(node_modules|JsLibraries)/, + use: [ + { + loader: 'babel-loader', + options: { + configFile: `${frontendFolder}/babel.config.js`, + envName: isProduction ? 'production' : 'development', + presets: [ + [ + '@babel/preset-env', + { + modules: false, + loose: true, + debug: false, + useBuiltIns: 'entry', + corejs: 3 + } + ] + ] + } + } + ] + }, + + // CSS Modules + { + test: /\.css$/, + exclude: /(node_modules|globals.css)/, + use: [ + { loader: MiniCssExtractPlugin.loader }, + { + loader: 'css-loader', + options: { + importLoaders: 1, + modules: { + localIdentName: '[name]/[local]/[hash:base64:5]' + } + } + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + config: 'frontend/postcss.config.js' + } + } + } + ] + }, + + // Global styles + { + test: /\.css$/, + include: /(node_modules|globals.css)/, + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] + }, + + // Fonts + { + test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10240, + mimetype: 'application/font-woff', + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + }, + + { + test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + } + ] + } + }; + + if (isProfiling) { + config.resolve.alias['react-dom$'] = 'react-dom/profiling'; + config.resolve.alias['scheduler/tracing'] = 'scheduler/tracing-profiling'; + + config.optimization.minimizer = [ + new TerserPlugin({ + cache: true, + parallel: true, + sourceMap: true, // Must be set to true if using source-maps in production + terserOptions: { + mangle: false, + keep_classnames: true, + keep_fnames: true + } + }) + ]; + } + + return config; +}; diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/build/webpack/css-variables-loader.js similarity index 100% rename from frontend/gulp/webpack/css-variables-loader.js rename to frontend/build/webpack/css-variables-loader.js diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js deleted file mode 100644 index f1f708e64..000000000 --- a/frontend/gulp/build.js +++ /dev/null @@ -1,18 +0,0 @@ -const gulp = require('gulp'); - -require('./clean'); -require('./copy'); -require('./webpack'); - -gulp.task('build', - gulp.series('clean', - gulp.parallel( - 'webpack', - 'copyHtml', - 'copyFonts', - 'copyImages', - 'copyRobots' - ) - ) -); - diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js deleted file mode 100644 index ac2e4026f..000000000 --- a/frontend/gulp/clean.js +++ /dev/null @@ -1,8 +0,0 @@ -const gulp = require('gulp'); -const del = require('del'); - -const paths = require('./helpers/paths'); - -gulp.task('clean', () => { - return del([paths.dest.root]); -}); diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js deleted file mode 100644 index ee84d2152..000000000 --- a/frontend/gulp/copy.js +++ /dev/null @@ -1,42 +0,0 @@ -const path = require('path'); -const gulp = require('gulp'); -const print = require('gulp-print').default; -const cache = require('gulp-cached'); -const livereload = require('gulp-livereload'); -const paths = require('./helpers/paths.js'); - -gulp.task('copyHtml', () => { - return gulp.src(paths.src.html, { base: paths.src.root }) - .pipe(cache('copyHtml')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyFonts', () => { - return gulp.src( - path.join(paths.src.fonts, '**', '*.*'), { base: paths.src.root } - ) - .pipe(cache('copyFonts')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyImages', () => { - return gulp.src( - path.join(paths.src.images, '**', '*.*'), { base: paths.src.root } - ) - .pipe(cache('copyImages')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyRobots', () => { - return gulp.src(paths.src.robots, { base: paths.src.root }) - .pipe(cache('copyRobots')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js deleted file mode 100644 index 64f14f654..000000000 --- a/frontend/gulp/gulpFile.js +++ /dev/null @@ -1,5 +0,0 @@ -require('./build.js'); -require('./clean.js'); -require('./copy.js'); -require('./watch.js'); -require('./webpack.js'); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js deleted file mode 100644 index 9c542398d..000000000 --- a/frontend/gulp/helpers/errorHandler.js +++ /dev/null @@ -1,6 +0,0 @@ -const colors = require('ansi-colors'); - -module.exports = function errorHandler(error) { - console.log(colors.red(`Error (${error.plugin}): ${error.message}`)); - this.emit('end'); -}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js deleted file mode 100644 index be866abcd..000000000 --- a/frontend/gulp/helpers/paths.js +++ /dev/null @@ -1,24 +0,0 @@ -const root = './frontend/src'; - -const paths = { - src: { - root, - html: `${root}/*.html`, - scripts: `${root}/**/*.js`, - content: `${root}/Content/`, - fonts: `${root}/Content/Fonts/`, - images: `${root}/Content/Images/`, - robots: `${root}/Content/robots.txt`, - exclude: { - libs: `!${root}/JsLibraries/**` - } - }, - dest: { - root: './_output/UI/', - content: './_output/UI/Content/', - fonts: './_output/UI/Content/Fonts/', - images: './_output/UI/Content/Images/' - } -}; - -module.exports = paths; diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js deleted file mode 100644 index bdb3ad8d2..000000000 --- a/frontend/gulp/watch.js +++ /dev/null @@ -1,19 +0,0 @@ -const gulp = require('gulp'); -const livereload = require('gulp-livereload'); -const gulpWatch = require('gulp-watch'); -const paths = require('./helpers/paths.js'); - -require('./copy.js'); -require('./webpack.js'); - -function watch() { - livereload.listen({ start: true }); - - gulp.task('webpackWatch')(); - gulpWatch(paths.src.html, gulp.series('copyHtml')); - gulpWatch(`${paths.src.fonts}**/*.*`, gulp.series('copyFonts')); - gulpWatch(`${paths.src.images}**/*.*`, gulp.series('copyImages')); - gulpWatch(paths.src.robots, gulp.series('copyRobots')); -} - -gulp.task('watch', gulp.series('build', watch)); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js deleted file mode 100644 index 7cd720370..000000000 --- a/frontend/gulp/webpack.js +++ /dev/null @@ -1,271 +0,0 @@ -const gulp = require('gulp'); -const webpackStream = require('webpack-stream'); -const livereload = require('gulp-livereload'); -const path = require('path'); -const webpack = require('webpack'); -const errorHandler = require('./helpers/errorHandler'); -const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const HtmlWebpackPluginHtmlTags = require('html-webpack-plugin/lib/html-tags'); -const TerserPlugin = require('terser-webpack-plugin'); - -const uiFolder = 'UI'; -const frontendFolder = path.join(__dirname, '..'); -const srcFolder = path.join(frontendFolder, 'src'); -const isProduction = process.argv.indexOf('--production') > -1; -const isProfiling = isProduction && process.argv.indexOf('--profile') > -1; -const inlineWebWorkers = 'no-fallback'; - -const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder); - -console.log('Source Folder:', srcFolder); -console.log('Output Folder:', distFolder); -console.log('isProduction:', isProduction); -console.log('isProfiling:', isProfiling); - -const cssVarsFiles = [ - '../src/Styles/Variables/colors', - '../src/Styles/Variables/dimensions', - '../src/Styles/Variables/fonts', - '../src/Styles/Variables/animations', - '../src/Styles/Variables/zIndexes' -].map(require.resolve); - -// Override the way HtmlWebpackPlugin injects the scripts -// TODO: Find a better way to get these paths without -HtmlWebpackPlugin.prototype.injectAssetsIntoHtml = function(html, assets, assetTags) { - const head = assetTags.headTags.map((v) => { - const href = v.attributes.href - .replace('\\', '/') - .replace('%5C', '/'); - - v.attributes = { rel: 'stylesheet', type: 'text/css', href: `/${href}` }; - return HtmlWebpackPluginHtmlTags.htmlTagObjectToString(v, this.options.xhtml); - }); - const body = assetTags.bodyTags.map((v) => { - v.attributes = { src: `/${v.attributes.src}` }; - return HtmlWebpackPluginHtmlTags.htmlTagObjectToString(v, this.options.xhtml); - }); - - return html - .replace('', head.join('\r\n ')) - .replace('', body.join('\r\n ')); -}; - -const plugins = [ - new OptimizeCssAssetsPlugin({}), - - new webpack.DefinePlugin({ - __DEV__: !isProduction, - 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') - }), - - new MiniCssExtractPlugin({ - filename: path.join('Content', 'styles.css') - }), - - new HtmlWebpackPlugin({ - template: 'frontend/src/index.html', - filename: 'index.html' - }) -]; - -const config = { - mode: isProduction ? 'production' : 'development', - devtool: '#source-map', - - stats: { - children: false - }, - - watchOptions: { - ignored: /node_modules/ - }, - - entry: { - index: 'index.js' - }, - - resolve: { - modules: [ - srcFolder, - path.join(srcFolder, 'Shims'), - 'node_modules' - ], - alias: { - jquery: 'jquery/src/jquery' - } - }, - - output: { - path: distFolder, - filename: '[name].js', - sourceMapFilename: '[file].map' - }, - - optimization: { - chunkIds: 'named', - splitChunks: { - chunks: 'initial' - } - }, - - performance: { - hints: false - }, - - plugins, - - resolveLoader: { - modules: [ - 'node_modules', - 'frontend/gulp/webpack/' - ] - }, - - module: { - rules: [ - { - test: /\.worker\.js$/, - use: { - loader: 'worker-loader', - options: { - filename: '[name].js', - inline: inlineWebWorkers - } - } - }, - { - test: /\.js?$/, - exclude: /(node_modules|JsLibraries)/, - use: [ - { - loader: 'babel-loader', - options: { - configFile: `${frontendFolder}/babel.config.js`, - envName: isProduction ? 'production' : 'development', - presets: [ - [ - '@babel/preset-env', - { - modules: false, - loose: true, - debug: false, - useBuiltIns: 'entry', - corejs: 3 - } - ] - ] - } - } - ] - }, - - // CSS Modules - { - test: /\.css$/, - exclude: /(node_modules|globals.css)/, - use: [ - { loader: MiniCssExtractPlugin.loader }, - { - loader: 'css-loader', - options: { - importLoaders: 1, - modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' - } - } - }, - { - loader: 'postcss-loader', - options: { - ident: 'postcss', - config: { - ctx: { - cssVarsFiles - }, - path: 'frontend/postcss.config.js' - } - } - } - ] - }, - - // Global styles - { - test: /\.css$/, - include: /(node_modules|globals.css)/, - use: [ - 'style-loader', - { - loader: 'css-loader' - } - ] - }, - - // Fonts - { - test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, - use: [ - { - loader: 'url-loader', - options: { - limit: 10240, - mimetype: 'application/font-woff', - emitFile: false, - name: 'Content/Fonts/[name].[ext]' - } - } - ] - }, - - { - test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, - use: [ - { - loader: 'file-loader', - options: { - emitFile: false, - name: 'Content/Fonts/[name].[ext]' - } - } - ] - } - ] - } -}; - -if (isProfiling) { - config.resolve.alias['react-dom$'] = 'react-dom/profiling'; - config.resolve.alias['scheduler/tracing'] = 'scheduler/tracing-profiling'; - - config.optimization.minimizer = [ - new TerserPlugin({ - cache: true, - parallel: true, - sourceMap: true, // Must be set to true if using source-maps in production - terserOptions: { - mangle: false, - keep_classnames: true, - keep_fnames: true - } - }) - ]; -} - -gulp.task('webpack', () => { - return webpackStream(config) - .pipe(gulp.dest('_output/UI')); -}); - -gulp.task('webpackWatch', () => { - config.watch = true; - - return webpackStream(config, webpack) - .on('error', errorHandler) - .pipe(gulp.dest('_output/UI')) - .on('error', errorHandler) - .pipe(livereload()) - .on('error', errorHandler); -}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index b0391ec6a..7b5bc5976 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,23 +1,32 @@ const reload = require('require-nocache')(module); -module.exports = (ctx, configPath, options) => { - const config = { - plugins: { - 'postcss-mixins': { - mixinsDir: [ - 'frontend/src/Styles/Mixins' - ] - }, - 'postcss-simple-vars': { - variables: () => - ctx.options.cssVarsFiles.reduce((acc, vars) => { - return Object.assign(acc, reload(vars)); - }, {}) - }, - 'postcss-color-function': {}, - 'postcss-nested': {} - } - }; +const cssVarsFiles = [ + './src/Styles/Variables/colors', + './src/Styles/Variables/dimensions', + './src/Styles/Variables/fonts', + './src/Styles/Variables/animations', + './src/Styles/Variables/zIndexes' +].map(require.resolve); - return config; +const mixinsFiles = [ + 'frontend/src/Styles/Mixins/cover.css', + 'frontend/src/Styles/Mixins/linkOverlay.css', + 'frontend/src/Styles/Mixins/scroller.css', + 'frontend/src/Styles/Mixins/truncate.css' +]; + +module.exports = { + plugins: [ + ['postcss-mixins', { + mixinsFiles + }], + ['postcss-simple-vars', { + variables: () => + cssVarsFiles.reduce((acc, vars) => { + return Object.assign(acc, reload(vars)); + }, {}) + }], + 'postcss-color-function', + 'postcss-nested' + ] }; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index 9fb05e25e..a7aed80b6 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -16,7 +16,7 @@ function createTagListSelector() { (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || selectedFilterBuilderProp.type === filterBuilderTypes.STRING) && filterType !== filterTypes.EQUAL && - filterType !== filterBuilderTypes.NOT_EQUAL || + filterType !== filterTypes.NOT_EQUAL || !selectedFilterBuilderProp.optionsSelector ) { return []; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js index b1a3bfc66..7e7c461ad 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -1,8 +1,8 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd-multi-backend'; +import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -127,7 +127,7 @@ class TableOptionsModal extends Component { const isDraggingDown = isDragging && dropIndex > dragIndex; return ( - + {item.albumType.name} diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js index 1be943355..4a40210bf 100644 --- a/frontend/src/Settings/Profiles/Profiles.js +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd-multi-backend'; +import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; @@ -25,7 +25,7 @@ class Profiles extends Component { /> - + diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js index 971a86c50..76db93f62 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfile.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -103,7 +103,7 @@ class QualityProfile extends Component { return (