diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj index 9569bf6f3..00f5f96e3 100644 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ b/src/NzbDrone.Api/NzbDrone.Api.csproj @@ -152,6 +152,7 @@ + diff --git a/src/NzbDrone.Api/Series/SeriesEditorModule.cs b/src/NzbDrone.Api/Series/SeriesEditorModule.cs new file mode 100644 index 000000000..f64b4098c --- /dev/null +++ b/src/NzbDrone.Api/Series/SeriesEditorModule.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.Remoting.Messaging; +using Nancy; +using NzbDrone.Api.Extensions; +using NzbDrone.Api.Mapping; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Api.Series +{ + public class SeriesEditorModule : NzbDroneApiModule + { + private readonly ISeriesService _seriesService; + + public SeriesEditorModule(ISeriesService seriesService) + : base("/series/editor") + { + _seriesService = seriesService; + Put["/"] = series => SaveAll(); + } + + private Response SaveAll() + { + //Read from request + var series = Request.Body.FromJson>().InjectTo>(); + + return _seriesService.UpdateSeries(series).InjectTo>().AsResponse(); + } + } +} diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs index c3767eaf6..18b67a732 100644 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ b/src/NzbDrone.Core/Tv/SeriesService.cs @@ -24,6 +24,7 @@ namespace NzbDrone.Core.Tv void DeleteSeries(int seriesId, bool deleteFiles); List GetAllSeries(); Series UpdateSeries(Series series); + List UpdateSeries(List series); bool SeriesPathExists(string folder); } @@ -138,6 +139,22 @@ namespace NzbDrone.Core.Tv return _seriesRepository.Update(series); } + public List UpdateSeries(List series) + { + foreach (var s in series) + { + if (!String.IsNullOrWhiteSpace(s.RootFolderPath)) + { + var folderName = new DirectoryInfo(s.Path).Name; + s.Path = Path.Combine(s.RootFolderPath, folderName); + } + } + + _seriesRepository.UpdateMany(series); + + return series; + } + public bool SeriesPathExists(string folder) { return _seriesRepository.SeriesPathExists(folder); diff --git a/src/UI/AppLayout.js b/src/UI/AppLayout.js index b063516f3..a0988a243 100644 --- a/src/UI/AppLayout.js +++ b/src/UI/AppLayout.js @@ -1,20 +1,22 @@ define( [ 'marionette', - 'Shared/Modal/ModalRegion' - ], function (Marionette, ModalRegion) { + 'Shared/Modal/ModalRegion', + 'Shared/ControlPanel/ControlPanelRegion' + ], function (Marionette, ModalRegion, ControlPanelRegion) { 'use strict'; var Layout = Marionette.Layout.extend({ regions: { - navbarRegion: '#nav-region', - mainRegion : '#main-region' + navbarRegion : '#nav-region', + mainRegion : '#main-region' }, initialize: function () { this.addRegions({ - modalRegion: ModalRegion + modalRegion : ModalRegion, + controlPanelRegion: ControlPanelRegion }); } }); diff --git a/src/UI/Cells/SeasonFolderCell.js b/src/UI/Cells/SeasonFolderCell.js new file mode 100644 index 000000000..5c9cc6d1b --- /dev/null +++ b/src/UI/Cells/SeasonFolderCell.js @@ -0,0 +1,16 @@ +'use strict'; +define( + [ + 'backgrid' + ], function (Backgrid) { + return Backgrid.Cell.extend({ + + className : 'season-folder-cell', + + render: function () { + var seasonFolder = this.model.get('seasonFolder'); + this.$el.html(seasonFolder.toString()); + return this; + } + }); + }); diff --git a/src/UI/Cells/cells.less b/src/UI/Cells/cells.less index 6efd806ad..b2ba4d140 100644 --- a/src/UI/Cells/cells.less +++ b/src/UI/Cells/cells.less @@ -117,4 +117,8 @@ td.delete-episode-file-cell { i { .clickable(); } +} + +.series-status-cell { + width: 16px; } \ No newline at end of file diff --git a/src/UI/Content/Backgrid/selectall.less b/src/UI/Content/Backgrid/selectall.less index 86f6e0107..322853304 100644 --- a/src/UI/Content/Backgrid/selectall.less +++ b/src/UI/Content/Backgrid/selectall.less @@ -6,8 +6,7 @@ Licensed under the MIT @license. */ -.backgrid { - .select-row-cell, .select-all-header-cell { - text-align: center; - } +.select-row-cell, .select-all-header-cell { + text-align: center; + width: 16px; } \ No newline at end of file diff --git a/src/UI/Content/Overrides/messenger.less b/src/UI/Content/Overrides/messenger.less new file mode 100644 index 000000000..dec17fe5a --- /dev/null +++ b/src/UI/Content/Overrides/messenger.less @@ -0,0 +1,5 @@ +body.control-panel-visible { + ul.messenger.messenger-fixed.messenger-on-bottom { + bottom: 95px; + } +} \ No newline at end of file diff --git a/src/UI/Content/overrides.less b/src/UI/Content/overrides.less index 040b76e92..1c0ec7e73 100644 --- a/src/UI/Content/overrides.less +++ b/src/UI/Content/overrides.less @@ -2,3 +2,4 @@ @import "Overrides/browser"; @import "Overrides/bootstrap.toggle-switch"; @import "Overrides/fullcalendar"; +@import "Overrides/messenger"; diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less index eb99df003..8bf71358e 100644 --- a/src/UI/Content/theme.less +++ b/src/UI/Content/theme.less @@ -69,6 +69,12 @@ color : white; } +.control-panel-visible { + #scroll-up { + bottom: 100px; + } +} + .label-large { padding : 4px 6px; font-size : 16px; @@ -104,7 +110,7 @@ body { } } -footer { +.footer { font-size : 13px; font-weight : lighter; padding-top : 0px; @@ -192,4 +198,19 @@ footer { .file-path { .mono-space(); +} + +.control-panel { + .card(#333333); + + color: #f5f5f5; + background-color: #333333; + margin: 0px; + margin-bottom: -100px; + position: fixed; + left: 0; + bottom: 0; + width: 100%; + height: 55px; + opacity: 0; } \ No newline at end of file diff --git a/src/UI/Controller.js b/src/UI/Controller.js index bd5be1819..db0b13e2b 100644 --- a/src/UI/Controller.js +++ b/src/UI/Controller.js @@ -12,7 +12,8 @@ define( 'Release/ReleaseLayout', 'System/SystemLayout', 'SeasonPass/SeasonPassLayout', - 'System/Update/UpdateLayout' + 'System/Update/UpdateLayout', + 'Series/Editor/SeriesEditorLayout' ], function (NzbDroneController, AppLayout, Marionette, @@ -24,7 +25,8 @@ define( ReleaseLayout, SystemLayout, SeasonPassLayout, - UpdateLayout) { + UpdateLayout, + SeriesEditorLayout) { return NzbDroneController.extend({ addSeries: function (action) { @@ -72,7 +74,13 @@ define( update: function () { this.setTitle('Updates'); this.showMainRegion(new UpdateLayout()); + }, + + seriesEditor: function () { + this.setTitle('Series Editor'); + this.showMainRegion(new SeriesEditorLayout()); } + }); }); diff --git a/src/UI/Router.js b/src/UI/Router.js index d8532756f..f2927787a 100644 --- a/src/UI/Router.js +++ b/src/UI/Router.js @@ -21,6 +21,7 @@ define( 'system' : 'system', 'system/:action' : 'system', 'seasonpass' : 'seasonPass', + 'serieseditor' : 'seriesEditor', ':whatever' : 'showNotFound' } }); diff --git a/src/UI/Series/Editor/SeriesEditorFooterView.js b/src/UI/Series/Editor/SeriesEditorFooterView.js new file mode 100644 index 000000000..27dca0d35 --- /dev/null +++ b/src/UI/Series/Editor/SeriesEditorFooterView.js @@ -0,0 +1,150 @@ +'use strict'; +define( + [ + 'underscore', + 'marionette', + 'backgrid', + 'vent', + 'Series/SeriesCollection', + 'Quality/QualityProfileCollection', + 'AddSeries/RootFolders/Collection', + 'Shared/Toolbar/ToolbarLayout', + 'AddSeries/RootFolders/Layout', + 'Config' + ], function (_, + Marionette, + Backgrid, + vent, + SeriesCollection, + QualityProfiles, + RootFolders, + ToolbarLayout, + RootFolderLayout, + Config) { + return Marionette.ItemView.extend({ + template: 'Series/Editor/SeriesEditorFooterViewTemplate', + + ui: { + monitored : '.x-monitored', + qualityProfile: '.x-quality-profiles', + seasonFolder : '.x-season-folder', + rootFolder : '.x-root-folder', + selectedCount : '.x-selected-count', + saveButton : '.x-save', + container : '.series-editor-footer' + }, + + events: { + 'click .x-save' : '_updateAndSave', + 'change .x-root-folder': '_rootFolderChanged' + }, + + templateHelpers: function () { + return { + qualityProfiles: QualityProfiles, + rootFolders : RootFolders.toJSON() + }; + }, + + initialize: function (options) { + RootFolders.fetch().done(function () { + RootFolders.synced = true; + }); + + this.editorGrid = options.editorGrid; + this.listenTo(SeriesCollection, 'backgrid:selected', this._updateInfo); + this.listenTo(RootFolders, 'all', this.render); + }, + + onRender: function () { + this._updateInfo(); + }, + + _updateAndSave: function () { + var selected = this.editorGrid.getSelectedModels(); + + var monitored = this.ui.monitored.val(); + var profile = this.ui.qualityProfile.val(); + var seasonFolder = this.ui.seasonFolder.val(); + var rootFolder = this.ui.rootFolder.val(); + + _.each(selected, function (model) { + if (monitored === 'true') { + model.set('monitored', true); + } + + else if (monitored === 'false') { + model.set('monitored', false); + } + + if (profile !== 'noChange') { + model.set('qualityProfileId', parseInt(profile, 10)); + } + + if (seasonFolder === 'true') { + model.set('seasonFolder', true); + } + + else if (seasonFolder === 'false') { + model.set('seasonFolder', false); + } + + if (rootFolder !== 'noChange') { + var rootFolderPath = RootFolders.get(parseInt(rootFolder, 10)); + + model.set('rootFolderPath', rootFolderPath.get('path')); + } + + model.trigger('backgrid:select', model, false); + }); + + this.ui.monitored.val('noChange'); + this.ui.qualityProfile.val('noChange'); + this.ui.seasonFolder.val('noChange'); + this.ui.rootFolder.val('noChange'); + + SeriesCollection.save(); + }, + + _updateInfo: function () { + var selected = this.editorGrid.getSelectedModels(); + var selectedCount = selected.length; + + this.ui.selectedCount.html('{0} series selected'.format(selectedCount)); + + if (selectedCount === 0) { + this.ui.monitored.attr('disabled', ''); + this.ui.qualityProfile.attr('disabled', ''); + this.ui.seasonFolder.attr('disabled', ''); + this.ui.rootFolder.attr('disabled', ''); + this.ui.saveButton.attr('disabled', ''); + } + + else { + this.ui.monitored.removeAttr('disabled', ''); + this.ui.qualityProfile.removeAttr('disabled', ''); + this.ui.seasonFolder.removeAttr('disabled', ''); + this.ui.rootFolder.removeAttr('disabled', ''); + this.ui.saveButton.removeAttr('disabled', ''); + } + }, + + _rootFolderChanged: function () { + var rootFolderValue = this.ui.rootFolder.val(); + if (rootFolderValue === 'addNew') { + var rootFolderLayout = new RootFolderLayout(); + this.listenToOnce(rootFolderLayout, 'folderSelected', this._setRootFolder); + vent.trigger(vent.Commands.OpenModalCommand, rootFolderLayout); + } + else { + Config.setValue(Config.Keys.DefaultRootFolderId, rootFolderValue); + } + }, + + _setRootFolder: function (options) { + vent.trigger(vent.Commands.CloseModalCommand); + this.ui.rootFolder.val(options.model.id); + this._rootFolderChanged(); + } + }); + }); diff --git a/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.html b/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.html new file mode 100644 index 000000000..ad5b16e14 --- /dev/null +++ b/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.html @@ -0,0 +1,50 @@ + \ No newline at end of file diff --git a/src/UI/Series/Editor/SeriesEditorLayout.js b/src/UI/Series/Editor/SeriesEditorLayout.js new file mode 100644 index 000000000..c3e134a84 --- /dev/null +++ b/src/UI/Series/Editor/SeriesEditorLayout.js @@ -0,0 +1,153 @@ +'use strict'; +define( + [ + 'vent', + 'marionette', + 'backgrid', + 'Series/Index/EmptyView', + 'Series/SeriesCollection', + 'Cells/SeriesTitleCell', + 'Cells/QualityProfileCell', + 'Cells/SeriesStatusCell', + 'Cells/SeasonFolderCell', + 'Shared/Toolbar/ToolbarLayout', + 'Series/Editor/SeriesEditorFooterView' + ], function (vent, + Marionette, + Backgrid, + EmptyView, + SeriesCollection, + SeriesTitleCell, + QualityProfileCell, + SeriesStatusCell, + SeasonFolderCell, + ToolbarLayout, + FooterView) { + return Marionette.Layout.extend({ + template: 'Series/Editor/SeriesEditorLayoutTemplate', + + regions: { + seriesRegion: '#x-series-editor', + toolbar : '#x-toolbar' + }, + + ui: { + monitored : '.x-monitored', + qualityProfiles: '.x-quality-profiles', + rootFolder : '.x-root-folder', + selectedCount : '.x-selected-count' + }, + + events: { + 'click .x-save' : '_updateAndSave', + 'change .x-root-folder': '_rootFolderChanged' + }, + + columns: + [ + { + name : '', + cell : 'select-row', + headerCell: 'select-all', + sortable : false + }, + { + name : 'statusWeight', + label : '', + cell : SeriesStatusCell + }, + { + name : 'title', + label : 'Title', + cell : SeriesTitleCell, + cellValue: 'this' + }, + { + name : 'qualityProfileId', + label: 'Quality', + cell : QualityProfileCell + }, + { + name : 'monitored', + label: 'Season Folder', + cell : SeasonFolderCell + }, + { + name : 'path', + label: 'Path', + cell : 'string' + } + ], + + leftSideButtons: { + type : 'default', + storeState: false, + items : + [ + { + title : 'Season Pass', + icon : 'icon-bookmark', + route : 'seasonpass' + }, + { + title : 'Update Library', + icon : 'icon-refresh', + command : 'refreshseries', + successMessage: 'Library was updated!', + errorMessage : 'Library update failed!' + } + ] + }, + + initialize: function () { + this.listenTo(SeriesCollection, 'sync', this._showTable); + this.listenTo(SeriesCollection, 'remove', this._showTable); + }, + + onRender: function () { + this._showToolbar(); + this._showTable(); + + this._fetchCollection(); + }, + + onClose: function () { + vent.trigger(vent.Commands.CloseControlPanelCommand); + }, + + _showTable: function () { + if (SeriesCollection.length === 0) { + this.seriesRegion.show(new EmptyView()); + this.toolbar.close(); + return; + } + + this.editorGrid = new Backgrid.Grid({ + collection: SeriesCollection, + columns : this.columns, + className : 'table table-hover' + }); + + this.seriesRegion.show(this.editorGrid); + this._showFooter(); + }, + + _fetchCollection: function () { + SeriesCollection.fetch(); + }, + + _showToolbar: function () { + this.toolbar.show(new ToolbarLayout({ + left : + [ + this.leftSideButtons + ], + context: this + })); + }, + + _showFooter: function () { + vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ editorGrid: this.editorGrid })); + } + }); + }); diff --git a/src/UI/Series/Editor/SeriesEditorLayoutTemplate.html b/src/UI/Series/Editor/SeriesEditorLayoutTemplate.html new file mode 100644 index 000000000..470e663eb --- /dev/null +++ b/src/UI/Series/Editor/SeriesEditorLayoutTemplate.html @@ -0,0 +1,7 @@ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/src/UI/Series/Index/SeriesIndexLayout.js b/src/UI/Series/Index/SeriesIndexLayout.js index b2716cfd4..4d794cf07 100644 --- a/src/UI/Series/Index/SeriesIndexLayout.js +++ b/src/UI/Series/Index/SeriesIndexLayout.js @@ -109,6 +109,11 @@ define( icon : 'icon-bookmark', route : 'seasonpass' }, + { + title : 'Series Editor', + icon : 'icon-nd-edit', + route : 'serieseditor' + }, { title : 'RSS Sync', icon : 'icon-rss', diff --git a/src/UI/Series/SeriesCollection.js b/src/UI/Series/SeriesCollection.js index b7458ef52..110044110 100644 --- a/src/UI/Series/SeriesCollection.js +++ b/src/UI/Series/SeriesCollection.js @@ -1,10 +1,11 @@ 'use strict'; define( [ + 'underscore', 'backbone', 'Series/SeriesModel', 'api!series' - ], function (Backbone, SeriesModel, SeriesData) { + ], function (_, Backbone, SeriesModel, SeriesData) { var Collection = Backbone.Collection.extend({ url : window.NzbDrone.ApiRoot + '/series', model: SeriesModel, @@ -16,6 +17,30 @@ define( state: { sortKey: 'title', order : -1 + }, + + save: function () { + var self = this; + + var proxy = _.extend( new Backbone.Model(), + { + id: '', + + url: self.url + '/editor', + + toJSON: function() + { + return self.filter(function (model) { + return model.hasChanged(); + }); + } + }); + + this.listenTo(proxy, 'sync', function (proxyModel, models) { + this.add(models, { merge: true }); + }); + + return proxy.save(); } }); diff --git a/src/UI/Series/series.less b/src/UI/Series/series.less index fb116379e..098c2c091 100644 --- a/src/UI/Series/series.less +++ b/src/UI/Series/series.less @@ -288,4 +288,17 @@ font-size : 24px; margin-top : 3px; } +} + +//Editor + +.series-editor-footer { + width: 1100px; + color: #f5f5f5; + margin-left: auto; + margin-right: auto; + + .selected-count { + margin-right: 10px; + } } \ No newline at end of file diff --git a/src/UI/Shared/ControlPanel/ControlPanelController.js b/src/UI/Shared/ControlPanel/ControlPanelController.js new file mode 100644 index 000000000..4e2a1100c --- /dev/null +++ b/src/UI/Shared/ControlPanel/ControlPanelController.js @@ -0,0 +1,24 @@ +'use strict'; +define( + [ + 'vent', + 'AppLayout', + 'marionette' + ], function (vent, AppLayout, Marionette) { + + return Marionette.AppRouter.extend({ + + initialize: function () { + vent.on(vent.Commands.OpenControlPanelCommand, this._openControlPanel, this); + vent.on(vent.Commands.CloseControlPanelCommand, this._closeControlPanel, this); + }, + + _openControlPanel: function (view) { + AppLayout.controlPanelRegion.show(view); + }, + + _closeControlPanel: function () { + AppLayout.controlPanelRegion.closePanel(); + } + }); + }); diff --git a/src/UI/Shared/ControlPanel/ControlPanelRegion.js b/src/UI/Shared/ControlPanel/ControlPanelRegion.js new file mode 100644 index 000000000..3b4ac55d5 --- /dev/null +++ b/src/UI/Shared/ControlPanel/ControlPanelRegion.js @@ -0,0 +1,35 @@ +'use strict'; +define( + [ + 'jquery', + 'backbone', + 'marionette' + ], function ($,Backbone, Marionette) { + var region = Marionette.Region.extend({ + el: '#control-panel-region', + + constructor: function () { + Backbone.Marionette.Region.prototype.constructor.apply(this, arguments); + this.on('show', this.showPanel, this); + }, + + getEl: function (selector) { + var $el = $(selector); + + return $el; + }, + + showPanel: function () { + $('body').addClass('control-panel-visible'); + this.$el.animate({ 'margin-bottom': 0, 'opacity': 1 }, { queue: false, duration: 300 }); + }, + + closePanel: function () { + $('body').removeClass('control-panel-visible'); + this.$el.animate({ 'margin-bottom': -100, 'opacity': 0 }, { queue: false, duration: 300 }); + this.reset(); + } + }); + + return region; + }); diff --git a/src/UI/Shared/Modal/ModalController.js b/src/UI/Shared/Modal/ModalController.js index 177092de3..9fcedea9a 100644 --- a/src/UI/Shared/Modal/ModalController.js +++ b/src/UI/Shared/Modal/ModalController.js @@ -15,8 +15,8 @@ define( return Marionette.AppRouter.extend({ initialize: function () { - vent.on(vent.Commands.OpenModalCommand, this._openModal, this); - vent.on(vent.Commands.CloseModalCommand, this._closeModal, this); + vent.on(vent.Commands.OpenModalCommand, this._openControlPanel, this); + vent.on(vent.Commands.CloseModalCommand, this._closeControlPanel, this); vent.on(vent.Commands.EditSeriesCommand, this._editSeries, this); vent.on(vent.Commands.DeleteSeriesCommand, this._deleteSeries, this); vent.on(vent.Commands.ShowEpisodeDetails, this._showEpisode, this); @@ -25,12 +25,12 @@ define( vent.on(vent.Commands.ShowRenamePreview, this._showRenamePreview, this); }, - _openModal: function (view) { + _openControlPanel: function (view) { AppLayout.modalRegion.show(view); }, - _closeModal: function () { - AppLayout.modalRegion.closeModal(); + _closeControlPanel: function () { + AppLayout.modalRegion.closePanel(); }, _editSeries: function (options) { diff --git a/src/UI/Shared/Modal/ModalRegion.js b/src/UI/Shared/Modal/ModalRegion.js index f2a776050..414138ee7 100644 --- a/src/UI/Shared/Modal/ModalRegion.js +++ b/src/UI/Shared/Modal/ModalRegion.js @@ -11,7 +11,7 @@ define( constructor: function () { Backbone.Marionette.Region.prototype.constructor.apply(this, arguments); - this.on('show', this.showModal, this); + this.on('show', this.showPanel, this); }, getEl: function (selector) { @@ -20,7 +20,7 @@ define( return $el; }, - showModal: function () { + showPanel: function () { this.$el.addClass('modal hide fade'); //need tab index so close on escape works @@ -32,7 +32,7 @@ define( 'backdrop': 'static'}); }, - closeModal: function () { + closePanel: function () { $(this.el).modal('hide'); this.reset(); } diff --git a/src/UI/app.js b/src/UI/app.js index 51a0ee0e4..4edde0dc1 100644 --- a/src/UI/app.js +++ b/src/UI/app.js @@ -209,13 +209,15 @@ define( 'Series/SeriesController', 'Router', 'Shared/Modal/ModalController', + 'Shared/ControlPanel/ControlPanelController', 'System/StatusModel', 'Instrumentation/StringFormat', 'LifeCycle' - ], function ($, Backbone, Marionette, RouteBinder, SignalRBroadcaster, NavbarView, AppLayout, SeriesController, Router, ModalController, serverStatusModel) { + ], function ($, Backbone, Marionette, RouteBinder, SignalRBroadcaster, NavbarView, AppLayout, SeriesController, Router, ModalController, ControlPanelController, serverStatusModel) { new SeriesController(); new ModalController(); + new ControlPanelController(); new Router(); var app = new Marionette.Application(); diff --git a/src/UI/index.html b/src/UI/index.html index 84603cd00..e04003bae 100644 --- a/src/UI/index.html +++ b/src/UI/index.html @@ -48,7 +48,7 @@ -
+
+ +