From 5894b4fd951cce75d54cd70bd4d45a27185ad7ff Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Fri, 12 Jan 2018 18:01:27 -0800 Subject: [PATCH] v3 UI --- .esprintrc | 9 + .npmrc | 1 + .yarnrc | 1 + build.sh | 8 +- frontend/.csscomb.json | 25 + frontend/.esformatter | 335 + frontend/.eslintignore | 1 + frontend/.eslintrc | 288 + frontend/.jsbeautifyrc | 12 + frontend/.stylelintrc | 396 + frontend/.tern-project | 7 + frontend/gulp/build.js | 15 + frontend/gulp/clean.js | 8 + frontend/gulp/copy.js | 45 + frontend/gulp/gulpFile.js | 10 + frontend/gulp/handlebars.js | 70 + frontend/gulp/helpers/errorHandler.js | 6 + frontend/gulp/helpers/html-annotate-loader.js | 15 + frontend/gulp/helpers/paths.js | 26 + frontend/gulp/helpers/phantom.js | 10 + frontend/gulp/imageMin.js | 15 + frontend/gulp/less.js | 46 + frontend/gulp/start.js | 104 + frontend/gulp/stripBom.js | 21 + frontend/gulp/watch.js | 27 + frontend/gulp/webpack.js | 185 +- frontend/gulp/webpack/css-variables-loader.js | 11 + frontend/postcss.config.js | 33 + frontend/src/.vscode/settings.json | 4 + frontend/src/Activity/Blacklist/Blacklist.js | 110 + .../Activity/Blacklist/BlacklistConnector.js | 145 + .../Blacklist/BlacklistDetailsModal.js | 89 + .../src/Activity/Blacklist/BlacklistRow.css | 18 + .../src/Activity/Blacklist/BlacklistRow.js | 186 + .../Blacklist/BlacklistRowConnector.js | 26 + .../History/Details/HistoryDetails.css | 5 + .../History/Details/HistoryDetails.js | 244 + .../History/Details/HistoryDetailsAge.js | 21 + .../Details/HistoryDetailsConnector.js | 19 + .../History/Details/HistoryDetailsModal.css | 5 + .../History/Details/HistoryDetailsModal.js | 104 + frontend/src/Activity/History/History.js | 161 + .../src/Activity/History/HistoryConnector.js | 157 + .../Activity/History/HistoryEventTypeCell.css | 6 + .../Activity/History/HistoryEventTypeCell.js | 82 + frontend/src/Activity/History/HistoryRow.css | 23 + frontend/src/Activity/History/HistoryRow.js | 263 + .../Activity/History/HistoryRowConnector.js | 76 + frontend/src/Activity/Queue/ProtocolLabel.css | 13 + frontend/src/Activity/Queue/ProtocolLabel.js | 20 + frontend/src/Activity/Queue/Queue.js | 266 + frontend/src/Activity/Queue/QueueConnector.js | 186 + frontend/src/Activity/Queue/QueueDetails.js | 97 + frontend/src/Activity/Queue/QueueOptions.js | 77 + .../Activity/Queue/QueueOptionsConnector.js | 19 + frontend/src/Activity/Queue/QueueRow.css | 23 + frontend/src/Activity/Queue/QueueRow.js | 369 + .../src/Activity/Queue/QueueRowConnector.js | 71 + .../src/Activity/Queue/QueueStatusCell.css | 5 + .../src/Activity/Queue/QueueStatusCell.js | 132 + .../Activity/Queue/RemoveQueueItemModal.css | 3 + .../Activity/Queue/RemoveQueueItemModal.js | 114 + .../Activity/Queue/RemoveQueueItemsModal.css | 3 + .../Activity/Queue/RemoveQueueItemsModal.js | 114 + .../Queue/Status/QueueStatusConnector.js | 70 + frontend/src/Activity/Queue/TimeleftCell.css | 5 + frontend/src/Activity/Queue/TimeleftCell.js | 82 + .../AddSeries/AddNewSeries/AddNewSeries.css | 54 + .../AddSeries/AddNewSeries/AddNewSeries.js | 182 + .../AddNewSeries/AddNewSeriesConnector.js | 102 + .../AddNewSeries/AddNewSeriesModal.js | 31 + .../AddNewSeries/AddNewSeriesModalContent.css | 74 + .../AddNewSeries/AddNewSeriesModalContent.js | 265 + .../AddNewSeriesModalContentConnector.js | 108 + .../AddNewSeries/AddNewSeriesSearchResult.css | 40 + .../AddNewSeries/AddNewSeriesSearchResult.js | 177 + .../AddNewSeriesSearchResultConnector.js | 20 + .../ImportSeries/Import/ImportSeries.js | 173 + .../Import/ImportSeriesConnector.js | 169 + .../Import/ImportSeriesFooter.css | 33 + .../ImportSeries/Import/ImportSeriesFooter.js | 291 + .../Import/ImportSeriesFooterConnector.js | 65 + .../Import/ImportSeriesHeader.css | 45 + .../ImportSeries/Import/ImportSeriesHeader.js | 115 + .../ImportSeries/Import/ImportSeriesRow.css | 52 + .../ImportSeries/Import/ImportSeriesRow.js | 120 + .../Import/ImportSeriesRowConnector.js | 89 + .../Import/ImportSeriesSelected.css | 3 + .../ImportSeries/Import/ImportSeriesTable.js | 197 + .../Import/ImportSeriesTableConnector.js | 44 + .../SelectSeries/ImportSeriesSearchResult.css | 8 + .../SelectSeries/ImportSeriesSearchResult.js | 52 + .../ImportSeriesSearchResultConnector.js | 17 + .../SelectSeries/ImportSeriesSelectSeries.css | 81 + .../SelectSeries/ImportSeriesSelectSeries.js | 280 + .../ImportSeriesSelectSeriesConnector.js | 76 + .../Import/SelectSeries/ImportSeriesTitle.css | 20 + .../Import/SelectSeries/ImportSeriesTitle.js | 53 + .../AddSeries/ImportSeries/ImportSeries.js | 30 + .../ImportSeriesRootFolderRow.css | 18 + .../SelectFolder/ImportSeriesRootFolderRow.js | 65 + .../ImportSeriesRootFolderRowConnector.js | 48 + .../SelectFolder/ImportSeriesSelectFolder.css | 32 + .../SelectFolder/ImportSeriesSelectFolder.js | 188 + .../ImportSeriesSelectFolderConnector.js | 91 + .../SeriesMonitoringOptionsPopoverContent.js | 46 + .../src/AddSeries/SeriesTypePopoverContent.js | 26 + frontend/src/App/App.js | 28 + frontend/src/App/AppRoutes.js | 248 + frontend/src/App/AppUpdatedModal.js | 30 + frontend/src/App/AppUpdatedModalConnector.js | 12 + frontend/src/App/AppUpdatedModalContent.css | 15 + frontend/src/App/AppUpdatedModalContent.js | 98 + .../App/AppUpdatedModalContentConnector.js | 76 + frontend/src/App/ConnectionLostModal.css | 3 + frontend/src/App/ConnectionLostModal.js | 55 + .../src/App/ConnectionLostModalConnector.js | 12 + frontend/src/Calendar/Agenda/Agenda.css | 3 + frontend/src/Calendar/Agenda/Agenda.js | 38 + .../src/Calendar/Agenda/AgendaConnector.js | 14 + frontend/src/Calendar/Agenda/AgendaEvent.css | 117 + frontend/src/Calendar/Agenda/AgendaEvent.js | 253 + .../Calendar/Agenda/AgendaEventConnector.js | 30 + frontend/src/Calendar/Calendar.css | 8 + frontend/src/Calendar/Calendar.js | 64 + frontend/src/Calendar/CalendarConnector.js | 195 + frontend/src/Calendar/CalendarPage.css | 14 + frontend/src/Calendar/CalendarPage.js | 178 + .../src/Calendar/CalendarPageConnector.js | 101 + frontend/src/Calendar/Day/CalendarDay.css | 25 + frontend/src/Calendar/Day/CalendarDay.js | 74 + .../src/Calendar/Day/CalendarDayConnector.js | 91 + frontend/src/Calendar/Day/CalendarDays.css | 14 + frontend/src/Calendar/Day/CalendarDays.js | 164 + .../src/Calendar/Day/CalendarDaysConnector.js | 25 + frontend/src/Calendar/Day/DayOfWeek.css | 13 + frontend/src/Calendar/Day/DayOfWeek.js | 56 + frontend/src/Calendar/Day/DaysOfWeek.css | 4 + frontend/src/Calendar/Day/DaysOfWeek.js | 97 + .../src/Calendar/Day/DaysOfWeekConnector.js | 22 + .../src/Calendar/Events/CalendarEvent.css | 78 + frontend/src/Calendar/Events/CalendarEvent.js | 244 + .../Calendar/Events/CalendarEventConnector.js | 29 + .../Calendar/Events/CalendarEventGroup.css | 82 + .../src/Calendar/Events/CalendarEventGroup.js | 245 + .../Events/CalendarEventGroupConnector.js | 37 + .../Events/CalendarEventQueueDetails.js | 50 + .../src/Calendar/Header/CalendarHeader.css | 53 + .../src/Calendar/Header/CalendarHeader.js | 253 + .../Header/CalendarHeaderConnector.js | 84 + .../Header/CalendarHeaderViewButton.js | 45 + frontend/src/Calendar/Legend/Legend.css | 6 + frontend/src/Calendar/Legend/Legend.js | 109 + .../src/Calendar/Legend/LegendConnector.js | 19 + .../src/Calendar/Legend/LegendIconItem.css | 10 + .../src/Calendar/Legend/LegendIconItem.js | 37 + frontend/src/Calendar/Legend/LegendItem.css | 41 + frontend/src/Calendar/Legend/LegendItem.js | 36 + .../Calendar/Options/CalendarOptionsModal.js | 29 + .../Options/CalendarOptionsModalContent.js | 258 + .../CalendarOptionsModalContentConnector.js | 25 + frontend/src/Calendar/calendarViews.js | 7 + frontend/src/Calendar/getStatusStyle.js | 30 + .../src/Calendar/iCal/CalendarLinkModal.js | 29 + .../Calendar/iCal/CalendarLinkModalContent.js | 221 + .../iCal/CalendarLinkModalContentConnector.js | 17 + frontend/src/Commands/commandNames.js | 20 + frontend/src/Components/Alert.css | 31 + frontend/src/Components/Alert.js | 32 + frontend/src/Components/Card.css | 19 + frontend/src/Components/Card.js | 60 + .../src/Components/CircularProgressBar.css | 21 + .../src/Components/CircularProgressBar.js | 139 + .../DescriptionList/DescriptionList.css | 4 + .../DescriptionList/DescriptionList.js | 33 + .../DescriptionList/DescriptionListItem.js | 44 + .../DescriptionListItemDescription.css | 13 + .../DescriptionListItemDescription.js | 27 + .../DescriptionListItemTitle.css | 18 + .../DescriptionListItemTitle.js | 27 + frontend/src/Components/DragPreviewLayer.css | 9 + frontend/src/Components/DragPreviewLayer.js | 22 + .../src/Components/Error/ErrorBoundary.js | 62 + .../Components/Error/ErrorBoundaryError.css | 38 + .../Components/Error/ErrorBoundaryError.js | 60 + frontend/src/Components/FieldSet.css | 19 + frontend/src/Components/FieldSet.js | 33 + .../FileBrowser/FileBrowserModal.css | 5 + .../FileBrowser/FileBrowserModal.js | 39 + .../FileBrowser/FileBrowserModalContent.css | 33 + .../FileBrowser/FileBrowserModalContent.js | 253 + .../FileBrowserModalContentConnector.js | 101 + .../Components/FileBrowser/FileBrowserRow.css | 5 + .../Components/FileBrowser/FileBrowserRow.js | 62 + .../Builder/BoolFilterBuilderRowValue.js | 18 + .../Builder/DateFilterBuilderRowValue.css | 15 + .../Builder/DateFilterBuilderRowValue.js | 171 + .../Builder/FilterBuilderModalContent.css | 16 + .../Builder/FilterBuilderModalContent.js | 226 + .../FilterBuilderModalContentConnector.js | 42 + .../Filter/Builder/FilterBuilderRow.css | 32 + .../Filter/Builder/FilterBuilderRow.js | 286 + .../Filter/Builder/FilterBuilderRowValue.js | 159 + .../Builder/FilterBuilderRowValueConnector.js | 55 + .../Builder/FilterBuilderRowValueTag.css | 19 + .../Builder/FilterBuilderRowValueTag.js | 31 + .../IndexerFilterBuilderRowValueConnector.js | 79 + ...geProfileFilterBuilderRowValueConnector.js | 28 + .../Builder/ProtocolFilterBuilderRowValue.js | 18 + .../QualityFilterBuilderRowValueConnector.js | 75 + ...tyProfileFilterBuilderRowValueConnector.js | 28 + .../SeriesStatusFilterBuilderRowValue.js | 18 + .../TagFilterBuilderRowValueConnector.js | 27 + .../Filter/CustomFilters/CustomFilter.css | 17 + .../Filter/CustomFilters/CustomFilter.js | 114 + .../CustomFiltersModalContent.css | 3 + .../CustomFiltersModalContent.js | 80 + .../CustomFiltersModalContentConnector.js | 23 + frontend/src/Components/Filter/FilterModal.js | 90 + .../src/Components/Form/AutoCompleteInput.css | 58 + .../src/Components/Form/AutoCompleteInput.js | 162 + frontend/src/Components/Form/CaptchaInput.css | 23 + frontend/src/Components/Form/CaptchaInput.js | 84 + .../Components/Form/CaptchaInputConnector.js | 98 + frontend/src/Components/Form/CheckInput.css | 105 + frontend/src/Components/Form/CheckInput.js | 191 + frontend/src/Components/Form/DeviceInput.css | 8 + frontend/src/Components/Form/DeviceInput.js | 103 + .../Components/Form/DeviceInputConnector.js | 99 + .../Components/Form/EnhancedSelectInput.css | 79 + .../Components/Form/EnhancedSelectInput.js | 411 + .../Form/EnhancedSelectInputOption.css | 41 + .../Form/EnhancedSelectInputOption.js | 81 + .../Form/EnhancedSelectInputSelectedValue.css | 7 + .../Form/EnhancedSelectInputSelectedValue.js | 35 + frontend/src/Components/Form/Form.js | 53 + frontend/src/Components/Form/FormGroup.css | 28 + frontend/src/Components/Form/FormGroup.js | 56 + .../src/Components/Form/FormInputButton.css | 12 + .../src/Components/Form/FormInputButton.js | 54 + .../src/Components/Form/FormInputGroup.css | 49 + .../src/Components/Form/FormInputGroup.js | 261 + .../src/Components/Form/FormInputHelpText.css | 39 + .../src/Components/Form/FormInputHelpText.js | 63 + frontend/src/Components/Form/FormLabel.css | 29 + frontend/src/Components/Form/FormLabel.js | 50 + frontend/src/Components/Form/Input.css | 30 + .../src/Components/Form/KeyValueListInput.css | 21 + .../src/Components/Form/KeyValueListInput.js | 152 + .../Components/Form/KeyValueListInputItem.css | 14 + .../Components/Form/KeyValueListInputItem.js | 117 + .../LanguageProfileSelectInputConnector.js | 98 + .../Form/MonitorEpisodesSelectInput.js | 50 + frontend/src/Components/Form/NumberInput.js | 81 + frontend/src/Components/Form/OAuthInput.js | 39 + .../Components/Form/OAuthInputConnector.js | 89 + .../src/Components/Form/PasswordInput.css | 5 + frontend/src/Components/Form/PasswordInput.js | 22 + frontend/src/Components/Form/PathInput.css | 68 + frontend/src/Components/Form/PathInput.js | 206 + .../src/Components/Form/PathInputConnector.js | 67 + .../Components/Form/ProviderFieldFormGroup.js | 120 + .../QualityProfileSelectInputConnector.js | 98 + .../Components/Form/RootFolderSelectInput.js | 109 + .../Form/RootFolderSelectInputConnector.js | 137 + .../Form/RootFolderSelectInputOption.css | 20 + .../Form/RootFolderSelectInputOption.js | 45 + .../RootFolderSelectInputSelectedValue.css | 22 + .../RootFolderSelectInputSelectedValue.js | 44 + frontend/src/Components/Form/SelectInput.css | 18 + frontend/src/Components/Form/SelectInput.js | 95 + .../Components/Form/SeriesTypeSelectInput.js | 53 + frontend/src/Components/Form/TagInput.css | 78 + frontend/src/Components/Form/TagInput.js | 303 + .../src/Components/Form/TagInputConnector.js | 156 + .../src/Components/Form/TagInputInput.css | 6 + frontend/src/Components/Form/TagInputInput.js | 76 + frontend/src/Components/Form/TagInputTag.js | 55 + frontend/src/Components/Form/TextInput.css | 19 + frontend/src/Components/Form/TextInput.js | 185 + .../Components/Form/TextTagInputConnector.js | 95 + frontend/src/Components/HeartRating.css | 4 + frontend/src/Components/HeartRating.js | 30 + frontend/src/Components/Icon.css | 27 + frontend/src/Components/Icon.js | 67 + frontend/src/Components/Label.css | 111 + frontend/src/Components/Label.js | 47 + frontend/src/Components/Link/Button.css | 119 + frontend/src/Components/Link/Button.js | 54 + .../src/Components/Link/ClipboardButton.css | 33 + .../src/Components/Link/ClipboardButton.js | 127 + frontend/src/Components/Link/IconButton.css | 21 + frontend/src/Components/Link/IconButton.js | 55 + frontend/src/Components/Link/Link.css | 24 + frontend/src/Components/Link/Link.js | 101 + .../src/Components/Link/SpinnerButton.css | 37 + frontend/src/Components/Link/SpinnerButton.js | 57 + .../Components/Link/SpinnerErrorButton.css | 23 + .../src/Components/Link/SpinnerErrorButton.js | 162 + .../src/Components/Link/SpinnerIconButton.js | 38 + .../Components/Loading/LoadingIndicator.css | 49 + .../Components/Loading/LoadingIndicator.js | 48 + .../src/Components/Loading/LoadingMessage.css | 6 + .../src/Components/Loading/LoadingMessage.js | 36 + frontend/src/Components/Measure.js | 38 + frontend/src/Components/Menu/FilterMenu.css | 9 + frontend/src/Components/Menu/FilterMenu.js | 110 + .../src/Components/Menu/FilterMenuContent.js | 85 + .../src/Components/Menu/FilterMenuItem.js | 45 + frontend/src/Components/Menu/Menu.css | 7 + frontend/src/Components/Menu/Menu.js | 207 + frontend/src/Components/Menu/MenuButton.css | 30 + frontend/src/Components/Menu/MenuButton.js | 49 + frontend/src/Components/Menu/MenuContent.css | 11 + frontend/src/Components/Menu/MenuContent.js | 43 + frontend/src/Components/Menu/MenuItem.css | 19 + frontend/src/Components/Menu/MenuItem.js | 38 + .../src/Components/Menu/MenuItemSeparator.css | 5 + .../src/Components/Menu/MenuItemSeparator.js | 10 + .../src/Components/Menu/PageMenuButton.css | 11 + .../src/Components/Menu/PageMenuButton.js | 36 + .../src/Components/Menu/SelectedMenuItem.css | 15 + .../src/Components/Menu/SelectedMenuItem.js | 63 + frontend/src/Components/Menu/SortMenu.js | 40 + frontend/src/Components/Menu/SortMenuItem.js | 37 + .../src/Components/Menu/ToolbarMenuButton.css | 11 + .../src/Components/Menu/ToolbarMenuButton.js | 38 + frontend/src/Components/Menu/ViewMenu.js | 37 + frontend/src/Components/Menu/ViewMenuItem.js | 28 + frontend/src/Components/Modal/ConfirmModal.js | 88 + frontend/src/Components/Modal/Modal.css | 92 + frontend/src/Components/Modal/Modal.js | 215 + frontend/src/Components/Modal/ModalBody.css | 12 + frontend/src/Components/Modal/ModalBody.js | 59 + .../src/Components/Modal/ModalContent.css | 23 + frontend/src/Components/Modal/ModalContent.js | 52 + frontend/src/Components/Modal/ModalError.css | 15 + frontend/src/Components/Modal/ModalError.js | 46 + frontend/src/Components/Modal/ModalFooter.css | 23 + frontend/src/Components/Modal/ModalFooter.js | 32 + frontend/src/Components/Modal/ModalHeader.css | 8 + frontend/src/Components/Modal/ModalHeader.js | 32 + .../src/Components/MonitorToggleButton.css | 11 + .../src/Components/MonitorToggleButton.js | 79 + frontend/src/Components/NotFound.css | 14 + frontend/src/Components/NotFound.js | 31 + frontend/src/Components/Page/ErrorPage.css | 12 + frontend/src/Components/Page/ErrorPage.js | 60 + .../Page/Header/KeyboardShortcutsModal.js | 31 + .../Header/KeyboardShortcutsModalContent.css | 15 + .../Header/KeyboardShortcutsModalContent.js | 90 + .../KeyboardShortcutsModalContentConnector.js | 17 + .../src/Components/Page/Header/PageHeader.css | 65 + .../src/Components/Page/Header/PageHeader.js | 101 + .../Page/Header/PageHeaderActionsMenu.css | 21 + .../Page/Header/PageHeaderActionsMenu.js | 88 + .../Header/PageHeaderActionsMenuConnector.js | 56 + .../Page/Header/SeriesSearchInput.css | 96 + .../Page/Header/SeriesSearchInput.js | 260 + .../Page/Header/SeriesSearchInputConnector.js | 97 + .../Page/Header/SeriesSearchResult.css | 38 + .../Page/Header/SeriesSearchResult.js | 89 + frontend/src/Components/Page/LoadingPage.css | 3 + frontend/src/Components/Page/LoadingPage.js | 15 + frontend/src/Components/Page/Page.css | 18 + frontend/src/Components/Page/Page.js | 130 + frontend/src/Components/Page/PageConnector.js | 194 + frontend/src/Components/Page/PageContent.css | 8 + frontend/src/Components/Page/PageContent.js | 36 + .../src/Components/Page/PageContentBody.css | 19 + .../src/Components/Page/PageContentBody.js | 52 + .../Page/PageContentBodyConnector.js | 17 + .../src/Components/Page/PageContentError.css | 3 + .../src/Components/Page/PageContentError.js | 19 + .../src/Components/Page/PageContentFooter.css | 26 + .../src/Components/Page/PageContentFooter.js | 33 + frontend/src/Components/Page/PageJumpBar.css | 22 + frontend/src/Components/Page/PageJumpBar.js | 133 + .../src/Components/Page/PageJumpBarItem.css | 14 + .../src/Components/Page/PageJumpBarItem.js | 40 + .../src/Components/Page/PageSectionContent.js | 39 + .../Page/Sidebar/Messages/Message.css | 42 + .../Page/Sidebar/Messages/Message.js | 70 + .../Page/Sidebar/Messages/MessageConnector.js | 67 + .../Page/Sidebar/Messages/Messages.css | 11 + .../Page/Sidebar/Messages/Messages.js | 27 + .../Sidebar/Messages/MessagesConnector.js | 16 + .../Components/Page/Sidebar/PageSidebar.css | 34 + .../Components/Page/Sidebar/PageSidebar.js | 525 + .../Page/Sidebar/PageSidebarItem.css | 50 + .../Page/Sidebar/PageSidebarItem.js | 106 + .../Page/Sidebar/PageSidebarStatus.css | 3 + .../Page/Sidebar/PageSidebarStatus.js | 35 + .../Components/Page/Toolbar/PageToolbar.css | 16 + .../Components/Page/Toolbar/PageToolbar.js | 33 + .../Page/Toolbar/PageToolbarButton.css | 32 + .../Page/Toolbar/PageToolbarButton.js | 57 + .../Page/Toolbar/PageToolbarSection.css | 27 + .../Page/Toolbar/PageToolbarSection.js | 221 + .../Page/Toolbar/PageToolbarSeparator.css | 12 + .../Page/Toolbar/PageToolbarSeparator.js | 17 + frontend/src/Components/ProgressBar.css | 93 + frontend/src/Components/ProgressBar.js | 100 + frontend/src/Components/Router/Switch.js | 44 + .../Components/Scroller/OverlayScroller.css | 15 + .../Components/Scroller/OverlayScroller.js | 127 + frontend/src/Components/Scroller/Scroller.css | 28 + frontend/src/Components/Scroller/Scroller.js | 81 + frontend/src/Components/SignalRConnector.js | 374 + frontend/src/Components/SpinnerIcon.js | 33 + .../Table/Cells/RelativeDateCell.css | 5 + .../Table/Cells/RelativeDateCell.js | 60 + .../Table/Cells/RelativeDateCellConnector.js | 21 + .../Components/Table/Cells/TableRowCell.css | 11 + .../Components/Table/Cells/TableRowCell.js | 37 + .../Table/Cells/TableRowCellButton.css | 4 + .../Table/Cells/TableRowCellButton.js | 25 + .../Table/Cells/TableSelectCell.css | 11 + .../Components/Table/Cells/TableSelectCell.js | 80 + .../Table/Cells/VirtualTableRowCell.css | 14 + .../Table/Cells/VirtualTableRowCell.js | 29 + .../Table/Cells/VirtualTableSelectCell.css | 11 + .../Table/Cells/VirtualTableSelectCell.js | 82 + frontend/src/Components/Table/Table.css | 16 + frontend/src/Components/Table/Table.js | 160 + frontend/src/Components/Table/TableBody.js | 25 + frontend/src/Components/Table/TableHeader.js | 28 + .../src/Components/Table/TableHeaderCell.css | 16 + .../src/Components/Table/TableHeaderCell.js | 94 + .../Table/TableOptions/TableOptionsColumn.css | 48 + .../Table/TableOptions/TableOptionsColumn.js | 68 + .../TableOptionsColumnDragPreview.css | 4 + .../TableOptionsColumnDragPreview.js | 78 + .../TableOptionsColumnDragSource.css | 18 + .../TableOptionsColumnDragSource.js | 164 + .../Table/TableOptions/TableOptionsModal.css | 5 + .../Table/TableOptions/TableOptionsModal.js | 252 + frontend/src/Components/Table/TablePager.css | 77 + frontend/src/Components/Table/TablePager.js | 180 + frontend/src/Components/Table/TableRow.css | 7 + frontend/src/Components/Table/TableRow.js | 33 + .../src/Components/Table/TableRowButton.css | 4 + .../src/Components/Table/TableRowButton.js | 16 + .../Table/TableSelectAllHeaderCell.css | 11 + .../Table/TableSelectAllHeaderCell.js | 47 + .../src/Components/Table/VirtualTable.css | 3 + frontend/src/Components/Table/VirtualTable.js | 167 + .../src/Components/Table/VirtualTableBody.css | 3 + .../src/Components/Table/VirtualTableBody.js | 40 + .../Components/Table/VirtualTableHeader.css | 3 + .../Components/Table/VirtualTableHeader.js | 17 + .../Table/VirtualTableHeaderCell.css | 16 + .../Table/VirtualTableHeaderCell.js | 107 + .../src/Components/Table/VirtualTableRow.css | 14 + .../src/Components/Table/VirtualTableRow.js | 34 + .../Table/VirtualTableSelectAllHeaderCell.css | 11 + .../Table/VirtualTableSelectAllHeaderCell.js | 47 + frontend/src/Components/TagList.css | 3 + frontend/src/Components/TagList.js | 38 + frontend/src/Components/TagListConnector.js | 17 + frontend/src/Components/Tooltip/Popover.css | 105 + frontend/src/Components/Tooltip/Popover.js | 160 + frontend/src/Components/Tooltip/Tooltip.css | 161 + frontend/src/Components/Tooltip/Tooltip.js | 163 + frontend/src/Components/keyboardShortcuts.js | 102 + frontend/src/Components/withCurrentPage.js | 25 + frontend/src/Components/withScrollPosition.js | 30 + frontend/src/Content/Fonts/Roboto-Light.ttf | Bin 0 -> 162420 bytes frontend/src/Content/Fonts/Roboto-Light.woff | Bin 0 -> 89300 bytes frontend/src/Content/Fonts/Roboto-Light.woff2 | Bin 0 -> 62832 bytes frontend/src/Content/Fonts/Roboto-Regular.ttf | Bin 0 -> 162876 bytes .../src/Content/Fonts/Roboto-Regular.woff | Bin 0 -> 89732 bytes .../src/Content/Fonts/Roboto-Regular.woff2 | Bin 0 -> 63412 bytes .../src/Content/Fonts/UbuntuMono-Regular.eot | Bin 0 -> 23691 bytes .../src/Content/Fonts/UbuntuMono-Regular.ttf | Bin 0 -> 205748 bytes .../src/Content/Fonts/UbuntuMono-Regular.woff | Bin 0 -> 27392 bytes frontend/src/Content/Fonts/fonts.css | 38 + .../src/Content/Fonts/text-security-disc.ttf | Bin 0 -> 12392 bytes .../src/Content/Fonts/text-security-disc.woff | Bin 0 -> 2988 bytes frontend/src/Content/Images/404.png | Bin 0 -> 103643 bytes .../Images/Icons/android-chrome-192x192.png | Bin 0 -> 4629 bytes .../Images/Icons/android-chrome-512x512.png | Bin 0 -> 11319 bytes .../Content/Images/Icons/apple-touch-icon.png | Bin 0 -> 3817 bytes .../Content/Images/Icons/browserconfig.xml | 9 + .../Content/Images/Icons/favicon-16x16.png | Bin 0 -> 723 bytes .../Content/Images/Icons/favicon-32x32.png | Bin 0 -> 1389 bytes .../Images/Icons/favicon-debug-16x16.png | Bin 0 -> 715 bytes .../Images/Icons/favicon-debug-32x32.png | Bin 0 -> 1385 bytes .../Content/Images/Icons/favicon-debug.ico | Bin 0 -> 15086 bytes frontend/src/Content/Images/Icons/favicon.ico | Bin 0 -> 15086 bytes .../src/Content/Images/Icons/manifest.json | 18 + .../Content/Images/Icons/mstile-144x144.png | Bin 0 -> 3592 bytes .../Content/Images/Icons/mstile-150x150.png | Bin 0 -> 3584 bytes .../Content/Images/Icons/mstile-310x150.png | Bin 0 -> 3734 bytes .../Content/Images/Icons/mstile-310x310.png | Bin 0 -> 6632 bytes .../src/Content/Images/Icons/mstile-70x70.png | Bin 0 -> 2439 bytes .../Images/Icons/safari-pinned-tab.svg | 38 + frontend/src/Content/Images/error.png | Bin 0 -> 163647 bytes frontend/src/Content/Images/logo.svg | 9 + frontend/src/Content/Images/poster-dark.png | Bin 0 -> 2553 bytes frontend/src/Episode/EpisodeDetailsModal.js | 39 + .../Episode/EpisodeDetailsModalContent.css | 44 + .../src/Episode/EpisodeDetailsModalContent.js | 218 + .../EpisodeDetailsModalContentConnector.js | 100 + frontend/src/Episode/EpisodeLanguage.js | 37 + frontend/src/Episode/EpisodeNumber.css | 7 + frontend/src/Episode/EpisodeNumber.js | 138 + frontend/src/Episode/EpisodeQuality.js | 57 + frontend/src/Episode/EpisodeSearchCell.css | 6 + frontend/src/Episode/EpisodeSearchCell.js | 83 + .../src/Episode/EpisodeSearchCellConnector.js | 50 + frontend/src/Episode/EpisodeStatus.css | 4 + frontend/src/Episode/EpisodeStatus.js | 128 + .../src/Episode/EpisodeStatusConnector.js | 53 + frontend/src/Episode/EpisodeTitleLink.css | 8 + frontend/src/Episode/EpisodeTitleLink.js | 68 + .../src/Episode/History/EpisodeHistory.js | 117 + .../History/EpisodeHistoryConnector.js | 63 + .../src/Episode/History/EpisodeHistoryRow.css | 6 + .../src/Episode/History/EpisodeHistoryRow.js | 163 + frontend/src/Episode/SceneInfo.css | 17 + frontend/src/Episode/SceneInfo.js | 83 + frontend/src/Episode/Search/EpisodeSearch.css | 16 + frontend/src/Episode/Search/EpisodeSearch.js | 55 + .../Episode/Search/EpisodeSearchConnector.js | 93 + frontend/src/Episode/SeasonEpisodeNumber.js | 32 + frontend/src/Episode/Summary/EpisodeAiring.js | 86 + .../Episode/Summary/EpisodeAiringConnector.js | 20 + .../src/Episode/Summary/EpisodeSummary.css | 48 + .../src/Episode/Summary/EpisodeSummary.js | 183 + .../Summary/EpisodeSummaryConnector.js | 59 + frontend/src/Episode/Summary/MediaInfo.js | 33 + frontend/src/Episode/episodeEntities.js | 13 + .../Editor/EpisodeFileEditorModal.js | 34 + .../Editor/EpisodeFileEditorModalContent.css | 8 + .../Editor/EpisodeFileEditorModalContent.js | 285 + .../EpisodeFileEditorModalContentConnector.js | 151 + .../Editor/EpisodeFileEditorRow.css | 3 + .../Editor/EpisodeFileEditorRow.js | 83 + .../EpisodeFileLanguageConnector.js | 17 + frontend/src/EpisodeFile/MediaInfo.js | 52 + .../src/EpisodeFile/MediaInfoConnector.js | 21 + frontend/src/EpisodeFile/mediaInfoTypes.js | 2 + .../Props/Shapes/createRouteMatchShape.js | 11 + .../src/Helpers/Props/Shapes/locationShape.js | 11 + .../src/Helpers/Props/Shapes/settingShape.js | 34 + frontend/src/Helpers/Props/align.js | 5 + .../src/Helpers/Props/filterBuilderTypes.js | 50 + .../Helpers/Props/filterBuilderValueTypes.js | 11 + .../src/Helpers/Props/filterTypePredicates.js | 45 + frontend/src/Helpers/Props/filterTypes.js | 21 + frontend/src/Helpers/Props/icons.js | 201 + frontend/src/Helpers/Props/index.js | 29 + frontend/src/Helpers/Props/inputTypes.js | 39 + frontend/src/Helpers/Props/kinds.js | 23 + frontend/src/Helpers/Props/messageTypes.js | 11 + .../src/Helpers/Props/scrollDirections.js | 5 + frontend/src/Helpers/Props/sizes.js | 7 + frontend/src/Helpers/Props/sortDirections.js | 4 + .../src/Helpers/Props/tooltipPositions.js | 11 + frontend/src/Helpers/dragTypes.js | 3 + frontend/src/Helpers/elementChildren.js | 149 + frontend/src/Helpers/getDisplayName.js | 3 + frontend/src/Hotkeys/Hotkeys.js | 34 + frontend/src/Hotkeys/HotkeysView.js | 6 + frontend/src/Hotkeys/HotkeysViewTemplate.hbs | 45 + frontend/src/Hotkeys/hotkeys.less | 23 + .../Episode/SelectEpisodeModal.js | 37 + .../Episode/SelectEpisodeModalContent.js | 184 + .../SelectEpisodeModalContentConnector.js | 101 + .../Episode/SelectEpisodeRow.js | 67 + ...eractiveImportSelectFolderModalContent.css | 24 + ...teractiveImportSelectFolderModalContent.js | 168 + ...ImportSelectFolderModalContentConnector.js | 80 + .../Folder/RecentFolderRow.css | 5 + .../Folder/RecentFolderRow.js | 64 + .../InteractiveImportModalContent.css | 73 + .../InteractiveImportModalContent.js | 402 + .../InteractiveImportModalContentConnector.js | 203 + .../Interactive/InteractiveImportRow.css | 18 + .../Interactive/InteractiveImportRow.js | 370 + .../InteractiveImportRowCellPlaceholder.css | 7 + .../InteractiveImportRowCellPlaceholder.js | 10 + .../InteractiveImportModal.js | 78 + .../Language/SelectLanguageModal.js | 37 + .../Language/SelectLanguageModalContent.js | 87 + .../SelectLanguageModalContentConnector.js | 87 + .../Quality/SelectQualityModal.js | 37 + .../Quality/SelectQualityModalContent.js | 166 + .../SelectQualityModalContentConnector.js | 95 + .../Season/SelectSeasonModal.js | 37 + .../Season/SelectSeasonModalContent.js | 58 + .../SelectSeasonModalContentConnector.js | 68 + .../Season/SelectSeasonRow.css | 4 + .../Season/SelectSeasonRow.js | 40 + .../Series/SelectSeriesModal.js | 37 + .../Series/SelectSeriesModalContent.css | 18 + .../Series/SelectSeriesModalContent.js | 99 + .../SelectSeriesModalContentConnector.js | 75 + .../Series/SelectSeriesRow.css | 4 + .../Series/SelectSeriesRow.js | 37 + .../InteractiveSearch/InteractiveSearch.css | 9 + .../InteractiveSearch/InteractiveSearch.js | 207 + .../InteractiveSearchConnector.js | 94 + .../InteractiveSearchFilterModalConnector.js | 32 + .../InteractiveSearchRow.css | 38 + .../InteractiveSearch/InteractiveSearchRow.js | 217 + frontend/src/InteractiveSearch/Peers.js | 57 + frontend/src/Organize/OrganizePreviewModal.js | 34 + .../Organize/OrganizePreviewModalConnector.js | 39 + .../Organize/OrganizePreviewModalContent.css | 24 + .../Organize/OrganizePreviewModalContent.js | 203 + .../OrganizePreviewModalContentConnector.js | 91 + frontend/src/Organize/OrganizePreviewRow.css | 20 + frontend/src/Organize/OrganizePreviewRow.js | 90 + frontend/src/Season/SeasonNumber.js | 29 + frontend/src/SeasonPass/SeasonPass.js | 217 + .../src/SeasonPass/SeasonPassConnector.js | 64 + .../SeasonPassFilterModalConnector.js | 24 + frontend/src/SeasonPass/SeasonPassFooter.css | 14 + frontend/src/SeasonPass/SeasonPassFooter.js | 145 + frontend/src/SeasonPass/SeasonPassRow.css | 20 + frontend/src/SeasonPass/SeasonPassRow.js | 101 + .../src/SeasonPass/SeasonPassRowConnector.js | 77 + frontend/src/SeasonPass/SeasonPassSeason.css | 24 + frontend/src/SeasonPass/SeasonPassSeason.js | 88 + .../src/Series/Delete/DeleteSeriesModal.js | 33 + .../Delete/DeleteSeriesModalContent.css | 12 + .../Series/Delete/DeleteSeriesModalContent.js | 144 + .../DeleteSeriesModalContentConnector.js | 55 + frontend/src/Series/Details/EpisodeRow.css | 32 + frontend/src/Series/Details/EpisodeRow.js | 284 + .../src/Series/Details/EpisodeRowConnector.js | 24 + frontend/src/Series/Details/SeasonInfo.css | 11 + frontend/src/Series/Details/SeasonInfo.js | 46 + .../Series/Details/SeriesAlternateTitles.css | 3 + .../Series/Details/SeriesAlternateTitles.js | 28 + frontend/src/Series/Details/SeriesDetails.css | 155 + frontend/src/Series/Details/SeriesDetails.js | 695 ++ .../Series/Details/SeriesDetailsConnector.js | 271 + .../src/Series/Details/SeriesDetailsLinks.css | 13 + .../src/Series/Details/SeriesDetailsLinks.js | 84 + .../Details/SeriesDetailsPageConnector.js | 76 + .../Series/Details/SeriesDetailsSeason.css | 112 + .../src/Series/Details/SeriesDetailsSeason.js | 519 + .../Details/SeriesDetailsSeasonConnector.js | 118 + frontend/src/Series/Details/SeriesTags.js | 30 + .../src/Series/Details/SeriesTagsConnector.js | 30 + frontend/src/Series/Edit/EditSeriesModal.js | 25 + .../Series/Edit/EditSeriesModalConnector.js | 39 + .../Series/Edit/EditSeriesModalContent.css | 5 + .../src/Series/Edit/EditSeriesModalContent.js | 223 + .../Edit/EditSeriesModalContentConnector.js | 120 + .../Series/Editor/Delete/DeleteSeriesModal.js | 31 + .../Delete/DeleteSeriesModalContent.css | 13 + .../Editor/Delete/DeleteSeriesModalContent.js | 123 + .../DeleteSeriesModalContentConnector.js | 45 + .../Editor/Organize/OrganizeSeriesModal.js | 31 + .../Organize/OrganizeSeriesModalContent.css | 8 + .../Organize/OrganizeSeriesModalContent.js | 74 + .../OrganizeSeriesModalContentConnector.js | 67 + frontend/src/Series/Editor/SeriesEditor.js | 289 + .../Series/Editor/SeriesEditorConnector.js | 90 + .../SeriesEditorFilterModalConnector.js | 24 + .../src/Series/Editor/SeriesEditorFooter.css | 57 + .../src/Series/Editor/SeriesEditorFooter.js | 353 + .../Series/Editor/SeriesEditorFooterLabel.css | 8 + .../Series/Editor/SeriesEditorFooterLabel.js | 40 + .../src/Series/Editor/SeriesEditorRow.css | 5 + frontend/src/Series/Editor/SeriesEditorRow.js | 124 + .../Series/Editor/SeriesEditorRowConnector.js | 34 + frontend/src/Series/Editor/Tags/TagsModal.js | 31 + .../Series/Editor/Tags/TagsModalContent.css | 12 + .../Series/Editor/Tags/TagsModalContent.js | 187 + .../Editor/Tags/TagsModalContentConnector.js | 36 + .../src/Series/History/SeriesHistoryModal.js | 31 + .../History/SeriesHistoryModalContent.js | 137 + .../SeriesHistoryModalContentConnector.js | 81 + .../src/Series/History/SeriesHistoryRow.css | 6 + .../src/Series/History/SeriesHistoryRow.js | 186 + .../History/SeriesHistoryRowConnector.js | 26 + .../Index/Menus/SeriesIndexFilterMenu.js | 41 + .../Series/Index/Menus/SeriesIndexSortMenu.js | 159 + .../Series/Index/Menus/SeriesIndexViewMenu.js | 55 + .../SeriesIndexOverviewOptionsModal.js | 25 + .../SeriesIndexOverviewOptionsModalContent.js | 305 + ...dexOverviewOptionsModalContentConnector.js | 23 + .../Index/Overview/SeriesIndexOverview.css | 96 + .../Index/Overview/SeriesIndexOverview.js | 280 + .../Overview/SeriesIndexOverviewInfo.css | 12 + .../Index/Overview/SeriesIndexOverviewInfo.js | 266 + .../Overview/SeriesIndexOverviewInfoRow.css | 10 + .../Overview/SeriesIndexOverviewInfoRow.js | 35 + .../Index/Overview/SeriesIndexOverviews.css | 3 + .../Index/Overview/SeriesIndexOverviews.js | 289 + .../Overview/SeriesIndexOverviewsConnector.js | 28 + .../Options/SeriesIndexPosterOptionsModal.js | 25 + .../SeriesIndexPosterOptionsModalContent.js | 213 + ...IndexPosterOptionsModalContentConnector.js | 23 + .../Index/Posters/SeriesIndexPoster.css | 102 + .../Series/Index/Posters/SeriesIndexPoster.js | 293 + .../Index/Posters/SeriesIndexPosterInfo.css | 5 + .../Index/Posters/SeriesIndexPosterInfo.js | 125 + .../Index/Posters/SeriesIndexPosters.css | 3 + .../Index/Posters/SeriesIndexPosters.js | 327 + .../Posters/SeriesIndexPostersConnector.js | 27 + .../ProgressBar/SeriesIndexProgressBar.css | 14 + .../ProgressBar/SeriesIndexProgressBar.js | 47 + frontend/src/Series/Index/SeriesIndex.css | 51 + frontend/src/Series/Index/SeriesIndex.js | 369 + .../src/Series/Index/SeriesIndexConnector.js | 164 + .../Index/SeriesIndexFilterModalConnector.js | 24 + .../src/Series/Index/SeriesIndexFooter.css | 66 + .../src/Series/Index/SeriesIndexFooter.js | 123 + .../Series/Index/SeriesIndexItemConnector.js | 125 + .../Index/Table/SeriesIndexActionsCell.js | 102 + .../Series/Index/Table/SeriesIndexHeader.css | 89 + .../Series/Index/Table/SeriesIndexHeader.js | 109 + .../Index/Table/SeriesIndexHeaderConnector.js | 13 + .../src/Series/Index/Table/SeriesIndexRow.css | 138 + .../src/Series/Index/Table/SeriesIndexRow.js | 515 + .../Series/Index/Table/SeriesIndexTable.css | 5 + .../Series/Index/Table/SeriesIndexTable.js | 131 + .../Index/Table/SeriesIndexTableConnector.js | 29 + .../Index/Table/SeriesIndexTableOptions.js | 100 + .../Table/SeriesIndexTableOptionsConnector.js | 14 + .../Series/Index/Table/SeriesStatusCell.css | 9 + .../Series/Index/Table/SeriesStatusCell.js | 50 + .../src/Series/MoveSeries/MoveSeriesModal.css | 5 + .../src/Series/MoveSeries/MoveSeriesModal.js | 83 + frontend/src/Series/NoSeries.css | 11 + frontend/src/Series/NoSeries.js | 51 + .../Search/SeasonInteractiveSearchModal.js | 36 + .../SeasonInteractiveSearchModalConnector.js | 15 + .../SeasonInteractiveSearchModalContent.js | 49 + frontend/src/Series/SeriesBanner.js | 25 + frontend/src/Series/SeriesImage.js | 198 + frontend/src/Series/SeriesPoster.js | 25 + frontend/src/Series/SeriesTitleLink.js | 20 + .../src/Settings/AdvancedSettingsButton.css | 31 + .../src/Settings/AdvancedSettingsButton.js | 59 + .../DownloadClients/DownloadClientSettings.js | 100 + .../DownloadClientSettingsConnector.js | 21 + .../DownloadClients/AddDownloadClientItem.css | 44 + .../DownloadClients/AddDownloadClientItem.js | 110 + .../DownloadClients/AddDownloadClientModal.js | 25 + .../AddDownloadClientModalContent.css | 5 + .../AddDownloadClientModalContent.js | 115 + .../AddDownloadClientModalContentConnector.js | 75 + .../AddDownloadClientPresetMenuItem.js | 49 + .../DownloadClients/DownloadClient.css | 19 + .../DownloadClients/DownloadClient.js | 113 + .../DownloadClients/DownloadClients.css | 20 + .../DownloadClients/DownloadClients.js | 115 + .../DownloadClientsConnector.js | 58 + .../EditDownloadClientModal.js | 27 + .../EditDownloadClientModalConnector.js | 65 + .../EditDownloadClientModalContent.css | 11 + .../EditDownloadClientModalContent.js | 178 + ...EditDownloadClientModalContentConnector.js | 88 + .../Options/DownloadClientOptions.js | 116 + .../Options/DownloadClientOptionsConnector.js | 101 + .../EditRemotePathMappingModal.js | 27 + .../EditRemotePathMappingModalConnector.js | 43 + .../EditRemotePathMappingModalContent.css | 12 + .../EditRemotePathMappingModalContent.js | 152 + ...tRemotePathMappingModalContentConnector.js | 138 + .../RemotePathMappings/RemotePathMapping.css | 23 + .../RemotePathMappings/RemotePathMapping.js | 114 + .../RemotePathMappings/RemotePathMappings.css | 23 + .../RemotePathMappings/RemotePathMappings.js | 100 + .../RemotePathMappingsConnector.js | 59 + .../src/Settings/General/AnalyticSettings.js | 42 + .../src/Settings/General/BackupSettings.js | 82 + .../src/Settings/General/GeneralSettings.js | 210 + .../General/GeneralSettingsConnector.js | 109 + frontend/src/Settings/General/HostSettings.js | 154 + .../src/Settings/General/LoggingSettings.js | 48 + .../src/Settings/General/ProxySettings.js | 142 + .../src/Settings/General/SecuritySettings.js | 170 + .../src/Settings/General/UpdateSettings.js | 117 + .../src/Settings/Indexers/IndexerSettings.js | 97 + .../Indexers/IndexerSettingsConnector.js | 21 + .../Indexers/Indexers/AddIndexerItem.css | 44 + .../Indexers/Indexers/AddIndexerItem.js | 110 + .../Indexers/Indexers/AddIndexerModal.js | 25 + .../Indexers/AddIndexerModalContent.css | 5 + .../Indexers/AddIndexerModalContent.js | 115 + .../AddIndexerModalContentConnector.js | 75 + .../Indexers/AddIndexerPresetMenuItem.js | 49 + .../Indexers/Indexers/EditIndexerModal.js | 27 + .../Indexers/EditIndexerModalConnector.js | 65 + .../Indexers/EditIndexerModalContent.css | 5 + .../Indexers/EditIndexerModalContent.js | 194 + .../EditIndexerModalContentConnector.js | 88 + .../Settings/Indexers/Indexers/Indexer.css | 19 + .../src/Settings/Indexers/Indexers/Indexer.js | 140 + .../Settings/Indexers/Indexers/Indexers.css | 20 + .../Settings/Indexers/Indexers/Indexers.js | 115 + .../Indexers/Indexers/IndexersConnector.js | 58 + .../Indexers/Options/IndexerOptions.js | 112 + .../Options/IndexerOptionsConnector.js | 101 + .../MediaManagement/MediaManagement.js | 384 + .../MediaManagementConnector.js | 86 + .../MediaManagement/Naming/Naming.css | 5 + .../Settings/MediaManagement/Naming/Naming.js | 342 + .../MediaManagement/Naming/NamingConnector.js | 97 + .../MediaManagement/Naming/NamingModal.css | 18 + .../MediaManagement/Naming/NamingModal.js | 551 ++ .../MediaManagement/Naming/NamingOption.css | 69 + .../MediaManagement/Naming/NamingOption.js | 84 + .../Metadata/Metadata/EditMetadataModal.js | 27 + .../Metadata/EditMetadataModalConnector.js | 44 + .../Metadata/EditMetadataModalContent.js | 103 + .../EditMetadataModalContentConnector.js | 93 + .../Settings/Metadata/Metadata/Metadata.css | 15 + .../Settings/Metadata/Metadata/Metadata.js | 149 + .../Settings/Metadata/Metadata/Metadatas.css | 4 + .../Settings/Metadata/Metadata/Metadatas.js | 44 + .../Metadata/Metadata/MetadatasConnector.js | 49 + .../src/Settings/Metadata/MetadataSettings.js | 21 + .../Notifications/NotificationSettings.js | 21 + .../Notifications/AddNotificationItem.css | 44 + .../Notifications/AddNotificationItem.js | 110 + .../Notifications/AddNotificationModal.js | 25 + .../AddNotificationModalContent.css | 5 + .../AddNotificationModalContent.js | 85 + .../AddNotificationModalContentConnector.js | 70 + .../AddNotificationPresetMenuItem.js | 49 + .../Notifications/EditNotificationModal.js | 27 + .../EditNotificationModalConnector.js | 65 + .../EditNotificationModalContent.css | 11 + .../EditNotificationModalContent.js | 237 + .../EditNotificationModalContentConnector.js | 88 + .../Notifications/Notification.css | 19 + .../Notifications/Notification.js | 150 + .../Notifications/Notifications.css | 20 + .../Notifications/Notifications.js | 115 + .../Notifications/NotificationsConnector.js | 58 + frontend/src/Settings/PendingChangesModal.js | 62 + .../Settings/Profiles/Delay/DelayProfile.css | 40 + .../Settings/Profiles/Delay/DelayProfile.js | 172 + .../Delay/DelayProfileDragPreview.css | 3 + .../Profiles/Delay/DelayProfileDragPreview.js | 78 + .../Profiles/Delay/DelayProfileDragSource.css | 17 + .../Profiles/Delay/DelayProfileDragSource.js | 148 + .../Settings/Profiles/Delay/DelayProfiles.css | 27 + .../Settings/Profiles/Delay/DelayProfiles.js | 148 + .../Profiles/Delay/DelayProfilesConnector.js | 105 + .../Profiles/Delay/EditDelayProfileModal.js | 27 + .../Delay/EditDelayProfileModalConnector.js | 43 + .../Delay/EditDelayProfileModalContent.css | 5 + .../Delay/EditDelayProfileModalContent.js | 188 + .../EditDelayProfileModalContentConnector.js | 178 + .../Language/EditLanguageProfileModal.js | 27 + .../EditLanguageProfileModalConnector.js | 43 + .../EditLanguageProfileModalContent.css | 3 + .../EditLanguageProfileModalContent.js | 167 + ...ditLanguageProfileModalContentConnector.js | 189 + .../Profiles/Language/LanguageProfile.css | 31 + .../Profiles/Language/LanguageProfile.js | 147 + .../Profiles/Language/LanguageProfileItem.css | 44 + .../Profiles/Language/LanguageProfileItem.js | 83 + .../LanguageProfileItemDragPreview.css | 4 + .../LanguageProfileItemDragPreview.js | 88 + .../LanguageProfileItemDragSource.css | 18 + .../Language/LanguageProfileItemDragSource.js | 157 + .../Language/LanguageProfileItems.css | 6 + .../Profiles/Language/LanguageProfileItems.js | 103 + .../Language/LanguageProfileNameConnector.js | 31 + .../Profiles/Language/LanguageProfiles.css | 21 + .../Profiles/Language/LanguageProfiles.js | 108 + .../Language/LanguageProfilesConnector.js | 67 + frontend/src/Settings/Profiles/Profiles.js | 38 + .../Quality/EditQualityProfileModal.js | 61 + .../EditQualityProfileModalConnector.js | 43 + .../EditQualityProfileModalContent.css | 18 + .../Quality/EditQualityProfileModalContent.js | 270 + ...EditQualityProfileModalContentConnector.js | 442 + .../Profiles/Quality/QualityProfile.css | 38 + .../Profiles/Quality/QualityProfile.js | 186 + .../Profiles/Quality/QualityProfileItem.css | 85 + .../Profiles/Quality/QualityProfileItem.js | 131 + .../Quality/QualityProfileItemDragPreview.css | 4 + .../Quality/QualityProfileItemDragPreview.js | 92 + .../Quality/QualityProfileItemDragSource.css | 18 + .../Quality/QualityProfileItemDragSource.js | 241 + .../Quality/QualityProfileItemGroup.css | 105 + .../Quality/QualityProfileItemGroup.js | 200 + .../Profiles/Quality/QualityProfileItems.css | 15 + .../Profiles/Quality/QualityProfileItems.js | 181 + .../Quality/QualityProfileNameConnector.js | 31 + .../Profiles/Quality/QualityProfiles.css | 21 + .../Profiles/Quality/QualityProfiles.js | 107 + .../Quality/QualityProfilesConnector.js | 65 + .../Release/EditReleaseProfileModal.js | 27 + .../EditReleaseProfileModalConnector.js | 39 + .../EditReleaseProfileModalContent.css | 5 + .../Release/EditReleaseProfileModalContent.js | 158 + ...EditReleaseProfileModalContentConnector.js | 113 + .../Profiles/Release/ReleaseProfile.css | 11 + .../Profiles/Release/ReleaseProfile.js | 168 + .../Profiles/Release/ReleaseProfiles.css | 20 + .../Profiles/Release/ReleaseProfiles.js | 98 + .../Release/ReleaseProfilesConnector.js | 61 + .../Quality/Definition/QualityDefinition.css | 93 + .../Quality/Definition/QualityDefinition.js | 193 + .../Definition/QualityDefinitionConnector.js | 70 + .../Quality/Definition/QualityDefinitions.css | 41 + .../Quality/Definition/QualityDefinitions.js | 63 + .../Definition/QualityDefinitionsConnector.js | 90 + frontend/src/Settings/Quality/Quality.js | 68 + frontend/src/Settings/Settings.css | 18 + frontend/src/Settings/Settings.js | 133 + frontend/src/Settings/SettingsToolbar.js | 105 + .../src/Settings/SettingsToolbarConnector.js | 147 + .../Tags/Details/TagDetailsDelayProfile.js | 47 + .../Settings/Tags/Details/TagDetailsModal.js | 33 + .../Tags/Details/TagDetailsModalContent.css | 26 + .../Tags/Details/TagDetailsModalContent.js | 178 + .../TagDetailsModalContentConnector.js | 61 + frontend/src/Settings/Tags/Tag.css | 11 + frontend/src/Settings/Tags/Tag.js | 166 + frontend/src/Settings/Tags/TagConnector.js | 22 + frontend/src/Settings/Tags/TagSettings.js | 21 + frontend/src/Settings/Tags/Tags.css | 4 + frontend/src/Settings/Tags/Tags.js | 49 + frontend/src/Settings/Tags/TagsConnector.js | 72 + frontend/src/Settings/UI/UISettings.js | 195 + .../src/Settings/UI/UISettingsConnector.js | 77 + frontend/src/Shared/piwikCheck.js | 11 + frontend/src/Shims/jquery.js | 10 + .../Creators/Reducers/createClearReducer.js | 12 + ...ateSetClientSideCollectionFilterReducer.js | 14 + ...reateSetClientSideCollectionSortReducer.js | 29 + .../createSetProviderFieldValueReducer.js | 23 + .../Reducers/createSetSettingValueReducer.js | 36 + .../Reducers/createSetTableOptionReducer.js | 21 + ...reateBatchToggleEpisodeMonitoredHandler.js | 42 + .../Actions/Creators/createFetchHandler.js | 44 + .../Creators/createFetchSchemaHandler.js | 33 + .../createFetchServerSideCollectionHandler.js | 67 + .../Actions/Creators/createHandleActions.js | 143 + .../Creators/createRemoveItemHandler.js | 45 + .../Actions/Creators/createSaveHandler.js | 43 + .../Creators/createSaveProviderHandler.js | 70 + .../createServerSideCollectionHandlers.js | 52 + ...ateSetServerSideCollectionFilterHandler.js | 10 + ...reateSetServerSideCollectionPageHandler.js | 35 + ...reateSetServerSideCollectionSortHandler.js | 26 + .../Creators/createTestAllProvidersHandler.js | 34 + .../Creators/createTestProviderHandler.js | 52 + .../Store/Actions/Settings/delayProfiles.js | 103 + .../Actions/Settings/downloadClientOptions.js | 64 + .../Store/Actions/Settings/downloadClients.js | 117 + .../src/Store/Actions/Settings/general.js | 64 + .../Store/Actions/Settings/indexerOptions.js | 64 + .../src/Store/Actions/Settings/indexers.js | 119 + .../Actions/Settings/languageProfiles.js | 97 + .../Store/Actions/Settings/mediaManagement.js | 64 + .../src/Store/Actions/Settings/metadata.js | 75 + frontend/src/Store/Actions/Settings/naming.js | 64 + .../Store/Actions/Settings/namingExamples.js | 79 + .../Store/Actions/Settings/notifications.js | 115 + .../Actions/Settings/qualityDefinitions.js | 135 + .../Store/Actions/Settings/qualityProfiles.js | 97 + .../Store/Actions/Settings/releaseProfiles.js | 71 + .../Actions/Settings/remotePathMappings.js | 69 + frontend/src/Store/Actions/Settings/ui.js | 64 + frontend/src/Store/Actions/actionTypes.js | 21 + .../src/Store/Actions/addSeriesActions.js | 181 + frontend/src/Store/Actions/appActions.js | 135 + frontend/src/Store/Actions/baseActions.js | 29 + .../src/Store/Actions/blacklistActions.js | 144 + frontend/src/Store/Actions/calendarActions.js | 391 + frontend/src/Store/Actions/captchaActions.js | 119 + frontend/src/Store/Actions/commandActions.js | 215 + .../src/Store/Actions/customFilterActions.js | 55 + frontend/src/Store/Actions/deviceActions.js | 83 + frontend/src/Store/Actions/episodeActions.js | 235 + .../src/Store/Actions/episodeFileActions.js | 210 + .../Store/Actions/episodeHistoryActions.js | 112 + frontend/src/Store/Actions/historyActions.js | 267 + .../src/Store/Actions/importSeriesActions.js | 328 + frontend/src/Store/Actions/index.js | 61 + .../Store/Actions/interactiveImportActions.js | 204 + frontend/src/Store/Actions/oAuthActions.js | 205 + .../Store/Actions/organizePreviewActions.js | 51 + frontend/src/Store/Actions/pathActions.js | 110 + frontend/src/Store/Actions/queueActions.js | 438 + frontend/src/Store/Actions/reducers.js | 20 + frontend/src/Store/Actions/releaseActions.js | 280 + .../src/Store/Actions/rootFolderActions.js | 97 + .../src/Store/Actions/seasonPassActions.js | 164 + frontend/src/Store/Actions/seriesActions.js | 379 + .../src/Store/Actions/seriesEditorActions.js | 190 + .../src/Store/Actions/seriesHistoryActions.js | 104 + .../src/Store/Actions/seriesIndexActions.js | 427 + frontend/src/Store/Actions/settingsActions.js | 134 + frontend/src/Store/Actions/systemActions.js | 375 + frontend/src/Store/Actions/tagActions.js | 75 + frontend/src/Store/Actions/wantedActions.js | 317 + .../Store/Middleware/createPersistState.js | 101 + .../Middleware/createSentryMiddleware.js | 91 + frontend/src/Store/Middleware/middlewares.js | 25 + frontend/src/Store/Migrators/migrate.js | 5 + .../Migrators/migrateAddSeriesDefaults.js | 14 + .../Selectors/createAllSeriesSelector.js | 12 + .../createClientSideCollectionSelector.js | 130 + .../createCommandExecutingSelector.js | 14 + .../Store/Selectors/createCommandSelector.js | 14 + .../Store/Selectors/createCommandsSelector.js | 12 + .../Selectors/createDimensionsSelector.js | 12 + .../Selectors/createEpisodeFileSelector.js | 17 + .../Store/Selectors/createEpisodeSelector.js | 15 + .../Selectors/createExistingSeriesSelector.js | 15 + .../createImportSeriesItemSelector.js | 28 + .../createLanguageProfileSelector.js | 14 + .../Selectors/createProfileInUseSelector.js | 19 + .../createProviderSettingsSelector.js | 63 + .../Selectors/createQualityProfileSelector.js | 14 + .../Selectors/createQueueItemSelector.js | 19 + .../Selectors/createSeriesCountSelector.js | 13 + .../Store/Selectors/createSeriesSelector.js | 15 + .../createSettingsSectionSelector.js | 32 + .../Selectors/createSystemStatusSelector.js | 12 + .../Selectors/createTagDetailsSelector.js | 13 + .../src/Store/Selectors/createTagsSelector.js | 12 + .../Selectors/createUISettingsSelector.js | 12 + .../src/Store/Selectors/selectSettings.js | 104 + frontend/src/Store/createAppStore.js | 15 + frontend/src/Store/scrollPositions.js | 5 + frontend/src/Store/thunks.js | 28 + frontend/src/Styles/Mixins/cover.css | 8 + frontend/src/Styles/Mixins/linkOverlay.css | 11 + frontend/src/Styles/Mixins/scroller.css | 26 + frontend/src/Styles/Mixins/truncate.css | 18 + frontend/src/Styles/Variables/animations.js | 8 + frontend/src/Styles/Variables/colors.js | 181 + frontend/src/Styles/Variables/dimensions.js | 53 + frontend/src/Styles/Variables/fonts.js | 15 + frontend/src/Styles/globals.css | 7 + frontend/src/Styles/scaffolding.css | 45 + frontend/src/System/Backup/BackupRow.css | 12 + frontend/src/System/Backup/BackupRow.js | 153 + frontend/src/System/Backup/Backups.js | 166 + .../src/System/Backup/BackupsConnector.js | 84 + .../src/System/Backup/RestoreBackupModal.js | 31 + .../Backup/RestoreBackupModalConnector.js | 15 + .../Backup/RestoreBackupModalContent.css | 24 + .../Backup/RestoreBackupModalContent.js | 232 + .../RestoreBackupModalContentConnector.js | 37 + frontend/src/System/Events/LogsTable.js | 127 + .../src/System/Events/LogsTableConnector.js | 127 + .../System/Events/LogsTableDetailsModal.css | 17 + .../System/Events/LogsTableDetailsModal.js | 74 + frontend/src/System/Events/LogsTableRow.css | 35 + frontend/src/System/Events/LogsTableRow.js | 158 + frontend/src/System/Logs/Files/LogFiles.js | 139 + .../System/Logs/Files/LogFilesConnector.js | 90 + .../System/Logs/Files/LogFilesTableRow.css | 5 + .../src/System/Logs/Files/LogFilesTableRow.js | 50 + frontend/src/System/Logs/Logs.js | 30 + frontend/src/System/Logs/LogsNavMenu.js | 71 + .../Logs/Updates/UpdateLogFilesConnector.js | 90 + frontend/src/System/Status/About/About.css | 5 + frontend/src/System/Status/About/About.js | 88 + .../src/System/Status/About/AboutConnector.js | 52 + frontend/src/System/Status/About/StartTime.js | 93 + .../src/System/Status/DiskSpace/DiskSpace.css | 5 + .../src/System/Status/DiskSpace/DiskSpace.js | 120 + .../Status/DiskSpace/DiskSpaceConnector.js | 54 + frontend/src/System/Status/Health/Health.css | 21 + frontend/src/System/Status/Health/Health.js | 206 + .../System/Status/Health/HealthConnector.js | 68 + .../Status/Health/HealthStatusConnector.js | 79 + .../src/System/Status/MoreInfo/MoreInfo.js | 73 + frontend/src/System/Status/Status.js | 29 + .../src/System/Tasks/Queued/QueuedTaskRow.css | 31 + .../src/System/Tasks/Queued/QueuedTaskRow.js | 265 + .../Tasks/Queued/QueuedTaskRowConnector.js | 31 + .../src/System/Tasks/Queued/QueuedTasks.js | 89 + .../Tasks/Queued/QueuedTasksConnector.js | 46 + .../Tasks/Scheduled/ScheduledTaskRow.css | 18 + .../Tasks/Scheduled/ScheduledTaskRow.js | 182 + .../Scheduled/ScheduledTaskRowConnector.js | 92 + .../System/Tasks/Scheduled/ScheduledTasks.js | 79 + .../Scheduled/ScheduledTasksConnector.js | 46 + frontend/src/System/Tasks/Tasks.js | 18 + frontend/src/System/Updates/UpdateChanges.css | 4 + frontend/src/System/Updates/UpdateChanges.js | 45 + frontend/src/System/Updates/Updates.css | 57 + frontend/src/System/Updates/Updates.js | 169 + .../src/System/Updates/UpdatesConnector.js | 76 + .../Array/getIndexOfFirstCharacter.js | 13 + frontend/src/Utilities/Array/sortByName.js | 5 + frontend/src/Utilities/Command/findCommand.js | 10 + frontend/src/Utilities/Command/index.js | 5 + .../Utilities/Command/isCommandComplete.js | 9 + .../Utilities/Command/isCommandExecuting.js | 9 + .../src/Utilities/Command/isCommandFailed.js | 12 + .../src/Utilities/Command/isSameCommand.js | 24 + frontend/src/Utilities/Constants/keyCodes.js | 7 + .../src/Utilities/Date/dateFilterPredicate.js | 33 + frontend/src/Utilities/Date/formatDate.js | 11 + frontend/src/Utilities/Date/formatDateTime.js | 39 + frontend/src/Utilities/Date/formatTime.js | 19 + frontend/src/Utilities/Date/formatTimeSpan.js | 24 + .../src/Utilities/Date/getRelativeDate.js | 42 + frontend/src/Utilities/Date/isAfter.js | 17 + frontend/src/Utilities/Date/isBefore.js | 17 + frontend/src/Utilities/Date/isInNextWeek.js | 11 + frontend/src/Utilities/Date/isSameWeek.js | 11 + frontend/src/Utilities/Date/isToday.js | 11 + frontend/src/Utilities/Date/isTomorrow.js | 11 + frontend/src/Utilities/Date/isYesterday.js | 11 + .../src/Utilities/Episode/updateEpisodes.js | 21 + .../Utilities/Filter/findSelectedFilters.js | 19 + .../src/Utilities/Filter/getFilterValue.js | 11 + .../src/Utilities/Number/convertToBytes.js | 16 + frontend/src/Utilities/Number/formatAge.js | 17 + frontend/src/Utilities/Number/formatBytes.js | 16 + frontend/src/Utilities/Number/padNumber.js | 10 + .../src/Utilities/Object/getErrorMessage.js | 11 + .../src/Utilities/Object/hasDifferentItems.js | 10 + .../src/Utilities/Object/selectUniqueIds.js | 15 + .../src/Utilities/Quality/getQualities.js | 16 + frontend/src/Utilities/ResolutionUtility.js | 26 + frontend/src/Utilities/Series/getNewSeries.js | 31 + .../Utilities/Series/getProgressBarKind.js | 15 + .../src/Utilities/Series/monitorOptions.js | 11 + .../src/Utilities/State/getProviderState.js | 35 + .../src/Utilities/State/getSectionState.js | 22 + .../Utilities/State/selectProviderSchema.js | 34 + .../src/Utilities/State/updateSectionState.js | 16 + frontend/src/Utilities/String/combinePath.js | 5 + .../src/Utilities/String/generateUUIDv4.js | 6 + frontend/src/Utilities/String/isString.js | 3 + frontend/src/Utilities/String/parseUrl.js | 36 + frontend/src/Utilities/String/split.js | 17 + frontend/src/Utilities/String/titleCase.js | 11 + .../src/Utilities/Table/areAllSelected.js | 17 + .../src/Utilities/Table/getSelectedIds.js | 15 + .../src/Utilities/Table/getToggledRange.js | 23 + .../Utilities/Table/removeOldSelectedState.js | 16 + frontend/src/Utilities/Table/selectAll.js | 17 + .../src/Utilities/Table/toggleSelected.js | 30 + frontend/src/Utilities/createAjaxRequest.js | 30 + frontend/src/Utilities/getPathWithUrlBase.js | 3 + frontend/src/Utilities/getUniqueElementId.js | 7 + frontend/src/Utilities/isMobile.js | 7 + frontend/src/Utilities/pagePopulator.js | 28 + frontend/src/Utilities/pages.js | 9 + frontend/src/Utilities/requestAction.js | 40 + frontend/src/Utilities/sectionTypes.js | 6 + .../Utilities/serverSideCollectionHandlers.js | 12 + .../src/Wanted/CutoffUnmet/CutoffUnmet.js | 280 + .../CutoffUnmet/CutoffUnmetConnector.js | 183 + .../src/Wanted/CutoffUnmet/CutoffUnmetRow.css | 7 + .../src/Wanted/CutoffUnmet/CutoffUnmetRow.js | 175 + .../CutoffUnmet/CutoffUnmetRowConnector.js | 17 + frontend/src/Wanted/Missing/Missing.js | 298 + .../src/Wanted/Missing/MissingConnector.js | 172 + frontend/src/Wanted/Missing/MissingRow.css | 6 + frontend/src/Wanted/Missing/MissingRow.js | 165 + .../src/Wanted/Missing/MissingRowConnector.js | 17 + frontend/src/index.css | 15 + frontend/src/index.html | 61 + frontend/src/index.js | 18 + frontend/src/jQuery/jquery.ajax.js | 47 + frontend/src/login.html | 226 + frontend/src/oauth.html | 13 + frontend/src/polyfills.js | 41 + frontend/src/preload.js | 4 + frontend/src/vendor.js | 28 + npm-shrinkwrap.json | 4809 ---------- package.json | 178 +- src/NzbDrone.Common/Serializer/Json.cs | 6 +- .../Messaging/Commands/CommandQueueManager.cs | 2 +- .../Owin/MiddleWare/SignalRMiddleWare.cs | 6 +- src/NzbDrone.Host/Owin/OwinServiceProvider.cs | 6 +- src/Sonarr.sln | 18 +- yarn.lock | 8528 +++++++++++++++++ 1183 files changed, 91622 insertions(+), 4978 deletions(-) create mode 100644 .esprintrc create mode 100644 .npmrc create mode 100644 .yarnrc create mode 100644 frontend/.csscomb.json create mode 100644 frontend/.esformatter create mode 100644 frontend/.eslintignore create mode 100644 frontend/.eslintrc create mode 100644 frontend/.jsbeautifyrc create mode 100644 frontend/.stylelintrc create mode 100644 frontend/.tern-project create mode 100644 frontend/gulp/build.js create mode 100644 frontend/gulp/clean.js create mode 100644 frontend/gulp/copy.js create mode 100644 frontend/gulp/gulpFile.js create mode 100644 frontend/gulp/handlebars.js create mode 100644 frontend/gulp/helpers/errorHandler.js create mode 100644 frontend/gulp/helpers/html-annotate-loader.js create mode 100644 frontend/gulp/helpers/paths.js create mode 100644 frontend/gulp/helpers/phantom.js create mode 100644 frontend/gulp/imageMin.js create mode 100644 frontend/gulp/less.js create mode 100644 frontend/gulp/start.js create mode 100644 frontend/gulp/stripBom.js create mode 100644 frontend/gulp/watch.js create mode 100644 frontend/gulp/webpack/css-variables-loader.js create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/.vscode/settings.json create mode 100644 frontend/src/Activity/Blacklist/Blacklist.js create mode 100644 frontend/src/Activity/Blacklist/BlacklistConnector.js create mode 100644 frontend/src/Activity/Blacklist/BlacklistDetailsModal.js create mode 100644 frontend/src/Activity/Blacklist/BlacklistRow.css create mode 100644 frontend/src/Activity/Blacklist/BlacklistRow.js create mode 100644 frontend/src/Activity/Blacklist/BlacklistRowConnector.js create mode 100644 frontend/src/Activity/History/Details/HistoryDetails.css create mode 100644 frontend/src/Activity/History/Details/HistoryDetails.js create mode 100644 frontend/src/Activity/History/Details/HistoryDetailsAge.js create mode 100644 frontend/src/Activity/History/Details/HistoryDetailsConnector.js create mode 100644 frontend/src/Activity/History/Details/HistoryDetailsModal.css create mode 100644 frontend/src/Activity/History/Details/HistoryDetailsModal.js create mode 100644 frontend/src/Activity/History/History.js create mode 100644 frontend/src/Activity/History/HistoryConnector.js create mode 100644 frontend/src/Activity/History/HistoryEventTypeCell.css create mode 100644 frontend/src/Activity/History/HistoryEventTypeCell.js create mode 100644 frontend/src/Activity/History/HistoryRow.css create mode 100644 frontend/src/Activity/History/HistoryRow.js create mode 100644 frontend/src/Activity/History/HistoryRowConnector.js create mode 100644 frontend/src/Activity/Queue/ProtocolLabel.css create mode 100644 frontend/src/Activity/Queue/ProtocolLabel.js create mode 100644 frontend/src/Activity/Queue/Queue.js create mode 100644 frontend/src/Activity/Queue/QueueConnector.js create mode 100644 frontend/src/Activity/Queue/QueueDetails.js create mode 100644 frontend/src/Activity/Queue/QueueOptions.js create mode 100644 frontend/src/Activity/Queue/QueueOptionsConnector.js create mode 100644 frontend/src/Activity/Queue/QueueRow.css create mode 100644 frontend/src/Activity/Queue/QueueRow.js create mode 100644 frontend/src/Activity/Queue/QueueRowConnector.js create mode 100644 frontend/src/Activity/Queue/QueueStatusCell.css create mode 100644 frontend/src/Activity/Queue/QueueStatusCell.js create mode 100644 frontend/src/Activity/Queue/RemoveQueueItemModal.css create mode 100644 frontend/src/Activity/Queue/RemoveQueueItemModal.js create mode 100644 frontend/src/Activity/Queue/RemoveQueueItemsModal.css create mode 100644 frontend/src/Activity/Queue/RemoveQueueItemsModal.js create mode 100644 frontend/src/Activity/Queue/Status/QueueStatusConnector.js create mode 100644 frontend/src/Activity/Queue/TimeleftCell.css create mode 100644 frontend/src/Activity/Queue/TimeleftCell.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeries.css create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeries.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js create mode 100644 frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css create mode 100644 frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js create mode 100644 frontend/src/AddSeries/ImportSeries/ImportSeries.js create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js create mode 100644 frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js create mode 100644 frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js create mode 100644 frontend/src/AddSeries/SeriesTypePopoverContent.js create mode 100644 frontend/src/App/App.js create mode 100644 frontend/src/App/AppRoutes.js create mode 100644 frontend/src/App/AppUpdatedModal.js create mode 100644 frontend/src/App/AppUpdatedModalConnector.js create mode 100644 frontend/src/App/AppUpdatedModalContent.css create mode 100644 frontend/src/App/AppUpdatedModalContent.js create mode 100644 frontend/src/App/AppUpdatedModalContentConnector.js create mode 100644 frontend/src/App/ConnectionLostModal.css create mode 100644 frontend/src/App/ConnectionLostModal.js create mode 100644 frontend/src/App/ConnectionLostModalConnector.js create mode 100644 frontend/src/Calendar/Agenda/Agenda.css create mode 100644 frontend/src/Calendar/Agenda/Agenda.js create mode 100644 frontend/src/Calendar/Agenda/AgendaConnector.js create mode 100644 frontend/src/Calendar/Agenda/AgendaEvent.css create mode 100644 frontend/src/Calendar/Agenda/AgendaEvent.js create mode 100644 frontend/src/Calendar/Agenda/AgendaEventConnector.js create mode 100644 frontend/src/Calendar/Calendar.css create mode 100644 frontend/src/Calendar/Calendar.js create mode 100644 frontend/src/Calendar/CalendarConnector.js create mode 100644 frontend/src/Calendar/CalendarPage.css create mode 100644 frontend/src/Calendar/CalendarPage.js create mode 100644 frontend/src/Calendar/CalendarPageConnector.js create mode 100644 frontend/src/Calendar/Day/CalendarDay.css create mode 100644 frontend/src/Calendar/Day/CalendarDay.js create mode 100644 frontend/src/Calendar/Day/CalendarDayConnector.js create mode 100644 frontend/src/Calendar/Day/CalendarDays.css create mode 100644 frontend/src/Calendar/Day/CalendarDays.js create mode 100644 frontend/src/Calendar/Day/CalendarDaysConnector.js create mode 100644 frontend/src/Calendar/Day/DayOfWeek.css create mode 100644 frontend/src/Calendar/Day/DayOfWeek.js create mode 100644 frontend/src/Calendar/Day/DaysOfWeek.css create mode 100644 frontend/src/Calendar/Day/DaysOfWeek.js create mode 100644 frontend/src/Calendar/Day/DaysOfWeekConnector.js create mode 100644 frontend/src/Calendar/Events/CalendarEvent.css create mode 100644 frontend/src/Calendar/Events/CalendarEvent.js create mode 100644 frontend/src/Calendar/Events/CalendarEventConnector.js create mode 100644 frontend/src/Calendar/Events/CalendarEventGroup.css create mode 100644 frontend/src/Calendar/Events/CalendarEventGroup.js create mode 100644 frontend/src/Calendar/Events/CalendarEventGroupConnector.js create mode 100644 frontend/src/Calendar/Events/CalendarEventQueueDetails.js create mode 100644 frontend/src/Calendar/Header/CalendarHeader.css create mode 100644 frontend/src/Calendar/Header/CalendarHeader.js create mode 100644 frontend/src/Calendar/Header/CalendarHeaderConnector.js create mode 100644 frontend/src/Calendar/Header/CalendarHeaderViewButton.js create mode 100644 frontend/src/Calendar/Legend/Legend.css create mode 100644 frontend/src/Calendar/Legend/Legend.js create mode 100644 frontend/src/Calendar/Legend/LegendConnector.js create mode 100644 frontend/src/Calendar/Legend/LegendIconItem.css create mode 100644 frontend/src/Calendar/Legend/LegendIconItem.js create mode 100644 frontend/src/Calendar/Legend/LegendItem.css create mode 100644 frontend/src/Calendar/Legend/LegendItem.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModal.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContent.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js create mode 100644 frontend/src/Calendar/calendarViews.js create mode 100644 frontend/src/Calendar/getStatusStyle.js create mode 100644 frontend/src/Calendar/iCal/CalendarLinkModal.js create mode 100644 frontend/src/Calendar/iCal/CalendarLinkModalContent.js create mode 100644 frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js create mode 100644 frontend/src/Commands/commandNames.js create mode 100644 frontend/src/Components/Alert.css create mode 100644 frontend/src/Components/Alert.js create mode 100644 frontend/src/Components/Card.css create mode 100644 frontend/src/Components/Card.js create mode 100644 frontend/src/Components/CircularProgressBar.css create mode 100644 frontend/src/Components/CircularProgressBar.js create mode 100644 frontend/src/Components/DescriptionList/DescriptionList.css create mode 100644 frontend/src/Components/DescriptionList/DescriptionList.js create mode 100644 frontend/src/Components/DescriptionList/DescriptionListItem.js create mode 100644 frontend/src/Components/DescriptionList/DescriptionListItemDescription.css create mode 100644 frontend/src/Components/DescriptionList/DescriptionListItemDescription.js create mode 100644 frontend/src/Components/DescriptionList/DescriptionListItemTitle.css create mode 100644 frontend/src/Components/DescriptionList/DescriptionListItemTitle.js create mode 100644 frontend/src/Components/DragPreviewLayer.css create mode 100644 frontend/src/Components/DragPreviewLayer.js create mode 100644 frontend/src/Components/Error/ErrorBoundary.js create mode 100644 frontend/src/Components/Error/ErrorBoundaryError.css create mode 100644 frontend/src/Components/Error/ErrorBoundaryError.js create mode 100644 frontend/src/Components/FieldSet.css create mode 100644 frontend/src/Components/FieldSet.js create mode 100644 frontend/src/Components/FileBrowser/FileBrowserModal.css create mode 100644 frontend/src/Components/FileBrowser/FileBrowserModal.js create mode 100644 frontend/src/Components/FileBrowser/FileBrowserModalContent.css create mode 100644 frontend/src/Components/FileBrowser/FileBrowserModalContent.js create mode 100644 frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js create mode 100644 frontend/src/Components/FileBrowser/FileBrowserRow.css create mode 100644 frontend/src/Components/FileBrowser/FileBrowserRow.js create mode 100644 frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css create mode 100644 frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRow.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRow.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js create mode 100644 frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFilter.css create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFilter.js create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js create mode 100644 frontend/src/Components/Filter/FilterModal.js create mode 100644 frontend/src/Components/Form/AutoCompleteInput.css create mode 100644 frontend/src/Components/Form/AutoCompleteInput.js create mode 100644 frontend/src/Components/Form/CaptchaInput.css create mode 100644 frontend/src/Components/Form/CaptchaInput.js create mode 100644 frontend/src/Components/Form/CaptchaInputConnector.js create mode 100644 frontend/src/Components/Form/CheckInput.css create mode 100644 frontend/src/Components/Form/CheckInput.js create mode 100644 frontend/src/Components/Form/DeviceInput.css create mode 100644 frontend/src/Components/Form/DeviceInput.js create mode 100644 frontend/src/Components/Form/DeviceInputConnector.js create mode 100644 frontend/src/Components/Form/EnhancedSelectInput.css create mode 100644 frontend/src/Components/Form/EnhancedSelectInput.js create mode 100644 frontend/src/Components/Form/EnhancedSelectInputOption.css create mode 100644 frontend/src/Components/Form/EnhancedSelectInputOption.js create mode 100644 frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css create mode 100644 frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js create mode 100644 frontend/src/Components/Form/Form.js create mode 100644 frontend/src/Components/Form/FormGroup.css create mode 100644 frontend/src/Components/Form/FormGroup.js create mode 100644 frontend/src/Components/Form/FormInputButton.css create mode 100644 frontend/src/Components/Form/FormInputButton.js create mode 100644 frontend/src/Components/Form/FormInputGroup.css create mode 100644 frontend/src/Components/Form/FormInputGroup.js create mode 100644 frontend/src/Components/Form/FormInputHelpText.css create mode 100644 frontend/src/Components/Form/FormInputHelpText.js create mode 100644 frontend/src/Components/Form/FormLabel.css create mode 100644 frontend/src/Components/Form/FormLabel.js create mode 100644 frontend/src/Components/Form/Input.css create mode 100644 frontend/src/Components/Form/KeyValueListInput.css create mode 100644 frontend/src/Components/Form/KeyValueListInput.js create mode 100644 frontend/src/Components/Form/KeyValueListInputItem.css create mode 100644 frontend/src/Components/Form/KeyValueListInputItem.js create mode 100644 frontend/src/Components/Form/LanguageProfileSelectInputConnector.js create mode 100644 frontend/src/Components/Form/MonitorEpisodesSelectInput.js create mode 100644 frontend/src/Components/Form/NumberInput.js create mode 100644 frontend/src/Components/Form/OAuthInput.js create mode 100644 frontend/src/Components/Form/OAuthInputConnector.js create mode 100644 frontend/src/Components/Form/PasswordInput.css create mode 100644 frontend/src/Components/Form/PasswordInput.js create mode 100644 frontend/src/Components/Form/PathInput.css create mode 100644 frontend/src/Components/Form/PathInput.js create mode 100644 frontend/src/Components/Form/PathInputConnector.js create mode 100644 frontend/src/Components/Form/ProviderFieldFormGroup.js create mode 100644 frontend/src/Components/Form/QualityProfileSelectInputConnector.js create mode 100644 frontend/src/Components/Form/RootFolderSelectInput.js create mode 100644 frontend/src/Components/Form/RootFolderSelectInputConnector.js create mode 100644 frontend/src/Components/Form/RootFolderSelectInputOption.css create mode 100644 frontend/src/Components/Form/RootFolderSelectInputOption.js create mode 100644 frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css create mode 100644 frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js create mode 100644 frontend/src/Components/Form/SelectInput.css create mode 100644 frontend/src/Components/Form/SelectInput.js create mode 100644 frontend/src/Components/Form/SeriesTypeSelectInput.js create mode 100644 frontend/src/Components/Form/TagInput.css create mode 100644 frontend/src/Components/Form/TagInput.js create mode 100644 frontend/src/Components/Form/TagInputConnector.js create mode 100644 frontend/src/Components/Form/TagInputInput.css create mode 100644 frontend/src/Components/Form/TagInputInput.js create mode 100644 frontend/src/Components/Form/TagInputTag.js create mode 100644 frontend/src/Components/Form/TextInput.css create mode 100644 frontend/src/Components/Form/TextInput.js create mode 100644 frontend/src/Components/Form/TextTagInputConnector.js create mode 100644 frontend/src/Components/HeartRating.css create mode 100644 frontend/src/Components/HeartRating.js create mode 100644 frontend/src/Components/Icon.css create mode 100644 frontend/src/Components/Icon.js create mode 100644 frontend/src/Components/Label.css create mode 100644 frontend/src/Components/Label.js create mode 100644 frontend/src/Components/Link/Button.css create mode 100644 frontend/src/Components/Link/Button.js create mode 100644 frontend/src/Components/Link/ClipboardButton.css create mode 100644 frontend/src/Components/Link/ClipboardButton.js create mode 100644 frontend/src/Components/Link/IconButton.css create mode 100644 frontend/src/Components/Link/IconButton.js create mode 100644 frontend/src/Components/Link/Link.css create mode 100644 frontend/src/Components/Link/Link.js create mode 100644 frontend/src/Components/Link/SpinnerButton.css create mode 100644 frontend/src/Components/Link/SpinnerButton.js create mode 100644 frontend/src/Components/Link/SpinnerErrorButton.css create mode 100644 frontend/src/Components/Link/SpinnerErrorButton.js create mode 100644 frontend/src/Components/Link/SpinnerIconButton.js create mode 100644 frontend/src/Components/Loading/LoadingIndicator.css create mode 100644 frontend/src/Components/Loading/LoadingIndicator.js create mode 100644 frontend/src/Components/Loading/LoadingMessage.css create mode 100644 frontend/src/Components/Loading/LoadingMessage.js create mode 100644 frontend/src/Components/Measure.js create mode 100644 frontend/src/Components/Menu/FilterMenu.css create mode 100644 frontend/src/Components/Menu/FilterMenu.js create mode 100644 frontend/src/Components/Menu/FilterMenuContent.js create mode 100644 frontend/src/Components/Menu/FilterMenuItem.js create mode 100644 frontend/src/Components/Menu/Menu.css create mode 100644 frontend/src/Components/Menu/Menu.js create mode 100644 frontend/src/Components/Menu/MenuButton.css create mode 100644 frontend/src/Components/Menu/MenuButton.js create mode 100644 frontend/src/Components/Menu/MenuContent.css create mode 100644 frontend/src/Components/Menu/MenuContent.js create mode 100644 frontend/src/Components/Menu/MenuItem.css create mode 100644 frontend/src/Components/Menu/MenuItem.js create mode 100644 frontend/src/Components/Menu/MenuItemSeparator.css create mode 100644 frontend/src/Components/Menu/MenuItemSeparator.js create mode 100644 frontend/src/Components/Menu/PageMenuButton.css create mode 100644 frontend/src/Components/Menu/PageMenuButton.js create mode 100644 frontend/src/Components/Menu/SelectedMenuItem.css create mode 100644 frontend/src/Components/Menu/SelectedMenuItem.js create mode 100644 frontend/src/Components/Menu/SortMenu.js create mode 100644 frontend/src/Components/Menu/SortMenuItem.js create mode 100644 frontend/src/Components/Menu/ToolbarMenuButton.css create mode 100644 frontend/src/Components/Menu/ToolbarMenuButton.js create mode 100644 frontend/src/Components/Menu/ViewMenu.js create mode 100644 frontend/src/Components/Menu/ViewMenuItem.js create mode 100644 frontend/src/Components/Modal/ConfirmModal.js create mode 100644 frontend/src/Components/Modal/Modal.css create mode 100644 frontend/src/Components/Modal/Modal.js create mode 100644 frontend/src/Components/Modal/ModalBody.css create mode 100644 frontend/src/Components/Modal/ModalBody.js create mode 100644 frontend/src/Components/Modal/ModalContent.css create mode 100644 frontend/src/Components/Modal/ModalContent.js create mode 100644 frontend/src/Components/Modal/ModalError.css create mode 100644 frontend/src/Components/Modal/ModalError.js create mode 100644 frontend/src/Components/Modal/ModalFooter.css create mode 100644 frontend/src/Components/Modal/ModalFooter.js create mode 100644 frontend/src/Components/Modal/ModalHeader.css create mode 100644 frontend/src/Components/Modal/ModalHeader.js create mode 100644 frontend/src/Components/MonitorToggleButton.css create mode 100644 frontend/src/Components/MonitorToggleButton.js create mode 100644 frontend/src/Components/NotFound.css create mode 100644 frontend/src/Components/NotFound.js create mode 100644 frontend/src/Components/Page/ErrorPage.css create mode 100644 frontend/src/Components/Page/ErrorPage.js create mode 100644 frontend/src/Components/Page/Header/KeyboardShortcutsModal.js create mode 100644 frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css create mode 100644 frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js create mode 100644 frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js create mode 100644 frontend/src/Components/Page/Header/PageHeader.css create mode 100644 frontend/src/Components/Page/Header/PageHeader.js create mode 100644 frontend/src/Components/Page/Header/PageHeaderActionsMenu.css create mode 100644 frontend/src/Components/Page/Header/PageHeaderActionsMenu.js create mode 100644 frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js create mode 100644 frontend/src/Components/Page/Header/SeriesSearchInput.css create mode 100644 frontend/src/Components/Page/Header/SeriesSearchInput.js create mode 100644 frontend/src/Components/Page/Header/SeriesSearchInputConnector.js create mode 100644 frontend/src/Components/Page/Header/SeriesSearchResult.css create mode 100644 frontend/src/Components/Page/Header/SeriesSearchResult.js create mode 100644 frontend/src/Components/Page/LoadingPage.css create mode 100644 frontend/src/Components/Page/LoadingPage.js create mode 100644 frontend/src/Components/Page/Page.css create mode 100644 frontend/src/Components/Page/Page.js create mode 100644 frontend/src/Components/Page/PageConnector.js create mode 100644 frontend/src/Components/Page/PageContent.css create mode 100644 frontend/src/Components/Page/PageContent.js create mode 100644 frontend/src/Components/Page/PageContentBody.css create mode 100644 frontend/src/Components/Page/PageContentBody.js create mode 100644 frontend/src/Components/Page/PageContentBodyConnector.js create mode 100644 frontend/src/Components/Page/PageContentError.css create mode 100644 frontend/src/Components/Page/PageContentError.js create mode 100644 frontend/src/Components/Page/PageContentFooter.css create mode 100644 frontend/src/Components/Page/PageContentFooter.js create mode 100644 frontend/src/Components/Page/PageJumpBar.css create mode 100644 frontend/src/Components/Page/PageJumpBar.js create mode 100644 frontend/src/Components/Page/PageJumpBarItem.css create mode 100644 frontend/src/Components/Page/PageJumpBarItem.js create mode 100644 frontend/src/Components/Page/PageSectionContent.js create mode 100644 frontend/src/Components/Page/Sidebar/Messages/Message.css create mode 100644 frontend/src/Components/Page/Sidebar/Messages/Message.js create mode 100644 frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js create mode 100644 frontend/src/Components/Page/Sidebar/Messages/Messages.css create mode 100644 frontend/src/Components/Page/Sidebar/Messages/Messages.js create mode 100644 frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebar.css create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebar.js create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebarItem.css create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebarItem.js create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebarStatus.css create mode 100644 frontend/src/Components/Page/Sidebar/PageSidebarStatus.js create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbar.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbar.js create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarButton.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarButton.js create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarSection.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarSection.js create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js create mode 100644 frontend/src/Components/ProgressBar.css create mode 100644 frontend/src/Components/ProgressBar.js create mode 100644 frontend/src/Components/Router/Switch.js create mode 100644 frontend/src/Components/Scroller/OverlayScroller.css create mode 100644 frontend/src/Components/Scroller/OverlayScroller.js create mode 100644 frontend/src/Components/Scroller/Scroller.css create mode 100644 frontend/src/Components/Scroller/Scroller.js create mode 100644 frontend/src/Components/SignalRConnector.js create mode 100644 frontend/src/Components/SpinnerIcon.js create mode 100644 frontend/src/Components/Table/Cells/RelativeDateCell.css create mode 100644 frontend/src/Components/Table/Cells/RelativeDateCell.js create mode 100644 frontend/src/Components/Table/Cells/RelativeDateCellConnector.js create mode 100644 frontend/src/Components/Table/Cells/TableRowCell.css create mode 100644 frontend/src/Components/Table/Cells/TableRowCell.js create mode 100644 frontend/src/Components/Table/Cells/TableRowCellButton.css create mode 100644 frontend/src/Components/Table/Cells/TableRowCellButton.js create mode 100644 frontend/src/Components/Table/Cells/TableSelectCell.css create mode 100644 frontend/src/Components/Table/Cells/TableSelectCell.js create mode 100644 frontend/src/Components/Table/Cells/VirtualTableRowCell.css create mode 100644 frontend/src/Components/Table/Cells/VirtualTableRowCell.js create mode 100644 frontend/src/Components/Table/Cells/VirtualTableSelectCell.css create mode 100644 frontend/src/Components/Table/Cells/VirtualTableSelectCell.js create mode 100644 frontend/src/Components/Table/Table.css create mode 100644 frontend/src/Components/Table/Table.js create mode 100644 frontend/src/Components/Table/TableBody.js create mode 100644 frontend/src/Components/Table/TableHeader.js create mode 100644 frontend/src/Components/Table/TableHeaderCell.css create mode 100644 frontend/src/Components/Table/TableHeaderCell.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumn.css create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumn.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsModal.css create mode 100644 frontend/src/Components/Table/TableOptions/TableOptionsModal.js create mode 100644 frontend/src/Components/Table/TablePager.css create mode 100644 frontend/src/Components/Table/TablePager.js create mode 100644 frontend/src/Components/Table/TableRow.css create mode 100644 frontend/src/Components/Table/TableRow.js create mode 100644 frontend/src/Components/Table/TableRowButton.css create mode 100644 frontend/src/Components/Table/TableRowButton.js create mode 100644 frontend/src/Components/Table/TableSelectAllHeaderCell.css create mode 100644 frontend/src/Components/Table/TableSelectAllHeaderCell.js create mode 100644 frontend/src/Components/Table/VirtualTable.css create mode 100644 frontend/src/Components/Table/VirtualTable.js create mode 100644 frontend/src/Components/Table/VirtualTableBody.css create mode 100644 frontend/src/Components/Table/VirtualTableBody.js create mode 100644 frontend/src/Components/Table/VirtualTableHeader.css create mode 100644 frontend/src/Components/Table/VirtualTableHeader.js create mode 100644 frontend/src/Components/Table/VirtualTableHeaderCell.css create mode 100644 frontend/src/Components/Table/VirtualTableHeaderCell.js create mode 100644 frontend/src/Components/Table/VirtualTableRow.css create mode 100644 frontend/src/Components/Table/VirtualTableRow.js create mode 100644 frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css create mode 100644 frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js create mode 100644 frontend/src/Components/TagList.css create mode 100644 frontend/src/Components/TagList.js create mode 100644 frontend/src/Components/TagListConnector.js create mode 100644 frontend/src/Components/Tooltip/Popover.css create mode 100644 frontend/src/Components/Tooltip/Popover.js create mode 100644 frontend/src/Components/Tooltip/Tooltip.css create mode 100644 frontend/src/Components/Tooltip/Tooltip.js create mode 100644 frontend/src/Components/keyboardShortcuts.js create mode 100644 frontend/src/Components/withCurrentPage.js create mode 100644 frontend/src/Components/withScrollPosition.js create mode 100644 frontend/src/Content/Fonts/Roboto-Light.ttf create mode 100644 frontend/src/Content/Fonts/Roboto-Light.woff create mode 100644 frontend/src/Content/Fonts/Roboto-Light.woff2 create mode 100644 frontend/src/Content/Fonts/Roboto-Regular.ttf create mode 100644 frontend/src/Content/Fonts/Roboto-Regular.woff create mode 100644 frontend/src/Content/Fonts/Roboto-Regular.woff2 create mode 100644 frontend/src/Content/Fonts/UbuntuMono-Regular.eot create mode 100644 frontend/src/Content/Fonts/UbuntuMono-Regular.ttf create mode 100644 frontend/src/Content/Fonts/UbuntuMono-Regular.woff create mode 100644 frontend/src/Content/Fonts/fonts.css create mode 100644 frontend/src/Content/Fonts/text-security-disc.ttf create mode 100644 frontend/src/Content/Fonts/text-security-disc.woff create mode 100644 frontend/src/Content/Images/404.png create mode 100644 frontend/src/Content/Images/Icons/android-chrome-192x192.png create mode 100644 frontend/src/Content/Images/Icons/android-chrome-512x512.png create mode 100644 frontend/src/Content/Images/Icons/apple-touch-icon.png create mode 100644 frontend/src/Content/Images/Icons/browserconfig.xml create mode 100644 frontend/src/Content/Images/Icons/favicon-16x16.png create mode 100644 frontend/src/Content/Images/Icons/favicon-32x32.png create mode 100644 frontend/src/Content/Images/Icons/favicon-debug-16x16.png create mode 100644 frontend/src/Content/Images/Icons/favicon-debug-32x32.png create mode 100644 frontend/src/Content/Images/Icons/favicon-debug.ico create mode 100644 frontend/src/Content/Images/Icons/favicon.ico create mode 100644 frontend/src/Content/Images/Icons/manifest.json create mode 100644 frontend/src/Content/Images/Icons/mstile-144x144.png create mode 100644 frontend/src/Content/Images/Icons/mstile-150x150.png create mode 100644 frontend/src/Content/Images/Icons/mstile-310x150.png create mode 100644 frontend/src/Content/Images/Icons/mstile-310x310.png create mode 100644 frontend/src/Content/Images/Icons/mstile-70x70.png create mode 100644 frontend/src/Content/Images/Icons/safari-pinned-tab.svg create mode 100644 frontend/src/Content/Images/error.png create mode 100644 frontend/src/Content/Images/logo.svg create mode 100644 frontend/src/Content/Images/poster-dark.png create mode 100644 frontend/src/Episode/EpisodeDetailsModal.js create mode 100644 frontend/src/Episode/EpisodeDetailsModalContent.css create mode 100644 frontend/src/Episode/EpisodeDetailsModalContent.js create mode 100644 frontend/src/Episode/EpisodeDetailsModalContentConnector.js create mode 100644 frontend/src/Episode/EpisodeLanguage.js create mode 100644 frontend/src/Episode/EpisodeNumber.css create mode 100644 frontend/src/Episode/EpisodeNumber.js create mode 100644 frontend/src/Episode/EpisodeQuality.js create mode 100644 frontend/src/Episode/EpisodeSearchCell.css create mode 100644 frontend/src/Episode/EpisodeSearchCell.js create mode 100644 frontend/src/Episode/EpisodeSearchCellConnector.js create mode 100644 frontend/src/Episode/EpisodeStatus.css create mode 100644 frontend/src/Episode/EpisodeStatus.js create mode 100644 frontend/src/Episode/EpisodeStatusConnector.js create mode 100644 frontend/src/Episode/EpisodeTitleLink.css create mode 100644 frontend/src/Episode/EpisodeTitleLink.js create mode 100644 frontend/src/Episode/History/EpisodeHistory.js create mode 100644 frontend/src/Episode/History/EpisodeHistoryConnector.js create mode 100644 frontend/src/Episode/History/EpisodeHistoryRow.css create mode 100644 frontend/src/Episode/History/EpisodeHistoryRow.js create mode 100644 frontend/src/Episode/SceneInfo.css create mode 100644 frontend/src/Episode/SceneInfo.js create mode 100644 frontend/src/Episode/Search/EpisodeSearch.css create mode 100644 frontend/src/Episode/Search/EpisodeSearch.js create mode 100644 frontend/src/Episode/Search/EpisodeSearchConnector.js create mode 100644 frontend/src/Episode/SeasonEpisodeNumber.js create mode 100644 frontend/src/Episode/Summary/EpisodeAiring.js create mode 100644 frontend/src/Episode/Summary/EpisodeAiringConnector.js create mode 100644 frontend/src/Episode/Summary/EpisodeSummary.css create mode 100644 frontend/src/Episode/Summary/EpisodeSummary.js create mode 100644 frontend/src/Episode/Summary/EpisodeSummaryConnector.js create mode 100644 frontend/src/Episode/Summary/MediaInfo.js create mode 100644 frontend/src/Episode/episodeEntities.js create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css create mode 100644 frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js create mode 100644 frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js create mode 100644 frontend/src/EpisodeFile/MediaInfo.js create mode 100644 frontend/src/EpisodeFile/MediaInfoConnector.js create mode 100644 frontend/src/EpisodeFile/mediaInfoTypes.js create mode 100644 frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js create mode 100644 frontend/src/Helpers/Props/Shapes/locationShape.js create mode 100644 frontend/src/Helpers/Props/Shapes/settingShape.js create mode 100644 frontend/src/Helpers/Props/align.js create mode 100644 frontend/src/Helpers/Props/filterBuilderTypes.js create mode 100644 frontend/src/Helpers/Props/filterBuilderValueTypes.js create mode 100644 frontend/src/Helpers/Props/filterTypePredicates.js create mode 100644 frontend/src/Helpers/Props/filterTypes.js create mode 100644 frontend/src/Helpers/Props/icons.js create mode 100644 frontend/src/Helpers/Props/index.js create mode 100644 frontend/src/Helpers/Props/inputTypes.js create mode 100644 frontend/src/Helpers/Props/kinds.js create mode 100644 frontend/src/Helpers/Props/messageTypes.js create mode 100644 frontend/src/Helpers/Props/scrollDirections.js create mode 100644 frontend/src/Helpers/Props/sizes.js create mode 100644 frontend/src/Helpers/Props/sortDirections.js create mode 100644 frontend/src/Helpers/Props/tooltipPositions.js create mode 100644 frontend/src/Helpers/dragTypes.js create mode 100644 frontend/src/Helpers/elementChildren.js create mode 100644 frontend/src/Helpers/getDisplayName.js create mode 100644 frontend/src/Hotkeys/Hotkeys.js create mode 100644 frontend/src/Hotkeys/HotkeysView.js create mode 100644 frontend/src/Hotkeys/HotkeysViewTemplate.hbs create mode 100644 frontend/src/Hotkeys/hotkeys.less create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js create mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css create mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js create mode 100644 frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Folder/RecentFolderRow.css create mode 100644 frontend/src/InteractiveImport/Folder/RecentFolderRow.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css create mode 100644 frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js create mode 100644 frontend/src/InteractiveImport/InteractiveImportModal.js create mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModal.js create mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js create mode 100644 frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModal.js create mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js create mode 100644 frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModal.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonRow.css create mode 100644 frontend/src/InteractiveImport/Season/SelectSeasonRow.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModal.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesRow.css create mode 100644 frontend/src/InteractiveImport/Series/SelectSeriesRow.js create mode 100644 frontend/src/InteractiveSearch/InteractiveSearch.css create mode 100644 frontend/src/InteractiveSearch/InteractiveSearch.js create mode 100644 frontend/src/InteractiveSearch/InteractiveSearchConnector.js create mode 100644 frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js create mode 100644 frontend/src/InteractiveSearch/InteractiveSearchRow.css create mode 100644 frontend/src/InteractiveSearch/InteractiveSearchRow.js create mode 100644 frontend/src/InteractiveSearch/Peers.js create mode 100644 frontend/src/Organize/OrganizePreviewModal.js create mode 100644 frontend/src/Organize/OrganizePreviewModalConnector.js create mode 100644 frontend/src/Organize/OrganizePreviewModalContent.css create mode 100644 frontend/src/Organize/OrganizePreviewModalContent.js create mode 100644 frontend/src/Organize/OrganizePreviewModalContentConnector.js create mode 100644 frontend/src/Organize/OrganizePreviewRow.css create mode 100644 frontend/src/Organize/OrganizePreviewRow.js create mode 100644 frontend/src/Season/SeasonNumber.js create mode 100644 frontend/src/SeasonPass/SeasonPass.js create mode 100644 frontend/src/SeasonPass/SeasonPassConnector.js create mode 100644 frontend/src/SeasonPass/SeasonPassFilterModalConnector.js create mode 100644 frontend/src/SeasonPass/SeasonPassFooter.css create mode 100644 frontend/src/SeasonPass/SeasonPassFooter.js create mode 100644 frontend/src/SeasonPass/SeasonPassRow.css create mode 100644 frontend/src/SeasonPass/SeasonPassRow.js create mode 100644 frontend/src/SeasonPass/SeasonPassRowConnector.js create mode 100644 frontend/src/SeasonPass/SeasonPassSeason.css create mode 100644 frontend/src/SeasonPass/SeasonPassSeason.js create mode 100644 frontend/src/Series/Delete/DeleteSeriesModal.js create mode 100644 frontend/src/Series/Delete/DeleteSeriesModalContent.css create mode 100644 frontend/src/Series/Delete/DeleteSeriesModalContent.js create mode 100644 frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js create mode 100644 frontend/src/Series/Details/EpisodeRow.css create mode 100644 frontend/src/Series/Details/EpisodeRow.js create mode 100644 frontend/src/Series/Details/EpisodeRowConnector.js create mode 100644 frontend/src/Series/Details/SeasonInfo.css create mode 100644 frontend/src/Series/Details/SeasonInfo.js create mode 100644 frontend/src/Series/Details/SeriesAlternateTitles.css create mode 100644 frontend/src/Series/Details/SeriesAlternateTitles.js create mode 100644 frontend/src/Series/Details/SeriesDetails.css create mode 100644 frontend/src/Series/Details/SeriesDetails.js create mode 100644 frontend/src/Series/Details/SeriesDetailsConnector.js create mode 100644 frontend/src/Series/Details/SeriesDetailsLinks.css create mode 100644 frontend/src/Series/Details/SeriesDetailsLinks.js create mode 100644 frontend/src/Series/Details/SeriesDetailsPageConnector.js create mode 100644 frontend/src/Series/Details/SeriesDetailsSeason.css create mode 100644 frontend/src/Series/Details/SeriesDetailsSeason.js create mode 100644 frontend/src/Series/Details/SeriesDetailsSeasonConnector.js create mode 100644 frontend/src/Series/Details/SeriesTags.js create mode 100644 frontend/src/Series/Details/SeriesTagsConnector.js create mode 100644 frontend/src/Series/Edit/EditSeriesModal.js create mode 100644 frontend/src/Series/Edit/EditSeriesModalConnector.js create mode 100644 frontend/src/Series/Edit/EditSeriesModalContent.css create mode 100644 frontend/src/Series/Edit/EditSeriesModalContent.js create mode 100644 frontend/src/Series/Edit/EditSeriesModalContentConnector.js create mode 100644 frontend/src/Series/Editor/Delete/DeleteSeriesModal.js create mode 100644 frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css create mode 100644 frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js create mode 100644 frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js create mode 100644 frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js create mode 100644 frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css create mode 100644 frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js create mode 100644 frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js create mode 100644 frontend/src/Series/Editor/SeriesEditor.js create mode 100644 frontend/src/Series/Editor/SeriesEditorConnector.js create mode 100644 frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js create mode 100644 frontend/src/Series/Editor/SeriesEditorFooter.css create mode 100644 frontend/src/Series/Editor/SeriesEditorFooter.js create mode 100644 frontend/src/Series/Editor/SeriesEditorFooterLabel.css create mode 100644 frontend/src/Series/Editor/SeriesEditorFooterLabel.js create mode 100644 frontend/src/Series/Editor/SeriesEditorRow.css create mode 100644 frontend/src/Series/Editor/SeriesEditorRow.js create mode 100644 frontend/src/Series/Editor/SeriesEditorRowConnector.js create mode 100644 frontend/src/Series/Editor/Tags/TagsModal.js create mode 100644 frontend/src/Series/Editor/Tags/TagsModalContent.css create mode 100644 frontend/src/Series/Editor/Tags/TagsModalContent.js create mode 100644 frontend/src/Series/Editor/Tags/TagsModalContentConnector.js create mode 100644 frontend/src/Series/History/SeriesHistoryModal.js create mode 100644 frontend/src/Series/History/SeriesHistoryModalContent.js create mode 100644 frontend/src/Series/History/SeriesHistoryModalContentConnector.js create mode 100644 frontend/src/Series/History/SeriesHistoryRow.css create mode 100644 frontend/src/Series/History/SeriesHistoryRow.js create mode 100644 frontend/src/Series/History/SeriesHistoryRowConnector.js create mode 100644 frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js create mode 100644 frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js create mode 100644 frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js create mode 100644 frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js create mode 100644 frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js create mode 100644 frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverview.css create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverview.js create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviews.css create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviews.js create mode 100644 frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js create mode 100644 frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js create mode 100644 frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js create mode 100644 frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPoster.css create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPoster.js create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPosters.css create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPosters.js create mode 100644 frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js create mode 100644 frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css create mode 100644 frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js create mode 100644 frontend/src/Series/Index/SeriesIndex.css create mode 100644 frontend/src/Series/Index/SeriesIndex.js create mode 100644 frontend/src/Series/Index/SeriesIndexConnector.js create mode 100644 frontend/src/Series/Index/SeriesIndexFilterModalConnector.js create mode 100644 frontend/src/Series/Index/SeriesIndexFooter.css create mode 100644 frontend/src/Series/Index/SeriesIndexFooter.js create mode 100644 frontend/src/Series/Index/SeriesIndexItemConnector.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexActionsCell.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexHeader.css create mode 100644 frontend/src/Series/Index/Table/SeriesIndexHeader.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexRow.css create mode 100644 frontend/src/Series/Index/Table/SeriesIndexRow.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexTable.css create mode 100644 frontend/src/Series/Index/Table/SeriesIndexTable.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexTableConnector.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexTableOptions.js create mode 100644 frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js create mode 100644 frontend/src/Series/Index/Table/SeriesStatusCell.css create mode 100644 frontend/src/Series/Index/Table/SeriesStatusCell.js create mode 100644 frontend/src/Series/MoveSeries/MoveSeriesModal.css create mode 100644 frontend/src/Series/MoveSeries/MoveSeriesModal.js create mode 100644 frontend/src/Series/NoSeries.css create mode 100644 frontend/src/Series/NoSeries.js create mode 100644 frontend/src/Series/Search/SeasonInteractiveSearchModal.js create mode 100644 frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js create mode 100644 frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js create mode 100644 frontend/src/Series/SeriesBanner.js create mode 100644 frontend/src/Series/SeriesImage.js create mode 100644 frontend/src/Series/SeriesPoster.js create mode 100644 frontend/src/Series/SeriesTitleLink.js create mode 100644 frontend/src/Settings/AdvancedSettingsButton.css create mode 100644 frontend/src/Settings/AdvancedSettingsButton.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClientSettings.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js create mode 100644 frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js create mode 100644 frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js create mode 100644 frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js create mode 100644 frontend/src/Settings/General/AnalyticSettings.js create mode 100644 frontend/src/Settings/General/BackupSettings.js create mode 100644 frontend/src/Settings/General/GeneralSettings.js create mode 100644 frontend/src/Settings/General/GeneralSettingsConnector.js create mode 100644 frontend/src/Settings/General/HostSettings.js create mode 100644 frontend/src/Settings/General/LoggingSettings.js create mode 100644 frontend/src/Settings/General/ProxySettings.js create mode 100644 frontend/src/Settings/General/SecuritySettings.js create mode 100644 frontend/src/Settings/General/UpdateSettings.js create mode 100644 frontend/src/Settings/Indexers/IndexerSettings.js create mode 100644 frontend/src/Settings/Indexers/IndexerSettingsConnector.js create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js create mode 100644 frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js create mode 100644 frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js create mode 100644 frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js create mode 100644 frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css create mode 100644 frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js create mode 100644 frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js create mode 100644 frontend/src/Settings/Indexers/Indexers/Indexer.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Indexer.js create mode 100644 frontend/src/Settings/Indexers/Indexers/Indexers.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Indexers.js create mode 100644 frontend/src/Settings/Indexers/Indexers/IndexersConnector.js create mode 100644 frontend/src/Settings/Indexers/Options/IndexerOptions.js create mode 100644 frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js create mode 100644 frontend/src/Settings/MediaManagement/MediaManagement.js create mode 100644 frontend/src/Settings/MediaManagement/MediaManagementConnector.js create mode 100644 frontend/src/Settings/MediaManagement/Naming/Naming.css create mode 100644 frontend/src/Settings/MediaManagement/Naming/Naming.js create mode 100644 frontend/src/Settings/MediaManagement/Naming/NamingConnector.js create mode 100644 frontend/src/Settings/MediaManagement/Naming/NamingModal.css create mode 100644 frontend/src/Settings/MediaManagement/Naming/NamingModal.js create mode 100644 frontend/src/Settings/MediaManagement/Naming/NamingOption.css create mode 100644 frontend/src/Settings/MediaManagement/Naming/NamingOption.js create mode 100644 frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js create mode 100644 frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js create mode 100644 frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js create mode 100644 frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js create mode 100644 frontend/src/Settings/Metadata/Metadata/Metadata.css create mode 100644 frontend/src/Settings/Metadata/Metadata/Metadata.js create mode 100644 frontend/src/Settings/Metadata/Metadata/Metadatas.css create mode 100644 frontend/src/Settings/Metadata/Metadata/Metadatas.js create mode 100644 frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js create mode 100644 frontend/src/Settings/Metadata/MetadataSettings.js create mode 100644 frontend/src/Settings/Notifications/NotificationSettings.js create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js create mode 100644 frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js create mode 100644 frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js create mode 100644 frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js create mode 100644 frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css create mode 100644 frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js create mode 100644 frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js create mode 100644 frontend/src/Settings/Notifications/Notifications/Notification.css create mode 100644 frontend/src/Settings/Notifications/Notifications/Notification.js create mode 100644 frontend/src/Settings/Notifications/Notifications/Notifications.css create mode 100644 frontend/src/Settings/Notifications/Notifications/Notifications.js create mode 100644 frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js create mode 100644 frontend/src/Settings/PendingChangesModal.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfile.css create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfile.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfiles.css create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfiles.js create mode 100644 frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js create mode 100644 frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js create mode 100644 frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css create mode 100644 frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfile.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfile.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItem.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItem.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItems.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileItems.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfiles.css create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfiles.js create mode 100644 frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js create mode 100644 frontend/src/Settings/Profiles/Profiles.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfile.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfile.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItem.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItem.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItems.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileItems.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfiles.css create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfiles.js create mode 100644 frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js create mode 100644 frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js create mode 100644 frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js create mode 100644 frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css create mode 100644 frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfile.css create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfile.js create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfiles.css create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfiles.js create mode 100644 frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinition.css create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinition.js create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinitions.css create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinitions.js create mode 100644 frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js create mode 100644 frontend/src/Settings/Quality/Quality.js create mode 100644 frontend/src/Settings/Settings.css create mode 100644 frontend/src/Settings/Settings.js create mode 100644 frontend/src/Settings/SettingsToolbar.js create mode 100644 frontend/src/Settings/SettingsToolbarConnector.js create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsModal.js create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsModalContent.css create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsModalContent.js create mode 100644 frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js create mode 100644 frontend/src/Settings/Tags/Tag.css create mode 100644 frontend/src/Settings/Tags/Tag.js create mode 100644 frontend/src/Settings/Tags/TagConnector.js create mode 100644 frontend/src/Settings/Tags/TagSettings.js create mode 100644 frontend/src/Settings/Tags/Tags.css create mode 100644 frontend/src/Settings/Tags/Tags.js create mode 100644 frontend/src/Settings/Tags/TagsConnector.js create mode 100644 frontend/src/Settings/UI/UISettings.js create mode 100644 frontend/src/Settings/UI/UISettingsConnector.js create mode 100644 frontend/src/Shared/piwikCheck.js create mode 100644 frontend/src/Shims/jquery.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js create mode 100644 frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createFetchHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createHandleActions.js create mode 100644 frontend/src/Store/Actions/Creators/createRemoveItemHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createSaveHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createSaveProviderHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js create mode 100644 frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createTestProviderHandler.js create mode 100644 frontend/src/Store/Actions/Settings/delayProfiles.js create mode 100644 frontend/src/Store/Actions/Settings/downloadClientOptions.js create mode 100644 frontend/src/Store/Actions/Settings/downloadClients.js create mode 100644 frontend/src/Store/Actions/Settings/general.js create mode 100644 frontend/src/Store/Actions/Settings/indexerOptions.js create mode 100644 frontend/src/Store/Actions/Settings/indexers.js create mode 100644 frontend/src/Store/Actions/Settings/languageProfiles.js create mode 100644 frontend/src/Store/Actions/Settings/mediaManagement.js create mode 100644 frontend/src/Store/Actions/Settings/metadata.js create mode 100644 frontend/src/Store/Actions/Settings/naming.js create mode 100644 frontend/src/Store/Actions/Settings/namingExamples.js create mode 100644 frontend/src/Store/Actions/Settings/notifications.js create mode 100644 frontend/src/Store/Actions/Settings/qualityDefinitions.js create mode 100644 frontend/src/Store/Actions/Settings/qualityProfiles.js create mode 100644 frontend/src/Store/Actions/Settings/releaseProfiles.js create mode 100644 frontend/src/Store/Actions/Settings/remotePathMappings.js create mode 100644 frontend/src/Store/Actions/Settings/ui.js create mode 100644 frontend/src/Store/Actions/actionTypes.js create mode 100644 frontend/src/Store/Actions/addSeriesActions.js create mode 100644 frontend/src/Store/Actions/appActions.js create mode 100644 frontend/src/Store/Actions/baseActions.js create mode 100644 frontend/src/Store/Actions/blacklistActions.js create mode 100644 frontend/src/Store/Actions/calendarActions.js create mode 100644 frontend/src/Store/Actions/captchaActions.js create mode 100644 frontend/src/Store/Actions/commandActions.js create mode 100644 frontend/src/Store/Actions/customFilterActions.js create mode 100644 frontend/src/Store/Actions/deviceActions.js create mode 100644 frontend/src/Store/Actions/episodeActions.js create mode 100644 frontend/src/Store/Actions/episodeFileActions.js create mode 100644 frontend/src/Store/Actions/episodeHistoryActions.js create mode 100644 frontend/src/Store/Actions/historyActions.js create mode 100644 frontend/src/Store/Actions/importSeriesActions.js create mode 100644 frontend/src/Store/Actions/index.js create mode 100644 frontend/src/Store/Actions/interactiveImportActions.js create mode 100644 frontend/src/Store/Actions/oAuthActions.js create mode 100644 frontend/src/Store/Actions/organizePreviewActions.js create mode 100644 frontend/src/Store/Actions/pathActions.js create mode 100644 frontend/src/Store/Actions/queueActions.js create mode 100644 frontend/src/Store/Actions/reducers.js create mode 100644 frontend/src/Store/Actions/releaseActions.js create mode 100644 frontend/src/Store/Actions/rootFolderActions.js create mode 100644 frontend/src/Store/Actions/seasonPassActions.js create mode 100644 frontend/src/Store/Actions/seriesActions.js create mode 100644 frontend/src/Store/Actions/seriesEditorActions.js create mode 100644 frontend/src/Store/Actions/seriesHistoryActions.js create mode 100644 frontend/src/Store/Actions/seriesIndexActions.js create mode 100644 frontend/src/Store/Actions/settingsActions.js create mode 100644 frontend/src/Store/Actions/systemActions.js create mode 100644 frontend/src/Store/Actions/tagActions.js create mode 100644 frontend/src/Store/Actions/wantedActions.js create mode 100644 frontend/src/Store/Middleware/createPersistState.js create mode 100644 frontend/src/Store/Middleware/createSentryMiddleware.js create mode 100644 frontend/src/Store/Middleware/middlewares.js create mode 100644 frontend/src/Store/Migrators/migrate.js create mode 100644 frontend/src/Store/Migrators/migrateAddSeriesDefaults.js create mode 100644 frontend/src/Store/Selectors/createAllSeriesSelector.js create mode 100644 frontend/src/Store/Selectors/createClientSideCollectionSelector.js create mode 100644 frontend/src/Store/Selectors/createCommandExecutingSelector.js create mode 100644 frontend/src/Store/Selectors/createCommandSelector.js create mode 100644 frontend/src/Store/Selectors/createCommandsSelector.js create mode 100644 frontend/src/Store/Selectors/createDimensionsSelector.js create mode 100644 frontend/src/Store/Selectors/createEpisodeFileSelector.js create mode 100644 frontend/src/Store/Selectors/createEpisodeSelector.js create mode 100644 frontend/src/Store/Selectors/createExistingSeriesSelector.js create mode 100644 frontend/src/Store/Selectors/createImportSeriesItemSelector.js create mode 100644 frontend/src/Store/Selectors/createLanguageProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createProfileInUseSelector.js create mode 100644 frontend/src/Store/Selectors/createProviderSettingsSelector.js create mode 100644 frontend/src/Store/Selectors/createQualityProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createQueueItemSelector.js create mode 100644 frontend/src/Store/Selectors/createSeriesCountSelector.js create mode 100644 frontend/src/Store/Selectors/createSeriesSelector.js create mode 100644 frontend/src/Store/Selectors/createSettingsSectionSelector.js create mode 100644 frontend/src/Store/Selectors/createSystemStatusSelector.js create mode 100644 frontend/src/Store/Selectors/createTagDetailsSelector.js create mode 100644 frontend/src/Store/Selectors/createTagsSelector.js create mode 100644 frontend/src/Store/Selectors/createUISettingsSelector.js create mode 100644 frontend/src/Store/Selectors/selectSettings.js create mode 100644 frontend/src/Store/createAppStore.js create mode 100644 frontend/src/Store/scrollPositions.js create mode 100644 frontend/src/Store/thunks.js create mode 100644 frontend/src/Styles/Mixins/cover.css create mode 100644 frontend/src/Styles/Mixins/linkOverlay.css create mode 100644 frontend/src/Styles/Mixins/scroller.css create mode 100644 frontend/src/Styles/Mixins/truncate.css create mode 100644 frontend/src/Styles/Variables/animations.js create mode 100644 frontend/src/Styles/Variables/colors.js create mode 100644 frontend/src/Styles/Variables/dimensions.js create mode 100644 frontend/src/Styles/Variables/fonts.js create mode 100644 frontend/src/Styles/globals.css create mode 100644 frontend/src/Styles/scaffolding.css create mode 100644 frontend/src/System/Backup/BackupRow.css create mode 100644 frontend/src/System/Backup/BackupRow.js create mode 100644 frontend/src/System/Backup/Backups.js create mode 100644 frontend/src/System/Backup/BackupsConnector.js create mode 100644 frontend/src/System/Backup/RestoreBackupModal.js create mode 100644 frontend/src/System/Backup/RestoreBackupModalConnector.js create mode 100644 frontend/src/System/Backup/RestoreBackupModalContent.css create mode 100644 frontend/src/System/Backup/RestoreBackupModalContent.js create mode 100644 frontend/src/System/Backup/RestoreBackupModalContentConnector.js create mode 100644 frontend/src/System/Events/LogsTable.js create mode 100644 frontend/src/System/Events/LogsTableConnector.js create mode 100644 frontend/src/System/Events/LogsTableDetailsModal.css create mode 100644 frontend/src/System/Events/LogsTableDetailsModal.js create mode 100644 frontend/src/System/Events/LogsTableRow.css create mode 100644 frontend/src/System/Events/LogsTableRow.js create mode 100644 frontend/src/System/Logs/Files/LogFiles.js create mode 100644 frontend/src/System/Logs/Files/LogFilesConnector.js create mode 100644 frontend/src/System/Logs/Files/LogFilesTableRow.css create mode 100644 frontend/src/System/Logs/Files/LogFilesTableRow.js create mode 100644 frontend/src/System/Logs/Logs.js create mode 100644 frontend/src/System/Logs/LogsNavMenu.js create mode 100644 frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js create mode 100644 frontend/src/System/Status/About/About.css create mode 100644 frontend/src/System/Status/About/About.js create mode 100644 frontend/src/System/Status/About/AboutConnector.js create mode 100644 frontend/src/System/Status/About/StartTime.js create mode 100644 frontend/src/System/Status/DiskSpace/DiskSpace.css create mode 100644 frontend/src/System/Status/DiskSpace/DiskSpace.js create mode 100644 frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js create mode 100644 frontend/src/System/Status/Health/Health.css create mode 100644 frontend/src/System/Status/Health/Health.js create mode 100644 frontend/src/System/Status/Health/HealthConnector.js create mode 100644 frontend/src/System/Status/Health/HealthStatusConnector.js create mode 100644 frontend/src/System/Status/MoreInfo/MoreInfo.js create mode 100644 frontend/src/System/Status/Status.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRow.css create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRow.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTasks.js create mode 100644 frontend/src/System/Tasks/Queued/QueuedTasksConnector.js create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTasks.js create mode 100644 frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js create mode 100644 frontend/src/System/Tasks/Tasks.js create mode 100644 frontend/src/System/Updates/UpdateChanges.css create mode 100644 frontend/src/System/Updates/UpdateChanges.js create mode 100644 frontend/src/System/Updates/Updates.css create mode 100644 frontend/src/System/Updates/Updates.js create mode 100644 frontend/src/System/Updates/UpdatesConnector.js create mode 100644 frontend/src/Utilities/Array/getIndexOfFirstCharacter.js create mode 100644 frontend/src/Utilities/Array/sortByName.js create mode 100644 frontend/src/Utilities/Command/findCommand.js create mode 100644 frontend/src/Utilities/Command/index.js create mode 100644 frontend/src/Utilities/Command/isCommandComplete.js create mode 100644 frontend/src/Utilities/Command/isCommandExecuting.js create mode 100644 frontend/src/Utilities/Command/isCommandFailed.js create mode 100644 frontend/src/Utilities/Command/isSameCommand.js create mode 100644 frontend/src/Utilities/Constants/keyCodes.js create mode 100644 frontend/src/Utilities/Date/dateFilterPredicate.js create mode 100644 frontend/src/Utilities/Date/formatDate.js create mode 100644 frontend/src/Utilities/Date/formatDateTime.js create mode 100644 frontend/src/Utilities/Date/formatTime.js create mode 100644 frontend/src/Utilities/Date/formatTimeSpan.js create mode 100644 frontend/src/Utilities/Date/getRelativeDate.js create mode 100644 frontend/src/Utilities/Date/isAfter.js create mode 100644 frontend/src/Utilities/Date/isBefore.js create mode 100644 frontend/src/Utilities/Date/isInNextWeek.js create mode 100644 frontend/src/Utilities/Date/isSameWeek.js create mode 100644 frontend/src/Utilities/Date/isToday.js create mode 100644 frontend/src/Utilities/Date/isTomorrow.js create mode 100644 frontend/src/Utilities/Date/isYesterday.js create mode 100644 frontend/src/Utilities/Episode/updateEpisodes.js create mode 100644 frontend/src/Utilities/Filter/findSelectedFilters.js create mode 100644 frontend/src/Utilities/Filter/getFilterValue.js create mode 100644 frontend/src/Utilities/Number/convertToBytes.js create mode 100644 frontend/src/Utilities/Number/formatAge.js create mode 100644 frontend/src/Utilities/Number/formatBytes.js create mode 100644 frontend/src/Utilities/Number/padNumber.js create mode 100644 frontend/src/Utilities/Object/getErrorMessage.js create mode 100644 frontend/src/Utilities/Object/hasDifferentItems.js create mode 100644 frontend/src/Utilities/Object/selectUniqueIds.js create mode 100644 frontend/src/Utilities/Quality/getQualities.js create mode 100644 frontend/src/Utilities/ResolutionUtility.js create mode 100644 frontend/src/Utilities/Series/getNewSeries.js create mode 100644 frontend/src/Utilities/Series/getProgressBarKind.js create mode 100644 frontend/src/Utilities/Series/monitorOptions.js create mode 100644 frontend/src/Utilities/State/getProviderState.js create mode 100644 frontend/src/Utilities/State/getSectionState.js create mode 100644 frontend/src/Utilities/State/selectProviderSchema.js create mode 100644 frontend/src/Utilities/State/updateSectionState.js create mode 100644 frontend/src/Utilities/String/combinePath.js create mode 100644 frontend/src/Utilities/String/generateUUIDv4.js create mode 100644 frontend/src/Utilities/String/isString.js create mode 100644 frontend/src/Utilities/String/parseUrl.js create mode 100644 frontend/src/Utilities/String/split.js create mode 100644 frontend/src/Utilities/String/titleCase.js create mode 100644 frontend/src/Utilities/Table/areAllSelected.js create mode 100644 frontend/src/Utilities/Table/getSelectedIds.js create mode 100644 frontend/src/Utilities/Table/getToggledRange.js create mode 100644 frontend/src/Utilities/Table/removeOldSelectedState.js create mode 100644 frontend/src/Utilities/Table/selectAll.js create mode 100644 frontend/src/Utilities/Table/toggleSelected.js create mode 100644 frontend/src/Utilities/createAjaxRequest.js create mode 100644 frontend/src/Utilities/getPathWithUrlBase.js create mode 100644 frontend/src/Utilities/getUniqueElementId.js create mode 100644 frontend/src/Utilities/isMobile.js create mode 100644 frontend/src/Utilities/pagePopulator.js create mode 100644 frontend/src/Utilities/pages.js create mode 100644 frontend/src/Utilities/requestAction.js create mode 100644 frontend/src/Utilities/sectionTypes.js create mode 100644 frontend/src/Utilities/serverSideCollectionHandlers.js create mode 100644 frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js create mode 100644 frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js create mode 100644 frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css create mode 100644 frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js create mode 100644 frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js create mode 100644 frontend/src/Wanted/Missing/Missing.js create mode 100644 frontend/src/Wanted/Missing/MissingConnector.js create mode 100644 frontend/src/Wanted/Missing/MissingRow.css create mode 100644 frontend/src/Wanted/Missing/MissingRow.js create mode 100644 frontend/src/Wanted/Missing/MissingRowConnector.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/index.html create mode 100644 frontend/src/index.js create mode 100644 frontend/src/jQuery/jquery.ajax.js create mode 100644 frontend/src/login.html create mode 100644 frontend/src/oauth.html create mode 100644 frontend/src/polyfills.js create mode 100644 frontend/src/preload.js create mode 100644 frontend/src/vendor.js delete mode 100644 npm-shrinkwrap.json create mode 100644 yarn.lock diff --git a/.esprintrc b/.esprintrc new file mode 100644 index 000000000..9330e00d1 --- /dev/null +++ b/.esprintrc @@ -0,0 +1,9 @@ +{ + "paths": [ + "frontend/src/**/*.js" + ], + "ignored": [ + "**/node_modules/**/*" + ], + "port": 5004 +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ad5884817 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-prefix="" diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000..fdd705c63 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/build.sh b/build.sh index 77526e0fb..cede3c535 100755 --- a/build.sh +++ b/build.sh @@ -90,16 +90,16 @@ Build() RunGulp() { - echo "##teamcity[progressStart 'npm install']" - npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links - echo "##teamcity[progressFinish 'npm install']" + ProgressStart 'yarn install' + yarn install + ProgressEnd 'yarn install' echo "##teamcity[progressStart 'Running gulp']" CheckExitCode npm run build echo "##teamcity[progressFinish 'Running gulp']" echo "##teamcity[progressStart 'Running gulp (phantom)']" - CheckExitCode npm run build -- --phantom --production + CheckExitCode yarn run build -- --production echo "##teamcity[progressFinish 'Running gulp (phantom)']" } diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json new file mode 100644 index 000000000..a82e49732 --- /dev/null +++ b/frontend/.csscomb.json @@ -0,0 +1,25 @@ +{ + "remove-empty-rulesets": true, + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": false, + "element-case": "lower", + "eof-newline": true, + "leading-zero": true, + "quotes": "double", + "sort-order-fallback": "abc", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-after-selector-delimiter": " ", + "space-before-selector-delimiter": "", + "space-before-closing-brace": "\n", + "strip-spaces": true, + "tab-size": true, + "unitless-zero": false +} diff --git a/frontend/.esformatter b/frontend/.esformatter new file mode 100644 index 000000000..600bb0751 --- /dev/null +++ b/frontend/.esformatter @@ -0,0 +1,335 @@ +{ + "indent": { + "value": " ", + "FunctionExpression": 1, + "ArrayExpression": 1, + "ObjectExpression": 1 + }, + "lineBreak": { + "value": "\n", + + "before": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": ">=1", + "ArrowFunctionExpressionOpeningBrace": 0, + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": ">=1", + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": 0, + "CatchClosingBrace": ">=1", + "CatchKeyword": 0, + "CatchOpeningBrace": 0, + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": 0, + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": ">=1", + "DoWhileStatementOpeningBrace": 0, + "ElseIfStatement": 0, + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": 0, + "ElseStatement": 0, + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": 0, + "EmptyStatement": -1, + "EndOfFile": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": 0, + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 0, + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": "<2", + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 0, + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace":0, + "IIFEClosingParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": 0, + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": -1, + "MethodDefinition": ">=1", + "ObjectExpressionClosingBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": "<=2", + "PropertyValue": 0, + "ReturnStatement": -1, + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": 0, + "ThisExpression": -1, + "ThrowStatement": ">=1", + "TryClosingBrace": ">=1", + "TryKeyword": -1, + "TryOpeningBrace": 0, + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": 0, + "VariableDeclarationWithoutInit": ">=1", + "VariableName": ">=1", + "VariableValue": 0, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": 0 + }, + + "after": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": -1, + "ArrowFunctionExpressionOpeningBrace": ">=1", + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": -1, + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": -1, + "CatchClosingBrace": ">=0", + "CatchKeyword": 0, + "CatchOpeningBrace": ">=1", + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": ">=1", + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": 0, + "DoWhileStatementOpeningBrace": ">=1", + "ElseIfStatement": ">=1", + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": ">=1", + "ElseStatement": ">=1", + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": ">=1", + "EmptyStatement": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": ">=1", + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": -1, + "ForInStatementExpressionOpening": "<2", + "ForInStatementOpeningBrace": ">=1", + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": -1, + "ForStatementExpressionOpening": "<2", + "ForStatementOpeningBrace": ">=1", + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": ">=1", + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": -1, + "FunctionExpressionOpeningBrace": 1, + "IIFEOpeningParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": ">=1", + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": 0, + "MethodDefinition": ">=1", + "ObjectExpressionOpeningBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": -1, + "PropertyName": 0, + "ReturnStatement": -1, + "SwitchCaseColon": ">=1", + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": ">=1", + "ThisExpression": 0, + "ThrowStatement": ">=1", + "TryClosingBrace": 0, + "TryKeyword": -1, + "TryOpeningBrace": ">=1", + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": ">=1", + "VariableValue": -1, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": ">=1" + } + }, + "whiteSpace": { + "value": " ", + "removeTrailing": 1, + "before": { + "ArgumentComma": 0, + "ArgumentList": 0, + "ArgumentListArrayExpression": 0, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 0, + "ArrayExpressionOpening": 1, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 1, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 0, + "ConditionalExpressionAlternate": 1, + "ConditionalExpressionConsequent": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementConditional": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionClosingParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 1, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 0, + "ForStatementExpressionOpening": 1, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 0, + "FunctionDeclarationClosingBrace": 1, + "FunctionDeclarationOpeningBrace": 1, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace": 1, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 1, + "IfStatementOpeningBrace": 1, + "LineComment": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionClosing": 0, + "ObjectExpressionClosingBrace": 1, + "ParameterComma": 0, + "ParameterList": 0, + "Property": 1, + "PropertyName": 1, + "PropertyValue": 1, + "SwitchDiscriminantClosing": 0, + "SwitchDiscriminantOpening": 1, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "VariableValue": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 0, + "WhileStatementConditionalOpening": 1, + "WhileStatementOpeningBrace": 1 + }, + "after": { + "ArgumentComma": 1, + "ArgumentList": 0, + "ArgumentListArrayExpression": 1, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 1, + "ArrayExpressionOpening": 0, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 0, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 1, + "ConditionalExpressionConsequent": 1, + "ConditionalExpressionTest": 1, + "DoWhileStatementBody": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionOpeningParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 1, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 1, + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 1, + "FunctionDeclarationClosingBrace": 0, + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpressionClosingBrace": 0, + "FunctionExpressionOpeningBrace": 0, + "FunctionName": 0, + "FunctionReservedWord": 0, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 0, + "IfStatementOpeningBrace": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionOpening": 0, + "ObjectExpressionClosingBrace": 0, + "ObjectExpressionOpeningBrace": 1, + "ParameterComma": 1, + "ParameterList": 0, + "PropertyName": 0, + "PropertyValue": 0, + "SwitchDiscriminantClosing": 1, + "SwitchDiscriminantOpening": 0, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 1, + "WhileStatementConditionalOpening": 0, + "WhileStatementOpeningBrace": 1 + } + } +} diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..d4b43f836 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +**/JsLibraries/** diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..31b1173ec --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,288 @@ +{ + "parser": "babel-eslint", + + "env": { + "browser": true, + "commonjs": true, + "node": true, + "es6": true + }, + + "globals": { + "expect": false, + "chai": false, + "sinon": false + }, + + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "impliedStrict": true + } + }, + + "plugins": [ + "filenames", + "react" + ], + + "rules": { + "filenames/match-exported": ["error"], + + # ECMAScript 6 + + "arrow-body-style": [0], + "arrow-parens": ["error", "always"], + "arrow-spacing": ["error", { "before": true, "after": true }], + "constructor-super": "error", + "generator-star-spacing": "off", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-new-symbol": "error", + "no-this-before-super": "error", + "no-useless-escape": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-var": "warn", + "object-shorthand": ["error", "properties"], + "prefer-arrow-callback": "error", + "prefer-const": "warn", + "prefer-reflect": "off", + "prefer-rest-params": "off", + "prefer-spread": "warn", + "prefer-template": "error", + "require-yield": "off", + "template-curly-spacing": ["error", "never"], + "yield-star-spacing": "off", + + # Possible Errors + + "comma-dangle": "error", + "no-cond-assign": "error", + "no-console": "off", + "no-constant-condition": "warn", + "no-control-regex": "error", + "no-debugger": "off", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-character-class": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": ["error", "functions"], + "no-extra-semi": "error", + "no-func-assign": "error", + "no-inner-declarations": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-negated-in-lhs": "error", + "no-obj-calls": "error", + "no-regex-spaces": "error", + "no-sparse-arrays": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "warn", + "no-unsafe-finally": "error", + "use-isnan": "error", + "valid-jsdoc": "off", + "valid-typeof": "error", + + # Best Practices + + "accessor-pairs": "off", + "array-callback-return": "warn", + "block-scoped-var": "warn", + "consistent-return": "off", + "curly": "error", + "default-case": "error", + "dot-location": ["error", "property"], + "dot-notation": "error", + "eqeqeq": ["error", "smart"], + "guard-for-in": "error", + "no-alert": "warn", + "no-caller": "error", + "no-case-declarations": "error", + "no-div-regex": "error", + "no-else-return": "error", + "no-empty-function": ["error", {"allow": ["arrowFunctions"]}], + "no-empty-pattern": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": ["error", { + "boolean": false, + "number": true, + "string": true, + "allow": [/* "!!", "~", "*", "+" */] + }], + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-invalid-this": "off", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-loop-func": "error", + "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }], + "no-multi-spaces": "error", + "no-multi-str": "error", + "no-native-reassign": ["error", {"exceptions": ["console"]}], + "no-new": "off", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "off", + "no-process-env": "off", + "no-proto": "error", + "no-redeclare": "error", + "no-return-assign": "warn", + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-useless-call": "error", + "no-useless-concat": "error", + "no-void": "error", + "no-warning-comments": "off", + "no-with": "error", + "radix": ["error", "as-needed"], + "vars-on-top": "off", + "wrap-iife": ["error", "inside"], + "yoda": "error", + + # Strict Mode + + "strict": ["error", "never"], + + # Variables + + "init-declarations": ["error", "always"], + "no-catch-shadow": "error", + "no-delete-var": "error", + "no-label-var": "error", + "no-restricted-globals": "off", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-undef": "error", + "no-undef-init": "off", + "no-undefined": "off", + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], + "no-use-before-define": "error", + + # Node.js and CommonJS + + "callback-return": "warn", + "global-require": "error", + "handle-callback-err": "warn", + "no-mixed-requires": "error", + "no-new-require": "error", + "no-path-concat": "error", + "no-process-exit": "error", + + # Stylistic Issues + + "array-bracket-spacing": ["error", "never"], + "block-spacing": ["error", "always"], + "brace-style": ["error", "1tbs", { "allowSingleLine": false }], + "camelcase": "off", + "comma-spacing": ["error", {"before": false, "after": true}], + "comma-style": ["error", "last"], + "computed-property-spacing": ["error", "never"], + "consistent-this": ["error", "self"], + "eol-last": "error", + "func-names": "off", + "func-style": ["error", "declaration"], + "indent": ["error", 2, {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": ["error", { "before": true, "after": true}], + "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], + "max-depth": ["error", {"maximum": 5}], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": "off", + "max-statements-per-line": ["error", { "max": 1 }], + "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}], + "new-parens": "error", + "newline-after-var": "off", + "newline-before-return": "off", + "newline-per-chained-call": "off", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-continue": "error", + "no-inline-comments": "off", + "no-lonely-if": "warn", + "no-mixed-spaces-and-tabs": "error", + "no-multiple-empty-lines": ["error", { "max": 1 }], + "no-negated-condition": "warn", + "no-nested-ternary": "error", + "no-new-object": "error", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-spaced-func": "error", + "no-ternary": "off", + "no-trailing-spaces": "error", + "no-underscore-dangle": ["error", { "allowAfterThis": true }], + "no-unneeded-ternary": "error", + "no-whitespace-before-property": "error", + "object-curly-spacing": ["error", "always"], + "one-var": ["error", "never"], + "one-var-declaration-per-line": ["error", "always"], + "operator-assignment": ["off", "never"], + "operator-linebreak": ["error", "after"], + "quote-props": ["error", "as-needed"], + "quotes": ["error", "single"], + "require-jsdoc": "off", + "semi": "error", + "semi-spacing": ["error", { "before": false, "after": true }], + "sort-vars": "off", + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "never"], + "space-in-parens": "off", + "space-infix-ops": "off", + "space-unary-ops": "off", + "spaced-comment": "error", + "wrap-regex": "error", + + # React + + "react/jsx-boolean-value": [2, "always"], + "react/jsx-uses-vars": 2, + "react/jsx-closing-bracket-location": 2, + "react/jsx-tag-spacing": ["error"], + "react/jsx-curly-spacing": [2, "never"], + "react/jsx-equals-spacing": [2, "never"], + "react/jsx-indent-props": [2, 2], + "react/jsx-indent": [2, 2], + "react/jsx-key": 2, + "react/jsx-no-bind": [2, { "allowArrowFunctions": true }], + "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }], + "react/jsx-max-props-per-line": [2, { "maximum": 2 }], + "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }], + "react/jsx-no-undef": 2, + "react/jsx-pascal-case": 2, + "react/jsx-uses-react": 2, + // Explicitly disabled in case we want to enable them again + "react/no-did-mount-set-state": 0, + "react/no-did-update-set-state": 0, + "react/no-direct-mutation-state": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], + "react/no-unknown-property": 2, + "react/prefer-es6-class": 2, + "react/prop-types": 2, + "react/react-in-jsx-scope": 2, + "react/self-closing-comp": 2, + "react/sort-comp": 2, + "react/jsx-wrap-multilines": 2 + } +} diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc new file mode 100644 index 000000000..50aa6aa29 --- /dev/null +++ b/frontend/.jsbeautifyrc @@ -0,0 +1,12 @@ +{ + "js": { + "indent_size": 2, + "indent_char": " ", + "indent_level": 2, + "indent_with_tabs": false, + "preserve_newlines": true, + "brace_style": "collapse", + "max_preserve_newlines": 2, + "jslint_happy": true + } +} \ No newline at end of file diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc new file mode 100644 index 000000000..5587e5d4d --- /dev/null +++ b/frontend/.stylelintrc @@ -0,0 +1,396 @@ +{ +"plugins": [ + "stylelint-order" +], +"ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" +], +"rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": [ + "inside-block" + ] + } + ], + "at-rule-name-case": "lower", + "at-rule-name-newline-after": "always-multi-line", + "at-rule-name-space-after": "always", + "at-rule-no-unknown": [ + true, + { + "ignoreAtRules": [ + "/^add\\-mixin$/", + "/^define\\-mixin$/" + ] + } + ], + "at-rule-no-vendor-prefix": true, + "at-rule-semicolon-newline-after": "always", + "at-rule-semicolon-space-before": "never", + "block-closing-brace-empty-line-before": "never", + "block-closing-brace-newline-after": "always", + "block-closing-brace-newline-before": "always", + "block-closing-brace-space-after": "always-single-line", + "block-closing-brace-space-before": "always-single-line", + "block-no-empty": true, + "block-opening-brace-newline-after": "always", + "block-opening-brace-newline-before": "never-single-line", + "block-opening-brace-space-after": "always-single-line", + "block-opening-brace-space-before": "always", + "color-hex-case": "lower", + "color-hex-length": "short", + "color-named": "never", + "color-no-invalid-hex": true, + "comment-whitespace-inside": "always", + "declaration-bang-space-after": "never", + "declaration-bang-space-before": "always", + "declaration-block-no-duplicate-properties": [ + true, + { + "ignoreProperties": [ + "composes" + ] + } + ], + "declaration-block-no-redundant-longhand-properties": true, + "declaration-block-no-shorthand-property-overrides": true, + "declaration-block-semicolon-newline-after": "always", + "declaration-block-semicolon-newline-before": "never-multi-line", + "declaration-block-semicolon-space-before": "never", + "declaration-block-single-line-max-declarations": 1, + "declaration-block-trailing-semicolon": "always", + "declaration-colon-space-after": "always", + "declaration-colon-space-before": "never", + "font-family-name-quotes": "always-unless-keyword", + "function-calc-no-unspaced-operator": true, + "function-comma-newline-after": "never-multi-line", + "function-comma-newline-before": "never-multi-line", + "function-comma-space-after": "always", + "function-comma-space-before": "never", + "function-linear-gradient-no-nonstandard-direction": true, + "function-name-case": "lower", + "function-parentheses-newline-inside": "never-multi-line", + "function-parentheses-space-inside": "never", + "function-url-quotes": "always", + "function-url-scheme-blacklist": [ + "data" + ], + "function-whitespace-after": "always", + "indentation": 2, + "keyframe-declaration-no-important": true, + "length-zero-no-unit": true, + "max-empty-lines": 1, + "max-line-length": [ + 100, + { + "ignore": [ + "non-comments" + ] + } + ], + "max-nesting-depth": 2, + "media-feature-colon-space-after": "always", + "media-feature-colon-space-before": "never", + "media-feature-name-case": "lower", + "media-feature-name-no-vendor-prefix": true, + "media-feature-range-operator-space-after": "always", + "media-feature-range-operator-space-before": "always", + "no-empty-source": true, + "no-eol-whitespace": true, + "no-extra-semicolons": true, + "no-invalid-double-slash-comments": true, + "no-missing-end-of-source-newline": true, + "number-leading-zero": "always", + "number-no-trailing-zeros": true, + "order/order": [ + "custom-properties", + "dollar-variables", + { + "hasBlock": false, + "name": "add-mixin", + "type": "at-rule" + }, + "declarations", + "rules", + "at-rules" + ], + "order/properties-order": [ + { + "emptyLineBefore": "always", + "properties": [ + "composes" + ] + }, + { + "emptyLineBefore": "always", + "properties": [ + "position", + "top", + "right", + "bottom", + "left", + "z-index", + "display", + "visibility", + "align-content", + "align-items", + "align-self", + "justify-content", + "flex", + "flex-direction", + "flex-order", + "flex-pack", + "flex-align", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-wrap", + "flex-flow", + "float", + "clear", + "overflow", + "overflow-x", + "overflow-y", + "-webkit-overflow-scrolling", + "clip", + "box-sizing", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "min-width", + "min-height", + "max-width", + "max-height", + "width", + "height", + "outline", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "border", + "border-spacing", + "border-collapse", + "border-width", + "border-style", + "border-color", + "border-top", + "border-top-width", + "border-top-style", + "border-top-color", + "border-right", + "border-right-width", + "border-right-style", + "border-right-color", + "border-bottom", + "border-bottom-width", + "border-bottom-style", + "border-bottom-color", + "border-left", + "border-left-width", + "border-left-style", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-image", + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + "border-top-image", + "border-right-image", + "border-bottom-image", + "border-left-image", + "border-corner-image", + "border-top-left-image", + "border-top-right-image", + "border-bottom-right-image", + "border-bottom-left-image", + "background", + "background-color", + "background-image", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-size", + "background-repeat", + "box-decoration-break", + "box-shadow", + "color", + "table-layout", + "caption-side", + "empty-cells", + "list-style", + "list-style-position", + "list-style-type", + "list-style-image", + "quotes", + "content", + "counter-increment", + "counter-reset", + "-ms-writing-mode", + "vertical-align", + "text-align", + "text-align-last", + "text-decoration", + "text-emphasis", + "text-emphasis-position", + "text-emphasis-style", + "text-emphasis-color", + "text-indent", + "text-justify", + "text-outline", + "text-transform", + "text-wrap", + "text-overflow", + "text-overflow-ellipsis", + "text-overflow-mode", + "text-shadow", + "white-space", + "word-spacing", + "word-wrap", + "word-break", + "tab-size", + "hyphens", + "letter-spacing", + "font", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "font-size", + "font-family", + "font-smoothing", + "-moz-osx-font-smoothing", + "-webkit-font-smoothing", + "src", + "line-height", + "opacity", + "filter", + "resize", + "cursor", + "appearance", + "nav-index", + "nav-up", + "nav-right", + "nav-down", + "nav-left", + "transition", + "transition-delay", + "transition-timing-function", + "transition-duration", + "transition-property", + "transform", + "transform-origin", + "transform-style", + "backface-visibility", + "animation", + "animation-name", + "animation-duration", + "animation-play-state", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "pointer-events", + "user-select", + "touch-action", + "-webkit-tap-highlight-color", + "unicode-bidi", + "direction", + "columns", + "column-span", + "column-width", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-width", + "column-rule-style", + "column-rule-color", + "break-before", + "break-inside", + "break-after", + "page-break-before", + "page-break-inside", + "page-break-after", + "orphans", + "widows", + "zoom", + "max-zoom", + "min-zoom", + "user-zoom", + "orientation" + ] + } + ], + "property-case": "lower", + "property-no-vendor-prefix": true, + "rule-empty-line-before": [ + "always", + { + "except": [ + "first-nested" + ], + "ignore": [ + "after-comment" + ] + } + ], + "selector-attribute-brackets-space-inside": "never", + "selector-attribute-operator-space-after": "never", + "selector-attribute-operator-space-before": "never", + "selector-attribute-quotes": "never", + "selector-class-pattern": "^[A-Za-z0-9]+$", + "selector-combinator-space-after": "always", + "selector-combinator-space-before": "always", + "selector-descendant-combinator-no-non-space": true, + "selector-list-comma-newline-after": "always", + "selector-list-comma-newline-before": "never-multi-line", + "selector-list-comma-space-before": "never", + "selector-max-attribute": 0, + "selector-max-class": 3, + "selector-max-compound-selectors": 3, + "selector-max-empty-lines": 0, + "selector-max-id": 0, + "selector-max-universal": 0, + "selector-pseudo-class-case": "lower", + "selector-pseudo-class-parentheses-space-inside": "never", + "selector-pseudo-element-case": "lower", + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-type-case": "lower", + "selector-type-no-unknown": true, + "shorthand-property-no-redundant-values": true, + "string-no-newline": true, + "string-quotes": "single", + "time-min-milliseconds": 100, + "unit-case": "lower", + "unit-no-unknown": true, + "value-list-comma-newline-after": "never-multi-line", + "value-list-comma-newline-before": "never-multi-line", + "value-list-comma-space-after": "always", + "value-list-comma-space-before": "never", + "value-list-max-empty-lines": 0, + "value-no-vendor-prefix": true + } +} diff --git a/frontend/.tern-project b/frontend/.tern-project new file mode 100644 index 000000000..aa9d76407 --- /dev/null +++ b/frontend/.tern-project @@ -0,0 +1,7 @@ +{ + "ecmaVersion": 6, + "libs": [ + "browser", + "jquery" + ] +} diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js new file mode 100644 index 000000000..cfeb5d138 --- /dev/null +++ b/frontend/gulp/build.js @@ -0,0 +1,15 @@ +const gulp = require('gulp'); +const runSequence = require('run-sequence'); + +require('./clean'); +require('./copy'); + +gulp.task('build', () => { + return runSequence('clean', [ + 'webpack', + 'copyHtml', + 'copyFonts', + 'copyImages', + 'copyJs' + ]); +}); diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js new file mode 100644 index 000000000..ac2e4026f --- /dev/null +++ b/frontend/gulp/clean.js @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..5b48eb755 --- /dev/null +++ b/frontend/gulp/copy.js @@ -0,0 +1,45 @@ +var path = require('path'); +var gulp = require('gulp'); +var print = require('gulp-print').default; +var cache = require('gulp-cached'); +var livereload = require('gulp-livereload'); +var paths = require('./helpers/paths.js'); + +gulp.task('copyJs', () => { + return gulp.src( + [ + path.join(paths.src.root, 'polyfills.js') + ]) + .pipe(cache('copyJs')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyHtml', () => { + return gulp.src(paths.src.html) + .pipe(cache('copyHtml')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyFonts', () => { + return gulp.src( + path.join(paths.src.fonts, '**', '*.*') + ) + .pipe(cache('copyFonts')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.fonts)) + .pipe(livereload()); +}); + +gulp.task('copyImages', () => { + return gulp.src( + path.join(paths.src.images, '**', '*.*') + ) + .pipe(cache('copyImages')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.images)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js new file mode 100644 index 000000000..821935a31 --- /dev/null +++ b/frontend/gulp/gulpFile.js @@ -0,0 +1,10 @@ +require('./build.js'); +require('./clean.js'); +require('./copy.js'); +require('./handlebars.js'); +require('./imageMin.js'); +require('./less.js'); +require('./start.js'); +require('./stripBom.js'); +require('./watch.js'); +require('./webpack.js'); diff --git a/frontend/gulp/handlebars.js b/frontend/gulp/handlebars.js new file mode 100644 index 000000000..679a0b7f6 --- /dev/null +++ b/frontend/gulp/handlebars.js @@ -0,0 +1,70 @@ +var gulp = require('gulp'); +var handlebars = require('gulp-handlebars'); +var declare = require('gulp-declare'); +var concat = require('gulp-concat'); +var wrap = require('gulp-wrap'); +var livereload = require('gulp-livereload'); +var path = require('path'); +var streamqueue = require('streamqueue'); +var stripbom = require('gulp-stripbom'); +var compliler = require('handlebars'); + +var errorHandler = require('./helpers/errorHandler'); +var paths = require('./helpers/paths.js'); + +console.log('Handlebars (gulp) Version: ', compliler.VERSION); +console.log('Handlebars (gulp) Compiler: ', compliler.COMPILER_REVISION); + +gulp.task('handlebars', () => { + var coreStream = gulp.src([ + paths.src.templates, + '!*/**/*Partial.*' + ]) + .pipe(stripbom({ + showLog: false + })) + .pipe(handlebars({ + handlebars: compliler + })) + .on('error', errorHandler) + .pipe(declare({ + namespace: 'T', + noRedeclare: true, + processName: (filePath) => { + filePath = path.relative(paths.src.root, filePath); + + return filePath.replace(/\\/g, '/') + .toLocaleLowerCase() + .replace('template', '') + .replace('.js', ''); + } + })); + + var partialStream = gulp.src([paths.src.partials]) + .pipe(stripbom({ + showLog: false + })) + .pipe(handlebars({ + handlebars: compliler + })) + .on('error', errorHandler) + .pipe(wrap('Handlebars.template(<%= contents %>)')) + .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, { + imports: { + processPartialName: function(fileName) { + return JSON.stringify( + path.basename(fileName, '.js') + ); + } + } + })); + + return streamqueue({ + objectMode: true + }, + partialStream, + coreStream + ).pipe(concat('templates.js')) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js new file mode 100644 index 000000000..f3e1c113b --- /dev/null +++ b/frontend/gulp/helpers/errorHandler.js @@ -0,0 +1,6 @@ +const gulpUtil = require('gulp-util'); + +module.exports = function errorHandler(error) { + gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`)); + this.emit('end'); +}; diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js new file mode 100644 index 000000000..6c7ce10b8 --- /dev/null +++ b/frontend/gulp/helpers/html-annotate-loader.js @@ -0,0 +1,15 @@ +const path = require('path'); +const rootPath = path.resolve(__dirname + '/../../src/'); +module.exports = function(source) { + if (this.cacheable) { + this.cacheable(); + } + + const resourcePath = this.resourcePath.replace(rootPath, ''); + const wrappedSource =` + + ${source} + `; + + return wrappedSource; +}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js new file mode 100644 index 000000000..88498d6f7 --- /dev/null +++ b/frontend/gulp/helpers/paths.js @@ -0,0 +1,26 @@ +const root = './frontend/src/'; + +const paths = { + src: { + root, + templates: root + '**/*.hbs', + html: root + '*.html', + partials: root + '**/*Partial.hbs', + scripts: root + '**/*.js', + less: [root + '**/*.less'], + content: root + 'Content/', + fonts: root + 'Content/Fonts/', + images: root + 'Content/Images/', + exclude: { + libs: `!${root}JsLibraries/**` + } + }, + dest: { + root: './_output/UI.Phantom/', + content: './_output/UI.Phantom/Content/', + fonts: './_output/UI.Phantom/Content/Fonts/', + images: './_output/UI.Phantom/Content/Images/' + } +}; + +module.exports = paths; diff --git a/frontend/gulp/helpers/phantom.js b/frontend/gulp/helpers/phantom.js new file mode 100644 index 000000000..1d696853a --- /dev/null +++ b/frontend/gulp/helpers/phantom.js @@ -0,0 +1,10 @@ +var phantom = false; +process.argv.forEach((val) => { + if (val === '--phantom') { + phantom = true; + } +}); + +console.log('Phantom:', phantom); + +module.exports = phantom; diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js new file mode 100644 index 000000000..8988c7ad4 --- /dev/null +++ b/frontend/gulp/imageMin.js @@ -0,0 +1,15 @@ +var gulp = require('gulp'); +var print = require('gulp-print').default; +var paths = require('./helpers/paths.js'); + +gulp.task('imageMin', () => { + var imagemin = require('gulp-imagemin'); + return gulp.src(paths.src.images) + .pipe(imagemin({ + progressive: false, + optimizationLevel: 4, + svgoPlugins: [{ removeViewBox: false }] + })) + .pipe(print()) + .pipe(gulp.dest(paths.src.content + 'Images/')); +}); diff --git a/frontend/gulp/less.js b/frontend/gulp/less.js new file mode 100644 index 000000000..797cb80cf --- /dev/null +++ b/frontend/gulp/less.js @@ -0,0 +1,46 @@ +const gulp = require('gulp'); + +const less = require('gulp-less'); +const postcss = require('gulp-postcss'); +const sourcemaps = require('gulp-sourcemaps'); +const autoprefixer = require('autoprefixer'); +const livereload = require('gulp-livereload'); +const path = require('path'); + +const print = require('gulp-print'); +const paths = require('./helpers/paths'); +const errorHandler = require('./helpers/errorHandler'); + +gulp.task('less', () => { + const src = [ + path.join(paths.src.content, 'Bootstrap', 'bootstrap.less'), + path.join(paths.src.content, 'Vendor', 'vendor.less'), + path.join(paths.src.content, 'sonarr.less') + ]; + + return gulp.src(src) + .pipe(print()) + .pipe(sourcemaps.init()) + .pipe(less({ + paths: [paths.src.root], + dumpLineNumbers: 'false', + compress: true, + yuicompress: true, + ieCompat: true, + strictImports: true + })) + .on('error', errorHandler) + .pipe(postcss([autoprefixer({ + browsers: ['last 2 versions'] + })])) + .on('error', errorHandler) + + // not providing a path will cause the source map + // to be embeded. which makes livereload much happier + // since it doesn't reload the whole page to load the map. + // this should be switched to sourcemaps.write('./') for production builds + .pipe(sourcemaps.write()) + .pipe(gulp.dest(paths.dest.content)) + .on('error', errorHandler) + .pipe(livereload()); +}); diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js new file mode 100644 index 000000000..eda9f0dba --- /dev/null +++ b/frontend/gulp/start.js @@ -0,0 +1,104 @@ +// will download and run sonarr (server) in a non-windows enviroment +// you can use this if you don't care about the server code and just want to work +// with the web code. + +var http = require('http'); +var gulp = require('gulp'); +var fs = require('fs'); +var targz = require('tar.gz'); +var del = require('del'); +var spawn = require('child_process').spawn; + +function download(url, dest, cb) { + console.log('Downloading ' + url + ' to ' + dest); + var file = fs.createWriteStream(dest); + http.get(url, function(response) { + response.pipe(file); + file.on('finish', function() { + console.log('Download completed'); + file.close(cb); + }); + }); +} + +function getLatest(cb) { + var branch = 'develop'; + process.argv.forEach(function(val) { + var branchMatch = /branch=([\S]*)/.exec(val); + if (branchMatch && branchMatch.length > 1) { + branch = branchMatch[1]; + } + }); + + var url = 'http://services.sonarr.tv/v1/update/' + branch + '?os=osx'; + + console.log('Checking for latest version:', url); + + http.get(url, function(res) { + var data = ''; + + res.on('data', function(chunk) { + data += chunk; + }); + + res.on('end', function() { + var updatePackage = JSON.parse(data).updatePackage; + console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate); + cb(updatePackage); + }); + }).on('error', function(e) { + console.log('problem with request: ' + e.message); + }); +} + +function extract(source, dest, cb) { + console.log('extracting download page to ' + dest); + new targz().extract(source, dest, function(err) { + if (err) { + console.log(err); + } + console.log('Update package extracted.'); + cb(); + }); +} + +gulp.task('getSonarr', function() { + try { + fs.mkdirSync('./_start/'); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + } + + getLatest(function(updatePackage) { + var packagePath = './_start/' + updatePackage.filename; + var dirName = './_start/' + updatePackage.version; + download(updatePackage.url, packagePath, function() { + extract(packagePath, dirName, function() { + // clean old binaries + console.log('Cleaning old binaries'); + del.sync(['./_output/*', '!./_output/UI/']); + console.log('copying binaries to target'); + gulp.src(dirName + '/NzbDrone/*.*') + .pipe(gulp.dest('./_output/')); + }); + }); + }); +}); + +gulp.task('startSonarr', function() { + var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']); + + ls.stdout.on('data', function(data) { + process.stdout.write(data); + }); + + ls.stderr.on('data', function(data) { + process.stdout.write(data); + }); + + ls.on('close', function(code) { + console.log('child process exited with code ' + code); + }); +}); diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js new file mode 100644 index 000000000..86a20bbe9 --- /dev/null +++ b/frontend/gulp/stripBom.js @@ -0,0 +1,21 @@ +const gulp = require('gulp'); +const paths = require('./helpers/paths.js'); +const stripbom = require('gulp-stripbom'); + +function stripBom(dest) { + gulp.src([paths.src.scripts, paths.src.exclude.libs]) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); + + gulp.src(paths.src.less) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); + + gulp.src(paths.src.templates) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); +} + +gulp.task('stripBom', () => { + stripBom(paths.src.root); +}); diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js new file mode 100644 index 000000000..ba1a47b66 --- /dev/null +++ b/frontend/gulp/watch.js @@ -0,0 +1,27 @@ +const gulp = require('gulp'); +const livereload = require('gulp-livereload'); +const watch = require('gulp-watch'); +const paths = require('./helpers/paths.js'); + +require('./copy.js'); +require('./webpack.js'); + +function watchTask(glob, task) { + const options = { + name: `watch: ${task}`, + verbose: true + }; + return watch(glob, options, () => { + gulp.start(task); + }); +} + +gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => { + livereload.listen({ start: true }); + + gulp.start('webpackWatch'); + + watchTask(paths.src.html, 'copyHtml'); + watchTask(`${paths.src.fonts}**/*.*`, 'copyFonts'); + watchTask(`${paths.src.images}**/*.*`, 'copyImages'); +}); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js index 2593b0de4..50aefcc1a 100644 --- a/frontend/gulp/webpack.js +++ b/frontend/gulp/webpack.js @@ -1,15 +1,11 @@ -const _ = require('lodash'); const gulp = require('gulp'); -const simpleVars = require('postcss-simple-vars'); -const nested = require('postcss-nested'); -const autoprefixer = require('autoprefixer'); const webpackStream = require('webpack-stream'); const livereload = require('gulp-livereload'); const path = require('path'); const webpack = require('webpack'); const errorHandler = require('./helpers/errorHandler'); -const reload = require('require-nocache')(module); const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); const uiFolder = 'UI'; const root = path.join(__dirname, '..', 'src'); @@ -18,66 +14,94 @@ const isProduction = process.argv.indexOf('--production') > -1; console.log('ROOT:', root); console.log('isProduction:', isProduction); -const cssVariables = [ +const cssVarsFiles = [ '../src/Styles/Variables/colors', '../src/Styles/Variables/dimensions', '../src/Styles/Variables/fonts', '../src/Styles/Variables/animations' ].map(require.resolve); +const extractCSSPlugin = new ExtractTextPlugin({ + filename: path.join('_output', uiFolder, 'Content', 'styles.css'), + allChunks: true, + disable: false, + ignoreOrder: true +}); + +const plugins = [ + extractCSSPlugin, + + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor' + }), + + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }) +]; + +if (isProduction) { + plugins.push(new UglifyJSPlugin({ + sourceMap: true, + uglifyOptions: { + mangle: false, + output: { + comments: false, + beautify: true + } + } + })); +} + const config = { devtool: '#source-map', + stats: { children: false }, + watchOptions: { ignored: /node_modules/ }, + entry: { preload: 'preload.js', vendor: 'vendor.js', index: 'index.js' }, + resolve: { - root: [ + modules: [ root, path.join(root, 'Shims'), - path.join(root, 'JsLibraries') - ] - }, - output: { - filename: path.join('_output', uiFolder, '[name].js'), - sourceMapFilename: path.join('_output', uiFolder, '[file].map') - }, - plugins: [ - new ExtractTextPlugin(path.join('_output', uiFolder, 'Content', 'styles.css'), { allChunks: true }), - new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor' - }), - new webpack.DefinePlugin({ - __DEV__: !isProduction, - 'process.env': { - NODE_ENV: isProduction ? JSON.stringify('production') : JSON.stringify('development') - } - }) - ], - resolveLoader: { - modulesDirectories: [ - 'node_modules', - 'gulp/webpack/' - ] - }, - eslint: { - formatter: function(results) { - return JSON.stringify(results); + 'node_modules' + ], + alias: { + jquery: 'jquery/src/jquery' } }, + + output: { + filename: path.join('_output', uiFolder, '[name].js'), + sourceMapFilename: '[file].map' + }, + + plugins, + + resolveLoader: { + modules: [ + 'node_modules', + 'frontend/gulp/webpack/' + ] + }, + module: { - loaders: [ + rules: [ { test: /\.js?$/, exclude: /(node_modules|JsLibraries)/, - loader: 'babel', + loader: 'babel-loader', query: { plugins: ['transform-class-properties'], presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'], @@ -93,51 +117,80 @@ const config = { { test: /\.css$/, exclude: /(node_modules|globals.css)/, - loader: ExtractTextPlugin.extract('style', 'css-loader?modules&importLoaders=1&sourceMap&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') + use: extractCSSPlugin.extract({ + fallback: 'style-loader', + use: [ + { + loader: 'css-variables-loader', + options: { + cssVarsFiles + } + }, + { + loader: 'css-loader', + options: { + modules: true, + importLoaders: 1, + localIdentName: '[name]-[local]-[hash:base64:5]', + sourceMap: true + } + }, + { + loader: 'postcss-loader', + options: { + config: { + ctx: { + cssVarsFiles + }, + path: 'frontend/postcss.config.js' + } + } + } + ] + }) }, // Global styles { test: /\.css$/, include: /(node_modules|globals.css)/, - loader: 'style!css-loader' + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] }, // Fonts { test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, - loader: 'url?limit=10240&mimetype=application/font-woff&emitFile=false&name=Content/Fonts/[name].[ext]' + 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])?$/, - loader: 'file-loader?emitFile=false&name=Content/Fonts/[name].[ext]' + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] } ] - }, - postcss: function(wpack) { - cssVariables.forEach(wpack.addDependency); - - return [ - simpleVars({ - variables: function() { - return cssVariables.reduce(function(obj, vars) { - return _.extend(obj, reload(vars)); - }, {}); - } - }), - nested(), - autoprefixer({ - browsers: [ - 'Chrome >= 30', - 'Firefox >= 30', - 'Safari >= 6', - 'Edge >= 12', - 'Explorer >= 10', - 'iOS >= 7', - 'Android >= 4.4' - ] - }) - ]; } }; diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js new file mode 100644 index 000000000..5683c98be --- /dev/null +++ b/frontend/gulp/webpack/css-variables-loader.js @@ -0,0 +1,11 @@ +const loaderUtils = require('loader-utils'); + +module.exports = function cssVariablesLoader(source) { + const options = loaderUtils.getOptions(this); + + options.cssVarsFiles.forEach((cssVarsFile) => { + this.addDependency(cssVarsFile); + }); + + return source; +}; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..f82554ba8 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,33 @@ +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-nested': {}, + autoprefixer: { + browsers: [ + 'Chrome >= 30', + 'Firefox >= 30', + 'Safari >= 6', + 'Edge >= 12', + 'Explorer >= 11', + 'iOS >= 7', + 'Android >= 4.4' + ] + } + } + }; + + return config; +}; diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json new file mode 100644 index 000000000..0fb2bf460 --- /dev/null +++ b/frontend/src/.vscode/settings.json @@ -0,0 +1,4 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true +} \ No newline at end of file diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js new file mode 100644 index 000000000..e3ecd2ff7 --- /dev/null +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BlacklistRowConnector from './BlacklistRowConnector'; + +class Blacklist extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + totalRecords, + isClearingBlacklistExecuting, + onClearBlacklistPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load blacklist
+ } + + { + isPopulated && !error && !items.length && +
+ No history blacklist +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +Blacklist.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + onClearBlacklistPress: PropTypes.func.isRequired +}; + +export default Blacklist; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js new file mode 100644 index 000000000..29cf3e08a --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as blacklistActions from 'Store/Actions/blacklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Blacklist from './Blacklist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.blacklist, + createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST), + (blacklist, isClearingBlacklistExecuting) => { + return { + isClearingBlacklistExecuting, + ...blacklist + }; + } + ); +} + +const mapDispatchToProps = { + ...blacklistActions, + executeCommand +}; + +class BlacklistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchBlacklist, + gotoBlacklistFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchBlacklist(); + } else { + gotoBlacklistFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) { + this.props.gotoBlacklistFirstPage(); + } + } + + componentWillUnmount() { + this.props.clearBlacklist(); + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchBlacklist(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoBlacklistFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoBlacklistPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoBlacklistNextPage(); + } + + onLastPagePress = () => { + this.props.gotoBlacklistLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoBlacklistPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setBlacklistSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + + onClearBlacklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +BlacklistConnector.propTypes = { + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchBlacklist: PropTypes.func.isRequired, + gotoBlacklistFirstPage: PropTypes.func.isRequired, + gotoBlacklistPreviousPage: PropTypes.func.isRequired, + gotoBlacklistNextPage: PropTypes.func.isRequired, + gotoBlacklistLastPage: PropTypes.func.isRequired, + gotoBlacklistPage: PropTypes.func.isRequired, + setBlacklistSort: PropTypes.func.isRequired, + setBlacklistTableOption: PropTypes.func.isRequired, + clearBlacklist: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector) +); diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js new file mode 100644 index 000000000..356512a9d --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class BlacklistDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + sourceTitle, + protocol, + indexer, + message, + onModalClose + } = this.props; + + return ( + + + + Details + + + + + + + + + { + !!message && + + } + + { + !!message && + + } + + + + + + + + + ); + } +} + +BlacklistDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default BlacklistDetailsModal; diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css new file mode 100644 index 000000000..b62d1e750 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.css @@ -0,0 +1,18 @@ +.language, +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js new file mode 100644 index 000000000..47859a80f --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import BlacklistDetailsModal from './BlacklistDetailsModal'; +import styles from './BlacklistRow.css'; + +class BlacklistRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + series, + sourceTitle, + language, + quality, + date, + protocol, + indexer, + message, + columns, + onRemovePress + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return ( + + {sourceTitle} + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +BlacklistRow.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + date: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default BlacklistRow; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js new file mode 100644 index 000000000..efba18fab --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import BlacklistRow from './BlacklistRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemovePress() { + dispatch(removeFromBlacklist({ id: props.id })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..03f8fd3ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js new file mode 100644 index 000000000..928b16bfa --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -0,0 +1,244 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './HistoryDetails.css'; + +function HistoryDetails(props) { + const { + eventType, + sourceTitle, + data, + shortDateFormat, + timeFormat + } = props; + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } + + if (eventType === 'downloadFailed') { + const { + message + } = data; + + return ( + + + + { + !!message && + + } + + ); + } + + if (eventType === 'downloadFolderImported') { + const { + droppedPath, + importedPath + } = data; + + return ( + + + + { + !!droppedPath && + + } + + { + !!importedPath && + + } + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { + reason + } = data; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = 'File was deleted by via UI'; + break; + case 'MissingFromDisk': + reasonMessage = 'Sonarr was unable to find the file on disk so it was removed'; + break; + case 'Upgrade': + reasonMessage = 'File was deleted to import an upgrade'; + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { + sourcePath, + sourceRelativePath, + path, + relativePath + } = data; + + return ( + + + + + + + + + + ); + } +} + +HistoryDetails.propTypes = { + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsAge.js b/frontend/src/Activity/History/Details/HistoryDetailsAge.js new file mode 100644 index 000000000..c702014ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsAge.js @@ -0,0 +1,21 @@ +var Handlebars = require('handlebars'); +var FormatHelpers = require('Shared/FormatHelpers'); + +Handlebars.registerHelper('historyAge', function() { + var age = this.age; + var unit = FormatHelpers.plural(Math.round(age), 'day'); + var ageHours = parseFloat(this.ageHours); + var ageMinutes = this.ageMinutes ? parseFloat(this.ageMinutes) : null; + + if (age < 2) { + age = ageHours.toFixed(1); + unit = FormatHelpers.plural(Math.round(ageHours), 'hour'); + } + + if (age < 2 && ageMinutes) { + age = parseFloat(ageMinutes).toFixed(1); + unit = FormatHelpers.plural(Math.round(ageMinutes), 'minute'); + } + + return new Handlebars.SafeString(`
Age (when grabbed):
${age} ${unit}
`); +}); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js new file mode 100644 index 000000000..0848c7905 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryDetails from './HistoryDetails'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css new file mode 100644 index 000000000..bdcb7f918 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css @@ -0,0 +1,5 @@ +.markAsFailedButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js new file mode 100644 index 000000000..2cf9294f6 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import HistoryDetails from './HistoryDetails'; +import styles from './HistoryDetailsModal.css'; + +function getHeaderTitle(eventType) { + switch (eventType) { + case 'grabbed': + return 'Grabbed'; + case 'downloadFailed': + return 'Download Failed'; + case 'downloadFolderImported': + return 'Episode Imported'; + case 'episodeFileDeleted': + return 'Episode File Deleted'; + case 'episodeFileRenamed': + return 'Episode File Renamed'; + default: + return 'Unknown'; + } +} + +function HistoryDetailsModal(props) { + const { + isOpen, + eventType, + sourceTitle, + data, + isMarkingAsFailed, + shortDateFormat, + timeFormat, + onMarkAsFailedPress, + onModalClose + } = props; + + return ( + + + + {getHeaderTitle(eventType)} + + + + + + + + { + eventType === 'grabbed' && + + Mark as Failed + + } + + + + + + ); +} + +HistoryDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +HistoryDetailsModal.defaultProps = { + isMarkingAsFailed: false +}; + +export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js new file mode 100644 index 000000000..75f504d22 --- /dev/null +++ b/frontend/src/Activity/History/History.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import HistoryRowConnector from './HistoryRowConnector'; + +class History extends Component { + + // + // Lifecycle + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) || + (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) + ) { + return false; + } + + return true; + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + onFilterSelect, + onFirstPagePress, + ...otherProps + } = this.props; + + const isFetchingAny = isFetching || isEpisodesFetching; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + + return ( + + + + + + + + + + + + + { + isFetchingAny && !isAllPopulated && + + } + + { + !isFetchingAny && hasError && +
Unable to load history
+ } + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length && +
+ No history found +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +History.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isEpisodesFetching: PropTypes.bool.isRequired, + isEpisodesPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired, + onFirstPagePress: PropTypes.func.isRequired +}; + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js new file mode 100644 index 000000000..35591f258 --- /dev/null +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import * as historyActions from 'Store/Actions/historyActions'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import History from './History'; + +function createMapStateToProps() { + return createSelector( + (state) => state.history, + (state) => state.episodes, + (history, episodes) => { + return { + isEpisodesFetching: episodes.isFetching, + isEpisodesPopulated: episodes.isPopulated, + episodesError: episodes.error, + ...history + }; + } + ); +} + +const mapDispatchToProps = { + ...historyActions, + fetchEpisodes, + clearEpisodes +}; + +class HistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchHistory, + gotoHistoryFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchHistory(); + } else { + gotoHistoryFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); + + if (episodeIds.length) { + this.props.fetchEpisodes({ episodeIds }); + } else { + this.props.clearEpisodes(); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearHistory(); + this.props.clearEpisodes(); + } + + // + // Control + + repopulate = () => { + this.props.fetchHistory(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoHistoryFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoHistoryPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoHistoryNextPage(); + } + + onLastPagePress = () => { + this.props.gotoHistoryLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoHistoryPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setHistorySort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setHistoryFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setHistoryTableOption(payload); + + if (payload.pageSize) { + this.props.gotoHistoryFirstPage(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HistoryConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchHistory: PropTypes.func.isRequired, + gotoHistoryFirstPage: PropTypes.func.isRequired, + gotoHistoryPreviousPage: PropTypes.func.isRequired, + gotoHistoryNextPage: PropTypes.func.isRequired, + gotoHistoryLastPage: PropTypes.func.isRequired, + gotoHistoryPage: PropTypes.func.isRequired, + setHistorySort: PropTypes.func.isRequired, + setHistoryFilter: PropTypes.func.isRequired, + setHistoryTableOption: PropTypes.func.isRequired, + clearHistory: PropTypes.func.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) +); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css new file mode 100644 index 000000000..fac97a6c7 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css @@ -0,0 +1,6 @@ +.cell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 35px; + text-align: center; +} diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js new file mode 100644 index 000000000..f013b3f55 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './HistoryEventTypeCell.css'; + +function getIconName(eventType) { + switch (eventType) { + case 'grabbed': + return icons.DOWNLOADING; + case 'seriesFolderImported': + return icons.DRIVE; + case 'downloadFolderImported': + return icons.DOWNLOADED; + case 'downloadFailed': + return icons.DOWNLOADING; + case 'episodeFileDeleted': + return icons.DELETE; + case 'episodeFileRenamed': + return icons.ORGANIZE; + default: + return icons.UNKNOWN; + } +} + +function getIconKind(eventType) { + switch (eventType) { + case 'downloadFailed': + return kinds.DANGER; + default: + return kinds.DEFAULT; + } +} + +function getTooltip(eventType, data) { + switch (eventType) { + case 'grabbed': + return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`; + case 'seriesFolderImported': + return 'Episode imported from series folder'; + case 'downloadFolderImported': + return 'Episode downloaded successfully and picked up from download client'; + case 'downloadFailed': + return 'Episode download failed'; + case 'episodeFileDeleted': + return 'Episode file deleted'; + case 'episodeFileRenamed': + return 'Episode file renamed'; + default: + return 'Unknown event'; + } +} + +function HistoryEventTypeCell({ eventType, data }) { + const iconName = getIconName(eventType); + const iconKind = getIconKind(eventType); + const tooltip = getTooltip(eventType, data); + + return ( + + + + ); +} + +HistoryEventTypeCell.propTypes = { + eventType: PropTypes.string.isRequired, + data: PropTypes.object +}; + +HistoryEventTypeCell.defaultProps = { + data: {} +}; + +export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css new file mode 100644 index 000000000..83586af58 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css @@ -0,0 +1,23 @@ +.downloadClient { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.releaseGroup { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 110px; +} + +.details { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js new file mode 100644 index 000000000..454913412 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.js @@ -0,0 +1,263 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import episodeEntities from 'Episode/episodeEntities'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import styles from './HistoryRow.css'; + +class HistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.setState({ isDetailsModalOpen: false }); + } + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeId, + series, + episode, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + isMarkingAsFailed, + columns, + shortDateFormat, + timeFormat, + onMarkAsFailedPress + } = this.props; + + if (!episode) { + return null; + } + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'downloadClient') { + return ( + + {data.downloadClient} + + ); + } + + if (name === 'indexer') { + return ( + + {data.indexer} + + ); + } + + if (name === 'releaseGroup') { + return ( + + {data.releaseGroup} + + ); + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +HistoryRow.propTypes = { + episodeId: PropTypes.number, + series: PropTypes.object.isRequired, + episode: PropTypes.object, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js new file mode 100644 index 000000000..271000193 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRowConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryRow from './HistoryRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createUISettingsSelector(), + (series, episode, uiSettings) => { + return { + series, + episode, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +class HistoryRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.props.fetchHistory(); + } + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.props.markAsFailed({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } + +} + +HistoryRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + fetchHistory: PropTypes.func.isRequired, + markAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css new file mode 100644 index 000000000..15e8e4fc6 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from 'Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from 'Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js new file mode 100644 index 000000000..9aadc828b --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.js @@ -0,0 +1,266 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; +import QueueRowConnector from './QueueRowConnector'; + +class Queue extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isPendingSelected: false, + isConfirmRemoveModalOpen: false + }; + } + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) || + (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) + ) { + return false; + } + + return true; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState({ selectedState: {} }); + return; + } + + const selectedIds = this.getSelectedIds(); + const isPendingSelected = _.some(this.props.items, (item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + }); + + if (isPendingSelected !== this.state.isPendingSelected) { + this.setState({ isPendingSelected }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onGrabSelectedPress = () => { + this.props.onGrabSelectedPress(this.getSelectedIds()); + } + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + } + + onRemoveSelectedConfirmed = (blacklist) => { + this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist); + this.setState({ isConfirmRemoveModalOpen: false }); + } + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + columns, + totalRecords, + isGrabbing, + isRemoving, + isCheckForFinishedDownloadExecuting, + onRefreshPress, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen, + isPendingSelected + } = this.state; + + const isRefreshing = isFetching || isEpisodesFetching || isCheckForFinishedDownloadExecuting; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + const selectedCount = this.getSelectedIds().length; + const disableSelectedActions = selectedCount === 0; + + return ( + + + + + + + + + + + + + + + { + isRefreshing && !isAllPopulated && + + } + + { + !isRefreshing && hasError && +
+ Failed to load Queue +
+ } + + { + isPopulated && !hasError && !items.length && +
+ Queue is empty +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+ + +
+ ); + } +} + +Queue.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isEpisodesFetching: PropTypes.bool.isRequired, + isEpisodesPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isGrabbing: PropTypes.bool.isRequired, + isRemoving: PropTypes.bool.isRequired, + isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onGrabSelectedPress: PropTypes.func.isRequired, + onRemoveSelectedPress: PropTypes.func.isRequired +}; + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js new file mode 100644 index 000000000..c598bbea5 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as queueActions from 'Store/Actions/queueActions'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import * as commandNames from 'Commands/commandNames'; +import Queue from './Queue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.episodes, + (state) => state.queue.options, + (state) => state.queue.paged, + createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), + (episodes, options, queue, isCheckForFinishedDownloadExecuting) => { + return { + isEpisodesFetching: episodes.isFetching, + isEpisodesPopulated: episodes.isPopulated, + episodesError: episodes.error, + isCheckForFinishedDownloadExecuting, + ...options, + ...queue + }; + } + ); +} + +const mapDispatchToProps = { + ...queueActions, + fetchEpisodes, + clearEpisodes, + executeCommand +}; + +class QueueConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchQueue, + gotoQueueFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); + + if (episodeIds.length) { + this.props.fetchEpisodes({ episodeIds }); + } else { + this.props.clearEpisodes(); + } + } + + if ( + this.props.includeUnknownSeriesItems !== + prevProps.includeUnknownSeriesItems + ) { + this.repopulate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearQueue(); + this.props.clearEpisodes(); + } + + // + // Control + + repopulate = () => { + this.props.fetchQueue(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoQueueFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoQueuePreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoQueueNextPage(); + } + + onLastPagePress = () => { + this.props.gotoQueueLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoQueuePage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setQueueSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setQueueTableOption(payload); + + if (payload.pageSize) { + this.props.gotoQueueFirstPage(); + } + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + }); + } + + onGrabSelectedPress = (ids) => { + this.props.grabQueueItems({ ids }); + } + + onRemoveSelectedPress = (ids, blacklist) => { + this.props.removeQueueItems({ ids, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchQueue: PropTypes.func.isRequired, + gotoQueueFirstPage: PropTypes.func.isRequired, + gotoQueuePreviousPage: PropTypes.func.isRequired, + gotoQueueNextPage: PropTypes.func.isRequired, + gotoQueueLastPage: PropTypes.func.isRequired, + gotoQueuePage: PropTypes.func.isRequired, + setQueueSort: PropTypes.func.isRequired, + setQueueTableOption: PropTypes.func.isRequired, + clearQueue: PropTypes.func.isRequired, + grabQueueItems: PropTypes.func.isRequired, + removeQueueItems: PropTypes.func.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js new file mode 100644 index 000000000..f6e360c0a --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; + +function QueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status: queueStatus, + errorMessage, + progressBar + } = props; + + const status = queueStatus.toLowerCase(); + + const progress = (100 - sizeleft / size * 100); + + if (status === 'pending') { + return ( + + ); + } + + if (status === 'completed') { + if (errorMessage) { + return ( + + ); + } + + // TODO: show an icon when download is complete, but not imported yet? + } + + if (errorMessage) { + return ( + + ); + } + + if (status === 'failed') { + return ( + + ); + } + + if (status === 'warning') { + return ( + + ); + } + + if (progress < 5) { + return ( + + ); + } + + return progressBar; +} + +QueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string, + progressBar: PropTypes.node.isRequired +}; + +export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..900cf85cb --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownSeriesItems: props.includeUnknownSeriesItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownSeriesItems + } = this.props; + + if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { + this.setState({ + includeUnknownSeriesItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + includeUnknownSeriesItems + } = this.state; + + return ( + + + Show Unknown Series Items + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownSeriesItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css new file mode 100644 index 000000000..6aa4a1622 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -0,0 +1,23 @@ +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.protocol { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.progress { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js new file mode 100644 index 000000000..1d5610168 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -0,0 +1,369 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TableRow from 'Components/Table/TableRow'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import QueueStatusCell from './QueueStatusCell'; +import TimeleftCell from './TimeleftCell'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; +import styles from './QueueRow.css'; + +class QueueRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveQueueItemModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + // + // Listeners + + onRemoveQueueItemPress = () => { + this.setState({ isRemoveQueueItemModalOpen: true }); + } + + onRemoveQueueItemModalConfirmed = (blacklist) => { + this.props.onRemoveQueueItemPress(blacklist); + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onRemoveQueueItemModalClose = () => { + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + downloadId, + title, + status, + trackedDownloadStatus, + statusMessages, + errorMessage, + series, + episode, + quality, + protocol, + indexer, + downloadClient, + estimatedCompletionTime, + timeleft, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat, + isGrabbing, + grabError, + isRemoving, + isSelected, + columns, + onSelectedChange, + onGrabPress + } = this.props; + + const { + isRemoveQueueItemModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const progress = 100 - (sizeleft / size * 100); + const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; + const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + { + series ? + : + title + } + + ); + } + + if (name === 'episode') { + return ( + + { + episode ? + : + '-' + } + + ); + } + + if (name === 'episode.title') { + return ( + + { + episode ? + : + '-' + } + + ); + } + + if (name === 'episode.airDateUtc') { + if (episode) { + return ( + + ); + } + + return ( + + - + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'downloadClient') { + return ( + + {downloadClient} + + ); + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + { + !!progress && + + } + + ); + } + + if (name === 'actions') { + return ( + + { + showInteractiveImport && + + } + + { + isPending && + + } + + + + ); + } + + return null; + }) + } + + + + + + ); + } + +} + +QueueRow.propTypes = { + id: PropTypes.number.isRequired, + downloadId: PropTypes.string, + title: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string, + series: PropTypes.object, + episode: PropTypes.object, + quality: PropTypes.object.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + downloadClient: PropTypes.string, + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + size: PropTypes.number, + sizeleft: PropTypes.number, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isGrabbing: PropTypes.bool.isRequired, + grabError: PropTypes.object, + isRemoving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired, + onRemoveQueueItemPress: PropTypes.func.isRequired +}; + +QueueRow.defaultProps = { + isGrabbing: false, + isRemoving: false +}; + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js new file mode 100644 index 000000000..10442e30f --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueueRow from './QueueRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createUISettingsSelector(), + (series, episode, uiSettings) => { + const result = _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'timeFormat' + ]); + + result.series = series; + result.episode = episode; + + return result; + } + ); +} + +const mapDispatchToProps = { + grabQueueItem, + removeQueueItem +}; + +class QueueRowConnector extends Component { + + // + // Listeners + + onGrabPress = () => { + this.props.grabQueueItem({ id: this.props.id }); + } + + onRemoveQueueItemPress = (blacklist) => { + this.props.removeQueueItem({ id: this.props.id, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueRowConnector.propTypes = { + id: PropTypes.number.isRequired, + episode: PropTypes.object, + grabQueueItem: PropTypes.func.isRequired, + removeQueueItem: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css new file mode 100644 index 000000000..6291ec949 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.css @@ -0,0 +1,5 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js new file mode 100644 index 000000000..f8cbc65ff --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import styles from './QueueStatusCell.css'; + +function getDetailedPopoverBody(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function QueueStatusCell(props) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'Ok', + statusMessages, + errorMessage + } = props; + + const hasWarning = trackedDownloadStatus === 'Warning'; + const hasError = trackedDownloadStatus === 'Error'; + + // status === 'downloading' + let iconName = icons.DOWNLOADING; + let iconKind = kinds.DEFAULT; + let title = 'Downloading'; + + if (hasWarning) { + iconKind = kinds.WARNING; + } + + if (status === 'Paused') { + iconName = icons.PAUSED; + title = 'Paused'; + } + + if (status === 'Queued') { + iconName = icons.QUEUED; + title = 'Queued'; + } + + if (status === 'Completed') { + iconName = icons.DOWNLOADED; + title = 'Downloaded'; + } + + if (status === 'Delay') { + iconName = icons.PENDING; + title = 'Pending'; + } + + if (status === 'DownloadClientUnavailable') { + iconName = icons.PENDING; + iconKind = kinds.WARNING; + title = 'Pending - Download client is unavailable'; + } + + if (status === 'Failed') { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + + if (status === 'Warning') { + iconName = icons.DOWNLOADING; + iconKind = kinds.WARNING; + title = `Download warning: ${errorMessage || 'check download client for more details'}`; + } + + if (hasError) { + if (status === 'Completed') { + iconName = icons.DOWNLOAD; + iconKind = kinds.DANGER; + title = `Import failed: ${sourceTitle}`; + } else { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + } + + return ( + + + } + title={title} + body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + position={tooltipPositions.RIGHT} + /> + + ); +} + +QueueStatusCell.propTypes = { + sourceTitle: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string +}; + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js new file mode 100644 index 000000000..52c2bc1cc --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemModal.css'; + +class RemoveQueueItemModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + sourceTitle + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove - {sourceTitle} + + + +
+ Are you sure you want to remove '{sourceTitle}' from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js new file mode 100644 index 000000000..8e8009ab1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemsModal.css'; + +class RemoveQueueItemsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + selectedCount + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove Selected Item{selectedCount > 1 ? 's' : ''} + + + +
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + selectedCount: PropTypes.number.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js new file mode 100644 index 000000000..0f6c9159d --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.queue.status, + (state) => state.queue.options.includeUnknownSeriesItems, + (app, status, includeUnknownSeriesItems) => { + const { + count, + unknownCount + } = status.item; + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: status.isPopulated, + ...status.item, + count: includeUnknownSeriesItems ? count : count - unknownCount + }; + } + ); +} + +const mapDispatchToProps = { + fetchQueueStatus +}; + +class QueueStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchQueueStatus(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchQueueStatus(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchQueueStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css new file mode 100644 index 000000000..eb58cf297 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css @@ -0,0 +1,5 @@ +.timeleft { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js new file mode 100644 index 000000000..c9515f172 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './TimeleftCell.css'; + +function TimeleftCell(props) { + const { + estimatedCompletionTime, + timeleft, + status, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (status === 'Delay') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (status === 'DownloadClientUnavailable') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (!timeleft) { + return ( + + - + + ); + } + + const totalSize = formatBytes(size); + const remainingSize = formatBytes(sizeleft); + + return ( + + {formatTimeSpan(timeleft)} + + ); +} + +TimeleftCell.propTypes = { + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + status: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default TimeleftCell; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css new file mode 100644 index 000000000..0bf8b0e15 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css @@ -0,0 +1,54 @@ +.searchContainer { + display: flex; + margin-bottom: 10px; +} + +.searchIconContainer { + width: 58px; + height: 46px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 46px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearLookupButton { + border: 1px solid $inputBorderColor; + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; +} + +.helpText { + margin-bottom: 10px; + font-weight: 300; + font-size: 24px; +} + +.noResults { + margin-bottom: 10px; + font-weight: 300; + font-size: 30px; +} + +.searchResults { + margin-top: 30px; +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js new file mode 100644 index 000000000..4c389e940 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector'; +import styles from './AddNewSeries.css'; + +class AddNewSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + term: props.term || '', + isFetching: false + }; + } + + componentDidMount() { + const term = this.state.term; + + if (term) { + this.props.onSeriesLookupChange(term); + } + } + + componentDidUpdate(prevProps) { + const { + term, + isFetching + } = this.props; + + if (term && term !== prevProps.term) { + this.setState({ + term, + isFetching: true + }); + this.props.onSeriesLookupChange(term); + } else if (isFetching !== prevProps.isFetching) { + this.setState({ + isFetching + }); + } + } + + // + // Listeners + + onSearchInputChange = ({ value }) => { + const hasValue = !!value.trim(); + + this.setState({ term: value, isFetching: hasValue }, () => { + if (hasValue) { + this.props.onSeriesLookupChange(value); + } else { + this.props.onClearSeriesLookup(); + } + }); + } + + onClearSeriesLookupPress = () => { + this.setState({ term: '' }); + this.props.onClearSeriesLookup(); + } + + // + // Render + + render() { + const { + error, + items + } = this.props; + + const term = this.state.term; + const isFetching = this.state.isFetching; + + return ( + + +
+
+ +
+ + + + +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
Failed to load search results, please try again.
+ } + + { + !isFetching && !error && !!items.length && +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ } + + { + !isFetching && !error && !items.length && !!term && +
+
Couldn't find any results for '{term}'
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+ + Why can't I find my show? + +
+
+ } + + { + !term && +
+
It's easy to add a new series, just start typing the name the series you want to add.
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+ } + +
+ + + ); + } +} + +AddNewSeries.propTypes = { + term: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeriesLookupChange: PropTypes.func.isRequired, + onClearSeriesLookup: PropTypes.func.isRequired +}; + +export default AddNewSeries; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js new file mode 100644 index 000000000..9374fb54e --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import parseUrl from 'Utilities/String/parseUrl'; +import { lookupSeries, clearAddSeries } from 'Store/Actions/addSeriesActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import AddNewSeries from './AddNewSeries'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.routing.location, + (addSeries, location) => { + const { params } = parseUrl(location.search); + + return { + term: params.term, + ...addSeries + }; + } + ); +} + +const mapDispatchToProps = { + lookupSeries, + clearAddSeries, + fetchRootFolders +}; + +class AddNewSeriesConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._seriesLookupTimeout = null; + } + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentWillUnmount() { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + this.props.clearAddSeries(); + } + + // + // Listeners + + onSeriesLookupChange = (term) => { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + if (term.trim() === '') { + this.props.clearAddSeries(); + } else { + this._seriesLookupTimeout = setTimeout(() => { + this.props.lookupSeries({ term }); + }, 300); + } + } + + onClearSeriesLookup = () => { + this.props.clearAddSeries(); + } + + // + // Render + + render() { + const { + term, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AddNewSeriesConnector.propTypes = { + term: PropTypes.string, + lookupSeries: PropTypes.func.isRequired, + clearAddSeries: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js new file mode 100644 index 000000000..cb603e7a6 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector'; + +function AddNewSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +AddNewSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNewSeriesModal; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css new file mode 100644 index 000000000..a58e22b53 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css @@ -0,0 +1,74 @@ +.container { + display: flex; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; +} + +.labelIcon { + margin-left: 8px; +} + +.searchForMissingEpisodesLabelContainer { + display: flex; + margin-top: 2px; +} + +.searchForMissingEpisodesLabel { + margin-right: 8px; + font-weight: normal; +} + +.searchForMissingEpisodesContainer { + composes: container from 'Components/Form/CheckInput.css'; + + flex: 0 1 0; +} + +.searchForMissingEpisodesInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.modalFooter { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; +} + +.addButton { + @add-mixin truncate; + composes: button from 'Components/Link/SpinnerButton.css'; +} + +.hideLanguageProfile { + composes: group from 'Components/Form/FormGroup.css'; + + display: none; +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + display: block; + text-align: center; + } + + .addButton { + margin-top: 10px; + } +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js new file mode 100644 index 000000000..1585ee492 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js @@ -0,0 +1,265 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import CheckInput from 'Components/Form/CheckInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Popover from 'Components/Tooltip/Popover'; +import SeriesPoster from 'Series/SeriesPoster'; +import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; +import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; +import styles from './AddNewSeriesModalContent.css'; + +class AddNewSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + searchForMissingEpisodes: false + }; + } + + // + // Listeners + + onSearchForMissingEpisodesChange = ({ value }) => { + this.setState({ searchForMissingEpisodes: value }); + } + + onQualityProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); + } + + onLanguageProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) }); + } + + onAddSeriesPress = () => { + this.props.onAddSeriesPress(this.state.searchForMissingEpisodes); + } + + // + // Render + + render() { + const { + title, + year, + overview, + images, + isAdding, + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags, + showLanguageProfile, + isSmallScreen, + onModalClose, + onInputChange + } = this.props; + + return ( + + + {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + + +
+ { + !isSmallScreen && +
+ +
+ } + +
+
+ {overview} +
+ +
+ + Root Folder + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Quality Profile + + + + + + Language Profile + + + + + + + Series Type + + + } + title="Series Types" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Season Folder + + + + + + Tags + + + +
+
+
+
+ + + + + + Add {title} + + +
+ ); + } +} + +AddNewSeriesModalContent.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + overview: PropTypes.string, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + languageProfileId: PropTypes.object, + seriesType: PropTypes.object.isRequired, + seasonFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onAddSeriesPress: PropTypes.func.isRequired +}; + +export default AddNewSeriesModalContent; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js new file mode 100644 index 000000000..dc351933e --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import AddNewSeriesModalContent from './AddNewSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.settings.languageProfiles, + createDimensionsSelector(), + (addSeriesState, languageProfiles, dimensions) => { + const { + isAdding, + addError, + defaults + } = addSeriesState; + + const { + settings, + validationErrors, + validationWarnings + } = selectSettings(defaults, {}, addError); + + return { + isAdding, + addError, + showLanguageProfile: languageProfiles.items.length > 1, + isSmallScreen: dimensions.isSmallScreen, + validationErrors, + validationWarnings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setAddSeriesDefault, + addSeries +}; + +class AddNewSeriesModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAddSeriesDefault({ [name]: value }); + } + + onAddSeriesPress = (searchForMissingEpisodes) => { + const { + tvdbId, + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags + } = this.props; + + this.props.addSeries({ + tvdbId, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + languageProfileId: languageProfileId.value, + seriesType: seriesType.value, + seasonFolder: seasonFolder.value, + tags: tags.value, + searchForMissingEpisodes + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNewSeriesModalContentConnector.propTypes = { + tvdbId: PropTypes.number.isRequired, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + languageProfileId: PropTypes.object, + seriesType: PropTypes.object.isRequired, + seasonFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + onModalClose: PropTypes.func.isRequired, + setAddSeriesDefault: PropTypes.func.isRequired, + addSeries: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css new file mode 100644 index 000000000..38ccffb4d --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css @@ -0,0 +1,40 @@ +.searchResult { + display: flex; + margin: 20px 0; + padding: 20px; + width: 100%; + background-color: $white; + color: inherit; + transition: background 500ms; + + &:hover { + background-color: #eaf2ff; + color: inherit; + text-decoration: none; + } +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.title { + font-weight: 300; + font-size: 36px; +} + +.year { + margin-left: 10px; + color: $disabledColor; +} + +.alreadyExistsIcon { + margin-left: 10px; + color: #37bc9b; +} + +.overview { + margin-top: 20px; +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js new file mode 100644 index 000000000..57f34dff4 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js @@ -0,0 +1,177 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import SeriesPoster from 'Series/SeriesPoster'; +import AddNewSeriesModal from './AddNewSeriesModal'; +import styles from './AddNewSeriesSearchResult.css'; + +class AddNewSeriesSearchResult extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNewAddSeriesModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (!prevProps.isExistingSeries && this.props.isExistingSeries) { + this.onAddSerisModalClose(); + } + } + + // + // Listeners + + onPress = () => { + this.setState({ isNewAddSeriesModalOpen: true }); + } + + onAddSerisModalClose = () => { + this.setState({ isNewAddSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + tvdbId, + title, + titleSlug, + year, + network, + status, + overview, + statistics, + ratings, + images, + isExistingSeries, + isSmallScreen + } = this.props; + + const seasonCount = statistics.seasonCount; + + const { + isNewAddSeriesModalOpen + } = this.state; + + const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress }; + let seasons = '1 Season'; + + if (seasonCount > 1) { + seasons = `${seasonCount} Seasons`; + } + + return ( +
+ + { + !isSmallScreen && + + } + +
+
+ {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + { + isExistingSeries && + + } +
+ +
+ + + { + !!network && + + } + + { + !!seasonCount && + + } + + { + status === 'ended' && + + } +
+ +
+ {overview} +
+
+ + + +
+ ); + } +} + +AddNewSeriesSearchResult.propTypes = { + tvdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + status: PropTypes.string.isRequired, + overview: PropTypes.string, + statistics: PropTypes.object.isRequired, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isExistingSeries: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default AddNewSeriesSearchResult; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js new file mode 100644 index 000000000..5ba942270 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import AddNewSeriesSearchResult from './AddNewSeriesSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingSeriesSelector(), + createDimensionsSelector(), + (isExistingSeries, dimensions) => { + return { + isExistingSeries, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(AddNewSeriesSearchResult); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js new file mode 100644 index 000000000..0f0e2ce1f --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ImportSeriesTableConnector from './ImportSeriesTableConnector'; +import ImportSeriesFooterConnector from './ImportSeriesFooterConnector'; + +class ImportSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + // + // Listeners + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState, { parseIds: false }); + } + + onSelectAllChange = ({ value }) => { + // Only select non-dupes + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRemoveSelectedStateItem = (id) => { + this.setState((state) => { + const selectedState = Object.assign({}, state.selectedState); + delete selectedState[id]; + + return { + ...state, + selectedState + }; + }); + } + + onInputChange = ({ name, value }) => { + this.props.onInputChange(this.getSelectedIds(), name, value); + } + + onImportPress = () => { + this.props.onImportPress(this.getSelectedIds()); + } + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + // + // Render + + render() { + const { + rootFolderId, + path, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + unmappedFolders, + showLanguageProfile + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + contentBody + } = this.state; + + return ( + + + { + rootFoldersFetching && !rootFoldersPopulated && + + } + + { + !rootFoldersFetching && !!rootFoldersError && +
Unable to load root folders
+ } + + { + !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && +
+ All series in {path} have been imported +
+ } + + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody && + + } +
+ + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && + + } +
+ ); + } +} + +ImportSeries.propTypes = { + rootFolderId: PropTypes.number.isRequired, + path: PropTypes.string, + rootFoldersFetching: PropTypes.bool.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + rootFoldersError: PropTypes.object, + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + items: PropTypes.arrayOf(PropTypes.object), + showLanguageProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired +}; + +ImportSeries.defaultProps = { + unmappedFolders: [] +}; + +export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js new file mode 100644 index 000000000..0ab206ef9 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportSeriesValue, importSeries, clearImportSeries } from 'Store/Actions/importSeriesActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions'; +import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; +import ImportSeries from './ImportSeries'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.rootFolders, + (state) => state.addSeries, + (state) => state.importSeries, + (state) => state.settings.qualityProfiles, + (state) => state.settings.languageProfiles, + ( + match, + rootFolders, + addSeries, + importSeriesState, + qualityProfiles, + languageProfiles + ) => { + const { + isFetching: rootFoldersFetching, + isPopulated: rootFoldersPopulated, + error: rootFoldersError, + items + } = rootFolders; + + const rootFolderId = parseInt(match.params.rootFolderId); + + const result = { + rootFolderId, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + qualityProfiles: qualityProfiles.items, + languageProfiles: languageProfiles.items, + showLanguageProfile: languageProfiles.items.length > 1, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultLanguageProfileId: addSeries.defaults.languageProfileId + }; + + if (items.length) { + const rootFolder = _.find(items, { id: rootFolderId }); + + return { + ...result, + ...rootFolder, + items: importSeriesState.items + }; + } + + return result; + } + ); +} + +const mapDispatchToProps = { + dispatchSetImportSeriesValue: setImportSeriesValue, + dispatchImportSeries: importSeries, + dispatchClearImportSeries: clearImportSeries, + dispatchFetchRootFolders: fetchRootFolders, + dispatchSetAddSeriesDefault: setAddSeriesDefault +}; + +class ImportSeriesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + qualityProfiles, + languageProfiles, + defaultQualityProfileId, + defaultLanguageProfileId, + dispatchFetchRootFolders, + dispatchSetAddSeriesDefault + } = this.props; + + if (!this.props.rootFoldersPopulated) { + dispatchFetchRootFolders(); + } + + let setDefaults = false; + const setDefaultPayload = {}; + + if ( + !defaultQualityProfileId || + !qualityProfiles.some((p) => p.id === defaultQualityProfileId) + ) { + setDefaults = true; + setDefaultPayload.qualityProfileId = qualityProfiles[0].id; + } + + if ( + !defaultLanguageProfileId || + !languageProfiles.some((p) => p.id === defaultLanguageProfileId) + ) { + setDefaults = true; + setDefaultPayload.languageProfileId = languageProfiles[0].id; + } + + if (setDefaults) { + dispatchSetAddSeriesDefault(setDefaultPayload); + } + } + + componentWillUnmount() { + this.props.dispatchClearImportSeries(); + } + + // + // Listeners + + onInputChange = (ids, name, value) => { + this.props.dispatchSetAddSeriesDefault({ [name]: value }); + + ids.forEach((id) => { + this.props.dispatchSetImportSeriesValue({ + id, + [name]: value + }); + }); + } + + onImportPress = (ids) => { + this.props.dispatchImportSeries({ ids }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +const routeMatchShape = createRouteMatchShape({ + rootFolderId: PropTypes.string.isRequired +}); + +ImportSeriesConnector.propTypes = { + match: routeMatchShape.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + languageProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + defaultQualityProfileId: PropTypes.number.isRequired, + defaultLanguageProfileId: PropTypes.number.isRequired, + dispatchSetImportSeriesValue: PropTypes.func.isRequired, + dispatchImportSeries: PropTypes.func.isRequired, + dispatchClearImportSeries: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchSetAddSeriesDefault: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css new file mode 100644 index 000000000..0a61ca509 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css @@ -0,0 +1,33 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.importButtonContainer { + display: flex; + align-items: center; +} + +.importButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + height: 35px; +} + +.loadingButton { + composes: importButton; + + margin-left: 10px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin: 0 10px 0 12px; + text-align: left; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js new file mode 100644 index 000000000..9d28dd299 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js @@ -0,0 +1,291 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import CheckInput from 'Components/Form/CheckInput'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './ImportSeriesFooter.css'; + +const MIXED = 'mixed'; + +class ImportSeriesFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeasonFolder, + defaultSeriesType + } = props; + + this.state = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed + } = this.props; + + const { + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder + } = this.state; + + const newState = {}; + + if (isMonitorMixed && monitor !== MIXED) { + newState.monitor = MIXED; + } else if (!isMonitorMixed && monitor !== defaultMonitor) { + newState.monitor = defaultMonitor; + } + + if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { + newState.qualityProfileId = MIXED; + } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { + newState.qualityProfileId = defaultQualityProfileId; + } + + if (isLanguageProfileIdMixed && languageProfileId !== MIXED) { + newState.languageProfileId = MIXED; + } else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) { + newState.languageProfileId = defaultLanguageProfileId; + } + + if (isSeriesTypeMixed && seriesType !== MIXED) { + newState.seriesType = MIXED; + } else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) { + newState.seriesType = defaultSeriesType; + } + + if (isSeasonFolderMixed && seasonFolder != null) { + newState.seasonFolder = null; + } else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) { + newState.seasonFolder = defaultSeasonFolder; + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + this.props.onInputChange({ name, value }); + } + + // + // Render + + render() { + const { + selectedCount, + isImporting, + isLookingUpSeries, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + hasUnsearchedItems, + showLanguageProfile, + onImportPress, + onLookupPress, + onCancelLookupPress + } = this.props; + + const { + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder + } = this.state; + + return ( + +
+
+ Monitor +
+ + +
+ +
+
+ Quality Profile +
+ + +
+ + { + showLanguageProfile && +
+
+ Language Profile +
+ + +
+ } + +
+
+ Series Type +
+ + +
+ +
+
+ Season Folder +
+ + +
+ +
+
+   +
+ +
+ + Import {selectedCount} Series + + + { + isLookingUpSeries && + + } + + { + hasUnsearchedItems && + + } + + { + isLookingUpSeries && + + } + + { + isLookingUpSeries && + 'Processing Folders' + } +
+
+
+ ); + } +} + +ImportSeriesFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isImporting: PropTypes.bool.isRequired, + isLookingUpSeries: PropTypes.bool.isRequired, + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultLanguageProfileId: PropTypes.number, + defaultSeriesType: PropTypes.string.isRequired, + defaultSeasonFolder: PropTypes.bool.isRequired, + isMonitorMixed: PropTypes.bool.isRequired, + isQualityProfileIdMixed: PropTypes.bool.isRequired, + isLanguageProfileIdMixed: PropTypes.bool.isRequired, + isSeriesTypeMixed: PropTypes.bool.isRequired, + isSeasonFolderMixed: PropTypes.bool.isRequired, + hasUnsearchedItems: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired, + onLookupPress: PropTypes.func.isRequired, + onCancelLookupPress: PropTypes.func.isRequired +}; + +export default ImportSeriesFooter; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js new file mode 100644 index 000000000..4983f5663 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js @@ -0,0 +1,65 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { lookupUnsearchedSeries, cancelLookupSeries } from 'Store/Actions/importSeriesActions'; +import ImportSeriesFooter from './ImportSeriesFooter'; + +function isMixed(items, selectedIds, defaultValue, key) { + return _.some(items, (series) => { + return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue; + }); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.importSeries, + (state, { selectedIds }) => selectedIds, + (addSeries, importSeries, selectedIds) => { + const { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + } = addSeries.defaults; + + const { + isLookingUpSeries, + isImporting, + items + } = importSeries; + + const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); + const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); + const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); + const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType'); + const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder'); + const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated); + + return { + selectedCount: selectedIds.length, + isLookingUpSeries, + isImporting, + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed, + hasUnsearchedItems + }; + } + ); +} + +const mapDispatchToProps = { + onLookupPress: lookupUnsearchedSeries, + onCancelLookupPress: cancelLookupSeries +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css new file mode 100644 index 000000000..36a57ea73 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css @@ -0,0 +1,45 @@ +.folder { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 200px; +} + +.monitor { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.languageProfile { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.seriesType { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 120px; +} + +.seasonFolder { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.series { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.detailsIcon { + margin-left: 8px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js new file mode 100644 index 000000000..fe60a4d5d --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; +import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; +import styles from './ImportSeriesHeader.css'; + +function ImportSeriesHeader(props) { + const { + showLanguageProfile, + allSelected, + allUnselected, + onSelectAllChange + } = props; + + return ( + + + + + Folder + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + Quality Profile + + + { + showLanguageProfile && + + Language Profile + + } + + + Series Type + + + } + title="Series Type" + body={} + position={tooltipPositions.RIGHT} + /> + + + + Season Folder + + + + Series + + + ); +} + +ImportSeriesHeader.propTypes = { + showLanguageProfile: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default ImportSeriesHeader; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css new file mode 100644 index 000000000..10329ea1c --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css @@ -0,0 +1,52 @@ +.selectInput { + composes: input from 'Components/Form/CheckInput.css'; +} + +.folder { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 200px; + line-height: 36px; +} + +.monitor { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.languageProfile { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.seriesType { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 120px; +} + +.seasonFolder { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.series { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.hideLanguageProfile { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + display: none; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js new file mode 100644 index 000000000..a9dad2af0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector'; +import styles from './ImportSeriesRow.css'; + +function ImportSeriesRow(props) { + const { + style, + id, + monitor, + qualityProfileId, + languageProfileId, + seasonFolder, + seriesType, + selectedSeries, + isExistingSeries, + showLanguageProfile, + isSelected, + onSelectedChange, + onInputChange + } = props; + + return ( + + + + + {id} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +ImportSeriesRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string.isRequired, + qualityProfileId: PropTypes.number.isRequired, + languageProfileId: PropTypes.number.isRequired, + seriesType: PropTypes.string.isRequired, + seasonFolder: PropTypes.bool.isRequired, + selectedSeries: PropTypes.object, + isExistingSeries: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +ImportSeriesRow.defaultsProps = { + items: [] +}; + +export default ImportSeriesRow; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js new file mode 100644 index 000000000..1eb8f01ff --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js @@ -0,0 +1,89 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import ImportSeriesRow from './ImportSeriesRow'; + +function createImportSeriesItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.importSeries.items, + (id, items) => { + return _.find(items, { id }) || {}; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createImportSeriesItemSelector(), + createAllSeriesSelector(), + (item, series) => { + const selectedSeries = item && item.selectedSeries; + const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); + + return { + ...item, + isExistingSeries + }; + } + ); +} + +const mapDispatchToProps = { + setImportSeriesValue +}; + +class ImportSeriesRowConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportSeriesValue({ + id: this.props.id, + [name]: value + }); + } + + // + // Render + + render() { + // Don't show the row until we have the information we require for it. + + const { + items, + monitor, + seriesType, + seasonFolder + } = this.props; + + if (!items || !monitor || !seriesType || !seasonFolder == null) { + return null; + } + + return ( + + ); + } +} + +ImportSeriesRowConnector.propTypes = { + rootFolderId: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string, + seriesType: PropTypes.string, + seasonFolder: PropTypes.bool, + items: PropTypes.arrayOf(PropTypes.object), + setImportSeriesValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css new file mode 100644 index 000000000..efc6dccb3 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css @@ -0,0 +1,3 @@ +.input { + composes: input from 'Components/Form/CheckInput.css'; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js new file mode 100644 index 000000000..1f4c6e251 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js @@ -0,0 +1,197 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import VirtualTable from 'Components/Table/VirtualTable'; +import ImportSeriesHeader from './ImportSeriesHeader'; +import ImportSeriesRowConnector from './ImportSeriesRowConnector'; + +class ImportSeriesTable extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + unmappedFolders, + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + onSeriesLookup, + onSetImportSeriesValue + } = this.props; + + const values = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + }; + + unmappedFolders.forEach((unmappedFolder) => { + const id = unmappedFolder.name; + + onSeriesLookup(id, unmappedFolder.path); + + onSetImportSeriesValue({ + id, + ...values + }); + }); + } + + // This isn't great, but it's the most reliable way to ensure the items + // are checked off even if they aren't actually visible since the cells + // are virtualized. + + componentDidUpdate(prevProps) { + const { + items, + selectedState, + onSelectedChange, + onRemoveSelectedStateItem + } = this.props; + + prevProps.items.forEach((prevItem) => { + const { + id + } = prevItem; + + const item = _.find(items, { id }); + + if (!item) { + onRemoveSelectedStateItem(id); + return; + } + + const selectedSeries = item.selectedSeries; + const isSelected = selectedState[id]; + + const isExistingSeries = !!selectedSeries && + _.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId }); + + // Props doesn't have a selected series or + // the selected series is an existing series. + if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) { + onSelectedChange({ id, value: false }); + + return; + } + + // State is selected, but a series isn't selected or + // the selected series is an existing series. + if (isSelected && (!selectedSeries || isExistingSeries)) { + onSelectedChange({ id, value: false }); + + return; + } + + // A series is being selected that wasn't previously selected. + if (selectedSeries && selectedSeries !== prevItem.selectedSeries) { + onSelectedChange({ id, value: true }); + + return; + } + }); + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + rootFolderId, + items, + selectedState, + showLanguageProfile, + onSelectedChange + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + allSelected, + allUnselected, + isSmallScreen, + contentBody, + showLanguageProfile, + scrollTop, + selectedState, + onSelectAllChange, + onScroll + } = this.props; + + if (!items.length) { + return null; + } + + return ( + + } + selectedState={selectedState} + onScroll={onScroll} + /> + ); + } +} + +ImportSeriesTable.propTypes = { + rootFolderId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultLanguageProfileId: PropTypes.number, + defaultSeriesType: PropTypes.string.isRequired, + defaultSeasonFolder: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + allSeries: PropTypes.arrayOf(PropTypes.object), + contentBody: PropTypes.object.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + onSelectAllChange: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, + onRemoveSelectedStateItem: PropTypes.func.isRequired, + onSeriesLookup: PropTypes.func.isRequired, + onSetImportSeriesValue: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ImportSeriesTable; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js new file mode 100644 index 000000000..a09d5fa80 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import ImportSeriesTable from './ImportSeriesTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.importSeries, + (state) => state.app.dimensions, + createAllSeriesSelector(), + (addSeries, importSeries, dimensions, allSeries) => { + return { + defaultMonitor: addSeries.defaults.monitor, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultLanguageProfileId: addSeries.defaults.languageProfileId, + defaultSeriesType: addSeries.defaults.seriesType, + defaultSeasonFolder: addSeries.defaults.seasonFolder, + items: importSeries.items, + isSmallScreen: dimensions.isSmallScreen, + allSeries + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSeriesLookup(name, path) { + dispatch(queueLookupSeries({ + name, + path, + term: name + })); + }, + + onSetImportSeriesValue(values) { + dispatch(setImportSeriesValue(values)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css new file mode 100644 index 000000000..a862c117c --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css @@ -0,0 +1,8 @@ +.series { + padding: 10px 20px; + width: 100%; + + &:hover { + background-color: $menuItemHoverBackgroundColor; + } +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js new file mode 100644 index 000000000..d82cdc924 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import ImportSeriesTitle from './ImportSeriesTitle'; +import styles from './ImportSeriesSearchResult.css'; + +class ImportSeriesSearchResult extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.tvdbId); + } + + // + // Render + + render() { + const { + title, + year, + network, + isExistingSeries + } = this.props; + + return ( + + + + ); + } +} + +ImportSeriesSearchResult.propTypes = { + tvdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + isExistingSeries: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default ImportSeriesSearchResult; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js new file mode 100644 index 000000000..81bb3059b --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; +import ImportSeriesSearchResult from './ImportSeriesSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingSeriesSelector(), + (isExistingSeries) => { + return { + isExistingSeries + }; + } + ); +} + +export default connect(createMapStateToProps)(ImportSeriesSearchResult); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css new file mode 100644 index 000000000..32ff0489b --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css @@ -0,0 +1,81 @@ +.tether { + z-index: 2000; +} + +.button { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; +} + +.loading { + display: inline-block; +} + +.warningIcon { + margin-right: 8px; +} + +.existing { + margin-left: 5px; +} + +.dropdownArrowContainer { + flex: 1 0 auto; + margin-left: 5px; + text-align: right; +} + +.contentContainer { + margin-top: 4px; + padding: 0 8px; + width: 400px; +} + +.content { + padding: 4px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.searchContainer { + display: flex; +} + +.searchIconContainer { + width: 58px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 33px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + border-radius: 0; +} + +.results { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; + + overflow-x: hidden; + overflow-y: scroll; + max-height: 165px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js new file mode 100644 index 000000000..8e21dcb05 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from 'Components/Form/FormInputButton'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector'; +import ImportSeriesTitle from './ImportSeriesTitle'; +import styles from './ImportSeriesSelectSeries.css'; + +const tetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + attachment: 'top center', + targetAttachment: 'bottom center' +}; + +class ImportSeriesSelectSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._seriesLookupTimeout = null; + + this.state = { + term: props.id, + isOpen: false + }; + } + + // + // Control + + _setButtonRef = (ref) => { + this._buttonRef = ref; + } + + _setContentRef = (ref) => { + this._contentRef = ref; + } + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = ReactDOM.findDOMNode(this._buttonRef); + const content = ReactDOM.findDOMNode(this._contentRef); + + if (!button) { + return; + } + + if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSearchInputChange = ({ value }) => { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + this.setState({ term: value }, () => { + this._seriesLookupTimeout = setTimeout(() => { + this.props.onSearchInputChange(value); + }, 200); + }); + } + + onRefreshPress = () => { + this.props.onSearchInputChange(this.state.term); + } + + onSeriesSelect = (tvdbId) => { + this.setState({ isOpen: false }); + + this.props.onSeriesSelect(tvdbId); + } + + // + // Render + + render() { + const { + selectedSeries, + isExistingSeries, + isFetching, + isPopulated, + error, + items, + isQueued, + isLookingUpSeries + } = this.props; + + const errorMessage = error && + error.responseJSON && + error.responseJSON.message; + + return ( + + + { + isLookingUpSeries && isQueued && !isPopulated && + + } + + { + isPopulated && selectedSeries && isExistingSeries && + + } + + { + isPopulated && selectedSeries && + + } + + { + isPopulated && !selectedSeries && +
+ + + No match found! +
+ } + + { + !isFetching && !!error && +
+ + + Search failed, please try again later. +
+ } + +
+ +
+ + + { + this.state.isOpen && +
+
+
+
+ +
+ + + + + + +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ } +
+ ); + } +} + +ImportSeriesSelectSeries.propTypes = { + id: PropTypes.string.isRequired, + selectedSeries: PropTypes.object, + isExistingSeries: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isQueued: PropTypes.bool.isRequired, + isLookingUpSeries: PropTypes.bool.isRequired, + onSearchInputChange: PropTypes.func.isRequired, + onSeriesSelect: PropTypes.func.isRequired +}; + +ImportSeriesSelectSeries.defaultProps = { + isFetching: true, + isPopulated: false, + items: [], + isQueued: true +}; + +export default ImportSeriesSelectSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js new file mode 100644 index 000000000..a460d6caf --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector'; +import ImportSeriesSelectSeries from './ImportSeriesSelectSeries'; + +function createMapStateToProps() { + return createSelector( + (state) => state.importSeries.isLookingUpSeries, + createImportSeriesItemSelector(), + (isLookingUpSeries, item) => { + return { + isLookingUpSeries, + ...item + }; + } + ); +} + +const mapDispatchToProps = { + queueLookupSeries, + setImportSeriesValue +}; + +class ImportSeriesSelectSeriesConnector extends Component { + + // + // Listeners + + onSearchInputChange = (term) => { + this.props.queueLookupSeries({ + name: this.props.id, + term, + topOfQueue: true + }); + } + + onSeriesSelect = (tvdbId) => { + const { + id, + items + } = this.props; + + this.props.setImportSeriesValue({ + id, + selectedSeries: _.find(items, { tvdbId }) + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesSelectSeriesConnector.propTypes = { + id: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + selectedSeries: PropTypes.object, + isSelected: PropTypes.bool, + queueLookupSeries: PropTypes.func.isRequired, + setImportSeriesValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css new file mode 100644 index 000000000..222623179 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css @@ -0,0 +1,20 @@ +.titleContainer { + display: flex; + align-items: center; + flex: 0 1 auto; + overflow: hidden; +} + +.title { + @add-mixin truncate; +} + +.year { + margin-right: 5px; + margin-left: 5px; + color: $disabledColor; +} + +.existing { + margin-left: 5px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js new file mode 100644 index 000000000..5dfabe6f4 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import styles from './ImportSeriesTitle.css'; + +function ImportSeriesTitle(props) { + const { + title, + year, + network, + isExistingSeries + } = props; + + return ( +
+
+ {title} +
+ + { + !title.contains(year) && + year > 0 && + + ({year}) + + } + + { + !!network && + + } + + { + isExistingSeries && + + } +
+ ); +} + +ImportSeriesTitle.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + isExistingSeries: PropTypes.bool.isRequired +}; + +export default ImportSeriesTitle; diff --git a/frontend/src/AddSeries/ImportSeries/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/ImportSeries.js new file mode 100644 index 000000000..15362a7d0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/ImportSeries.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector'; +import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector'; + +class ImportSeries extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css new file mode 100644 index 000000000..d9c5ccb01 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; +} + +.freeSpace, +.unmappedFolders { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js new file mode 100644 index 000000000..89ea713a0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ImportSeriesRootFolderRow.css'; + +function ImportSeriesRootFolderRow(props) { + const { + id, + path, + freeSpace, + unmappedFolders, + onDeletePress + } = props; + + const unmappedFoldersCount = unmappedFolders.length || '-'; + + return ( + + + + {path} + + + + + {formatBytes(freeSpace) || '-'} + + + + {unmappedFoldersCount} + + + + + + + ); +} + +ImportSeriesRootFolderRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + freeSpace: PropTypes.number.isRequired, + unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired +}; + +ImportSeriesRootFolderRow.defaultProps = { + freeSpace: 0, + unmappedFolders: [] +}; + +export default ImportSeriesRootFolderRow; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js new file mode 100644 index 000000000..f0fb03921 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportSeriesRootFolderRow from './ImportSeriesRootFolderRow'; + +function createMapStateToProps() { + return createSelector( + () => { + return { + }; + } + ); +} + +const mapDispatchToProps = { + deleteRootFolder +}; + +class ImportSeriesRootFolderRowConnector extends Component { + + // + // Listeners + + onDeletePress = () => { + this.props.deleteRootFolder({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesRootFolderRowConnector.propTypes = { + id: PropTypes.number.isRequired, + deleteRootFolder: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRootFolderRowConnector); diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css new file mode 100644 index 000000000..030da96fb --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css @@ -0,0 +1,32 @@ +.header { + margin-bottom: 40px; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.tips { + font-size: 20px; +} + +.tip { + font-size: $defaultFontSize; +} + +.code { + font-size: 12px; + font-family: $monoSpaceFontFamily; +} + +.recentFolders { + margin-top: 40px; +} + +.startImport { + margin-top: 40px; + text-align: center; +} + +.importButtonIcon { + margin-right: 8px; +} diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js new file mode 100644 index 000000000..13b9d5f6d --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ImportSeriesRootFolderRowConnector from './ImportSeriesRootFolderRowConnector'; +import styles from './ImportSeriesSelectFolder.css'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class ImportSeriesSelectFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNewRootFolderModalOpen: false + }; + } + + // + // Lifecycle + + onAddNewRootFolderPress = () => { + this.setState({ isAddNewRootFolderModalOpen: true }); + } + + onNewRootFolderSelect = ({ value }) => { + this.props.onNewRootFolderSelect(value); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddNewRootFolderModalOpen: false }); + } + + // + // Render + + render() { + const { + isWindows, + isFetching, + isPopulated, + error, + items + } = this.props; + + return ( + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load root folders
+ } + + { + !error && isPopulated && +
+
+ Import series you already have +
+ +
+ Some tips to ensure the import goes smoothly: +
    +
  • + Make sure your files include the quality in the name. eg. episode.s02e15.bluray.mkv +
  • +
  • + Point Sonarr to the folder containing all of your tv shows not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}" +
  • +
+
+ + { + items.length > 0 ? +
+
+ + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+
+ + +
: + +
+ +
+ } + + +
+ } +
+
+ ); + } +} + +ImportSeriesSelectFolder.propTypes = { + isWindows: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onNewRootFolderSelect: PropTypes.func.isRequired, + onDeleteRootFolderPress: PropTypes.func.isRequired +}; + +export default ImportSeriesSelectFolder; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js new file mode 100644 index 000000000..60729cd6a --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportSeriesSelectFolder from './ImportSeriesSelectFolder'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + createSystemStatusSelector(), + (rootFolders, systemStatus) => { + return { + ...rootFolders, + isWindows: systemStatus.isWindows + }; + } + ); +} + +const mapDispatchToProps = { + fetchRootFolders, + addRootFolder, + deleteRootFolder, + push +}; + +class ImportSeriesSelectFolderConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentDidUpdate(prevProps) { + const { + items, + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); + + if (newRootFolders.length === 1) { + this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`); + } + } + } + + // + // Listeners + + onNewRootFolderSelect = (path) => { + this.props.addRootFolder({ path }); + } + + onDeleteRootFolderPress = (id) => { + this.props.deleteRootFolder({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesSelectFolderConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchRootFolders: PropTypes.func.isRequired, + addRootFolder: PropTypes.func.isRequired, + deleteRootFolder: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector); diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js new file mode 100644 index 000000000..e889fbb09 --- /dev/null +++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js @@ -0,0 +1,46 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function SeriesMonitoringOptionsPopoverContent() { + return ( + + + + + + + + + + + + + + + + ); +} + +export default SeriesMonitoringOptionsPopoverContent; diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js new file mode 100644 index 000000000..e57d49a9e --- /dev/null +++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js @@ -0,0 +1,26 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function SeriesTypePopoverContent() { + return ( + + + + + + + + ); +} + +export default SeriesTypePopoverContent; diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js new file mode 100644 index 000000000..a05ff1fc6 --- /dev/null +++ b/frontend/src/App/App.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'react-router-redux'; +import PageConnector from 'Components/Page/PageConnector'; +import AppRoutes from './AppRoutes'; + +function App({ store, history }) { + return ( + + + + + + + + + + ); +} + +App.propTypes = { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js new file mode 100644 index 000000000..4ab75eb22 --- /dev/null +++ b/frontend/src/App/AppRoutes.js @@ -0,0 +1,248 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector'; +import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; +import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; +import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector'; +import SeasonPassConnector from 'SeasonPass/SeasonPassConnector'; +import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; +import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import HistoryConnector from 'Activity/History/HistoryConnector'; +import QueueConnector from 'Activity/Queue/QueueConnector'; +import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector'; +import MissingConnector from 'Wanted/Missing/MissingConnector'; +import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; +import Settings from 'Settings/Settings'; +import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import Profiles from 'Settings/Profiles/Profiles'; +import Quality from 'Settings/Quality/Quality'; +import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import Status from 'System/Status/Status'; +import Tasks from 'System/Tasks/Tasks'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import LogsTableConnector from 'System/Events/LogsTableConnector'; +import Logs from 'System/Logs/Logs'; + +function AppRoutes(props) { + const { + app + } = props; + + return ( + + {/* + Series + */} + + + + { + window.Sonarr.urlBase && + { + return ( + + ); + }} + /> + } + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +AppRoutes.propTypes = { + app: PropTypes.func.isRequired +}; + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js new file mode 100644 index 000000000..abc7f8832 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; + +function AppUpdatedModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +AppUpdatedModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js new file mode 100644 index 000000000..a21afbc5a --- /dev/null +++ b/frontend/src/App/AppUpdatedModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import AppUpdatedModal from './AppUpdatedModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css new file mode 100644 index 000000000..37b89c9be --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -0,0 +1,15 @@ +.version { + margin: 0 3px; + font-weight: bold; +} + +.maintenance { + margin-top: 20px; +} + +.changes { + margin-top: 20px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; + font-size: 18px; +} diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js new file mode 100644 index 000000000..c08dc6177 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import styles from './AppUpdatedModalContent.css'; + +function AppUpdatedModalContent(props) { + const { + version, + isPopulated, + error, + items, + onSeeChangesPress, + onModalClose + } = props; + + const update = items[0]; + + return ( + + + Sonarr Updated + + + +
+ Version {version} of Sonarr has been installed, in order to get the latest changes you'll need to reload Sonarr. +
+ + { + isPopulated && !error && !!update && +
+ { + !update.changes && +
Maintenance release
+ } + + { + !!update.changes && +
+
+ What's new? +
+ + + + +
+ } +
+ } + + { + !isPopulated && !error && + + } +
+ + + + + + +
+ ); +} + +AppUpdatedModalContent.propTypes = { + version: PropTypes.string.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeeChangesPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js new file mode 100644 index 000000000..b252868ce --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.version, + (state) => state.system.updates, + (version, updates) => { + const { + isPopulated, + error, + items + } = updates; + + return { + version, + isPopulated, + error, + items + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchUpdates() { + dispatch(fetchUpdates()); + }, + + onSeeChangesPress() { + window.location = `${window.Sonarr.urlBase}/system/updates`; + } + }; +} + +class AppUpdatedModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchUpdates(); + } + + componentDidUpdate(prevProps) { + if (prevProps.version !== this.props.version) { + this.props.dispatchFetchUpdates(); + } + } + + // + // Render + + render() { + const { + dispatchFetchUpdates, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AppUpdatedModalContentConnector.propTypes = { + version: PropTypes.string.isRequired, + dispatchFetchUpdates: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ConnectionLostModal.css b/frontend/src/App/ConnectionLostModal.css new file mode 100644 index 000000000..f0a9d220f --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.css @@ -0,0 +1,3 @@ +.automatic { + margin-top: 20px; +} diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js new file mode 100644 index 000000000..9ebed8ed3 --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ConnectionLostModal.css'; + +function ConnectionLostModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + Connnection Lost + + + +
+ Sonarr has lost it's connection to the backend and will need to be reloaded to restore functionality. +
+ +
+ Sonarr will try to connect automatically, or you can click reload below. +
+
+ + + +
+
+ ); +} + +ConnectionLostModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js new file mode 100644 index 000000000..8ab8e3cd0 --- /dev/null +++ b/frontend/src/App/ConnectionLostModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import ConnectionLostModal from './ConnectionLostModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css new file mode 100644 index 000000000..0304d9db5 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.css @@ -0,0 +1,3 @@ +.agenda { + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js new file mode 100644 index 000000000..89472301d --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.js @@ -0,0 +1,38 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import AgendaEventConnector from './AgendaEventConnector'; +import styles from './Agenda.css'; + +function Agenda(props) { + const { + items + } = props; + + return ( +
+ { + items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = index === 0 || + !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + + return ( + + ); + }) + } +
+ ); +} + +Agenda.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js new file mode 100644 index 000000000..b6f238873 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Agenda from './Agenda'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (calendar) => { + return calendar; + } + ); +} + +export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css new file mode 100644 index 000000000..e62cf63c2 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -0,0 +1,117 @@ +.event { + display: flex; + overflow-x: hidden; + padding: 5px; + border-bottom: 1px solid $borderColor; + font-size: $defaultFontSize; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.eventWrapper { + display: flex; + flex: 1 0 1px; + overflow-x: hidden; + padding-left: 6px; + border-left-width: 4px; + border-left-style: solid; +} + +.date { + flex: 0 0 250px; + font-weight: bold; +} + +.time { + flex: 0 0 120px; + margin-right: 10px; + border: none !important; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 0 1 300px; + margin-right: 10px; +} + +.episodeTitle { + flex: 1 1 1px; +} + +.seasonEpisodeNumber { + flex: 0 0 100px; +} + +.episodeSeparator { + display: none; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .event { + flex-direction: column; + } + + .eventWrapper { + display: block; + flex: 0 0 auto; + } + + .date { + margin-left: 10px; + } + + .date, + .time, + .seriesTitle { + flex: 0 0 100%; + } + + .seasonEpisodeNumber { + flex: 0 0 auto; + } + + .episodeSeparator { + display: inline-block; + margin: 0 5px; + } +} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js new file mode 100644 index 000000000..3d5aa36fb --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -0,0 +1,253 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import styles from './AgendaEvent.css'; + +class AgendaEvent extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + series, + episodeFile, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + hasFile, + grabbed, + queueItem, + showDate, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + timeFormat, + longDateFormat, + colorImpairedMode + } = this.props; + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored); + const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); + const seasonStatistics = season.statistics || {}; + + return ( +
+ +
+ { + showDate && + startTime.format(longDateFormat) + } +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ +
+ {series.title} +
+ + { + showEpisodeInformation && +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + series.seriesType === 'anime' && absoluteEpisodeNumber && + ({absoluteEpisodeNumber}) + } + +
-
+
+ } + +
+ { + showEpisodeInformation && + title + } +
+ + { + missingAbsoluteNumber && + + } + + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.languageCutoffNotMet && + !episodeFile.qualityCutoffNotMet && + + } + + { + episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + episodeNumber !== 1 && + seasonNumber > 0 && + episodeNumber === seasonStatistics.totalEpisodeCount && + + } + + { + showSpecialIcon && + (episodeNumber === 0 || seasonNumber === 0) && + + } +
+ + + +
+ ); + } +} + +AgendaEvent.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + episodeFile: PropTypes.object, + title: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showDate: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js new file mode 100644 index 000000000..e1d996225 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import AgendaEvent from './AgendaEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createEpisodeFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css new file mode 100644 index 000000000..37e6ff618 --- /dev/null +++ b/frontend/src/Calendar/Calendar.css @@ -0,0 +1,8 @@ +.calendar { + flex-grow: 1; + width: 100%; +} + +.calendarContent { + width: 100%; +} diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js new file mode 100644 index 000000000..6ceb1f3bb --- /dev/null +++ b/frontend/src/Calendar/Calendar.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import * as calendarViews from './calendarViews'; +import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; +import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; +import CalendarDaysConnector from './Day/CalendarDaysConnector'; +import AgendaConnector from './Agenda/AgendaConnector'; +import styles from './Calendar.css'; + +class Calendar extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + view + } = this.props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && view === calendarViews.AGENDA && +
+ + +
+ } + + { + !error && isPopulated && view !== calendarViews.AGENDA && +
+ + + +
+ } +
+ ); + } +} + +Calendar.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + view: PropTypes.string.isRequired +}; + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js new file mode 100644 index 000000000..6b743c2c7 --- /dev/null +++ b/frontend/src/Calendar/CalendarConnector.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import * as calendarActions from 'Store/Actions/calendarActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as commandNames from 'Commands/commandNames'; +import Calendar from './Calendar'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.settings.ui.item.firstDayOfWeek, + createCommandExecutingSelector(commandNames.REFRESH_SERIES), + (calendar, firstDayOfWeek, isRefreshingSeries) => { + return { + ...calendar, + isRefreshingSeries, + firstDayOfWeek + }; + } + ); +} + +const mapDispatchToProps = { + ...calendarActions, + fetchEpisodeFiles, + clearEpisodeFiles, + fetchQueueDetails, + clearQueueDetails +}; + +class CalendarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.updateTimeoutId = null; + } + + componentDidMount() { + const { + useCurrentPage, + fetchCalendar, + gotoCalendarToday + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchCalendar(); + } else { + gotoCalendarToday(); + } + + this.scheduleUpdate(); + } + + componentDidUpdate(prevProps) { + const { + items, + time, + view, + isRefreshingSeries, + firstDayOfWeek + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); + + if (items.length) { + this.props.fetchQueueDetails({ episodeIds }); + } + + if (episodeFileIds.length) { + this.props.fetchEpisodeFiles({ episodeFileIds }); + } + } + + if (prevProps.time !== time) { + this.scheduleUpdate(); + } + + if (prevProps.firstDayOfWeek !== firstDayOfWeek) { + this.props.fetchCalendar({ time, view }); + } + + if (prevProps.isRefreshingSeries && !isRefreshingSeries) { + this.props.fetchCalendar({ time, view }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCalendar(); + this.props.clearQueueDetails(); + this.props.clearEpisodeFiles(); + this.clearUpdateTimeout(); + } + + // + // Control + + repopulate = () => { + const { + time, + view + } = this.props; + + this.props.fetchQueueDetails({ time, view }); + this.props.fetchCalendar({ time, view }); + } + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + + this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + updateCalendar = () => { + this.props.gotoCalendarToday(); + this.scheduleUpdate(); + } + + // + // Listeners + + onCalendarViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarConnector.propTypes = { + time: PropTypes.string, + view: PropTypes.string.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired, + clearCalendar: PropTypes.func.isRequired, + fetchCalendar: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css new file mode 100644 index 000000000..776f3100f --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.css @@ -0,0 +1,14 @@ +.calendarPageBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; +} + +.calendarInnerPageBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js new file mode 100644 index 000000000..734bd88ff --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import Measure from 'Components/Measure'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import NoSeries from 'Series/NoSeries'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import LegendConnector from './Legend/LegendConnector'; +import CalendarConnector from './CalendarConnector'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +class CalendarPage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isCalendarLinkModalOpen: false, + isOptionsModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ width }); + const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); + + this.props.onDaysCountChange(days); + } + + onGetCalendarLinkPress = () => { + this.setState({ isCalendarLinkModalOpen: true }); + } + + onGetCalendarLinkModalClose = () => { + this.setState({ isCalendarLinkModalOpen: false }); + } + + onOptionsPress = () => { + this.setState({ isOptionsModalOpen: true }); + } + + onOptionsModalClose = () => { + this.setState({ isOptionsModalOpen: false }); + } + + onSearchMissingPress = () => { + const { + missingEpisodeIds, + onSearchMissingPress + } = this.props; + + onSearchMissingPress(missingEpisodeIds); + } + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + hasSeries, + missingEpisodeIds, + isSearchingForMissing, + useCurrentPage, + onFilterSelect + } = this.props; + + const { + isCalendarLinkModalOpen, + isOptionsModalOpen + } = this.state; + + const isMeasured = this.state.width > 0; + const PageComponent = hasSeries ? CalendarConnector : NoSeries; + + return ( + + + + + + + + + + + + + + + + + + { + isMeasured ? + : +
+ } + + + { + hasSeries && + + } + + + + + + + ); + } +} + +CalendarPage.propTypes = { + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + hasSeries: PropTypes.bool.isRequired, + missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, + isSearchingForMissing: PropTypes.bool.isRequired, + useCurrentPage: PropTypes.bool.isRequired, + onSearchMissingPress: PropTypes.func.isRequired, + onDaysCountChange: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js new file mode 100644 index 000000000..74261c0cd --- /dev/null +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -0,0 +1,101 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import moment from 'moment'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import withCurrentPage from 'Components/withCurrentPage'; +import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; +import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import CalendarPage from './CalendarPage'; + +function createMissingEpisodeIdsSelector() { + return createSelector( + (state) => state.calendar.start, + (state) => state.calendar.end, + (state) => state.calendar.items, + (state) => state.queue.details.items, + (start, end, episodes, queueDetails) => { + return episodes.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; + + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some((details) => details.episode.id === episode.id) + ) { + acc.push(episode.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting(commands.find((command) => { + return command.id === searchMissingCommandId; + })); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.selectedFilterKey, + (state) => state.calendar.filters, + createSeriesCountSelector(), + createUISettingsSelector(), + createMissingEpisodeIdsSelector(), + createIsSearchingSelector(), + ( + selectedFilterKey, + filters, + seriesCount, + uiSettings, + missingEpisodeIds, + isSearchingForMissing + ) => { + return { + selectedFilterKey, + filters, + colorImpairedMode: uiSettings.enableColorImpairedMode, + hasSeries: !!seriesCount, + missingEpisodeIds, + isSearchingForMissing + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchMissingPress(episodeIds) { + dispatch(searchMissing({ episodeIds })); + }, + + onDaysCountChange(dayCount) { + dispatch(setCalendarDaysCount({ dayCount })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setCalendarFilter({ selectedFilterKey })); + } + }; +} + +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) +); diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css new file mode 100644 index 000000000..1c7694f0b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.css @@ -0,0 +1,25 @@ +.day { + flex: 1 0 14.28%; + overflow: hidden; + min-height: 70px; + border-bottom: 1px solid $borderColor; + border-left: 1px solid $borderColor; +} + +.isSingleDay { + width: 100%; +} + +.dayOfMonth { + padding-right: 5px; + border-bottom: 1px solid $borderColor; + text-align: right; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} + +.isDifferentMonth { + color: $disabledColor; +} diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js new file mode 100644 index 000000000..faa45b28b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.js @@ -0,0 +1,74 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; +import styles from './CalendarDay.css'; + +function CalendarDay(props) { + const { + date, + time, + isTodaysDate, + events, + view, + onEventModalOpenToggle + } = props; + + return ( +
+ { + view === calendarViews.MONTH && +
+ {moment(date).date()} +
+ } +
+ { + events.map((event) => { + if (event.isGroup) { + return ( + + ); + } + + return ( + + ); + }) + } +
+
+ ); +} + +CalendarDay.propTypes = { + date: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + view: PropTypes.string.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js new file mode 100644 index 000000000..8fd6cc5a1 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDayConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import CalendarDay from './CalendarDay'; + +function sort(items) { + return _.sortBy(items, (item) => { + if (item.isGroup) { + return moment(item.events[0].airDateUtc).unix(); + } + + return moment(item.airDateUtc).unix(); + }); +} + +function createCalendarEventsConnector() { + return createSelector( + (state, { date }) => date, + (state) => state.calendar.items, + (state) => state.calendar.options.collapseMultipleEpisodes, + (date, items, collapseMultipleEpisodes) => { + const filtered = _.filter(items, (item) => { + return moment(date).isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort(filtered); + } + + const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); + const grouped = []; + + Object.keys(groupedObject).forEach((key) => { + const events = groupedObject[key]; + + if (events.length === 1) { + grouped.push(events[0]); + } else { + grouped.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) + }); + } + }); + + const sorted = sort(grouped); + + return sorted; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createCalendarEventsConnector(), + (calendar, events) => { + return { + time: calendar.time, + view: calendar.view, + events + }; + } + ); +} + +class CalendarDayConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarDayConnector.propTypes = { + date: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css new file mode 100644 index 000000000..22005e3e6 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.css @@ -0,0 +1,14 @@ +.days { + display: flex; + border-right: 1px solid $borderColor; +} + +.day, +.week, +.forecast { + flex-wrap: nowrap; +} + +.month { + flex-wrap: wrap; +} diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js new file mode 100644 index 000000000..0a1a36172 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.js @@ -0,0 +1,164 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import isToday from 'Utilities/Date/isToday'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarDayConnector from './CalendarDayConnector'; +import styles from './CalendarDays.css'; + +class CalendarDays extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStart = null; + + this.state = { + todaysDate: moment().startOf('day').toISOString(), + isEventModalOpen: false + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view === calendarViews.MONTH) { + this.scheduleUpdate(); + } + + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + window.addEventListener('touchmove', this.onTouchMove); + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + window.removeEventListener('touchmove', this.onTouchMove); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + this.setState({ todaysDate: todaysDate.toISOString() }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Listeners + + onEventModalOpenToggle = (isEventModalOpen) => { + this.setState({ isEventModalOpen }); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStart = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if ( + touchStart < 50 || + this.props.isSidebarVisible || + this.state.isEventModalOpen + ) { + return; + } + + this._touchStart = touchStart; + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStart) { + return; + } + + if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { + this.props.onNavigatePrevious(); + } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { + this.props.onNavigateNext(); + } + + this._touchStart = null; + } + + onTouchCancel = (event) => { + this._touchStart = null; + } + + onTouchMove = (event) => { + if (!this._touchStart) { + return; + } + } + + // + // Render + + render() { + const { + dates, + view + } = this.props; + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +CalendarDays.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string).isRequired, + view: PropTypes.string.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onNavigatePrevious: PropTypes.func.isRequired, + onNavigateNext: PropTypes.func.isRequired +}; + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js new file mode 100644 index 000000000..3dea906a7 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarDays from './CalendarDays'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.app.isSidebarVisible, + (calendar, isSidebarVisible) => { + return { + dates: calendar.dates, + view: calendar.view, + isSidebarVisible + }; + } + ); +} + +const mapDispatchToProps = { + onNavigatePrevious: gotoCalendarPreviousRange, + onNavigateNext: gotoCalendarNextRange +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css new file mode 100644 index 000000000..8c3552e55 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.css @@ -0,0 +1,13 @@ +.dayOfWeek { + flex: 1 0 14.28%; + background-color: #e4eaec; + text-align: center; +} + +.isSingleDay { + width: 100%; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js new file mode 100644 index 000000000..d97671522 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.js @@ -0,0 +1,56 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DayOfWeek.css'; + +class DayOfWeek extends Component { + + // + // Render + + render() { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates + } = this.props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); + } + + return ( +
+ {formatedDate} +
+ ); + } +} + +DayOfWeek.propTypes = { + date: PropTypes.string.isRequired, + view: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired +}; + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css new file mode 100644 index 000000000..518664633 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.css @@ -0,0 +1,4 @@ +.daysOfWeek { + display: flex; + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js new file mode 100644 index 000000000..a67777f7c --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DayOfWeek from './DayOfWeek'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DaysOfWeek.css'; + +class DaysOfWeek extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + todaysDate: moment().startOf('day').toISOString() + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { + this.scheduleUpdate(); + } + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = todaysDate.clone().add(1, 'day').diff(moment()); + + this.setState({ + todaysDate: todaysDate.toISOString() + }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Render + + render() { + const { + dates, + view, + ...otherProps + } = this.props; + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +DaysOfWeek.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string), + view: PropTypes.string.isRequired +}; + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js new file mode 100644 index 000000000..7f5cdef19 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DaysOfWeek from './DaysOfWeek'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createUISettingsSelector(), + (calendar, UiSettings) => { + return { + dates: calendar.dates.slice(0, 7), + view: calendar.view, + calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, + shortDateFormat: UiSettings.shortDateFormat, + showRelativeDates: UiSettings.showRelativeDates + }; + } + ); +} + +export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css new file mode 100644 index 000000000..c135dbc5b --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -0,0 +1,78 @@ +.event { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.episodeInfo { + display: flex; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; +} + +.seriesTitle { + color: #3a3f51; + font-size: $defaultFontSize; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + border-left-color: $successColor !important; +} + +.downloading { + border-left-color: $purple !important; +} + +.unmonitored { + border-left-color: $gray !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.onAir { + border-left-color: $warningColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.missing { + border-left-color: $dangerColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.unaired { + border-left-color: $primaryColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js new file mode 100644 index 000000000..d76e6da3f --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -0,0 +1,244 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import episodeEntities from 'Episode/episodeEntities'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +class CalendarEvent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }, () => { + this.props.onEventModalOpenToggle(true); + }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }, () => { + this.props.onEventModalOpenToggle(false); + }); + } + + // + // Render + + render() { + const { + id, + series, + episodeFile, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + hasFile, + grabbed, + queueItem, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + timeFormat, + colorImpairedMode + } = this.props; + + if (!series) { + return null; + } + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored); + const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); + const seasonStatistics = season.statistics || {}; + + return ( +
+ +
+
+ {series.title} +
+ + { + missingAbsoluteNumber && + + } + + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.languageCutoffNotMet && + !episodeFile.qualityCutoffNotMet && + + } + + { + episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + episodeNumber !== 1 && + seasonNumber > 0 && + episodeNumber === seasonStatistics.totalEpisodeCount && + + } + + { + showSpecialIcon && + (episodeNumber === 0 || seasonNumber === 0) && + + } +
+ + { + showEpisodeInformation && +
+
+ {title} +
+ +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + series.seriesType === 'anime' && absoluteEpisodeNumber && + ({absoluteEpisodeNumber}) + } +
+
+ } + +
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ + + +
+ ); + } +} + +CalendarEvent.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + episodeFile: PropTypes.object, + title: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js new file mode 100644 index 000000000..f3b663dae --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEvent from './CalendarEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createEpisodeFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css new file mode 100644 index 000000000..7d1c64b9b --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -0,0 +1,82 @@ +.eventGroup { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.airingInfo { + display: flex; +} + +.seriesTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; + color: #3a3f51; + font-size: $defaultFontSize; +} + +.airTime { + flex: 1 0 1px; +} + +.episodeInfo { + margin-left: 10px; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.expandContainerInline { + display: flex; + justify-content: flex-end; + flex: 1 0 20px; +} + +.expandContainer, +.collapseContainer { + display: flex; + justify-content: center; +} + +.collapseContainer { + margin-bottom: 5px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js new file mode 100644 index 000000000..147440e94 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.js @@ -0,0 +1,245 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import styles from './CalendarEventGroup.css'; + +function getEventsInfo(events) { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length + }; +} + +class CalendarEventGroup extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isExpanded: false + }; + } + + // + // Listeners + + onExpandPress = () => { + this.setState({ isExpanded: !this.state.isExpanded }); + } + + // + // Render + + render() { + const { + series, + events, + isDownloading, + showEpisodeInformation, + showFinaleIcon, + timeFormat, + colorImpairedMode, + onEventModalOpenToggle + } = this.props; + + const { isExpanded } = this.state; + const { + allDownloaded, + anyQueued, + anyMonitored, + allAbsoluteEpisodeNumbers + } = getEventsInfo(events); + const anyDownloading = isDownloading || anyQueued; + const firstEpisode = events[0]; + const lastEpisode = events[events.length -1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); + const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers; + + if (isExpanded) { + return ( +
+ { + events.map((event) => { + if (event.isGroup) { + return null; + } + + return ( + + ); + }) + } + + + + +
+ ); + } + + return ( +
+
+
+ {series.title} +
+ + { + isMissingAbsoluteNumber && + + } + + { + anyDownloading && + + } + + { + firstEpisode.episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + lastEpisode.episodeNumber !== 1 && + seasonNumber > 0 && + lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount && + + } +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ + { + showEpisodeInformation ? +
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)} + + { + series.seriesType === 'anime' && + firstEpisode.absoluteEpisodeNumber && + lastEpisode.absoluteEpisodeNumber && + + ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber}) + + } +
: + + + + } +
+ + { + showEpisodeInformation && + + + + } +
+ ); + } +} + +CalendarEventGroup.propTypes = { + series: PropTypes.object.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + isDownloading: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js new file mode 100644 index 000000000..731038d58 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEventGroup from './CalendarEventGroup'; + +function createIsDownloadingSelector() { + return createSelector( + (state, { episodeIds }) => episodeIds, + (state) => state.queue.details, + (episodeIds, details) => { + return details.items.some((item) => { + return episodeIds.includes(item.episode.id); + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createIsDownloadingSelector(), + createUISettingsSelector(), + (calendarOptions, series, isDownloading, uiSettings) => { + return { + series, + isDownloading, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js new file mode 100644 index 000000000..81d81465c --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import colors from 'Styles/Variables/colors'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; + +function CalendarEventQueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status, + errorMessage + } = props; + + const progress = (100 - sizeleft / size * 100); + + return ( + + +
+ } + /> + ); +} + +CalendarEventQueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string +}; + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css new file mode 100644 index 000000000..1127bb3c3 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.css @@ -0,0 +1,53 @@ +.header { + display: flex; +} + +.navigationButtons { + flex: 1 1 33%; + text-align: left; +} + +.todayButton { + composes: button from 'Components/Link/Button.css'; + + margin-left: 5px; +} + +.titleDesktop, +.titleMobile { + text-align: center; + font-size: 18px; +} + +.titleMobile { + margin-bottom: 5px; +} + +.viewButtonsContainer { + display: flex; + justify-content: flex-end; + flex: 1 1 33%; +} + +.viewMenu { + composes: menu from 'Components/Menu/Menu.css'; + + line-height: 31px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-right: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .navigationButtons { + flex: 1 0 50%; + } + + .viewButtonsContainer { + flex: 0 0 100px; + } +} diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js new file mode 100644 index 000000000..4fea8356d --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.js @@ -0,0 +1,253 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function getTitle(time, start, end, view, longDateFormat) { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return 'Agenda'; + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; +} + +// TODO Convert to a stateful Component so we can track view internally when changed + +class CalendarHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + view: props.view + }; + } + + componentDidUpdate(prevProps) { + const view = this.props.view; + + if (prevProps.view !== view) { + this.setState({ view }); + } + } + + // + // Listeners + + onViewChange = (view) => { + this.setState({ view }, () => { + this.props.onViewChange(view); + }); + } + + // + // Render + + render() { + const { + isFetching, + time, + start, + end, + longDateFormat, + isSmallScreen, + onTodayPress, + onPreviousPress, + onNextPress + } = this.props; + + const view = this.state.view; + + const title = getTitle(time, start, end, view, longDateFormat); + + return ( +
+ { + isSmallScreen && +
+ {title} +
+ } + +
+
+ + + + + +
+ + { + !isSmallScreen && +
+ {title} +
+ } + +
+ { + isFetching && + + } + + { + isSmallScreen ? + + + + + + + + Week + + + + Forecast + + + + Day + + + + Agenda + + + : + +
+ + + + + + + + + +
+ } +
+
+
+ ); + } +} + +CalendarHeader.propTypes = { + isFetching: PropTypes.bool.isRequired, + time: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + view: PropTypes.oneOf(calendarViews.all).isRequired, + isSmallScreen: PropTypes.bool.isRequired, + longDateFormat: PropTypes.string.isRequired, + onViewChange: PropTypes.func.isRequired, + onTodayPress: PropTypes.func.isRequired, + onPreviousPress: PropTypes.func.isRequired, + onNextPress: PropTypes.func.isRequired +}; + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js new file mode 100644 index 000000000..c96cf2869 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarHeader from './CalendarHeader'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createDimensionsSelector(), + createUISettingsSelector(), + (calendar, dimensions, uiSettings) => { + const result = _.pick(calendar, [ + 'isFetching', + 'view', + 'time', + 'start', + 'end' + ]); + + result.isSmallScreen = dimensions.isSmallScreen; + result.longDateFormat = uiSettings.longDateFormat; + + return result; + } + ); +} + +const mapDispatchToProps = { + setCalendarView, + gotoCalendarToday, + gotoCalendarPreviousRange, + gotoCalendarNextRange +}; + +class CalendarHeaderConnector extends Component { + + // + // Listeners + + onViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarHeaderConnector.propTypes = { + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js new file mode 100644 index 000000000..8dd5ae9f0 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import Button from 'Components/Link/Button'; +import * as calendarViews from 'Calendar/calendarViews'; +// import styles from './CalendarHeaderViewButton.css'; + +class CalendarHeaderViewButton extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.view); + } + + // + // Render + + render() { + const { + view, + selectedView, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +CalendarHeaderViewButton.propTypes = { + view: PropTypes.oneOf(calendarViews.all).isRequired, + selectedView: PropTypes.oneOf(calendarViews.all).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css new file mode 100644 index 000000000..296cbd9d5 --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.css @@ -0,0 +1,6 @@ +.legend { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + padding: 3px 0; +} diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js new file mode 100644 index 000000000..57d3bad2a --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import LegendItem from './LegendItem'; +import LegendIconItem from './LegendIconItem'; +import styles from './Legend.css'; + +function Legend(props) { + const { + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + colorImpairedMode + } = props; + + const iconsToShow = []; + if (showFinaleIcon) { + iconsToShow.push( + + ); + } + + if (showSpecialIcon) { + iconsToShow.push( + + ); + } + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + + {iconsToShow[0]} +
+ + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ } +
+ ); +} + +Legend.propTypes = { + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js new file mode 100644 index 000000000..30bbc4adb --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Legend from './Legend'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createUISettingsSelector(), + (calendarOptions, uiSettings) => { + return { + ...calendarOptions, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css new file mode 100644 index 000000000..01db0ba5a --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -0,0 +1,10 @@ +.legendIconItem { + margin: 3px 0; + margin-right: 6px; + width: 150px; + cursor: default; +} + +.icon { + margin-right: 5px; +} diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js new file mode 100644 index 000000000..2074a9b1e --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './legendIconItem.css'; + +function LegendIconItem(props) { + const { + name, + icon, + kind, + tooltip + } = props; + + return ( +
+ + + {name} +
+ ); +} + +LegendIconItem.propTypes = { + name: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + kind: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired +}; + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css new file mode 100644 index 000000000..d146e9d68 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.css @@ -0,0 +1,41 @@ +.legendItem { + margin: 3px 0; + margin-right: 6px; + padding-left: 5px; + width: 150px; + border-left-width: 4px; + border-left-style: solid; + cursor: default; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +.unaired { + composes: unaired from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js new file mode 100644 index 000000000..961f48b86 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import styles from './LegendItem.css'; + +function LegendItem(props) { + const { + name, + status, + tooltip, + colorImpairedMode + } = props; + + return ( +
+ {name ? name : titleCase(status)} +
+ ); +} + +LegendItem.propTypes = { + name: PropTypes.string, + status: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js new file mode 100644 index 000000000..b68c83f30 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; + +function CalendarOptionsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js new file mode 100644 index 000000000..9ff7b3f82 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,258 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings'; + +class CalendarOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = props; + + this.state = { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }; + } + + componentDidUpdate(prevProps) { + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.props; + + if ( + prevProps.firstDayOfWeek !== firstDayOfWeek || + prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || + prevProps.timeFormat !== timeFormat || + prevProps.enableColorImpairedMode !== enableColorImpairedMode + ) { + this.setState({ + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }); + } + } + + // + // Listeners + + onOptionInputChange = ({ name, value }) => { + const { + dispatchSetCalendarOption + } = this.props; + + dispatchSetCalendarOption({ [name]: value }); + } + + onGlobalInputChange = ({ name, value }) => { + const { + dispatchSaveUISettings + } = this.props; + + const setting = { [name]: value }; + + this.setState(setting, () => { + dispatchSaveUISettings(setting); + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + onModalClose + } = this.props; + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.state; + + return ( + + + Calendar Options + + + +
+
+ + Collapse Multiple Episodes + + + + + + Show Episode Information + + + + + + Icon for Finales + + + + + + Icon for Specials + + + + + + Icon for Cutoff Unmet + + + +
+
+ +
+
+ + First Day of Week + + + + + + Week Column Header + + + + + + Time Format + + + + Enable Color-Impaired Mode + + + + +
+
+
+ + + + +
+ ); + } +} + +CalendarOptionsModalContent.propTypes = { + collapseMultipleEpisodes: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + dispatchSetCalendarOption: PropTypes.func.isRequired, + dispatchSaveUISettings: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js new file mode 100644 index 000000000..eb979f74e --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; +import { saveUISettings } from 'Store/Actions/settingsActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + (state) => state.settings.ui.item, + (options, uiSettings) => { + return { + ...options, + ...uiSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetCalendarOption: setCalendarOption, + dispatchSaveUISettings: saveUISettings +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js new file mode 100644 index 000000000..929958b66 --- /dev/null +++ b/frontend/src/Calendar/calendarViews.js @@ -0,0 +1,7 @@ +export const DAY = 'day'; +export const WEEK = 'week'; +export const MONTH = 'month'; +export const FORECAST = 'forecast'; +export const AGENDA = 'agenda'; + +export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js new file mode 100644 index 000000000..b149a8aab --- /dev/null +++ b/frontend/src/Calendar/getStatusStyle.js @@ -0,0 +1,30 @@ +/* eslint max-params: 0 */ +import moment from 'moment'; + +function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { + const currentTime = moment(); + + if (hasFile) { + return 'downloaded'; + } + + if (downloading) { + return 'downloading'; + } + + if (!isMonitored) { + return 'unmonitored'; + } + + if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) { + return 'onAir'; + } + + if (endTime.isBefore(currentTime) && !hasFile) { + return 'missing'; + } + + return 'unaired'; +} + +export default getStatusStyle; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js new file mode 100644 index 000000000..8cc487c16 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; + +function CalendarLinkModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarLinkModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js new file mode 100644 index 000000000..e965b862d --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function getUrls(state) { + const { + unmonitored, + premieresOnly, + asAllDay, + tags + } = state; + + let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/calendar/Sonarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${window.Sonarr.apiKey}`; + + const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; + const iCalWebCalUrl = `webcal://${icalUrl}`; + + return { + iCalHttpUrl, + iCalWebCalUrl + }; +} + +class CalendarLinkModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const defaultState = { + unmonitored: false, + premieresOnly: false, + asAllDay: false, + tags: [] + }; + + const urls = getUrls(defaultState); + + this.state = { + ...defaultState, + ...urls + }; + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + const state = { + ...this.state, + [name]: value + }; + + const urls = getUrls(state); + + this.setState({ + [name]: value, + ...urls + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + unmonitored, + premieresOnly, + asAllDay, + tags, + iCalHttpUrl, + iCalWebCalUrl + } = this.state; + + return ( + + + Sonarr Calendar Feed + + + +
+ + Include Unmonitored + + + + + + Season Premieres Only + + + + + + Show as All-Day Events + + + + + + Tags + + + + + + iCal Feed + + , + + + + + ]} + onChange={this.onInputChange} + onFocus={this.onLinkFocus} + /> + +
+
+ + + + +
+ ); + } +} + +CalendarLinkModalContent.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js new file mode 100644 index 000000000..e10c5c3f9 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js new file mode 100644 index 000000000..a48c59157 --- /dev/null +++ b/frontend/src/Commands/commandNames.js @@ -0,0 +1,20 @@ +export const APPLICATION_UPDATE = 'ApplicationUpdate'; +export const BACKUP = 'Backup'; +export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload'; +export const CLEAR_BLACKLIST = 'ClearBlacklist'; +export const CLEAR_LOGS = 'ClearLog'; +export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; +export const DELETE_LOG_FILES = 'DeleteLogFiles'; +export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; +export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan'; +export const EPISODE_SEARCH = 'EpisodeSearch'; +export const INTERACTIVE_IMPORT = 'ManualImport'; +export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; +export const MOVE_SERIES = 'MoveSeries'; +export const REFRESH_SERIES = 'RefreshSeries'; +export const RENAME_FILES = 'RenameFiles'; +export const RENAME_SERIES = 'RenameSeries'; +export const RESET_API_KEY = 'ResetApiKey'; +export const RSS_SYNC = 'RssSync'; +export const SEASON_SEARCH = 'SeasonSearch'; +export const SERIES_SEARCH = 'SeriesSearch'; diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css new file mode 100644 index 000000000..312fbb4f2 --- /dev/null +++ b/frontend/src/Components/Alert.css @@ -0,0 +1,31 @@ +.alert { + display: block; + margin: 5px; + padding: 15px; + border: 1px solid transparent; + border-radius: 4px; +} + +.danger { + border-color: $alertDangerBorderColor; + background-color: $alertDangerBackgroundColor; + color: $alertDangerColor; +} + +.info { + border-color: $alertInfoBorderColor; + background-color: $alertInfoBackgroundColor; + color: $alertInfoColor; +} + +.success { + border-color: $alertSuccessBorderColor; + background-color: $alertSuccessBackgroundColor; + color: $alertSuccessColor; +} + +.warning { + border-color: $alertWarningBorderColor; + background-color: $alertWarningBackgroundColor; + color: $alertWarningColor; +} diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js new file mode 100644 index 000000000..dc19a418c --- /dev/null +++ b/frontend/src/Components/Alert.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds } from 'Helpers/Props'; +import styles from './Alert.css'; + +function Alert({ className, kind, children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +Alert.propTypes = { + className: PropTypes.string.isRequired, + kind: PropTypes.oneOf(kinds.all).isRequired, + children: PropTypes.node.isRequired +}; + +Alert.defaultProps = { + className: styles.alert, + kind: kinds.INFO +}; + +export default Alert; diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css new file mode 100644 index 000000000..b54bbcdf4 --- /dev/null +++ b/frontend/src/Components/Card.css @@ -0,0 +1,19 @@ +.card { + position: relative; + margin: 10px; + padding: 10px; + border-radius: 3px; + background-color: $white; + box-shadow: 0 0 10px 1px $cardShadowColor; + color: $defaultColor; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + position: relative; +} diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js new file mode 100644 index 000000000..c5a4d164c --- /dev/null +++ b/frontend/src/Components/Card.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './Card.css'; + +class Card extends Component { + + // + // Render + + render() { + const { + className, + overlayClassName, + overlayContent, + children, + onPress + } = this.props; + + if (overlayContent) { + return ( +
+ + +
+ {children} +
+
+ ); + } + + return ( + + {children} + + ); + } +} + +Card.propTypes = { + className: PropTypes.string.isRequired, + overlayClassName: PropTypes.string.isRequired, + overlayContent: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onPress: PropTypes.func.isRequired +}; + +Card.defaultProps = { + className: styles.card, + overlayClassName: styles.overlay, + overlayContent: false +}; + +export default Card; diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css new file mode 100644 index 000000000..32b349404 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.css @@ -0,0 +1,21 @@ +.circularProgressBarContainer { + position: relative; + display: inline-block; + vertical-align: top; + text-align: center; +} + +.circularProgressBar { + position: absolute; + top: 0; + left: 0; + transform: rotate(-90deg); + transform-origin: center center; +} + +.circularProgressBarText { + position: absolute; + width: 100%; + height: 100%; + font-weight: bold; +} diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js new file mode 100644 index 000000000..95c84f59a --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import colors from 'Styles/Variables/colors'; +import styles from './CircularProgressBar.css'; + +class CircularProgressBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + progress: 0 + }; + } + + componentDidMount() { + this._progressStep(); + } + + componentDidUpdate(prevProps) { + const progress = this.props.progress; + + if (prevProps.progress !== progress) { + this._cancelProgressStep(); + this._progressStep(); + } + } + + componentWillUnmount() { + this._cancelProgressStep(); + } + + // + // Control + + _progressStep() { + this.requestAnimationFrame = window.requestAnimationFrame(() => { + this.setState({ + progress: this.state.progress + 1 + }, () => { + if (this.state.progress < this.props.progress) { + this._progressStep(); + } + }); + }); + } + + _cancelProgressStep() { + if (this.requestAnimationFrame) { + window.cancelAnimationFrame(this.requestAnimationFrame); + } + } + + // + // Render + + render() { + const { + className, + containerClassName, + size, + strokeWidth, + strokeColor, + showProgressText + } = this.props; + + const progress = this.state.progress; + + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - progress) / 100) * circumference; + const progressText = `${Math.round(progress)}%`; + + return ( +
+ + + + + { + showProgressText && +
+ {progressText} +
+ } +
+ ); + } +} + +CircularProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + size: PropTypes.number, + progress: PropTypes.number.isRequired, + strokeWidth: PropTypes.number, + strokeColor: PropTypes.string, + showProgressText: PropTypes.bool +}; + +CircularProgressBar.defaultProps = { + className: styles.circularProgressBar, + containerClassName: styles.circularProgressBarContainer, + size: 60, + strokeWidth: 5, + strokeColor: colors.sonarrBlue, + showProgressText: false +}; + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css new file mode 100644 index 000000000..230347f80 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.css @@ -0,0 +1,4 @@ +.descriptionList { + margin-top: 0; + margin-bottom: 0; +} diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js new file mode 100644 index 000000000..be2c87c55 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './DescriptionList.css'; + +class DescriptionList extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +DescriptionList.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node +}; + +DescriptionList.defaultProps = { + className: styles.descriptionList +}; + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js new file mode 100644 index 000000000..4ba70bf33 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DescriptionListItemTitle from './DescriptionListItemTitle'; +import DescriptionListItemDescription from './DescriptionListItemDescription'; + +class DescriptionListItem extends Component { + + // + // Render + + render() { + const { + titleClassName, + descriptionClassName, + title, + data + } = this.props; + + return ( + + + {title} + + + + {data} + + + ); + } +} + +DescriptionListItem.propTypes = { + titleClassName: PropTypes.string, + descriptionClassName: PropTypes.string, + title: PropTypes.string, + data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css new file mode 100644 index 000000000..b23415a76 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -0,0 +1,13 @@ +.description { + line-height: $lineHeight; +} + +.description { + margin-left: 0; +} + +@media (min-width: 768px) { + .description { + margin-left: 180px; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js new file mode 100644 index 000000000..4ef3c015e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemDescription.css'; + +function DescriptionListItemDescription(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemDescription.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +DescriptionListItemDescription.defaultProps = { + className: styles.description +}; + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css new file mode 100644 index 000000000..e496e463d --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css @@ -0,0 +1,18 @@ +.title { + line-height: $lineHeight; +} + +.title { + font-weight: bold; +} + +@media (min-width: 768px) { + .title { + @add-mixin truncate; + + float: left; + clear: left; + width: 160px; + text-align: right; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js new file mode 100644 index 000000000..e1632c1cf --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemTitle.css'; + +function DescriptionListItemTitle(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemTitle.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.string +}; + +DescriptionListItemTitle.defaultProps = { + className: styles.title +}; + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css new file mode 100644 index 000000000..46f721fef --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.css @@ -0,0 +1,9 @@ +.dragLayer { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js new file mode 100644 index 000000000..a111df70e --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +function DragPreviewLayer({ children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +DragPreviewLayer.propTypes = { + children: PropTypes.node, + className: PropTypes.string +}; + +DragPreviewLayer.defaultProps = { + className: styles.dragLayer +}; + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..50fcf98b5 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import * as sentry from '@sentry/browser'; + +class ErrorBoundary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + error: null, + info: null + }; + } + + componentDidCatch(error, info) { + this.setState({ + error, + info + }); + + sentry.captureException(error); + } + + // + // Render + + render() { + const { + children, + errorComponent: ErrorComponent, + ...otherProps + } = this.props; + + const { + error, + info + } = this.state; + + if (error) { + return ( + + ); + } + + return children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + errorComponent: PropTypes.func.isRequired +}; + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css new file mode 100644 index 000000000..b6d1f917e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -0,0 +1,38 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.imageContainer { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.image { + height: 350px; +} + +.details { + margin: 20px; + text-align: left; + white-space: pre-wrap; +} + +@media only screen and (max-width: $breakpointMedium) { + .image { + height: 250px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .image { + height: 150px; + } +} diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js new file mode 100644 index 000000000..e0181db96 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ErrorBoundaryError.css'; + +function ErrorBoundaryError(props) { + const { + className, + messageClassName, + detailsClassName, + message, + error, + info + } = props; + + return ( +
+
+ {message} +
+ +
+ +
+ +
+ { + error && +
+ {error.toString()} +
+ } + +
+ {info.componentStack} +
+
+
+ ); +} + +ErrorBoundaryError.propTypes = { + className: PropTypes.string.isRequired, + messageClassName: PropTypes.string.isRequired, + detailsClassName: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + info: PropTypes.object.isRequired +}; + +ErrorBoundaryError.defaultProps = { + className: styles.container, + messageClassName: styles.message, + detailsClassName: styles.details, + message: 'There was an error loading this content' +}; + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css new file mode 100644 index 000000000..daf3bdf2e --- /dev/null +++ b/frontend/src/Components/FieldSet.css @@ -0,0 +1,19 @@ +.fieldSet { + margin: 0; + margin-bottom: 20px; + padding: 0; + min-width: 0; + border: 0; +} + +.legend { + display: block; + margin-bottom: 21px; + padding: 0; + width: 100%; + border: 0; + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + line-height: inherit; +} diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js new file mode 100644 index 000000000..76e68a934 --- /dev/null +++ b/frontend/src/Components/FieldSet.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './FieldSet.css'; + +class FieldSet extends Component { + + // + // Render + + render() { + const { + legend, + children + } = this.props; + + return ( +
+ + {legend} + + {children} +
+ ); + } + +} + +FieldSet.propTypes = { + legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + children: PropTypes.node +}; + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css new file mode 100644 index 000000000..30b936800 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css @@ -0,0 +1,5 @@ +.modal { + composes: modal from 'Components/Modal/Modal.css'; + + height: 600px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js new file mode 100644 index 000000000..6b58dbb8c --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; +import styles from './FileBrowserModal.css'; + +class FileBrowserModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +FileBrowserModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css new file mode 100644 index 000000000..9ae11f0bd --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css @@ -0,0 +1,33 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex-direction: column; +} + +.mappedDrivesWarning { + composes: alert from 'Components/Alert.css'; + + margin: 0; + margin-bottom: 20px; +} + +.faqLink { + color: $alertWarningColor; + font-weight: bold; +} + +.pathInput { + composes: pathInputWrapper from 'Components/Form/PathInput.css'; + + flex: 0 0 auto; +} + +.scroller { + margin-top: 20px; +} + +.loading { + display: inline-block; + margin-right: auto; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js new file mode 100644 index 000000000..6c178f6c7 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -0,0 +1,253 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PathInput from 'Components/Form/PathInput'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns = [ + { + name: 'type', + label: 'Type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + } +]; + +class FileBrowserModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scrollerNode = null; + + this.state = { + isFileBrowserModalOpen: false, + currentPath: props.value + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + currentPath + } = this.props; + + if ( + currentPath !== this.state.currentPath && + currentPath !== prevState.currentPath + ) { + this.setState({ currentPath }); + this._scrollerNode.scrollTop = 0; + } + } + + // + // Control + + setScrollerRef = (ref) => { + if (ref) { + this._scrollerNode = ReactDOM.findDOMNode(ref); + } else { + this._scrollerNode = null; + } + } + + // + // Listeners + + onPathInputChange = ({ value }) => { + this.setState({ currentPath: value }); + } + + onRowPress = (path) => { + this.props.onFetchPaths(path); + } + + onOkPress = () => { + this.props.onChange({ + name: this.props.name, + value: this.state.currentPath + }); + + this.props.onClearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + parent, + directories, + files, + isWindowsService, + onModalClose, + ...otherProps + } = this.props; + + const emptyParent = parent === ''; + + return ( + + + File Browser + + + + { + isWindowsService && + + Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. + + } + + + + + { + !!error && +
Error loading contents
+ } + + { + isPopulated && !error && + + + { + emptyParent && + + } + + { + !emptyParent && parent && + + } + + { + directories.map((directory) => { + return ( + + ); + }) + } + + { + files.map((file) => { + return ( + + ); + }) + } + +
+ } +
+
+ + + { + isFetching && + + } + + + + + +
+ ); + } +} + +FileBrowserModalContent.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + parent: PropTypes.string, + currentPath: PropTypes.string.isRequired, + directories: PropTypes.arrayOf(PropTypes.object).isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + isWindowsService: PropTypes.bool.isRequired, + onFetchPaths: PropTypes.func.isRequired, + onClearPaths: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js new file mode 100644 index 000000000..fe577b896 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchPaths, clearPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import FileBrowserModalContent from './FileBrowserModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.paths, + createSystemStatusSelector(), + (paths, systemStatus) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files + } = paths; + + const filteredPaths = _.filter([...directories, ...files], ({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' + }; + } + ); +} + +const mapDispatchToProps = { + fetchPaths, + clearPaths +}; + +class FileBrowserModalContentConnector extends Component { + + // Lifecycle + + componentDidMount() { + this.props.fetchPaths({ + path: this.props.value, + allowFoldersWithoutTrailingSlashes: true + }); + } + + // + // Listeners + + onFetchPaths = (path) => { + this.props.fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true + }); + } + + onClearPaths = () => { + // this.props.clearPaths(); + } + + onModalClose = () => { + this.props.clearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +FileBrowserModalContentConnector.propTypes = { + value: PropTypes.string, + fetchPaths: PropTypes.func.isRequired, + clearPaths: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css new file mode 100644 index 000000000..a9c34be6a --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css @@ -0,0 +1,5 @@ +.type { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 32px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js new file mode 100644 index 000000000..42ac30405 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './FileBrowserRow.css'; + +function getIconName(type) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +class FileBrowserRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.path); + } + + // + // Render + + render() { + const { + type, + name + } = this.props; + + return ( + + + + + + {name} + + ); + } + +} + +FileBrowserRow.propTypes = { + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FileBrowserRow; diff --git a/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js new file mode 100644 index 000000000..eea574dd1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: true, name: 'true' }, + { id: false, name: 'false' } +]; + +function BoolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default BoolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css new file mode 100644 index 000000000..fd56a4917 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css @@ -0,0 +1,15 @@ +.container { + display: flex; +} + +.numberInput { + composes: input from 'Components/Form/TextInput.css'; + + margin-right: 3px; +} + +.selectInput { + composes: select from 'Components/Form/SelectInput.css'; + + margin-left: 3px; +} diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js new file mode 100644 index 000000000..f0c2d3626 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js @@ -0,0 +1,171 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isString from 'Utilities/String/isString'; +import { IN_LAST, IN_NEXT } from 'Helpers/Props/filterTypes'; +import NumberInput from 'Components/Form/NumberInput'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import { NAME } from './FilterBuilderRowValue'; +import styles from './DateFilterBuilderRowValue.css'; + +const timeOptions = [ + { key: 'seconds', value: 'seconds' }, + { key: 'minutes', value: 'minutes' }, + { key: 'hours', value: 'hours' }, + { key: 'days', value: 'days' }, + { key: 'weeks', value: 'weeks' }, + { key: 'months', value: 'months' } +]; + +function isInFilter(filterType) { + return filterType === IN_LAST || filterType === IN_NEXT; +} + +class DateFilterBuilderRowValue extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + } + } + + componentDidUpdate(prevProps) { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (prevProps.filterType === filterType) { + return; + } + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + + return; + } + + if (!isInFilter(filterType) && !isString(filterValue)) { + onChange({ + name: NAME, + value: '' + }); + } + } + + // + // Listeners + + onValueChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + let newValue = value; + + if (!isString(value)) { + newValue = { + time: filterValue.time, + value + }; + } + + onChange({ + name: NAME, + value: newValue + }); + } + + onTimeChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + onChange({ + name: NAME, + value: { + time: value, + value: filterValue.value + } + }); + } + + // + // Render + + render() { + const { + filterType, + filterValue + } = this.props; + + if ( + (isInFilter(filterType) && isString(filterValue)) || + (!isInFilter(filterType) && !isString(filterValue)) + ) { + return null; + } + + if (isInFilter(filterType)) { + return ( +
+ + + +
+ ); + } + + return ( + + ); + } +} + +DateFilterBuilderRowValue.propTypes = { + filterType: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + onChange: PropTypes.func.isRequired +}; + +export default DateFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css new file mode 100644 index 000000000..6cc8fab67 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css @@ -0,0 +1,16 @@ +.labelContainer { + margin-bottom: 20px; +} + +.label { + margin-bottom: 5px; + font-weight: bold; +} + +.labelInputContainer { + width: 300px; +} + +.rows { + margin-bottom: 100px; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js new file mode 100644 index 000000000..ed3bc2409 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -0,0 +1,226 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import FilterBuilderRow from './FilterBuilderRow'; +import styles from './FilterBuilderModalContent.css'; + +class FilterBuilderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const filters = [...props.filters]; + + // Push an empty filter if there aren't any filters. FilterBuilderRow + // will handle initializing the filter. + + if (!filters.length) { + filters.push({}); + } + + this.state = { + label: props.label, + filters, + labelErrors: [] + }; + } + + componentDidUpdate(prevProps) { + const { + id, + customFilters, + isSaving, + saveError, + dispatchSetFilter, + onModalClose + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + if (id) { + dispatchSetFilter({ selectedFilterKey: id }); + } else { + const last = customFilters[customFilters.length -1]; + dispatchSetFilter({ selectedFilterKey: last.id }); + } + + onModalClose(); + } + } + + // + // Listeners + + onLabelChange = ({ value }) => { + this.setState({ label: value }); + } + + onFilterChange = (index, filter) => { + const filters = [...this.state.filters]; + filters.splice(index, 1, filter); + + this.setState({ + filters + }); + } + + onAddFilterPress = () => { + const filters = [...this.state.filters]; + filters.push({}); + + this.setState({ + filters + }); + } + + onRemoveFilterPress = (index) => { + const filters = [...this.state.filters]; + filters.splice(index, 1); + + this.setState({ + filters + }); + } + + onSaveFilterPress = () => { + const { + id, + customFilterType, + onSaveCustomFilterPress + } = this.props; + + const { + label, + filters + } = this.state; + + if (!label) { + this.setState({ + labelErrors: [ + { + message: 'Label is required' + } + ] + }); + + return; + } + + onSaveCustomFilterPress({ + id, + type: customFilterType, + label, + filters + }); + } + + // + // Render + + render() { + const { + sectionItems, + filterBuilderProps, + isSaving, + saveError, + onModalClose + } = this.props; + + const { + label, + filters, + labelErrors + } = this.state; + + return ( + + + Custom Filter + + + +
+
+ Label +
+ +
+ +
+
+ +
Filters
+ +
+ { + filters.map((filter, index) => { + return ( + + ); + }) + } +
+
+ + + + + + Save + + +
+ ); + } +} + +FilterBuilderModalContent.propTypes = { + id: PropTypes.number, + label: PropTypes.string.isRequired, + customFilterType: PropTypes.string.isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + onSaveCustomFilterPress: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterBuilderModalContent; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js new file mode 100644 index 000000000..c94db9925 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js @@ -0,0 +1,42 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import FilterBuilderModalContent from './FilterBuilderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { customFilters }) => customFilters, + (state, { id }) => id, + (state) => state.customFilters.isSaving, + (state) => state.customFilters.saveError, + (customFilters, id, isSaving, saveError) => { + if (id) { + const customFilter = customFilters.find((c) => c.id === id); + + return { + id: customFilter.id, + label: customFilter.label, + filters: customFilter.filters, + customFilters, + isSaving, + saveError + }; + } + + return { + label: '', + filters: [], + customFilters, + isSaving, + saveError + }; + } + ); +} + +const mapDispatchToProps = { + onSaveCustomFilterPress: saveCustomFilter, + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css new file mode 100644 index 000000000..c5471b253 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css @@ -0,0 +1,32 @@ +.filterRow { + display: flex; + margin-bottom: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.inputContainer { + flex: 0 1 200px; + margin-right: 10px; +} + +.valueInputContainer { + flex: 0 1 300px; + margin-right: 10px; +} + +.actionsContainer { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterRow { + display: block; + } + + .inputContainer { + margin-bottom: 10px; + } +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js new file mode 100644 index 000000000..7c3ab83e1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -0,0 +1,286 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import IconButton from 'Components/Link/IconButton'; +import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; +import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import LanguageProfileFilterBuilderRowValueConnector from './LanguageProfileFilterBuilderRowValueConnector'; +import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; +import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; +import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; +import styles from './FilterBuilderRow.css'; + +function getselectedFilterBuilderProp(filterBuilderProps, name) { + return filterBuilderProps.find((a) => { + return a.name === name; + }); +} + +function getFilterTypeOptions(filterBuilderProps, filterKey) { + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey); + + if (!selectedFilterBuilderProp) { + return []; + } + + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type]; +} + +function getDefaultFilterType(selectedFilterBuilderProp) { + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key; +} + +function getDefaultFilterValue(selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) { + return ''; + } + + return []; +} + +function getRowValueConnector(selectedFilterBuilderProp) { + if (!selectedFilterBuilderProp) { + return FilterBuilderRowValueConnector; + } + + const valueType = selectedFilterBuilderProp.valueType; + + switch (valueType) { + case filterBuilderValueTypes.BOOL: + return BoolFilterBuilderRowValue; + + case filterBuilderValueTypes.DATE: + return DateFilterBuilderRowValue; + + case filterBuilderValueTypes.INDEXER: + return IndexerFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.LANGUAGE_PROFILE: + return LanguageProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.PROTOCOL: + return ProtocolFilterBuilderRowValue; + + case filterBuilderValueTypes.QUALITY: + return QualityFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.QUALITY_PROFILE: + return QualityProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.SERIES_STATUS: + return SeriesStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.TAG: + return TagFilterBuilderRowValueConnector; + + default: + return FilterBuilderRowValueConnector; + } +} + +class FilterBuilderRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + filterKey, + filterBuilderProps + } = props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + } + } + + componentDidMount() { + const { + index, + filterKey, + filterBuilderProps, + onFilterChange + } = this.props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + + return; + } + + const selectedFilterBuilderProp = filterBuilderProps[0]; + + const filter = { + key: selectedFilterBuilderProp.name, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type: getDefaultFilterType(selectedFilterBuilderProp) + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + // + // Listeners + + onFilterKeyChange = ({ value: key }) => { + const { + index, + filterBuilderProps, + onFilterChange + } = this.props; + + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key); + const type = getDefaultFilterType(selectedFilterBuilderProp); + + const filter = { + key, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + onFilterChange = ({ name, value }) => { + const { + index, + filterKey, + filterValue, + filterType, + onFilterChange + } = this.props; + + const filter = { + key: filterKey, + value: filterValue, + type: filterType + }; + + filter[name] = value; + + onFilterChange(index, filter); + } + + onAddPress = () => { + const { + index, + onAddPress + } = this.props; + + onAddPress(index); + } + + onRemovePress = () => { + const { + index, + onRemovePress + } = this.props; + + onRemovePress(index); + } + + // + // Render + + render() { + const { + filterKey, + filterType, + filterValue, + filterCount, + filterBuilderProps, + sectionItems + } = this.props; + + const selectedFilterBuilderProp = this.selectedFilterBuilderProp; + + const keyOptions = filterBuilderProps.map((availablePropFilter) => { + return { + key: availablePropFilter.name, + value: availablePropFilter.label + }; + }); + + const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); + + return ( +
+
+ { + filterKey && + + } +
+ +
+ { + filterType && + + } +
+ +
+ { + filterValue != null && !!selectedFilterBuilderProp && + + } +
+ +
+ + + +
+
+ ); + } +} + +FilterBuilderRow.propTypes = { + index: PropTypes.number.isRequired, + filterKey: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]), + filterType: PropTypes.string, + filterCount: PropTypes.number.isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + onFilterChange: PropTypes.func.isRequired, + onAddPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default FilterBuilderRow; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js new file mode 100644 index 000000000..70c496620 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import convertToBytes from 'Utilities/Number/convertToBytes'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; +import TagInput, { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; + +export const NAME = 'value'; + +function getTagDisplayValue(value, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + return formatBytes(value); + } + + return value; +} + +function getValue(input, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + if (match && match.length > 1) { + const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + switch (unit.toLowerCase()) { + case 'k': + return convertToBytes(value, 1, true); + case 'm': + return convertToBytes(value, 2, true); + case 'g': + return convertToBytes(value, 3, true); + case 't': + return convertToBytes(value, 4, true); + case 'kb': + return convertToBytes(value, 1, true); + case 'mb': + return convertToBytes(value, 2, true); + case 'gb': + return convertToBytes(value, 3, true); + case 'tb': + return convertToBytes(value, 4, true); + case 'kib': + return convertToBytes(value, 1, true); + case 'mib': + return convertToBytes(value, 2, true); + case 'gib': + return convertToBytes(value, 3, true); + case 'tib': + return convertToBytes(value, 4, true); + default: + return parseInt(value); + } + } + } + + if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { + return parseInt(input); + } + + return input; +} + +class FilterBuilderRowValue extends Component { + + // + // Listeners + + onTagAdd = (tag) => { + const { + filterValue, + selectedFilterBuilderProp, + onChange + } = this.props; + + let value = tag.id; + + if (value == null) { + value = getValue(tag.name, selectedFilterBuilderProp); + } + + onChange({ + name: NAME, + value: [...filterValue, value] + }); + } + + onTagDelete = ({ index }) => { + const { + filterValue, + onChange + } = this.props; + + const value = filterValue.filter((v, i) => i !== index); + + onChange({ + name: NAME, + value + }); + } + + // + // Render + + render() { + const { + filterValue, + selectedFilterBuilderProp, + tagList + } = this.props; + + const hasItems = !!tagList.length; + + const tags = filterValue.map((id) => { + if (hasItems) { + const tag = tagList.find((t) => t.id === id); + + return { + id, + name: tag && tag.name + }; + } + + return { + id, + name: getTagDisplayValue(id, selectedFilterBuilderProp) + }; + }); + + return ( + + ); + } +} + +FilterBuilderRowValue.propTypes = { + filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired, + selectedFilterBuilderProp: PropTypes.object.isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + onChange: PropTypes.func.isRequired +}; + +FilterBuilderRowValue.defaultProps = { + filterValue: [] +}; + +export default FilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js new file mode 100644 index 000000000..ac74240e4 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes } from 'Helpers/Props'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createTagListSelector() { + return createSelector( + (state, { sectionItems }) => sectionItems, + (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp, + (sectionItems, selectedFilterBuilderProp) => { + if ( + selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || + selectedFilterBuilderProp.type === filterBuilderTypes.STRING + ) { + return []; + } + + let items = []; + + if (selectedFilterBuilderProp.optionsSelector) { + items = selectedFilterBuilderProp.optionsSelector(sectionItems); + } else { + items = sectionItems.reduce((acc, item) => { + const name = item[selectedFilterBuilderProp.name]; + + if (name) { + acc.push({ + id: name, + name + }); + } + + return acc; + }, []).sort(sortByName); + } + + return _.uniqBy(items, 'id'); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createTagListSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css new file mode 100644 index 000000000..1c4c5acf1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css @@ -0,0 +1,19 @@ +.tag { + &.isLastTag { + .or { + display: none; + } + } +} + +.label { + composes: label from 'Components/Label.css'; + + border-style: none; + font-size: 13px; +} + +.or { + margin: 0 3px; + color: $themeDarkColor; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js new file mode 100644 index 000000000..573e05759 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import TagInputTag from 'Components/Form/TagInputTag'; +import styles from './FilterBuilderRowValueTag.css'; + +function FilterBuilderRowValueTag(props) { + return ( + + + + { + !props.isLastTag && + + or + + } + + ); +} + +FilterBuilderRowValueTag.propTypes = { + isLastTag: PropTypes.bool.isRequired +}; + +export default FilterBuilderRowValueTag; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..968b26d2c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (qualityProfiles) => { + const { + isFetching, + isPopulated, + error, + items + } = qualityProfiles; + + const tagList = items.map((item) => { + return { + id: item.id, + name: item.name + }; + }); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +IndexerFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..31b1e952a --- /dev/null +++ b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + (languageProfiles) => { + const tagList = languageProfiles.items.map((languageProfile) => { + const { + id, + name + } = languageProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js new file mode 100644 index 000000000..ae63ae0eb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'torrent', name: 'Torrent' }, + { id: 'usenet', name: 'Usenet' } +]; + +function ProtocolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ProtocolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..0290bcdcb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + const tagList = getQualities(schema.items); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema +}; + +class QualityFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +QualityFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..4a8b82283 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js @@ -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); diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js new file mode 100644 index 000000000..50841a013 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'continuing', name: 'Continuing' }, + { id: 'ended', name: 'Ended' } +]; + +function SeriesStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default SeriesStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..60e04c446 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList: tagList.map((tag) => { + const { + id, + label: name + } = tag; + + return { + id, + name + }; + }) + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css new file mode 100644 index 000000000..7acb69dc7 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css @@ -0,0 +1,17 @@ +.customFilter { + display: flex; + margin-bottom: 5px; + padding: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.label { + flex: 0 1 300px; +} + +.actions { + flex: 0 0 60px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js new file mode 100644 index 000000000..c9c326d78 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './CustomFilter.css'; + +class CustomFilter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDeleting: false + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) { + this.setState({ isDeleting: false }); + } + } + + componentWillUnmount() { + const { + id, + selectedFilterKey, + dispatchSetFilter + } = this.props; + + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a ancestor would be more accurate, but would have + // more boilerplate. + if (this.state.isDeleting && id === selectedFilterKey) { + dispatchSetFilter({ selectedFilterKey: 'all' }); + } + } + + // + // Listeners + + onEditPress = () => { + const { + id, + onEditPress + } = this.props; + + onEditPress(id); + } + + onRemovePress = () => { + const { + id, + dispatchDeleteCustomFilter + } = this.props; + + this.setState({ isDeleting: true }, () => { + dispatchDeleteCustomFilter({ id }); + }); + + } + + // + // Render + + render() { + const { + label + } = this.props; + + return ( +
+
+ {label} +
+ +
+ + + +
+
+ ); + } +} + +CustomFilter.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchSetFilter: PropTypes.func.isRequired, + onEditPress: PropTypes.func.isRequired, + dispatchDeleteCustomFilter: PropTypes.func.isRequired +}; + +export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css new file mode 100644 index 000000000..c391764dc --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css @@ -0,0 +1,3 @@ +.addButtonContainer { + margin-top: 15px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js new file mode 100644 index 000000000..1a7168fca --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CustomFilter from './CustomFilter'; +import styles from './CustomFiltersModalContent.css'; + +function CustomFiltersModalContent(props) { + const { + selectedFilterKey, + customFilters, + isDeleting, + deleteError, + dispatchDeleteCustomFilter, + dispatchSetFilter, + onAddCustomFilter, + onEditCustomFilter, + onModalClose + } = props; + + return ( + + + Custom Filters + + + + { + customFilters.map((customFilter, index) => { + return ( + + ); + }) + } + +
+ +
+
+ + + + +
+ ); +} + +CustomFiltersModalContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onAddCustomFilter: PropTypes.func.isRequired, + onEditCustomFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CustomFiltersModalContent; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js new file mode 100644 index 000000000..32425d766 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import CustomFiltersModalContent from './CustomFiltersModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.customFilters.isDeleting, + (state) => state.customFilters.deleteError, + (isDeleting, deleteError) => { + return { + isDeleting, + deleteError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent); diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js new file mode 100644 index 000000000..750d1ed48 --- /dev/null +++ b/frontend/src/Components/Filter/FilterModal.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector'; +import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector'; + +class FilterModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filterBuilder: !props.customFilters.length, + id: null + }; + } + + // + // Listeners + + onAddCustomFilter = () => { + this.setState({ + filterBuilder: true + }); + } + + onEditCustomFilter = (id) => { + this.setState({ + filterBuilder: true, + id + }); + } + + onModalClose = () => { + this.setState({ + filterBuilder: false, + id: null + }, () => { + this.props.onModalClose(); + }); + } + + // + // Render + + render() { + const { + isOpen, + ...otherProps + } = this.props; + + const { + filterBuilder, + id + } = this.state; + + return ( + + { + filterBuilder ? + : + + } + + ); + } +} + +FilterModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterModal; diff --git a/frontend/src/Components/Form/AutoCompleteInput.css b/frontend/src/Components/Form/AutoCompleteInput.css new file mode 100644 index 000000000..417a71437 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.css @@ -0,0 +1,58 @@ +.input { + composes: input from 'Components/Form/Input.css'; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.inputWrapper { + display: flex; +} + +.inputContainer { + position: relative; + flex-grow: 1; +} + +.container { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.inputContainerOpen { + .container { + position: absolute; + z-index: 1; + overflow-y: auto; + max-height: 200px; + width: 100%; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; +} + +.match { + font-weight: bold; +} + +.highlighted { + background-color: $menuItemHoverBackgroundColor; +} diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js new file mode 100644 index 000000000..740726b36 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.js @@ -0,0 +1,162 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import classNames from 'classnames'; +import jdu from 'jdu'; +import styles from './AutoCompleteInput.css'; + +class AutoCompleteInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + suggestions: [] + }; + } + + // + // Control + + getSuggestionValue(item) { + return item; + } + + renderSuggestion(item) { + return item; + } + + // + // Listeners + + onInputChange = (event, { newValue }) => { + this.props.onChange({ + name: this.props.name, + value: newValue + }); + } + + onInputKeyDown = (event) => { + const { + name, + value, + onChange + } = this.props; + + const { suggestions } = this.state; + + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== this.props.value + ) { + event.preventDefault(); + + if (value) { + onChange({ + name, + value: suggestions[0] + }); + } + } + } + + onInputBlur = () => { + this.setState({ suggestions: [] }); + } + + onSuggestionsFetchRequested = ({ value }) => { + const { values } = this.props; + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().contains(lowerCaseValue); + }); + + this.setState({ suggestions: filteredValues }); + } + + onSuggestionsClearRequested = () => { + this.setState({ suggestions: [] }); + } + + // + // Render + + render() { + const { + className, + inputClassName, + name, + value, + placeholder, + hasError, + hasWarning + } = this.props; + + const { suggestions } = this.state; + + const inputProps = { + className: classNames( + inputClassName, + hasError && styles.hasError, + hasWarning && styles.hasWarning, + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: this.onInputChange, + onKeyDown: this.onInputKeyDown, + onBlur: this.onInputBlur + }; + + const theme = { + container: styles.inputContainer, + containerOpen: styles.inputContainerOpen, + suggestionsContainer: styles.container, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ +
+ ); + } +} + +AutoCompleteInput.propTypes = { + className: PropTypes.string.isRequired, + inputClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.string).isRequired, + placeholder: PropTypes.string, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + onChange: PropTypes.func.isRequired +}; + +AutoCompleteInput.defaultProps = { + className: styles.inputWrapper, + inputClassName: styles.input, + value: '' +}; + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css new file mode 100644 index 000000000..e7cd1dc4e --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.css @@ -0,0 +1,23 @@ +.captchaInputWrapper { + display: flex; +} + +.input { + composes: input from 'Components/Form/Input.css'; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.hasButton { + composes: hasButton from 'Components/Form/Input.css'; +} + +.recaptchaWrapper { + margin-top: 10px; +} diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js new file mode 100644 index 000000000..e1a5df458 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +function CaptchaInput(props) { + const { + className, + name, + value, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, + onRefreshPress, + onCaptchaChange + } = props; + + return ( +
+
+ + + + + +
+ + { + !!siteKey && !!secretToken && +
+ +
+ } +
+ ); +} + +CaptchaInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + refreshing: PropTypes.bool.isRequired, + siteKey: PropTypes.string, + secretToken: PropTypes.string, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onCaptchaChange: PropTypes.func.isRequired +}; + +CaptchaInput.defaultProps = { + className: styles.input, + value: '' +}; + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js new file mode 100644 index 000000000..17b875c88 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInputConnector.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions'; +import CaptchaInput from './CaptchaInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.captcha, + (captcha) => { + return captcha; + } + ); +} + +const mapDispatchToProps = { + refreshCaptcha, + getCaptchaCookie, + resetCaptcha +}; + +class CaptchaInputConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + name, + token, + onChange + } = this.props; + + if (token && token !== prevProps.token) { + onChange({ name, value: token }); + } + } + + componentWillUnmount = () => { + this.props.resetCaptcha(); + } + + // + // Listeners + + onRefreshPress = () => { + const { + provider, + providerData + } = this.props; + + this.props.refreshCaptcha({ provider, providerData }); + } + + onCaptchaChange = (captchaResponse) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!captchaResponse) { + return; + } + + const { + provider, + providerData + } = this.props; + + this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CaptchaInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + token: PropTypes.string, + onChange: PropTypes.func.isRequired, + refreshCaptcha: PropTypes.func.isRequired, + getCaptchaCookie: PropTypes.func.isRequired, + resetCaptcha: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css new file mode 100644 index 000000000..5c35e5d2f --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.css @@ -0,0 +1,105 @@ +.container { + position: relative; + display: flex; + flex: 1 1 65%; + user-select: none; +} + +.label { + display: flex; + margin-bottom: 0; + min-height: 21px; + font-weight: normal; + cursor: pointer; +} + +.checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + pointer-events: none; + + &:global(.isDisabled) { + cursor: not-allowed; + } +} + +.input { + flex: 1 0 auto; + margin-top: 7px; + margin-right: 5px; + width: 20px; + height: 20px; + border: 1px solid #ccc; + border-radius: 2px; + background-color: $white; + color: $white; + text-align: center; + line-height: 20px; +} + +.checkbox:focus + .input { + outline: 0; + border-color: $inputFocusBorderColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; +} + +.dangerIsChecked { + border-color: $dangerColor; + background-color: $dangerColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.primaryIsChecked { + border-color: $primaryColor; + background-color: $primaryColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.successIsChecked { + border-color: $successColor; + background-color: $successColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.warningIsChecked { + border-color: $warningColor; + background-color: $warningColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.isNotChecked { + &.isDisabled { + border-color: $disabledCheckInputColor; + background-color: $disabledCheckInputColor; + opacity: 0.7; + } +} + +.isIndeterminate { + border-color: $gray; + background-color: $gray; +} + +.helpText { + composes: helpText from 'Components/Form/FormInputHelpText.css'; + + margin-top: 8px; + margin-left: 5px; +} + +.isDisabled { + cursor: not-allowed; +} diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js new file mode 100644 index 000000000..134290111 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.js @@ -0,0 +1,191 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +class CheckInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._checkbox = null; + } + + componentDidMount() { + this.setIndeterminate(); + } + + componentDidUpdate() { + this.setIndeterminate(); + } + + // + // Control + + setIndeterminate() { + if (!this._checkbox) { + return; + } + + const { + value, + uncheckedValue, + checkedValue + } = this.props; + + this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; + } + + toggleChecked = (checked, shiftKey) => { + const { + name, + value, + checkedValue, + uncheckedValue + } = this.props; + + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + this.props.onChange({ + name, + value: newValue, + shiftKey + }); + } + } + + // + // Listeners + + setRef = (ref) => { + this._checkbox = ref; + } + + onClick = (event) => { + if (this.props.isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !this._checkbox.checked; + + event.preventDefault(); + this.toggleChecked(checked, shiftKey); + } + + onChange = (event) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + this.toggleChecked(checked, shiftKey); + } + + // + // Render + + render() { + const { + className, + containerClassName, + name, + value, + checkedValue, + uncheckedValue, + helpText, + helpTextWarning, + isDisabled, + kind + } = this.props; + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass = `${kind}IsChecked`; + + return ( +
+ +
+ ); + } +} + +CheckInput.propTypes = { + className: PropTypes.string.isRequired, + containerClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + checkedValue: PropTypes.bool, + uncheckedValue: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + helpText: PropTypes.string, + helpTextWarning: PropTypes.string, + isDisabled: PropTypes.bool, + kind: PropTypes.oneOf(kinds.all).isRequired, + onChange: PropTypes.func.isRequired +}; + +CheckInput.defaultProps = { + className: styles.input, + containerClassName: styles.container, + checkedValue: true, + uncheckedValue: false, + kind: kinds.PRIMARY +}; + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css new file mode 100644 index 000000000..0c518e98e --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.css @@ -0,0 +1,8 @@ +.deviceInputWrapper { + display: flex; +} + +.inputContainer { + composes: inputContainer from './TagInput.css'; + composes: hasButton from 'Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js new file mode 100644 index 000000000..a38648e1a --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TagInput, { tagShape } from './TagInput'; +import styles from './DeviceInput.css'; + +class DeviceInput extends Component { + + onTagAdd = (device) => { + const { + name, + value, + onChange + } = this.props; + + // New tags won't have an ID, only a name. + const deviceId = device.id || device.name; + + onChange({ + name, + value: [...value, deviceId] + }); + } + + onTagDelete = ({ index }) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue + }); + } + + // + // Render + + render() { + const { + className, + items, + selectedDevices, + hasError, + hasWarning, + isFetching, + onRefreshPress + } = this.props; + + return ( +
+ + + + + +
+ ); + } +} + +DeviceInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired +}; + +DeviceInput.defaultProps = { + className: styles.deviceInputWrapper, + inputClassName: styles.input +}; + +export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js new file mode 100644 index 000000000..d53372b35 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions'; +import DeviceInput from './DeviceInput'; + +function createMapStateToProps() { + return createSelector( + (state, { value }) => value, + (state) => state.devices, + (value, devices) => { + + return { + ...devices, + selectedDevices: value.map((valueDevice) => { + // Disable equality ESLint rule so we don't need to worry about + // a type mismatch between the value items and the device ID. + // eslint-disable-next-line eqeqeq + const device = devices.items.find((d) => d.id == valueDevice); + + if (device) { + return { + id: device.id, + name: `${device.name} (${device.id})` + }; + } + + return { + id: valueDevice, + name: `Unknown (${valueDevice})` + }; + }) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDevices: fetchDevices, + dispatchClearDevices: clearDevices +}; + +class DeviceInputConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + this._populate(); + } + + componentWillUnmount = () => { + // this.props.dispatchClearDevices(); + } + + // + // Control + + _populate() { + const { + provider, + providerData, + dispatchFetchDevices + } = this.props; + + dispatchFetchDevices({ provider, providerData }); + } + + // + // Listeners + + onRefreshPress = () => { + this._populate(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeviceInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchDevices: PropTypes.func.isRequired, + dispatchClearDevices: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css new file mode 100644 index 000000000..568e35f40 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -0,0 +1,79 @@ +.tether { + z-index: 2000; +} + +.enhancedSelect { + composes: input from 'Components/Form/Input.css'; + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $black; + cursor: default; +} + +.hasError { + composes: hasError from 'Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; +} + +.isDisabled { + opacity: 0.7; + cursor: not-allowed; +} + +.dropdownArrowContainer { + margin-left: 12px; +} + +.dropdownArrowContainerDisabled { + composes: dropdownArrowContainer; + + color: $disabledInputColor; +} + +.optionsContainer { + width: auto; +} + +.options { + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.optionsModal { + display: flex; + justify-content: center; + max-width: 90%; + width: 350px !important; + height: auto !important; +} + +.optionsModalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + justify-content: center; + flex-direction: column; + padding: 10px 0; +} + +.optionsModalScroller { + composes: scroller from 'Components/Scroller/Scroller.css'; + + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js new file mode 100644 index 000000000..a127feaed --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -0,0 +1,411 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { icons, scrollDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import Scroller from 'Components/Scroller/Scroller'; +import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; +import EnhancedSelectInputOption from './EnhancedSelectInputOption'; +import styles from './EnhancedSelectInput.css'; + +const tetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + attachment: 'top left', + targetAttachment: 'bottom left' +}; + +function isArrowKey(keyCode) { + return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; +} + +function getSelectedOption(selectedIndex, values) { + return values[selectedIndex]; +} + +function findIndex(startingIndex, direction, values) { + let indexToTest = startingIndex + direction; + + while (indexToTest !== startingIndex) { + if (indexToTest < 0) { + indexToTest = values.length - 1; + } else if (indexToTest >= values.length) { + indexToTest = 0; + } + + if (getSelectedOption(indexToTest, values).isDisabled) { + indexToTest = indexToTest + direction; + } else { + return indexToTest; + } + } +} + +function previousIndex(selectedIndex, values) { + return findIndex(selectedIndex, -1, values); +} + +function nextIndex(selectedIndex, values) { + return findIndex(selectedIndex, 1, values); +} + +function getSelectedIndex(props) { + const { + value, + values + } = props; + + return values.findIndex((v) => { + return v.key === value; + }); +} + +function getKey(selectedIndex, values) { + return values[selectedIndex].key; +} + +class EnhancedSelectInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false, + selectedIndex: getSelectedIndex(props), + width: 0, + isMobile: isMobileUtil() + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.value !== this.props.value) { + this.setState({ + selectedIndex: getSelectedIndex(this.props) + }); + } + } + + // + // Control + + _setButtonRef = (ref) => { + this._buttonRef = ref; + } + + _setOptionsRef = (ref) => { + this._optionsRef = ref; + } + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = ReactDOM.findDOMNode(this._buttonRef); + const options = ReactDOM.findDOMNode(this._optionsRef); + + if (!button || this.state.isMobile) { + return; + } + + if ( + !button.contains(event.target) && + options && + !options.contains(event.target) && + this.state.isOpen + ) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onBlur = () => { + this.setState({ + selectedIndex: getSelectedIndex(this.props) + }); + } + + onKeyDown = (event) => { + const { + values + } = this.props; + + const { + isOpen, + selectedIndex + } = this.state; + + const keyCode = event.keyCode; + const newState = {}; + + if (!isOpen) { + if (isArrowKey(keyCode)) { + event.preventDefault(); + newState.isOpen = true; + } + + if ( + selectedIndex == null || + getSelectedOption(selectedIndex, values).isDisabled + ) { + if (keyCode === keyCodes.UP_ARROW) { + newState.selectedIndex = previousIndex(0, values); + } else if (keyCode === keyCodes.DOWN_ARROW) { + newState.selectedIndex = nextIndex(values.length - 1, values); + } + } + + this.setState(newState); + return; + } + + if (keyCode === keyCodes.UP_ARROW) { + event.preventDefault(); + newState.selectedIndex = previousIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.DOWN_ARROW) { + event.preventDefault(); + newState.selectedIndex = nextIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.ENTER) { + event.preventDefault(); + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.TAB) { + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.ESCAPE) { + event.preventDefault(); + event.stopPropagation(); + newState.isOpen = false; + newState.selectedIndex = getSelectedIndex(this.props); + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSelect = (value) => { + this.setState({ isOpen: false }); + + this.props.onChange({ + name: this.props.name, + value + }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + onOptionsModalClose = () => { + this.setState({ isOpen: false }); + } + + // + // Render + + render() { + const { + className, + disabledClassName, + values, + isDisabled, + hasError, + hasWarning, + selectedValueOptions, + selectedValueComponent: SelectedValueComponent, + optionComponent: OptionComponent + } = this.props; + + const { + selectedIndex, + width, + isOpen, + isMobile + } = this.state; + + const selectedOption = getSelectedOption(selectedIndex, values); + + return ( +
+ + + + + {selectedOption ? selectedOption.value : null} + + +
+ +
+ +
+ + { + isOpen && !isMobile && +
+
+ { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } +
+
+ } +
+ + { + isMobile && + + + + { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } + + + + } +
+ ); + } +} + +EnhancedSelectInput.propTypes = { + className: PropTypes.string, + disabledClassName: PropTypes.string, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + selectedValueOptions: PropTypes.object.isRequired, + selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, + optionComponent: PropTypes.func, + onChange: PropTypes.func.isRequired +}; + +EnhancedSelectInput.defaultProps = { + className: styles.enhancedSelect, + disabledClassName: styles.isDisabled, + isDisabled: false, + selectedValueOptions: {}, + selectedValueComponent: EnhancedSelectInputSelectedValue, + optionComponent: EnhancedSelectInputOption +}; + +export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css new file mode 100644 index 000000000..2b96de47f --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -0,0 +1,41 @@ +.option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + width: 100%; + cursor: default; + + &:hover { + background-color: #f9f9f9; + } +} + +.isSelected { + background-color: #e2e2e2; + + &.isMobile { + background-color: inherit; + + .iconContainer { + color: $primaryColor; + } + } +} + +.isDisabled { + background-color: #aaa; +} + +.isHidden { + display: none; +} + +.isMobile { + height: 50px; + border-bottom: 1px solid $borderColor; + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js new file mode 100644 index 000000000..e1b410c28 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './EnhancedSelectInputOption.css'; + +class EnhancedSelectInputOption extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + onSelect + } = this.props; + + onSelect(id); + } + + // + // Render + + render() { + const { + className, + isSelected, + isDisabled, + isHidden, + isMobile, + children + } = this.props; + + return ( + + {children} + + { + isMobile && +
+ +
+ } + + ); + } +} + +EnhancedSelectInputOption.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + isHidden: PropTypes.bool.isRequired, + isMobile: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onSelect: PropTypes.func.isRequired +}; + +EnhancedSelectInputOption.defaultProps = { + className: styles.option, + isDisabled: false, + isHidden: false +}; + +export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css new file mode 100644 index 000000000..6b8b73af9 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css @@ -0,0 +1,7 @@ +.selectedValue { + flex: 1 1 auto; +} + +.isDisabled { + color: $disabledInputColor; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js new file mode 100644 index 000000000..c40ee93c1 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './EnhancedSelectInputSelectedValue.css'; + +function EnhancedSelectInputSelectedValue(props) { + const { + className, + children, + isDisabled + } = props; + + return ( +
+ {children} +
+ ); +} + +EnhancedSelectInputSelectedValue.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + isDisabled: PropTypes.bool.isRequired +}; + +EnhancedSelectInputSelectedValue.defaultProps = { + className: styles.selectedValue, + isDisabled: false +}; + +export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js new file mode 100644 index 000000000..9a605297a --- /dev/null +++ b/frontend/src/Components/Form/Form.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; + +function Form({ children, validationErrors, validationWarnings, ...otherProps }) { + return ( +
+
+ { + validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + }) + } + + { + validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + }) + } +
+ + {children} +
+ ); +} + +Form.propTypes = { + children: PropTypes.node.isRequired, + validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, + validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +Form.defaultProps = { + validationErrors: [], + validationWarnings: [] +}; + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css new file mode 100644 index 000000000..ddce8863b --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.css @@ -0,0 +1,28 @@ +.group { + display: flex; + margin-bottom: 20px; +} + +/* Sizes */ + +.extraSmall { + max-width: $formGroupExtraSmallWidth; +} + +.small { + max-width: $formGroupSmallWidth; +} + +.medium { + max-width: $formGroupMediumWidth; +} + +.large { + max-width: $formGroupLargeWidth; +} + +@media only screen and (max-width: $breakpointLarge) { + .group { + display: block; + } +} diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js new file mode 100644 index 000000000..d2e04c350 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import { sizes } from 'Helpers/Props'; +import styles from './FormGroup.css'; + +function FormGroup(props) { + const { + className, + children, + size, + advancedSettings, + isAdvanced, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ { + map(children, (child) => { + return React.cloneElement(child, childProps); + }) + } +
+ ); +} + +FormGroup.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + advancedSettings: PropTypes.bool.isRequired, + isAdvanced: PropTypes.bool.isRequired +}; + +FormGroup.defaultProps = { + className: styles.group, + size: sizes.SMALL, + advancedSettings: false, + isAdvanced: false +}; + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css new file mode 100644 index 000000000..27a1923be --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.css @@ -0,0 +1,12 @@ +.button { + composes: button from 'Components/Link/Button.css'; + + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.middleButton { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js new file mode 100644 index 000000000..4b6491663 --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import { kinds } from 'Helpers/Props'; +import styles from './FormInputButton.css'; + +function FormInputButton(props) { + const { + className, + canSpin, + isLastButton, + ...otherProps + } = props; + + if (canSpin) { + return ( + + ); + } + + return ( + + ); +} + +SpinnerButton.propTypes = { + className: PropTypes.string.isRequired, + isSpinning: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool, + spinnerIcon: PropTypes.object.isRequired, + children: PropTypes.node +}; + +SpinnerButton.defaultProps = { + className: styles.button, + spinnerIcon: icons.SPINNER +}; + +export default SpinnerButton; diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css new file mode 100644 index 000000000..5f4e68545 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.css @@ -0,0 +1,23 @@ +.iconContainer { + composes: spinnerContainer from 'Components/Link/SpinnerButton.css'; +} + +.icon { + z-index: 1; +} + +.label { + composes: label from 'Components/Link/SpinnerButton.css'; +} + +.showIcon { + .iconContainer { + left: 50%; + visibility: visible; + } + + .label { + left: 100%; + opacity: 0; + } +} diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js new file mode 100644 index 000000000..0575db094 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.js @@ -0,0 +1,162 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import styles from './SpinnerErrorButton.css'; + +function getTestResult(error) { + if (!error) { + return { + wasSuccessful: true, + hasWarning: false, + hasError: false + }; + } + + if (error.status !== 400) { + return { + wasSuccessful: false, + hasWarning: false, + hasError: true + }; + } + + const failures = error.responseJSON; + + const hasWarning = _.some(failures, { isWarning: true }); + const hasError = _.some(failures, (failure) => !failure.isWarning); + + return { + wasSuccessful: false, + hasWarning, + hasError + }; +} + +class SpinnerErrorButton extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._testResultTimeout = null; + + this.state = { + wasSuccessful: false, + hasWarning: false, + hasError: false + }; + } + + componentDidUpdate(prevProps) { + const { + isSpinning, + error + } = this.props; + + if (prevProps.isSpinning && !isSpinning) { + const testResult = getTestResult(error); + + this.setState(testResult, () => { + const { + wasSuccessful, + hasWarning, + hasError + } = testResult; + + if (wasSuccessful || hasWarning || hasError) { + this._testResultTimeout = setTimeout(this.resetState, 3000); + } + }); + } + } + + componentWillUnmount() { + if (this._testResultTimeout) { + clearTimeout(this._testResultTimeout); + } + } + + // + // Control + + resetState = () => { + this.setState({ + wasSuccessful: false, + hasWarning: false, + hasError: false + }); + } + + // + // Render + + render() { + const { + isSpinning, + error, + children, + ...otherProps + } = this.props; + + const { + wasSuccessful, + hasWarning, + hasError + } = this.state; + + const showIcon = wasSuccessful || hasWarning || hasError; + + let iconName = icons.CHECK; + let iconKind = kinds.SUCCESS; + + if (hasWarning) { + iconName = icons.WARNING; + iconKind = kinds.WARNING; + } + + if (hasError) { + iconName = icons.DANGER; + iconKind = kinds.DANGER; + } + + return ( + + + { + showIcon && + + + + } + + { + + { + children + } + + } + + + ); + } +} + +SpinnerErrorButton.propTypes = { + isSpinning: PropTypes.bool.isRequired, + error: PropTypes.object, + children: PropTypes.node.isRequired +}; + +export default SpinnerErrorButton; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js new file mode 100644 index 000000000..a804fafc5 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from './IconButton'; + +function SpinnerIconButton(props) { + const { + name, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIconButton.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isDisabled: PropTypes.bool.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIconButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default SpinnerIconButton; diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css new file mode 100644 index 000000000..fd224b1d6 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.css @@ -0,0 +1,49 @@ +.loading { + margin-top: 20px; + text-align: center; +} + +.rippleContainer { + position: relative; + display: inline-block; +} + +.ripple:nth-child(0) { + animation-delay: -0.8s; +} + +.ripple:nth-child(1) { + animation-delay: -0.6s; +} + +.ripple:nth-child(2) { + animation-delay: -0.4s; +} + +.ripple:nth-child(3) { + animation-delay: -0.2s; +} + +.ripple { + position: absolute; + border: 2px solid #3a3f51; + border-radius: 100%; + animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); + animation-fill-mode: both; +} + +@keyframes rippleContainer { + 0% { + opacity: 1; + transform: scale(0.1); + } + + 70% { + opacity: 0.7; + transform: scale(1); + } + + 100% { + opacity: 0; + } +} diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js new file mode 100644 index 000000000..5f9a15b1a --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './LoadingIndicator.css'; + +function LoadingIndicator({ className, size }) { + const sizeInPx = `${size}px`; + const width = sizeInPx; + const height = sizeInPx; + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +LoadingIndicator.propTypes = { + className: PropTypes.string, + size: PropTypes.number +}; + +LoadingIndicator.defaultProps = { + className: styles.loading, + size: 50 +}; + +export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css new file mode 100644 index 000000000..a7b39e76f --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.css @@ -0,0 +1,6 @@ +.loadingMessage { + margin: 50px 10px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js new file mode 100644 index 000000000..f080731c3 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.js @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './LoadingMessage.css'; + +const messages = [ + 'Downloading more RAM', + 'Now in Technicolor', + 'Previously on Sonarr...', + 'Bleep Bloop.', + 'Locating the required gigapixels to render...', + 'Spinning up the hamster wheel...', + 'At least you\'re not on hold', + 'Hum something loud while others stare', + 'Loading humorous message... Please Wait', + 'I could\'ve been faster in Python', + 'Don\'t forget to rewind your episodes', + 'Congratulations! you are the 1000th visitor.', + 'HELP! I\'m being held hostage and forced to write these stupid lines!', + 'RE-calibrating the internet...', + 'I\'ll be here all week', + 'Don\'t forget to tip your waitress', + 'Apply directly to the forehead', + 'Loading Battlestation' +]; + +function LoadingMessage() { + const index = Math.floor(Math.random() * messages.length); + const message = messages[index]; + + return ( +
+ {message} +
+ ); +} + +export default LoadingMessage; diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js new file mode 100644 index 000000000..a2f113de7 --- /dev/null +++ b/frontend/src/Components/Measure.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactMeasure from 'react-measure'; + +class Measure extends Component { + + // + // Lifecycle + + componentWillUnmount() { + this.onMeasure.cancel(); + } + + // + // Listeners + + onMeasure = _.debounce((payload) => { + this.props.onMeasure(payload); + }, 250, { leading: true, trailing: false }) + + // + // Render + + render() { + return ( + + ); + } +} + +Measure.propTypes = { + onMeasure: PropTypes.func.isRequired +}; + +export default Measure; diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css new file mode 100644 index 000000000..34991aed9 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.css @@ -0,0 +1,9 @@ +.filterMenu { + composes: menu from './Menu.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterMenu { + margin-right: 10px; + } +} diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js new file mode 100644 index 000000000..d37876c22 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FilterMenuContent from './FilterMenuContent'; +import Menu from './Menu'; +import ToolbarMenuButton from './ToolbarMenuButton'; +import styles from './FilterMenu.css'; + +class FilterMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFilterModalOpen: false + }; + } + + // + // Listeners + + onCustomFiltersPress = () => { + this.setState({ isFilterModalOpen: true }); + } + + onFiltersModalClose = () => { + this.setState({ isFilterModalOpen: false }); + } + + // + // Render + + render(props) { + const { + className, + isDisabled, + selectedFilterKey, + filters, + customFilters, + buttonComponent: ButtonComponent, + filterModalConnectorComponent: FilterModalConnectorComponent, + filterModalConnectorComponentProps, + onFilterSelect, + ...otherProps + } = this.props; + + const showCustomFilters = !!FilterModalConnectorComponent; + + return ( +
+ + + + + + + + { + showCustomFilters && + + } +
+ ); + } +} + +FilterMenu.propTypes = { + className: PropTypes.string, + isDisabled: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + buttonComponent: PropTypes.func.isRequired, + filterModalConnectorComponent: PropTypes.func, + filterModalConnectorComponentProps: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired +}; + +FilterMenu.defaultProps = { + className: styles.filterMenu, + isDisabled: false, + buttonComponent: ToolbarMenuButton +}; + +export default FilterMenu; diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js new file mode 100644 index 000000000..7463e2c9e --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuContent from './MenuContent'; +import FilterMenuItem from './FilterMenuItem'; +import MenuItem from './MenuItem'; +import MenuItemSeparator from './MenuItemSeparator'; + +class FilterMenuContent extends Component { + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + customFilters, + showCustomFilters, + onFilterSelect, + onCustomFiltersPress, + ...otherProps + } = this.props; + + return ( + + { + filters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + customFilters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + showCustomFilters && + + } + + { + showCustomFilters && + + Custom Filters + + } + + ); + } +} + +FilterMenuContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + showCustomFilters: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onCustomFiltersPress: PropTypes.func.isRequired +}; + +FilterMenuContent.defaultProps = { + showCustomFilters: false +}; + +export default FilterMenuContent; diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js new file mode 100644 index 000000000..d2c495187 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +class FilterMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + filterKey, + onPress + } = this.props; + + onPress(filterKey); + } + + // + // Render + + render() { + const { + filterKey, + selectedFilterKey, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +FilterMenuItem.propTypes = { + filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FilterMenuItem; diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css new file mode 100644 index 000000000..9cce48fee --- /dev/null +++ b/frontend/src/Components/Menu/Menu.css @@ -0,0 +1,7 @@ +.tether { + z-index: 2000; +} + +.menu { + position: relative; +} diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js new file mode 100644 index 000000000..da778bb7a --- /dev/null +++ b/frontend/src/Components/Menu/Menu.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { align } from 'Helpers/Props'; +import styles from './Menu.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [align.RIGHT]: { + ...baseTetherOptions, + attachment: 'top right', + targetAttachment: 'bottom right' + }, + + [align.LEFT]: { + ...baseTetherOptions, + attachment: 'top left', + targetAttachment: 'bottom left' + } +}; + +class Menu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false, + maxHeight: 0 + }; + } + + componentDidMount() { + this.setMaxHeight(); + } + + componentWillUnmount() { + this._removeListener(); + } + + // + // Control + + getMaxHeight() { + if (!this.props.enforceMaxHeight) { + return; + } + + const menu = ReactDOM.findDOMNode(this.refs.menu); + + if (!menu) { + return; + } + + const { bottom } = menu.getBoundingClientRect(); + const maxHeight = window.innerHeight - bottom; + + return maxHeight; + } + + setMaxHeight() { + this.setState({ + maxHeight: this.getMaxHeight() + }); + } + + _addListener() { + // Listen to resize events on the window and scroll events + // on all elements to ensure the menu is the best size possible. + // Listen for click events on the window to support closing the + // menu on clicks outside. + + window.addEventListener('resize', this.onWindowResize); + window.addEventListener('scroll', this.onWindowScroll, { capture: true }); + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('resize', this.onWindowResize); + window.removeEventListener('scroll', this.onWindowScroll, { capture: true }); + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const menu = ReactDOM.findDOMNode(this.refs.menu); + const menuContent = ReactDOM.findDOMNode(this.refs.menuContent); + + if (!menu) { + return; + } + + if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) { + this.setState({ isMenuOpen: false }); + this._removeListener(); + } + } + + onWindowResize = () => { + this.setMaxHeight(); + } + + onWindowScroll = () => { + this.setMaxHeight(); + } + + onMenuButtonPress = () => { + const state = { + isMenuOpen: !this.state.isMenuOpen + }; + + if (this.state.isMenuOpen) { + this._removeListener(); + } else { + state.maxHeight = this.getMaxHeight(); + this._addListener(); + } + + this.setState(state); + } + + // + // Render + + render() { + const { + className, + children, + alignMenu + } = this.props; + + const { + maxHeight, + isMenuOpen + } = this.state; + + const childrenArray = React.Children.toArray(children); + const button = React.cloneElement( + childrenArray[0], + { + onPress: this.onMenuButtonPress + } + ); + + const content = React.cloneElement( + childrenArray[1], + { + ref: 'menuContent', + alignMenu, + maxHeight, + isOpen: isMenuOpen + } + ); + + return ( + +
+ {button} +
+ + { + isMenuOpen && + content + } +
+ ); + } +} + +Menu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]), + enforceMaxHeight: PropTypes.bool.isRequired +}; + +Menu.defaultProps = { + className: styles.menu, + alignMenu: align.LEFT, + enforceMaxHeight: true +}; + +export default Menu; diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css new file mode 100644 index 000000000..ff4f0dadb --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.css @@ -0,0 +1,30 @@ +.menuButton { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + &::after { + margin-left: 5px; + content: '\25BE'; + } + + &:hover { + color: $toobarButtonHoverColor; + } +} + +.isDisabled { + color: $disabledColor; + + pointer-events: none; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + &::after { + margin-left: 0; + content: '\25BE'; + } + } +} diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js new file mode 100644 index 000000000..477334a1d --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import Link from 'Components/Link/Link'; +import styles from './MenuButton.css'; + +class MenuButton extends Component { + + // + // Render + + render() { + const { + className, + children, + isDisabled, + onPress, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuButton.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired, + onPress: PropTypes.func +}; + +MenuButton.defaultProps = { + className: styles.menuButton, + isDisabled: false +}; + +export default MenuButton; diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css new file mode 100644 index 000000000..0acc07390 --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.css @@ -0,0 +1,11 @@ +.menuContent { + display: flex; + flex-direction: column; + background-color: $toolbarMenuItemBackgroundColor; + line-height: 20px; +} + +.scroller { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js new file mode 100644 index 000000000..1acacf80f --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './MenuContent.css'; + +class MenuContent extends Component { + + // + // Render + + render() { + const { + className, + children, + maxHeight + } = this.props; + + return ( +
+ + {children} + +
+ ); + } +} + +MenuContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + maxHeight: PropTypes.number +}; + +MenuContent.defaultProps = { + className: styles.menuContent +}; + +export default MenuContent; diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css new file mode 100644 index 000000000..bae1a649c --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.css @@ -0,0 +1,19 @@ +.menuItem { + @add-mixin truncate; + + display: block; + flex-shrink: 0; + padding: 10px 20px; + min-width: 150px; + max-width: 250px; + background-color: $toolbarMenuItemBackgroundColor; + color: $menuItemColor; + line-height: 20px; + + &:hover, + &:focus { + background-color: $toolbarMenuItemHoverBackgroundColor; + color: $menuItemHoverColor; + text-decoration: none; + } +} diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js new file mode 100644 index 000000000..ff083450b --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './MenuItem.css'; + +class MenuItem extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuItem.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +MenuItem.defaultProps = { + className: styles.menuItem +}; + +export default MenuItem; diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css new file mode 100644 index 000000000..a867e3153 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.css @@ -0,0 +1,5 @@ +.separator { + overflow: hidden; + height: 1px; + background-color: $themeDarkColor; +} diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js new file mode 100644 index 000000000..e586670c9 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './MenuItemSeparator.css'; + +function MenuItemSeparator() { + return ( +
+ ); +} + +export default MenuItemSeparator; diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css new file mode 100644 index 000000000..e6954f600 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + &:hover { + color: #666; + } +} + +.label { + margin-left: 5px; +} diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js new file mode 100644 index 000000000..abbfc98f8 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './PageMenuButton.css'; + +function PageMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + + + +
+ {text} +
+
+ ); +} + +PageMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default PageMenuButton; diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css new file mode 100644 index 000000000..739419d69 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.css @@ -0,0 +1,15 @@ +.item { + display: flex; + justify-content: space-between; + white-space: nowrap; +} + +.isSelected { + visibility: visible; + margin-left: 20px; +} + +.isNotSelected { + visibility: hidden; + margin-left: 20px; +} diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js new file mode 100644 index 000000000..8b0805c57 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MenuItem from './MenuItem'; +import styles from './SelectedMenuItem.css'; + +class SelectedMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + onPress + } = this.props; + + onPress(name); + } + + // + // Render + + render() { + const { + children, + selectedIconName, + isSelected, + ...otherProps + } = this.props; + + return ( + +
+ {children} + + +
+
+ ); + } +} + +SelectedMenuItem.propTypes = { + name: PropTypes.string, + children: PropTypes.node.isRequired, + selectedIconName: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +SelectedMenuItem.defaultProps = { + selectedIconName: icons.CHECK +}; + +export default SelectedMenuItem; diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js new file mode 100644 index 000000000..a9a6a184e --- /dev/null +++ b/frontend/src/Components/Menu/SortMenu.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function SortMenu(props) { + const { + className, + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +SortMenu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +SortMenu.defaultProps = { + isDisabled: false +}; + +export default SortMenu; diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js new file mode 100644 index 000000000..e35864ae6 --- /dev/null +++ b/frontend/src/Components/Menu/SortMenuItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import SelectedMenuItem from './SelectedMenuItem'; + +function SortMenuItem(props) { + const { + name, + sortKey, + sortDirection, + ...otherProps + } = props; + + const isSelected = name === sortKey; + + return ( + + ); +} + +SortMenuItem.propTypes = { + name: PropTypes.string, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onPress: PropTypes.func.isRequired +}; + +SortMenuItem.defaultProps = { + name: null +}; + +export default SortMenuItem; diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css new file mode 100644 index 000000000..c8a905e17 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + width: $toolbarButtonWidth; + height: $toolbarHeight; + text-align: center; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js new file mode 100644 index 000000000..b80d6eaa3 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './ToolbarMenuButton.css'; + +function ToolbarMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + +
+ + +
+ {text} +
+
+
+ ); +} + +ToolbarMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default ToolbarMenuButton; diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js new file mode 100644 index 000000000..60c77e003 --- /dev/null +++ b/frontend/src/Components/Menu/ViewMenu.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function ViewMenu(props) { + const { + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +ViewMenu.propTypes = { + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +ViewMenu.defaultProps = { + isDisabled: false +}; + +export default ViewMenu; diff --git a/frontend/src/Components/Menu/ViewMenuItem.js b/frontend/src/Components/Menu/ViewMenuItem.js new file mode 100644 index 000000000..d355d6e94 --- /dev/null +++ b/frontend/src/Components/Menu/ViewMenuItem.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +function ViewMenuItem(props) { + const { + name, + selectedView, + ...otherProps + } = props; + + const isSelected = name === selectedView; + + return ( + + ); +} + +ViewMenuItem.propTypes = { + name: PropTypes.string, + selectedView: PropTypes.string.isRequired +}; + +export default ViewMenuItem; diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js new file mode 100644 index 000000000..5bb783d43 --- /dev/null +++ b/frontend/src/Components/Modal/ConfirmModal.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function ConfirmModal(props) { + const { + isOpen, + kind, + size, + title, + message, + confirmLabel, + cancelLabel, + hideCancelButton, + isSpinning, + onConfirm, + onCancel + } = props; + + return ( + + + {title} + + + {message} + + + + { + !hideCancelButton && + + } + + + {confirmLabel} + + + + + ); +} + +ConfirmModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + size: PropTypes.oneOf(sizes.all), + title: PropTypes.string.isRequired, + message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + confirmLabel: PropTypes.string, + cancelLabel: PropTypes.string, + hideCancelButton: PropTypes.bool, + isSpinning: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +ConfirmModal.defaultProps = { + kind: kinds.PRIMARY, + size: sizes.MEDIUM, + confirmLabel: 'OK', + cancelLabel: 'Cancel', + isSpinning: false +}; + +export default ConfirmModal; diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css new file mode 100644 index 000000000..a9b2a27ae --- /dev/null +++ b/frontend/src/Components/Modal/Modal.css @@ -0,0 +1,92 @@ +.modalContainer { + position: absolute; + top: 0; + z-index: 1000; + width: 100%; + height: 100%; +} + +.modalBackdrop { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: $modalBackdropBackgroundColor; + opacity: 1; +} + +.modal { + position: relative; + display: flex; + max-height: 90%; + border-radius: 6px; + opacity: 1; +} + +.modalOpen { + /* Prevent the body from scrolling when the modal is open */ + overflow: hidden !important; +} + +/* + * Sizes + */ + +.small { + composes: modal; + + width: 480px; +} + +.medium { + composes: modal; + + width: 720px; +} + +.large { + composes: modal; + + width: 1080px; +} + +.extraLarge { + composes: modal; + + width: 1280px; +} + +@media only screen and (max-width: $breakpointExtraLarge) { + .modal.extraLarge { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .modal.large { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .modal.small, + .modal.medium { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalContainer { + position: fixed; + } + + .modal.small, + .modal.medium, + .modal.large, + .modal.extraLarge { + max-height: 100%; + width: 100%; + height: 100% !important; + } +} diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js new file mode 100644 index 000000000..a9de82c6d --- /dev/null +++ b/frontend/src/Components/Modal/Modal.js @@ -0,0 +1,215 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import elementClass from 'element-class'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { sizes } from 'Helpers/Props'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import ModalError from './ModalError'; +import styles from './Modal.css'; + +const openModals = []; + +function removeFromOpenModals(id) { + const index = openModals.indexOf(id); + + if (index >= 0) { + openModals.splice(index, 1); + } +} + +class Modal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._node = document.getElementById('modal-root'); + this._backgroundRef = null; + this._modalId = getUniqueElememtId(); + } + + componentDidMount() { + if (this.props.isOpen) { + this._openModal(); + } + } + + componentDidUpdate(prevProps) { + const { + isOpen + } = this.props; + + if (!prevProps.isOpen && isOpen) { + this._openModal(); + } else if (prevProps.isOpen && !isOpen) { + this._closeModal(); + } + } + + componentWillUnmount() { + if (this.props.isOpen) { + this._closeModal(); + } + } + + // + // Control + + _setBackgroundRef = (ref) => { + this._backgroundRef = ref; + } + + _openModal() { + openModals.push(this._modalId); + window.addEventListener('keydown', this.onKeyDown); + + if (openModals.length === 1) { + elementClass(document.body).add(styles.modalOpen); + } + } + + _closeModal() { + removeFromOpenModals(this._modalId); + window.removeEventListener('keydown', this.onKeyDown); + + if (openModals.length === 0) { + elementClass(document.body).remove(styles.modalOpen); + } + } + + _isBackdropTarget(event) { + const targetElement = this._findEventTarget(event); + + if (targetElement) { + const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef); + + return backgroundElement.isEqualNode(targetElement); + } + + return false; + } + + _findEventTarget(event) { + const changedTouches = event.changedTouches; + + if (!changedTouches) { + return event.target; + } + + if (changedTouches.length === 1) { + const touch = changedTouches[0]; + + return document.elementFromPoint(touch.clientX, touch.clientY); + } + } + + // + // Listeners + + onBackdropBeginPress = (event) => { + this._isBackdropPressed = this._isBackdropTarget(event); + } + + onBackdropEndPress = (event) => { + const { + closeOnBackgroundClick, + onModalClose + } = this.props; + + if ( + this._isBackdropPressed && + this._isBackdropTarget(event) && + closeOnBackgroundClick + ) { + onModalClose(); + } + + this._isBackdropPressed = false; + } + + onKeyDown = (event) => { + const keyCode = event.keyCode; + + if (keyCode === keyCodes.ESCAPE) { + if (openModals.indexOf(this._modalId) === openModals.length - 1) { + event.preventDefault(); + event.stopPropagation(); + + this.props.onModalClose(); + } + } + } + + // + // Render + + render() { + const { + className, + style, + backdropClassName, + size, + children, + isOpen, + onModalClose + } = this.props; + + if (!isOpen) { + return null; + } + + return ReactDOM.createPortal( +
+
+
+ + {children} + +
+
+
, + this._node + ); + } +} + +Modal.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + backdropClassName: PropTypes.string, + size: PropTypes.oneOf(sizes.all), + children: PropTypes.node, + isOpen: PropTypes.bool.isRequired, + closeOnBackgroundClick: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +Modal.defaultProps = { + className: styles.modal, + backdropClassName: styles.modalBackdrop, + size: sizes.LARGE, + closeOnBackgroundClick: true +}; + +export default Modal; diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css new file mode 100644 index 000000000..ebeef29de --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.css @@ -0,0 +1,12 @@ +.modalBody { + flex: 1 0 1px; + padding: $modalBodyPadding; +} + +.modalScroller { + flex-grow: 1; +} + +.innerModalBody { + padding: $modalBodyPadding; +} diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js new file mode 100644 index 000000000..a35f2ecf5 --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './ModalBody.css'; + +class ModalBody extends Component { + + // + // Render + + render() { + const { + innerClassName, + scrollDirection, + children, + ...otherProps + } = this.props; + + let className = this.props.className; + const hasScroller = scrollDirection !== scrollDirections.NONE; + + if (!className) { + className = hasScroller ? styles.modalScroller : styles.modalBody; + } + + return ( + + { + hasScroller ? +
+ {children} +
: + children + } +
+ ); + } + +} + +ModalBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + children: PropTypes.node, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]) +}; + +ModalBody.defaultProps = { + innerClassName: styles.innerModalBody, + scrollDirection: scrollDirections.VERTICAL +}; + +export default ModalBody; diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css new file mode 100644 index 000000000..afd798dfa --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.css @@ -0,0 +1,23 @@ +.modalContent { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; + background-color: $modalBackgroundColor; +} + +.closeButton { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 60px; + height: 60px; + text-align: center; + line-height: 60px; + + &:hover { + color: $modalCloseButtonHoverColor; + } +} diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js new file mode 100644 index 000000000..655046fe4 --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './ModalContent.css'; + +function ModalContent(props) { + const { + className, + children, + showCloseButton, + onModalClose, + ...otherProps + } = props; + + return ( +
+ { + showCloseButton && + + + + } + + {children} +
+ ); +} + +ModalContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + showCloseButton: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +ModalContent.defaultProps = { + className: styles.modalContent, + showCloseButton: true +}; + +export default ModalContent; diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css new file mode 100644 index 000000000..54dbdbc63 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.css @@ -0,0 +1,15 @@ +.message { + composes: message from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-bottom: 30px; + font-weight: normal; + font-size: 26px; +} + +.details { + composes: details from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-top: 20px; +} diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js new file mode 100644 index 000000000..df99a5b32 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ModalError.css'; + +function ModalError(props) { + const { + onModalClose, + ...otherProps + } = props; + + return ( + + + Error + + + + + + + + + + ); +} + +ModalError.propTypes = { + onModalClose: PropTypes.func.isRequired +}; + +export default ModalError; diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css new file mode 100644 index 000000000..3b817d2bf --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.css @@ -0,0 +1,23 @@ +.modalFooter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + padding: 15px 30px; + border-top: 1px solid $borderColor; + + a, + button { + margin-left: 10px; + + &:first-child { + margin-left: 0; + } + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + padding: 15px; + } +} diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js new file mode 100644 index 000000000..0cf8811d3 --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalFooter.css'; + +class ModalFooter extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalFooter.propTypes = { + children: PropTypes.node +}; + +export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css new file mode 100644 index 000000000..eab77a9f8 --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.css @@ -0,0 +1,8 @@ +.modalHeader { + @add-mixin truncate; + + flex-shrink: 0; + padding: 15px 50px 15px 30px; + border-bottom: 1px solid $borderColor; + font-size: 18px; +} diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js new file mode 100644 index 000000000..52879b57d --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalHeader.css'; + +class ModalHeader extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalHeader.propTypes = { + children: PropTypes.node +}; + +export default ModalHeader; diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css new file mode 100644 index 000000000..794af1e98 --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.css @@ -0,0 +1,11 @@ +.toggleButton { + composes: button from 'Components/Link/IconButton.css'; + + padding: 0; + font-size: inherit; +} + +.isDisabled { + color: $disabledColor; + cursor: not-allowed; +} diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js new file mode 100644 index 000000000..15ac3d7fe --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './MonitorToggleButton.css'; + +function getTooltip(monitored, isDisabled) { + if (isDisabled) { + return 'Cannot toogle monitored state when series is unmonitored'; + } + + if (monitored) { + return 'Monitored, click to unmonitor'; + } + + return 'Unmonitored, click to monitor'; +} + +class MonitorToggleButton extends Component { + + // + // Listeners + + onPress = (event) => { + const shiftKey = event.nativeEvent.shiftKey; + + this.props.onPress(!this.props.monitored, { shiftKey }); + } + + // + // Render + + render() { + const { + className, + monitored, + isDisabled, + isSaving, + size, + ...otherProps + } = this.props; + + const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; + + return ( + + ); + } +} + +MonitorToggleButton.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + size: PropTypes.number, + isDisabled: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +MonitorToggleButton.defaultProps = { + className: styles.toggleButton, + isDisabled: false, + isSaving: false +}; + +export default MonitorToggleButton; diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css new file mode 100644 index 000000000..9aaf1114f --- /dev/null +++ b/frontend/src/Components/NotFound.css @@ -0,0 +1,14 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.image { + height: 350px; +} diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js new file mode 100644 index 000000000..7043da46f --- /dev/null +++ b/frontend/src/Components/NotFound.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import styles from './NotFound.css'; + +function NotFound({ message }) { + return ( + +
+
+ {message} +
+ + +
+
+ ); +} + +NotFound.propTypes = { + message: PropTypes.string.isRequired +}; + +NotFound.defaultProps = { + message: 'You must be lost, nothing to see here.' +}; + +export default NotFound; diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css new file mode 100644 index 000000000..e62a82a6b --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.css @@ -0,0 +1,12 @@ +.page { + composes: page from './Page.css'; + + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.version { + margin-top: 20px; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js new file mode 100644 index 000000000..891056c2c --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import styles from './ErrorPage.css'; + +function ErrorPage(props) { + const { + version, + isLocalStorageSupported, + seriesError, + customFiltersError, + tagsError, + qualityProfilesError, + languageProfilesError, + uiSettingsError + } = props; + + let errorMessage = 'Failed to load Sonarr'; + + if (!isLocalStorageSupported) { + errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; + } else if (seriesError) { + errorMessage = getErrorMessage(seriesError, 'Failed to load series from API'); + } else if (customFiltersError) { + errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API'); + } else if (tagsError) { + errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API'); + } else if (qualityProfilesError) { + errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API'); + } else if (languageProfilesError) { + errorMessage = getErrorMessage(languageProfilesError, 'Failed to load language profiles from API'); + } else if (uiSettingsError) { + errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API'); + } + + return ( +
+
+ {errorMessage} +
+ +
+ Version {version} +
+
+ ); +} + +ErrorPage.propTypes = { + version: PropTypes.string.isRequired, + isLocalStorageSupported: PropTypes.bool.isRequired, + seriesError: PropTypes.object, + customFiltersError: PropTypes.object, + tagsError: PropTypes.object, + qualityProfilesError: PropTypes.object, + languageProfilesError: PropTypes.object, + uiSettingsError: PropTypes.object +}; + +export default ErrorPage; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js new file mode 100644 index 000000000..a1d106b58 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector'; + +function KeyboardShortcutsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +KeyboardShortcutsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css new file mode 100644 index 000000000..4425e0e0d --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css @@ -0,0 +1,15 @@ +.shortcut { + display: flex; + justify-content: space-between; + padding: 5px 20px; + font-size: 18px; +} + +.key { + padding: 2px 4px; + border-radius: 3px; + background-color: $defaultColor; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); + color: $white; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js new file mode 100644 index 000000000..9c07e047c --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { shortcuts } from 'Components/keyboardShortcuts'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './KeyboardShortcutsModalContent.css'; + +function getShortcuts() { + const allShortcuts = []; + + Object.keys(shortcuts).forEach((key) => { + allShortcuts.push(shortcuts[key]); + }); + + return allShortcuts; +} + +function getShortcutKey(combo, isOsx) { + const comboMatch = combo.match(/(.+?)\+(.)/); + + if (!comboMatch) { + return combo; + } + + const modifier = comboMatch[1]; + const key = comboMatch[2]; + let osModifier = modifier; + + if (modifier === 'mod') { + osModifier = isOsx ? 'cmd' : 'ctrl'; + } + + return `${osModifier} + ${key}`; +} + +function KeyboardShortcutsModalContent(props) { + const { + isOsx, + onModalClose + } = props; + + const allShortcuts = getShortcuts(); + + return ( + + + Keyboard Shortcuts + + + + { + allShortcuts.map((shortcut) => { + return ( +
+
+ {getShortcutKey(shortcut.key, isOsx)} +
+ +
+ {shortcut.name} +
+
+ ); + }) + } +
+ + + + +
+ ); +} + +KeyboardShortcutsModalContent.propTypes = { + isOsx: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModalContent; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js new file mode 100644 index 000000000..d80877153 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent'; + +function createMapStateToProps() { + return createSelector( + createSystemStatusSelector(), + (systemStatus) => { + return { + isOsx: systemStatus.isOsx + }; + } + ); +} + +export default connect(createMapStateToProps)(KeyboardShortcutsModalContent); diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css new file mode 100644 index 000000000..1974cbcb1 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -0,0 +1,65 @@ +.header { + z-index: 3; + display: flex; + align-items: center; + flex: 0 0 auto; + height: $headerHeight; + background-color: $themeAlternateBlue; + color: $white; +} + +.logoContainer { + display: flex; + align-items: center; + flex: 0 0 $sidebarWidth; + padding-left: 20px; +} + +.logoLink { + line-height: 0; +} + +.logo { + width: 32px; + height: 32px; +} + +.sidebarToggleContainer { + display: none; + justify-content: center; + flex: 0 0 45px; + margin-right: 14px; +} + +.right { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.donate { + composes: link from 'Components/Link/Link.css'; + + width: 30px; + color: $themeRed; + text-align: center; + line-height: 60px; + + &:hover { + color: #9c1f30; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .logoContainer { + flex: 0 0 60px; + } + + .sidebarToggleContainer { + display: flex; + } + + .donate { + display: none; + } +} diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js new file mode 100644 index 000000000..9a1117a49 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SeriesSearchInputConnector from './SeriesSearchInputConnector'; +import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector'; +import KeyboardShortcutsModal from './KeyboardShortcutsModal'; +import styles from './PageHeader.css'; + +class PageHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props); + + this.state = { + isKeyboardShortcutsModalOpen: false + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal); + } + + // + // Control + + onOpenKeyboardShortcutsModal = () => { + this.setState({ isKeyboardShortcutsModalOpen: true }); + } + + // + // Listeners + + onKeyboardShortcutsModalClose = () => { + this.setState({ isKeyboardShortcutsModalOpen: false }); + } + + // + // Render + + render() { + const { + onSidebarToggle + } = this.props; + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+ + +
+ + +
+ ); + } +} + +PageHeader.propTypes = { + onSidebarToggle: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(PageHeader); diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css new file mode 100644 index 000000000..e27ad883e --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css @@ -0,0 +1,21 @@ +.menuButton { + margin-right: 15px; + width: 30px; + height: 60px; + text-align: center; + + &:hover { + color: $themeDarkColor; + } +} + +.itemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + margin-right: 5px; + } +} + diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js new file mode 100644 index 000000000..ddeeeef04 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, kinds } from 'Helpers/Props'; +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 styles from './PageHeaderActionsMenu.css'; + +function PageHeaderActionsMenu(props) { + const { + formsAuth, + onKeyboardShortcutsPress, + onRestartPress, + onShutdownPress + } = props; + + return ( +
+ + + + + + + + + Keyboard Shortcuts + + + + + + + Restart + + + + + Shutdown + + + { + formsAuth && +
+ } + + { + formsAuth && + + + Logout + + } + +
+
+ ); +} + +PageHeaderActionsMenu.propTypes = { + formsAuth: PropTypes.bool.isRequired, + onKeyboardShortcutsPress: PropTypes.func.isRequired, + onRestartPress: PropTypes.func.isRequired, + onShutdownPress: PropTypes.func.isRequired +}; + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js new file mode 100644 index 000000000..66d131521 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js @@ -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 ( + + ); + } +} + +PageHeaderActionsMenuConnector.propTypes = { + restart: PropTypes.func.isRequired, + shutdown: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.css b/frontend/src/Components/Page/Header/SeriesSearchInput.css new file mode 100644 index 000000000..ce9a92271 --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInput.css @@ -0,0 +1,96 @@ +.wrapper { + display: flex; + align-items: center; +} + +.input { + margin-left: 8px; + width: 200px; + border: none; + border-bottom: solid 1px $white; + border-radius: 0; + background-color: transparent; + box-shadow: none; + color: $white; + transition: border 0.3s ease-out; + + &::placeholder { + color: $white; + transition: color 0.3s ease-out; + } + + &:focus { + outline: 0; + border-bottom-color: transparent; + + &::placeholder { + color: transparent; + } + } +} + +.container { + position: relative; + flex-grow: 1; +} + +.seriesContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.containerOpen { + .seriesContainer { + position: absolute; + top: 42px; + z-index: 1; + overflow-y: auto; + min-width: 100%; + max-height: 230px; + border: 1px solid $themeDarkColor; + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $themeDarkColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $menuItemColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; + white-space: nowrap; +} + +.highlighted { + background-color: $themeLightColor; +} + +.sectionTitle { + padding: 5px 8px; + color: $disabledColor; +} + +.addNewSeriesSuggestion { + padding: 0 3px; + cursor: pointer; +} + +@media only screen and (max-width: $breakpointSmall) { + .input { + min-width: 150px; + max-width: 200px; + } + + .container { + min-width: 0; + max-width: 200px; + } +} diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.js b/frontend/src/Components/Page/Header/SeriesSearchInput.js new file mode 100644 index 000000000..44c75a49a --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInput.js @@ -0,0 +1,260 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import jdu from 'jdu'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import SeriesSearchResult from './SeriesSearchResult'; +import styles from './SeriesSearchInput.css'; + +const ADD_NEW_TYPE = 'addNew'; + +class SeriesSearchInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._autosuggest = null; + + this.state = { + value: '', + suggestions: [] + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.SERIES_SEARCH_INPUT.key, this.focusInput); + } + + // + // Control + + setAutosuggestRef = (ref) => { + this._autosuggest = ref; + } + + focusInput = (event) => { + event.preventDefault(); + this._autosuggest.input.focus(); + } + + getSectionSuggestions(section) { + return section.suggestions; + } + + renderSectionTitle(section) { + return ( +
+ {section.title} +
+ ); + } + + getSuggestionValue({ title }) { + return title; + } + + renderSuggestion(item, { query }) { + if (item.type === ADD_NEW_TYPE) { + return ( +
+ Search for {query} +
+ ); + } + + return ( + + ); + } + + goToSeries(series) { + this.setState({ value: '' }); + this.props.onGoToSeries(series.titleSlug); + } + + reset() { + this.setState({ + value: '', + suggestions: [] + }); + } + + // + // Listeners + + onChange = (event, { newValue, method }) => { + if (method === 'up' || method === 'down') { + return; + } + + this.setState({ value: newValue }); + } + + onKeyDown = (event) => { + if (event.key !== 'Tab' && event.key !== 'Enter') { + return; + } + + const { + suggestions, + value + } = this.state; + + const { + highlightedSectionIndex, + highlightedSuggestionIndex + } = this._autosuggest.state; + + if (!suggestions.length || highlightedSectionIndex) { + this.props.onGoToAddNewSeries(value); + this._autosuggest.input.blur(); + + return; + } + + // If an suggestion is not selected go to the first series, + // otherwise go to the selected series. + + if (highlightedSuggestionIndex == null) { + this.goToSeries(suggestions[0]); + } else { + this.goToSeries(suggestions[highlightedSuggestionIndex]); + } + } + + onBlur = () => { + this.reset(); + } + + onSuggestionsFetchRequested = ({ value }) => { + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const suggestions = this.props.series.filter((series) => { + // Check the title first and if there isn't a match fallback to + // the alternate titles and finally the tags. + + if (value.length === 1) { + return ( + series.cleanTitle.startsWith(lowerCaseValue) || + series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) || + series.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue)) + ); + } + + return ( + series.cleanTitle.contains(lowerCaseValue) || + series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) || + series.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue)) + ); + }); + + this.setState({ suggestions }); + } + + onSuggestionsClearRequested = () => { + this.setState({ + suggestions: [] + }); + } + + onSuggestionSelected = (event, { suggestion }) => { + if (suggestion.type === ADD_NEW_TYPE) { + this.props.onGoToAddNewSeries(this.state.value); + } else { + this.goToSeries(suggestion); + } + } + + // + // Render + + render() { + const { + value, + suggestions + } = this.state; + + const suggestionGroups = []; + + if (suggestions.length) { + suggestionGroups.push({ + title: 'Existing Series', + suggestions + }); + } + + suggestionGroups.push({ + title: 'Add New Series', + suggestions: [ + { + type: ADD_NEW_TYPE, + title: value + } + ] + }); + + const inputProps = { + ref: this.setInputRef, + className: styles.input, + name: 'seriesSearch', + value, + placeholder: 'Search', + autoComplete: 'off', + spellCheck: false, + onChange: this.onChange, + onKeyDown: this.onKeyDown, + onBlur: this.onBlur, + onFocus: this.onFocus + }; + + const theme = { + container: styles.container, + containerOpen: styles.containerOpen, + suggestionsContainer: styles.seriesContainer, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ + + +
+ ); + } +} + +SeriesSearchInput.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired, + onGoToSeries: PropTypes.func.isRequired, + onGoToAddNewSeries: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(SeriesSearchInput); diff --git a/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js new file mode 100644 index 000000000..c90a49330 --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js @@ -0,0 +1,97 @@ +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import { createSelector } from 'reselect'; +import jdu from 'jdu'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import SeriesSearchInput from './SeriesSearchInput'; + +function createCleanTagsSelector() { + return createSelector( + createTagsSelector(), + (tags) => { + return tags.map((tag) => { + const { + id, + label + } = tag; + + return { + id, + label, + cleanLabel: jdu.replace(label).toLowerCase() + }; + }); + } + ); +} + +function createCleanSeriesSelector() { + return createSelector( + createAllSeriesSelector(), + createCleanTagsSelector(), + (allSeries, allTags) => { + return allSeries.map((series) => { + const { + title, + titleSlug, + sortTitle, + images, + alternateTitles = [], + tags = [] + } = series; + + return { + title, + titleSlug, + sortTitle, + images, + cleanTitle: jdu.replace(title).toLowerCase(), + alternateTitles: alternateTitles.map((alternateTitle) => { + return { + title: alternateTitle.title, + cleanTitle: jdu.replace(alternateTitle.title).toLowerCase() + }; + }), + tags: tags.map((id) => { + return allTags.find((tag) => tag.id === id); + }) + }; + }).sort((a, b) => { + if (a.cleanTitle < b.cleanTitle) { + return -1; + } + if (a.cleanTitle > b.cleanTitle) { + return 1; + } + + return 0; + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createCleanSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGoToSeries(titleSlug) { + dispatch(push(`${window.Sonarr.urlBase}/series/${titleSlug}`)); + }, + + onGoToAddNewSeries(query) { + dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesSearchInput); diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.css b/frontend/src/Components/Page/Header/SeriesSearchResult.css new file mode 100644 index 000000000..29edc382b --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.css @@ -0,0 +1,38 @@ +.result { + display: flex; + padding: 3px; + cursor: pointer; +} + +.poster { + width: 35px; + height: 50px; +} + +.titles { + flex: 1 1 1px; +} + +.title { + flex: 1 1 1px; + margin-left: 5px; +} + +.alternateTitle { + composes: title; + + color: $disabledColor; + font-size: $smallFontSize; +} + +.tagContainer { + composes: title; +} + +@media only screen and (max-width: $breakpointSmall) { + .titles, + .title, + .alternateTitle { + @add-mixin truncate; + } +} diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.js b/frontend/src/Components/Page/Header/SeriesSearchResult.js new file mode 100644 index 000000000..9e2adb82c --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import SeriesPoster from 'Series/SeriesPoster'; +import styles from './SeriesSearchResult.css'; + +function findMatchingAlternateTitle(alternateTitles, cleanQuery) { + return alternateTitles.find((alternateTitle) => { + return alternateTitle.cleanTitle.contains(cleanQuery); + }); +} + +function getMatchingTag(tags, cleanQuery) { + return tags.find((tag) => { + return tag.cleanLabel.contains(cleanQuery); + }); +} + +function SeriesSearchResult(props) { + const { + cleanQuery, + title, + cleanTitle, + images, + alternateTitles, + tags + } = props; + + const titleContains = cleanTitle.contains(cleanQuery); + let alternateTitle = null; + let tag = null; + + if (!titleContains) { + alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery); + } + + if (!titleContains && !alternateTitle) { + tag = getMatchingTag(tags, cleanQuery); + } + + return ( +
+ + +
+
+ {title} +
+ + { + !!alternateTitle && +
+ {alternateTitle.title} +
+ } + + { + !!tag && +
+ +
+ } +
+
+ ); +} + +SeriesSearchResult.propTypes = { + cleanQuery: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + cleanTitle: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SeriesSearchResult; diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css new file mode 100644 index 000000000..dd5852e61 --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.css @@ -0,0 +1,3 @@ +.page { + composes: page from './Page.css'; +} diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js new file mode 100644 index 000000000..398b70c4b --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.js @@ -0,0 +1,15 @@ +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import LoadingMessage from 'Components/Loading/LoadingMessage'; +import styles from './LoadingPage.css'; + +function LoadingPage() { + return ( +
+ + +
+ ); +} + +export default LoadingPage; diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css new file mode 100644 index 000000000..9facbfc22 --- /dev/null +++ b/frontend/src/Components/Page/Page.css @@ -0,0 +1,18 @@ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.main { + position: relative; /* need this to position inner content - is this really needed? */ + display: flex; + flex: 1 1 auto; +} + +@media only screen and (max-width: $breakpointSmall) { + .page { + flex-grow: 1; + height: initial; + } +} diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js new file mode 100644 index 000000000..fc7502fd4 --- /dev/null +++ b/frontend/src/Components/Page/Page.js @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import SignalRConnector from 'Components/SignalRConnector'; +import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; +import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; +import PageHeader from './Header/PageHeader'; +import PageSidebar from './Sidebar/PageSidebar'; +import styles from './Page.css'; + +class Page extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isUpdatedModalOpen: false, + isConnectionLostModalOpen: false + }; + } + + componentDidMount() { + window.addEventListener('resize', this.onResize); + } + + componentDidUpdate(prevProps) { + const { + isDisconnected, + isUpdated + } = this.props; + + if (!prevProps.isUpdated && isUpdated) { + this.setState({ isUpdatedModalOpen: true }); + } + + if (prevProps.isDisconnected !== isDisconnected) { + this.setState({ isConnectionLostModalOpen: isDisconnected }); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onResize); + } + + // + // Listeners + + onResize = () => { + this.props.onResize({ + width: window.innerWidth, + height: window.innerHeight + }); + } + + onUpdatedModalClose = () => { + this.setState({ isUpdatedModalOpen: false }); + } + + onConnectionLostModalClose = () => { + this.setState({ isConnectionLostModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + location, + children, + isSmallScreen, + isSidebarVisible, + onSidebarToggle, + onSidebarVisibleChange + } = this.props; + + return ( +
+ + + + +
+ + + {children} +
+ + + + +
+ ); + } +} + +Page.propTypes = { + className: PropTypes.string, + location: locationShape.isRequired, + children: PropTypes.node.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + isUpdated: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + onResize: PropTypes.func.isRequired, + onSidebarToggle: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +Page.defaultProps = { + className: styles.page +}; + +export default Page; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js new file mode 100644 index 000000000..5085d139b --- /dev/null +++ b/frontend/src/Components/Page/PageConnector.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; +import { fetchSeries } from 'Store/Actions/seriesActions'; +import { fetchTags } from 'Store/Actions/tagActions'; +import { fetchQualityProfiles, fetchLanguageProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import ErrorPage from './ErrorPage'; +import LoadingPage from './LoadingPage'; +import Page from './Page'; + +function testLocalStorage() { + const key = 'sonarrTest'; + + try { + localStorage.setItem(key, key); + localStorage.removeItem(key); + + return true; + } catch (e) { + return false; + } +} + +function createMapStateToProps() { + return createSelector( + (state) => state.series, + (state) => state.customFilters, + (state) => state.tags, + (state) => state.settings, + (state) => state.app, + createDimensionsSelector(), + (series, customFilters, tags, settings, app, dimensions) => { + const isPopulated = ( + series.isPopulated && + customFilters.isPopulated && + tags.isPopulated && + settings.qualityProfiles.isPopulated && + settings.ui.isPopulated + ); + + const hasError = !!( + series.error || + customFilters.error || + tags.error || + settings.qualityProfiles.error || + settings.languageProfiles.error || + settings.ui.error + ); + + return { + isPopulated, + hasError, + seriesError: series.error, + customFiltersError: tags.error, + tagsError: tags.error, + qualityProfilesError: settings.qualityProfiles.error, + uiSettingsError: settings.ui.error, + isSmallScreen: dimensions.isSmallScreen, + isSidebarVisible: app.isSidebarVisible, + version: app.version, + isUpdated: app.isUpdated, + isDisconnected: app.isDisconnected + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchSeries() { + dispatch(fetchSeries()); + }, + dispatchFetchCustomFilters() { + dispatch(fetchCustomFilters()); + }, + dispatchFetchTags() { + dispatch(fetchTags()); + }, + dispatchFetchQualityProfiles() { + dispatch(fetchQualityProfiles()); + }, + dispatchFetchLanguageProfiles() { + dispatch(fetchLanguageProfiles()); + }, + dispatchFetchUISettings() { + dispatch(fetchUISettings()); + }, + dispatchFetchStatus() { + dispatch(fetchStatus()); + }, + onResize(dimensions) { + dispatch(saveDimensions(dimensions)); + }, + onSidebarVisibleChange(isSidebarVisible) { + dispatch(setIsSidebarVisible({ isSidebarVisible })); + } + }; +} + +class PageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLocalStorageSupported: testLocalStorage() + }; + } + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchSeries(); + this.props.dispatchFetchCustomFilters(); + this.props.dispatchFetchTags(); + this.props.dispatchFetchQualityProfiles(); + this.props.dispatchFetchLanguageProfiles(); + this.props.dispatchFetchUISettings(); + this.props.dispatchFetchStatus(); + } + } + + // + // Listeners + + onSidebarToggle = () => { + this.props.onSidebarVisibleChange(!this.props.isSidebarVisible); + } + + // + // Render + + render() { + const { + isPopulated, + hasError, + dispatchFetchSeries, + dispatchFetchTags, + dispatchFetchQualityProfiles, + dispatchFetchLanguageProfiles, + dispatchFetchUISettings, + dispatchFetchStatus, + ...otherProps + } = this.props; + + if (hasError || !this.state.isLocalStorageSupported) { + return ( + + ); + } + + if (isPopulated) { + return ( + + ); + } + + return ( + + ); + } +} + +PageConnector.propTypes = { + isPopulated: PropTypes.bool.isRequired, + hasError: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + dispatchFetchSeries: PropTypes.func.isRequired, + dispatchFetchCustomFilters: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchFetchLanguageProfiles: PropTypes.func.isRequired, + dispatchFetchUISettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default withRouter( + connect(createMapStateToProps, createMapDispatchToProps)(PageConnector) +); diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css new file mode 100644 index 000000000..4580077c3 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.css @@ -0,0 +1,8 @@ +.content { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + width: 100%; +} diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js new file mode 100644 index 000000000..62003c2b0 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import PageContentError from './PageContentError'; +import styles from './PageContent.css'; + +function PageContent(props) { + const { + className, + title, + children + } = props; + + return ( + + +
+ {children} +
+
+
+ ); +} + +PageContent.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContent.defaultProps = { + className: styles.content +}; + +export default PageContent; diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css new file mode 100644 index 000000000..8b41754dd --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.css @@ -0,0 +1,19 @@ +.contentBody { + /* 1px for flex-basis so the div grows correctly in Edge/Firefox */ + flex: 1 0 1px; +} + +.innerContentBody { + padding: $pageContentBodyPadding; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentBody { + flex-basis: auto; + overflow-y: hidden !important; + } + + .innerContentBody { + padding: $pageContentBodyPaddingSmallScreen; + } +} diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js new file mode 100644 index 000000000..81bd9b29b --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './PageContentBody.css'; + +class PageContentBody extends Component { + + // + // Render + + render() { + const { + className, + innerClassName, + isSmallScreen, + children, + dispatch, + ...otherProps + } = this.props; + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( + +
+ {children} +
+
+ ); + } +} + +PageContentBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + isSmallScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + dispatch: PropTypes.func +}; + +PageContentBody.defaultProps = { + className: styles.contentBody, + innerClassName: styles.innerContentBody +}; + +export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js new file mode 100644 index 000000000..b5cdfbb21 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBodyConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import PageContentBody from './PageContentBody'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(PageContentBody); diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css new file mode 100644 index 000000000..7b1f7a6db --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.css @@ -0,0 +1,3 @@ +.content { + composes: content from './PageContent.css'; +} diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js new file mode 100644 index 000000000..5ae41a936 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import PageContentBodyConnector from './PageContentBodyConnector'; +import styles from './PageContentError.css'; + +function PageContentError(props) { + return ( +
+ + + +
+ ); +} + +export default PageContentError; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css new file mode 100644 index 000000000..74bdb3811 --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -0,0 +1,26 @@ +.contentFooter { + display: flex; + flex: 0 0 auto; + padding: 20px; + background-color: #f1f1f1; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentFooter { + display: block; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + } +} + +@media only screen and (max-width: $breakpointLarge) { + .contentFooter { + flex-wrap: wrap; + } +} diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js new file mode 100644 index 000000000..1f6e2d21a --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageContentFooter.css'; + +class PageContentFooter extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageContentFooter.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContentFooter.defaultProps = { + className: styles.contentFooter +}; + +export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css new file mode 100644 index 000000000..9a116fb54 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.css @@ -0,0 +1,22 @@ +.jumpBar { + display: flex; + align-content: stretch; + align-items: stretch; + align-self: stretch; + justify-content: center; + flex: 0 0 30px; +} + +.jumpBarItems { + display: flex; + justify-content: space-around; + flex: 0 0 100%; + flex-direction: column; + overflow: hidden; +} + +@media only screen and (max-width: $breakpointSmall) { + .jumpBar { + display: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js new file mode 100644 index 000000000..f7d44ae9a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.js @@ -0,0 +1,133 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import PageJumpBarItem from './PageJumpBarItem'; +import styles from './PageJumpBar.css'; + +const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight); + +class PageJumpBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 0, + visibleItems: props.items + }; + } + + componentDidMount() { + this.computeVisibleItems(); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.items !== this.props.items || + prevState.height !== this.state.height + ) { + this.computeVisibleItems(); + } + } + + // + // Control + + computeVisibleItems() { + const { + items, + minimumItems + } = this.props; + + const height = this.state.height; + const maximumItems = Math.floor(height / ITEM_HEIGHT); + const diff = items.length - maximumItems; + + if (diff < 0) { + this.setState({ visibleItems: items }); + return; + } + + if (items.length < minimumItems) { + this.setState({ visibleItems: items }); + return; + } + + const removeDiff = Math.ceil(items.length / maximumItems); + + const visibleItems = _.reduce(items, (acc, item, index) => { + if (index % removeDiff === 0) { + acc.push(item); + } + + return acc; + }, []); + + this.setState({ visibleItems }); + } + + // + // Listeners + + onMeasure = ({ height }) => { + this.setState({ height }); + } + + // + // Render + + render() { + const { + minimumItems, + onItemPress + } = this.props; + + const { + visibleItems + } = this.state; + + if (!visibleItems.length || visibleItems.length < minimumItems) { + return null; + } + + return ( +
+ +
+ { + visibleItems.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +PageJumpBar.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, + minimumItems: PropTypes.number.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +PageJumpBar.defaultProps = { + minimumItems: 5 +}; + +export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css new file mode 100644 index 000000000..e829dd31a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.css @@ -0,0 +1,14 @@ +.jumpBarItem { + flex: 1 0 $jumpBarItemHeight; + border-bottom: 1px solid $borderColor; + text-align: center; + font-weight: bold; + + &:hover { + color: #777; + } + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js new file mode 100644 index 000000000..aeffe4ddd --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './PageJumpBarItem.css'; + +class PageJumpBarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + label, + onItemPress + } = this.props; + + onItemPress(label); + } + + // + // Render + + render() { + return ( + + {this.props.label.toUpperCase()} + + ); + } +} + +PageJumpBarItem.propTypes = { + label: PropTypes.string.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js new file mode 100644 index 000000000..774b88669 --- /dev/null +++ b/frontend/src/Components/Page/PageSectionContent.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function PageSectionContent(props) { + const { + isFetching, + isPopulated, + error, + errorMessage, + children + } = props; + + if (isFetching) { + return ( + + ); + } else if (!isFetching && !!error) { + return ( +
{errorMessage}
+ ); + } else if (isPopulated && !error) { + return ( +
{children}
+ ); + } + + return null; +} + +PageSectionContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + errorMessage: PropTypes.string.isRequired, + children: PropTypes.node.isRequired +}; + +export default PageSectionContent; diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css new file mode 100644 index 000000000..7d53adb69 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css @@ -0,0 +1,42 @@ +.message { + display: flex; + border-left: 3px solid $infoColor; +} + +.iconContainer, +.text { + display: flex; + justify-content: center; + flex-direction: column; + padding: 2px 0; + color: $sidebarColor; +} + +.iconContainer { + flex: 0 0 25px; + margin-left: 24px; + padding: 10px 0; +} + +.text { + margin-right: 24px; + font-size: 13px; +} + +/* Types */ + +.error { + border-left-color: $dangerColor; +} + +.info { + border-left-color: $infoColor; +} + +.success { + border-left-color: $successColor; +} + +.warning { + border-left-color: $warningColor; +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js new file mode 100644 index 000000000..7f2343d7c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './Message.css'; + +function getIconName(name) { + switch (name) { + case 'ApplicationUpdate': + return icons.RESTART; + case 'Backup': + return icons.BACKUP; + case 'CheckHealth': + return icons.HEALTH; + case 'EpisodeSearch': + return icons.SEARCH; + case 'Housekeeping': + return icons.HOUSEKEEPING; + case 'RefreshSeries': + return icons.REFRESH; + case 'RssSync': + return icons.RSS; + case 'SeasonSearch': + return icons.SEARCH; + case 'SeriesSearch': + return icons.SEARCH; + case 'UpdateSceneMapping': + return icons.REFRESH; + default: + return icons.SPINNER; + } +} + +function Message(props) { + const { + name, + message, + type + } = props; + + return ( +
+
+ +
+ +
+ {message} +
+
+ ); +} + +Message.propTypes = { + name: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + type: PropTypes.string.isRequired +}; + +export default Message; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js new file mode 100644 index 000000000..06c545c27 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { hideMessage } from 'Store/Actions/appActions'; +import Message from './Message'; + +const mapDispatchToProps = { + hideMessage +}; + +class MessageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._hideTimeoutId = null; + this.scheduleHideMessage(props.hideAfter); + } + + componentDidUpdate() { + this.scheduleHideMessage(this.props.hideAfter); + } + + // + // Control + + scheduleHideMessage = (hideAfter) => { + if (this._hideTimeoutId) { + clearTimeout(this._hideTimeoutId); + } + + if (hideAfter) { + this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000); + } + } + + hideMessage = () => { + this.props.hideMessage({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MessageConnector.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + hideAfter: PropTypes.number.isRequired, + hideMessage: PropTypes.func.isRequired +}; + +MessageConnector.defaultProps = { + // Hide messages after 60 seconds if there is no activity + // hideAfter: 60 +}; + +export default connect(undefined, mapDispatchToProps)(MessageConnector); diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css new file mode 100644 index 000000000..ef01ad02c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css @@ -0,0 +1,11 @@ +.messages { + margin-top: auto; + margin-bottom: 20px; + padding-top: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .messages { + margin-bottom: 0; + } +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js new file mode 100644 index 000000000..ec8876f6e --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MessageConnector from './MessageConnector'; +import styles from './Messages.css'; + +function Messages({ messages }) { + return ( +
+ { + messages.map((message) => { + return ( + + ); + }) + } +
+ ); +} + +Messages.propTypes = { + messages: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Messages; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js new file mode 100644 index 000000000..5d20d9194 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Messages from './Messages'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.messages.items, + (messages) => { + return { + messages: messages.slice().reverse() + }; + } + ); +} + +export default connect(createMapStateToProps)(Messages); diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css new file mode 100644 index 000000000..fdbd80320 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css @@ -0,0 +1,34 @@ +.sidebarContainer { + flex: 0 0 $sidebarWidth; + overflow: hidden; + width: $sidebarWidth; + background-color: $sidebarBackgroundColor; + transition: transform 300ms ease-in-out; + transform: translateX(0); +} + +.sidebar { + display: flex; + flex-direction: column; + overflow: hidden; + background-color: $sidebarBackgroundColor; + color: $white; +} + +@media only screen and (max-width: $breakpointSmall) { + .sidebarContainer { + position: fixed; + top: 0; + z-index: 2; + height: 100vh; + } + + .sidebar { + position: fixed; + z-index: 2; + overflow-y: auto; + width: 100%; + height: 100%; + } +} + diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js new file mode 100644 index 000000000..6ffdf53cc --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -0,0 +1,525 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import dimensions from 'Styles/Variables/dimensions'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector'; +import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import MessagesConnector from './Messages/MessagesConnector'; +import PageSidebarItem from './PageSidebarItem'; +import styles from './PageSidebar.css'; + +const HEADER_HEIGHT = parseInt(dimensions.headerHeight); +const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); + +const links = [ + { + iconName: icons.SERIES_CONTINUING, + title: 'Series', + to: '/', + alias: '/series', + children: [ + { + title: 'Add New', + to: '/add/new' + }, + { + title: 'Import', + to: '/add/import' + }, + { + title: 'Mass Editor', + to: '/serieseditor' + }, + { + title: 'Season Pass', + to: '/seasonpass' + } + ] + }, + + { + iconName: icons.CALENDAR, + title: 'Calendar', + to: '/calendar' + }, + + { + iconName: icons.ACTIVITY, + title: 'Activity', + to: '/activity/queue', + children: [ + { + title: 'Queue', + to: '/activity/queue', + statusComponent: QueueStatusConnector + }, + { + title: 'History', + to: '/activity/history' + }, + { + title: 'Blacklist', + to: '/activity/blacklist' + } + ] + }, + + { + iconName: icons.WARNING, + title: 'Wanted', + to: '/wanted/missing', + children: [ + { + title: 'Missing', + to: '/wanted/missing' + }, + { + title: 'Cutoff Unmet', + to: '/wanted/cutoffunmet' + } + ] + }, + + { + iconName: icons.SETTINGS, + title: 'Settings', + to: '/settings', + children: [ + { + title: 'Media Management', + to: '/settings/mediamanagement' + }, + { + title: 'Profiles', + to: '/settings/profiles' + }, + { + title: 'Quality', + to: '/settings/quality' + }, + { + title: 'Indexers', + to: '/settings/indexers' + }, + { + title: 'Download Clients', + to: '/settings/downloadclients' + }, + { + title: 'Connect', + to: '/settings/connect' + }, + { + title: 'Metadata', + to: '/settings/metadata' + }, + { + title: 'Tags', + to: '/settings/tags' + }, + { + title: 'General', + to: '/settings/general' + }, + { + title: 'UI', + to: '/settings/ui' + } + ] + }, + + { + iconName: icons.SYSTEM, + title: 'System', + to: '/system/status', + children: [ + { + title: 'Status', + to: '/system/status', + statusComponent: HealthStatusConnector + }, + { + title: 'Tasks', + to: '/system/tasks' + }, + { + title: 'Backup', + to: '/system/backup' + }, + { + title: 'Updates', + to: '/system/updates' + }, + { + title: 'Events', + to: '/system/events' + }, + { + title: 'Log Files', + to: '/system/logs/files' + } + ] + } +]; + +function getActiveParent(pathname) { + let activeParent = links[0].to; + + links.forEach((link) => { + if (link.to && link.to === pathname) { + activeParent = link.to; + + return false; + } + + const children = link.children; + + if (children) { + children.forEach((childLink) => { + if (pathname.startsWith(childLink.to)) { + activeParent = link.to; + + return false; + } + }); + } + + if ( + (link.to !== '/' && pathname.startsWith(link.to)) || + (link.alias && pathname.startsWith(link.alias)) + ) { + activeParent = link.to; + + return false; + } + }); + + return activeParent; +} + +function hasActiveChildLink(link, pathname) { + const children = link.children; + + if (!children || !children.length) { + return false; + } + + return _.some(children, (child) => { + return child.to === pathname; + }); +} + +function getPositioning() { + const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY; + const top = Math.max(HEADER_HEIGHT - windowScroll, 0); + const height = window.innerHeight - top; + + return { + top: `${top}px`, + height: `${height}px` + }; +} + +class PageSidebar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStartX = null; + this._touchStartY = null; + this._sidebarRef = null; + + this.state = { + top: dimensions.headerHeight, + height: `${window.innerHeight - HEADER_HEIGHT}px`, + transition: null, + transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }; + } + + componentDidMount() { + if (this.props.isSmallScreen) { + window.addEventListener('click', this.onWindowClick, { capture: true }); + window.addEventListener('scroll', this.onWindowScroll); + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchmove', this.onTouchMove); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + } + } + + componentDidUpdate(prevProps) { + const { + isSidebarVisible + } = this.props; + + const transform = this.state.transform; + + if (prevProps.isSidebarVisible !== isSidebarVisible) { + this._setSidebarTransform(isSidebarVisible); + } else if (transform === 0 && !isSidebarVisible) { + this.props.onSidebarVisibleChange(true); + } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) { + this.props.onSidebarVisibleChange(false); + } + } + + componentWillUnmount() { + if (this.props.isSmallScreen) { + window.removeEventListener('click', this.onWindowClick, { capture: true }); + window.removeEventListener('scroll', this.onWindowScroll); + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchmove', this.onTouchMove); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + } + } + + // + // Control + + _setSidebarRef = (ref) => { + this._sidebarRef = ref; + } + + _setSidebarTransform(isSidebarVisible, transition, callback) { + this.setState({ + transition, + transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }, callback); + } + + // + // Listeners + + onWindowClick = (event) => { + const sidebar = ReactDOM.findDOMNode(this._sidebarRef); + const toggleButton = document.getElementById('sidebar-toggle-button'); + + if (!sidebar) { + return; + } + + if ( + !sidebar.contains(event.target) && + !toggleButton.contains(event.target) && + this.props.isSidebarVisible + ) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSidebarVisibleChange(false); + } + } + + onWindowScroll = () => { + this.setState(getPositioning()); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStartX = touches[0].pageX; + const touchStartY = touches[0].pageY; + const isSidebarVisible = this.props.isSidebarVisible; + + if (touches.length !== 1) { + return; + } + + if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) { + return; + } else if (!isSidebarVisible && touchStartX > 40) { + return; + } + + this._touchStartX = touchStartX; + this._touchStartY = touchStartY; + } + + onTouchMove = (event) => { + const touches = event.touches; + const currentTouchX = touches[0].pageX; + // const currentTouchY = touches[0].pageY; + // const isSidebarVisible = this.props.isSidebarVisible; + + if (!this._touchStartX) { + return; + } + + // This is a bit funky when trying to close and you scroll + // vertical too much by mistake, commenting out for now. + // TODO: Evaluate if this should be nuked + + // if (Math.abs(this._touchStartY - currentTouchY) > 40) { + // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1; + + // this.setState({ + // transition: 'none', + // transform + // }); + + // return; + // } + + if (Math.abs(this._touchStartX - currentTouchX) < 40) { + return; + } + + const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0); + + this.setState({ + transition: 'none', + transform + }); + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStartX) { + return; + } + + if (currentTouch > this._touchStartX && currentTouch > 50) { + this._setSidebarTransform(true, 'none'); + } else if (currentTouch < this._touchStartX && currentTouch < 80) { + this._setSidebarTransform(false, 'transform 50ms ease-in-out'); + } else { + this._setSidebarTransform(this.props.isSidebarVisible); + } + + this._touchStartX = null; + this._touchStartY = null; + } + + onTouchCancel = (event) => { + this._touchStartX = null; + this._touchStartY = null; + } + + onItemPress = () => { + this.props.onSidebarVisibleChange(false); + } + + // + // Render + + render() { + const { + location, + isSmallScreen + } = this.props; + + const { + top, + height, + transition, + transform + } = this.state; + + const urlBase = window.Sonarr.urlBase; + const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname; + const activeParent = getActiveParent(pathname); + + let containerStyle = {}; + let sidebarStyle = {}; + + if (isSmallScreen) { + containerStyle = { + transition, + transform: `translateX(${transform}px)` + }; + + sidebarStyle = { + top, + height + }; + } + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( +
+ +
+ { + links.map((link) => { + const childWithStatusComponent = _.find(link.children, (child) => { + return !!child.statusComponent; + }); + + const childStatusComponent = childWithStatusComponent ? + childWithStatusComponent.statusComponent : + null; + + const isActiveParent = activeParent === link.to; + const hasActiveChild = hasActiveChildLink(link, pathname); + + return ( + + { + link.children && link.to === activeParent && + link.children.map((child) => { + return ( + + ); + }) + } + + ); + }) + } +
+ + +
+
+ ); + } +} + +PageSidebar.propTypes = { + location: locationShape.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css new file mode 100644 index 000000000..dbf0bd2ba --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -0,0 +1,50 @@ +.item { + border-left: 3px solid transparent; + color: $sidebarColor; + transition: border-left 0.3s ease-in-out; +} + +.isActiveItem { + border-left: 3px solid $themeAlternateBlue; +} + +.link { + display: block; + padding: 12px 24px; + color: $sidebarColor; + + &:hover, + &:focus { + color: $themeBlue; + text-decoration: none; + } +} + +.childLink { + composes: link; + + padding: 10px 24px; +} + +.isActiveLink { + color: $themeAlternateBlue; +} + +.isActiveParentLink { + background-color: $sidebarActiveBackgroundColor; +} + +.iconContainer { + display: inline-block; + margin-right: 7px; + width: 18px; + text-align: center; +} + +.noIcon { + margin-left: 25px; +} + +.status { + float: right; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js new file mode 100644 index 000000000..d494150c0 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageSidebarItem.css'; + +class PageSidebarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + isChildItem, + isParentItem, + onPress + } = this.props; + + if (isChildItem || !isParentItem) { + onPress(); + } + } + + // + // Render + + render() { + const { + iconName, + title, + to, + isActive, + isActiveParent, + isChildItem, + statusComponent: StatusComponent, + children + } = this.props; + + return ( +
+ + { + !!iconName && + + + + } + + + {title} + + + { + !!StatusComponent && + + + + } + + + { + children && + map(children, (child) => { + return React.cloneElement(child, { isChildItem: true }); + }) + } +
+ ); + } +} + +PageSidebarItem.propTypes = { + iconName: PropTypes.object, + title: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + isActive: PropTypes.bool, + isActiveParent: PropTypes.bool, + isParentItem: PropTypes.bool.isRequired, + isChildItem: PropTypes.bool.isRequired, + statusComponent: PropTypes.func, + children: PropTypes.node, + onPress: PropTypes.func +}; + +PageSidebarItem.defaultProps = { + isChildItem: false +}; + +export default PageSidebarItem; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css new file mode 100644 index 000000000..4dd0cc678 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css @@ -0,0 +1,3 @@ +.status { + composes: label from 'Components/Label.css'; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js new file mode 100644 index 000000000..c1ea615ed --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function PageSidebarStatus({ count, errors, warnings }) { + if (!count) { + return null; + } + + let kind = kinds.INFO; + + if (errors) { + kind = kinds.DANGER; + } else if (warnings) { + kind = kinds.WARNING; + } + + return ( + + ); +} + +PageSidebarStatus.propTypes = { + count: PropTypes.number, + errors: PropTypes.bool, + warnings: PropTypes.bool +}; + +export default PageSidebarStatus; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css new file mode 100644 index 000000000..e040bc884 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css @@ -0,0 +1,16 @@ +.toolbar { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + padding: 0 20px; + height: $toolbarHeight; + background-color: $toolbarBackgroundColor; + color: $toolbarColor; + line-height: 60px; +} + +@media only screen and (max-width: $breakpointSmall) { + .toolbar { + padding: 0 10px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js new file mode 100644 index 000000000..728f1b0d9 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageToolbar.css'; + +class PageToolbar extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageToolbar.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageToolbar.defaultProps = { + className: styles.toolbar +}; + +export default PageToolbar; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css new file mode 100644 index 000000000..11944c1e9 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -0,0 +1,32 @@ +.toolbarButton { + composes: link from 'Components/Link/Link.css'; + + width: $toolbarButtonWidth; + text-align: center; + + &:hover { + color: $toobarButtonHoverColor; + } + + &.isDisabled { + color: $disabledColor; + } +} + +.isDisabled { + color: $disabledColor; +} + +.labelContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 16px; +} + +.label { + padding: 0 3px; + color: $toolbarLabelColor; + font-size: $extraSmallFontSize; + line-height: calc($extraSmallFontSize + 1px); +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js new file mode 100644 index 000000000..381046bf5 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageToolbarButton.css'; + +function PageToolbarButton(props) { + const { + label, + iconName, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + + +
+
+ {label} +
+
+ + ); +} + +PageToolbarButton.propTypes = { + label: PropTypes.string.isRequired, + iconName: PropTypes.object.isRequired, + spinningName: PropTypes.object, + isSpinning: PropTypes.bool, + isDisabled: PropTypes.bool +}; + +PageToolbarButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default PageToolbarButton; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css new file mode 100644 index 000000000..5fb56b77c --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css @@ -0,0 +1,27 @@ +.sectionContainer { + display: flex; + flex: 1 1 10%; + overflow: hidden; +} + +.section { + display: flex; + align-items: center; + flex-grow: 1; +} + +.left { + justify-content: flex-start; +} + +.center { + justify-content: center; +} + +.right { + justify-content: flex-end; +} + +.overflowMenuItemIcon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js new file mode 100644 index 000000000..57b53ff4e --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { forEach } from 'Helpers/elementChildren'; +import { align, icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import Measure from 'Components/Measure'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; +import styles from './PageToolbarSection.css'; + +const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); +const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin); +const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1; +const SEPARATOR_NAME = 'PageToolbarSeparator'; + +function calculateOverflowItems(children, isMeasured, width, collapseButtons) { + let buttonCount = 0; + let separatorCount = 0; + const validChildren = []; + + forEach(children, (child) => { + const name = child.type.name; + + if (name === SEPARATOR_NAME) { + separatorCount++; + } else { + buttonCount++; + } + + validChildren.push(child); + }); + + const buttonsWidth = buttonCount * BUTTON_WIDTH; + const separatorsWidth = separatorCount + SEPARATOR_WIDTH; + const totalWidth = buttonsWidth + separatorsWidth; + + // If the width of buttons and separators is less than + // the available width return all valid children. + + if ( + !isMeasured || + !collapseButtons || + totalWidth < width + ) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1); + const buttons = []; + const overflowItems = []; + let actualButtons = 0; + + // Return all buttons if only one is being pushed to the overflow menu. + if (buttonCount - 1 === maxButtons) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + validChildren.forEach((child, index) => { + if (actualButtons < maxButtons) { + if (child.type.name !== SEPARATOR_NAME) { + buttons.push(child); + actualButtons++; + } + } else if (child.type.name !== SEPARATOR_NAME) { + overflowItems.push(child.props); + } + }); + + return { + buttons, + buttonCount, + overflowItems + }; +} + +class PageToolbarSection extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMeasured: false, + width: 0, + buttons: [], + overflowItems: [] + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + isMeasured: true, + width + }); + } + + // + // Render + + render() { + const { + children, + alignContent, + collapseButtons + } = this.props; + + const { + isMeasured, + width + } = this.state; + + const { + buttons, + buttonCount, + overflowItems + } = calculateOverflowItems(children, isMeasured, width, collapseButtons); + + return ( + +
+ { + isMeasured ? +
+ { + buttons.map((button) => { + return button; + }) + } + + { + !!overflowItems.length && + + + + + { + overflowItems.map((item) => { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning, + ...otherProps + } = item; + + return ( + + + {label} + + ); + }) + } + + + } +
: + null + } +
+
+ ); + } + +} + +PageToolbarSection.propTypes = { + children: PropTypes.node, + alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]), + collapseButtons: PropTypes.bool.isRequired +}; + +PageToolbarSection.defaultProps = { + alignContent: align.LEFT, + collapseButtons: true +}; + +export default PageToolbarSection; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css new file mode 100644 index 000000000..968673593 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css @@ -0,0 +1,12 @@ +.separator { + margin: 10px $toolbarSeparatorMargin; + height: 40px; + border-right: 1px solid #e5e5e5; + opacity: 0.35; +} + +@media only screen and (max-width: $breakpointSmall) { + .separator { + margin: 10px 5px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js new file mode 100644 index 000000000..754248f99 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; +import styles from './PageToolbarSeparator.css'; + +class PageToolbarSeparator extends Component { + + // + // Render + + render() { + return ( +
+ ); + } + +} + +export default PageToolbarSeparator; diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css new file mode 100644 index 000000000..2f0019043 --- /dev/null +++ b/frontend/src/Components/ProgressBar.css @@ -0,0 +1,93 @@ +.container { + position: relative; + overflow: hidden; + width: 100%; + border-radius: 4px; + background-color: #f5f5f5; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progressBar { + position: relative; + z-index: 1; + float: left; + width: 0; + height: 100%; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + color: $white; + transition: width 0.6s ease; +} + +.frontTextContainer { + z-index: 1; + color: $white; +} + +.backTextContainer, +.frontTextContainer { + position: absolute; + overflow: hidden; + width: 0; + height: 100%; +} + +.backText, +.frontText { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 12px; + cursor: default; +} + +.primary { + background-color: $primaryColor; +} + +.danger { + background-color: $dangerColor; +} + +.success { + background-color: $successColor; +} + +.purple { + background-color: $purple; +} + +.warning { + background-color: $warningColor; +} + +.info { + background-color: $infoColor; +} + +.small { + height: $progressBarSmallHeight; + + .backText, + .frontText { + height: $progressBarSmallHeight; + } +} + +.medium { + height: $progressBarMediumHeight; + + .backText, + .frontText { + height: $progressBarMediumHeight; + } +} + +.large { + height: $progressBarLargeHeight; + + .backText, + .frontText { + height: $progressBarLargeHeight; + } +} diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js new file mode 100644 index 000000000..4f457d558 --- /dev/null +++ b/frontend/src/Components/ProgressBar.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds, sizes } from 'Helpers/Props'; +import styles from './ProgressBar.css'; + +function ProgressBar(props) { + const { + className, + containerClassName, + title, + progress, + precision, + showText, + text, + kind, + size, + width + } = props; + + const progressPercent = `${progress.toFixed(precision)}%`; + const progressText = text || progressPercent; + const actualWidth = width ? `${width}px` : '100%'; + + return ( +
+ { + showText && !!width && +
+
+
+ {progressText} +
+
+
+ } + +
+ { + showText && +
+
+
+ {progressText} +
+
+
+ } +
+ ); +} + +ProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + title: PropTypes.string, + progress: PropTypes.number.isRequired, + precision: PropTypes.number.isRequired, + showText: PropTypes.bool.isRequired, + text: PropTypes.string, + kind: PropTypes.oneOf(kinds.all).isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + width: PropTypes.number +}; + +ProgressBar.defaultProps = { + className: styles.progressBar, + containerClassName: styles.container, + precision: 1, + showText: false, + kind: kinds.PRIMARY, + size: sizes.MEDIUM +}; + +export default ProgressBar; diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js new file mode 100644 index 000000000..0c0004a50 --- /dev/null +++ b/frontend/src/Components/Router/Switch.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Switch as RouterSwitch } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import { map } from 'Helpers/elementChildren'; + +class Switch extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + { + map(children, (child) => { + const { + path: childPath, + addUrlBase = true + } = child.props; + + if (!childPath) { + return child; + } + + const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath; + + return React.cloneElement(child, { path }); + }) + } + + ); + } +} + +Switch.propTypes = { + children: PropTypes.node.isRequired +}; + +export default Switch; diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css new file mode 100644 index 000000000..707a9ac6f --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.css @@ -0,0 +1,15 @@ +.scroller { + /* Placeholder */ +} + +.thumb { + min-height: 50px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js new file mode 100644 index 000000000..23fe6058a --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Scrollbars } from 'react-custom-scrollbars'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './OverlayScroller.css'; + +class OverlayScroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + this._isScrolling = false; + } + + componentDidUpdate(prevProps) { + const { + scrollTop + } = this.props; + + if (!this._isScrolling && scrollTop != null && scrollTop !== prevProps.scrollTop) { + this._scroller.scrollTop(scrollTop); + } + } + + // + // Control + + _setScrollRef = (ref) => { + this._scroller = ref; + } + + _renderThumb = (props) => { + return ( +
+ ); + } + + _renderView = (props) => { + return ( +
+ ); + } + + // + // Listers + + onScrollStart = () => { + this._isScrolling = true; + } + + onScrollStop = () => { + this._isScrolling = false; + } + + onScroll = (event) => { + const { + scrollTop, + scrollLeft + } = event.currentTarget; + + this._isScrolling = true; + const onScroll = this.props.onScroll; + + if (onScroll) { + onScroll({ scrollTop, scrollLeft }); + } + } + + // + // Render + + render() { + const { + autoHide, + autoScroll, + children + } = this.props; + + return ( + + {children} + + ); + } + +} + +OverlayScroller.propTypes = { + className: PropTypes.string, + trackClassName: PropTypes.string, + scrollTop: PropTypes.number, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, + autoHide: PropTypes.bool.isRequired, + autoScroll: PropTypes.bool.isRequired, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +OverlayScroller.defaultProps = { + className: styles.scroller, + trackClassName: styles.thumb, + scrollDirection: scrollDirections.VERTICAL, + autoHide: false, + autoScroll: true +}; + +export default OverlayScroller; diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css new file mode 100644 index 000000000..c8783a8de --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.css @@ -0,0 +1,28 @@ +.scroller { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.none { + overflow-x: hidden; + overflow-y: hidden; +} + +.vertical { + overflow-x: hidden; + overflow-y: scroll; + + &.autoScroll { + overflow-y: auto; + } +} + +.horizontal { + overflow-x: scroll; + overflow-y: hidden; + + &.autoScroll { + overflow-x: auto; + } +} diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js new file mode 100644 index 000000000..701ac0cf4 --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './Scroller.css'; + +class Scroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + } + + componentDidMount() { + const { + scrollTop + } = this.props; + + if (this.props.scrollTop != null) { + this._scroller.scrollTop = scrollTop; + } + } + + // + // Control + + _setScrollerRef = (ref) => { + this._scroller = ref; + } + + // + // Render + + render() { + const { + className, + scrollDirection, + autoScroll, + children, + scrollTop, + onScroll, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +Scroller.propTypes = { + className: PropTypes.string, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, + autoScroll: PropTypes.bool.isRequired, + scrollTop: PropTypes.number, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +Scroller.defaultProps = { + scrollDirection: scrollDirections.VERTICAL, + autoScroll: true +}; + +export default Scroller; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js new file mode 100644 index 000000000..8b7b91fe2 --- /dev/null +++ b/frontend/src/Components/SignalRConnector.js @@ -0,0 +1,374 @@ +import $ from 'jquery'; +import 'signalr'; +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { repopulatePage } from 'Utilities/pagePopulator'; +import titleCase from 'Utilities/String/titleCase'; +import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions'; +import { setAppValue, setVersion } from 'Store/Actions/appActions'; +import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; + +function getState(status) { + switch (status) { + case 0: + return 'connecting'; + case 1: + return 'connected'; + case 2: + return 'reconnecting'; + case 4: + return 'disconnected'; + default: + throw new Error(`invalid status ${status}`); + } +} + +function isAppDisconnected(disconnectedTime) { + if (!disconnectedTime) { + return false; + } + + return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180; +} + +function getHandlerName(name) { + name = titleCase(name); + name = name.replace('/', ''); + + return `handle${name}`; +} + +function createMapStateToProps() { + return createSelector( + (state) => state.app.isReconnecting, + (state) => state.app.isDisconnected, + (state) => state.queue.paged.isPopulated, + (isReconnecting, isDisconnected, isQueuePopulated) => { + return { + isReconnecting, + isDisconnected, + isQueuePopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands, + dispatchUpdateCommand: updateCommand, + dispatchFinishCommand: finishCommand, + dispatchSetAppValue: setAppValue, + dispatchSetVersion: setVersion, + dispatchUpdate: update, + dispatchUpdateItem: updateItem, + dispatchRemoveItem: removeItem, + dispatchFetchHealth: fetchHealth, + dispatchFetchQueue: fetchQueue, + dispatchFetchQueueDetails: fetchQueueDetails, + dispatchFetchTags: fetchTags, + dispatchFetchTagDetails: fetchTagDetails +}; + +class SignalRConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] }; + this.signalRconnection = null; + this.retryInterval = 1; + this.retryTimeoutId = null; + this.disconnectedTime = null; + } + + componentDidMount() { + console.log('Starting signalR'); + + this.signalRconnection = $.connection('/signalr', { apiKey: window.Sonarr.apiKey }); + + this.signalRconnection.stateChanged(this.onStateChanged); + this.signalRconnection.received(this.onReceived); + this.signalRconnection.reconnecting(this.onReconnecting); + this.signalRconnection.disconnected(this.onDisconnected); + + this.signalRconnection.start(this.signalRconnectionOptions); + } + + componentWillUnmount() { + if (this.retryTimeoutId) { + this.retryTimeoutId = clearTimeout(this.retryTimeoutId); + } + + this.signalRconnection.stop(); + this.signalRconnection = null; + } + + // + // Control + + retryConnection = () => { + if (isAppDisconnected(this.disconnectedTime)) { + this.setState({ + isDisconnected: true + }); + } + + this.retryTimeoutId = setTimeout(() => { + if (!this.signalRconnection) { + console.error('signalR: Connection was disposed'); + return; + } + + this.signalRconnection.start(this.signalRconnectionOptions); + this.retryInterval = Math.min(this.retryInterval + 1, 10); + }, this.retryInterval * 1000); + } + + handleMessage = (message) => { + const { + name, + body + } = message; + + const handler = this[getHandlerName(name)]; + + if (handler) { + handler(body); + return; + } + + console.error(`signalR: Unable to find handler for ${name}`); + } + + handleCalendar = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'calendar', + updateOnly: true, + ...body.resource + }); + } + } + + handleCommand = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchCommands(); + return; + } + + const resource = body.resource; + const status = resource.status; + + // Both sucessful and failed commands need to be + // completed, otherwise they spin until they timeout. + + if (status === 'completed' || status === 'failed') { + this.props.dispatchFinishCommand(resource); + } else { + this.props.dispatchUpdateCommand(resource); + } + } + + handleEpisode = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'episodes', + updateOnly: true, + ...body.resource + }); + } + } + + handleEpisodefile = (body) => { + const section = 'episodeFiles'; + + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + + // Repopulate the page to handle recently imported file + repopulatePage('episodeFileUpdated'); + } else if (body.action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleHealth = () => { + this.props.dispatchFetchHealth(); + } + + handleSeries = (body) => { + const action = body.action; + const section = 'series'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleQueue = () => { + if (this.props.isQueuePopulated) { + this.props.dispatchFetchQueue(); + } + } + + handleQueueDetails = () => { + this.props.dispatchFetchQueueDetails(); + } + + handleQueueStatus = (body) => { + this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); + } + + handleVersion = (body) => { + const version = body.Version; + + this.props.dispatchSetVersion({ version }); + } + + handleWantedCutoff = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'cutoffUnmet', + updateOnly: true, + ...body.resource + }); + } + } + + handleWantedMissing = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'missing', + updateOnly: true, + ...body.resource + }); + } + } + + handleSystemTask = () => { + // No-op for now, we may want this later + } + + handleTag = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchTags(); + this.props.dispatchFetchTagDetails(); + return; + } + } + + // + // Listeners + + onStateChanged = (change) => { + const state = getState(change.newState); + console.log(`signalR: ${state}`); + + if (state === 'connected') { + // Clear disconnected time + this.disconnectedTime = null; + + const { + dispatchFetchCommands, + dispatchSetAppValue + } = this.props; + + // Repopulate the page (if a repopulator is set) to ensure things + // are in sync after reconnecting. + + if (this.props.isReconnecting || this.props.isDisconnected) { + dispatchFetchCommands(); + repopulatePage(); + } + + dispatchSetAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false + }); + + this.retryInterval = 5; + + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId); + } + } + } + + onReceived = (message) => { + console.debug('signalR: received', message.name, message.body); + + this.handleMessage(message); + } + + onReconnecting = () => { + if (window.Sonarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isReconnecting: true + }); + } + + onDisconnected = () => { + if (window.Sonarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isConnected: false, + isReconnecting: true, + isDisconnected: isAppDisconnected(this.disconnectedTime) + }); + + this.retryConnection(); + } + + // + // Render + + render() { + return null; + } +} + +SignalRConnector.propTypes = { + isReconnecting: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + isQueuePopulated: PropTypes.bool.isRequired, + dispatchFetchCommands: PropTypes.func.isRequired, + dispatchUpdateCommand: PropTypes.func.isRequired, + dispatchFinishCommand: PropTypes.func.isRequired, + dispatchSetAppValue: PropTypes.func.isRequired, + dispatchSetVersion: PropTypes.func.isRequired, + dispatchUpdate: PropTypes.func.isRequired, + dispatchUpdateItem: PropTypes.func.isRequired, + dispatchRemoveItem: PropTypes.func.isRequired, + dispatchFetchHealth: PropTypes.func.isRequired, + dispatchFetchQueue: PropTypes.func.isRequired, + dispatchFetchQueueDetails: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchTagDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js new file mode 100644 index 000000000..d21674d9e --- /dev/null +++ b/frontend/src/Components/SpinnerIcon.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from './Icon'; + +function SpinnerIcon(props) { + const { + name, + spinningName, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIcon.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIcon.defaultProps = { + spinningName: icons.SPINNER +}; + +export default SpinnerIcon; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css new file mode 100644 index 000000000..7be20ce5d --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css @@ -0,0 +1,5 @@ +.cell { + composes: cell from './TableRowCell.css'; + + width: 180px; +} diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js new file mode 100644 index 000000000..93004b447 --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import TableRowCell from './TableRowCell'; +import styles from './RelativeDateCell.css'; + +function RelativeDateCell(props) { + const { + className, + date, + includeSeconds, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + component: Component, + dispatch, + ...otherProps + } = props; + + if (!date) { + return ( + + ); + } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} + + ); +} + +RelativeDateCell.propTypes = { + className: PropTypes.string.isRequired, + date: PropTypes.string, + includeSeconds: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + component: PropTypes.func, + dispatch: PropTypes.func +}; + +RelativeDateCell.defaultProps = { + className: styles.cell, + includeSeconds: false, + component: TableRowCell +}; + +export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js new file mode 100644 index 000000000..ed996abbe --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import RelativeDateCell from './RelativeDateCell'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'longDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps, null)(RelativeDateCell); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css new file mode 100644 index 000000000..1c3e6fc5a --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -0,0 +1,11 @@ +.cell { + padding: 8px; + border-top: 1px solid #eee; + line-height: 1.52857143; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js new file mode 100644 index 000000000..f66bbf3aa --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './TableRowCell.css'; + +class TableRowCell extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +TableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +TableRowCell.defaultProps = { + className: styles.cell +}; + +export default TableRowCell; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css new file mode 100644 index 000000000..f01e7cba6 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css @@ -0,0 +1,4 @@ +.cell { + composes: cell from './TableRowCell.css'; + composes: link from 'Components/Link/Link.css'; +} diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js new file mode 100644 index 000000000..ff50d3bc9 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +function TableRowCellButton({ className, ...otherProps }) { + return ( + + ); +} + +TableRowCellButton.propTypes = { + className: PropTypes.string.isRequired +}; + +TableRowCellButton.defaultProps = { + className: styles.cell +}; + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css new file mode 100644 index 000000000..21ab944d7 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css @@ -0,0 +1,11 @@ +.selectCell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js new file mode 100644 index 000000000..9c10f4444 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRowCell from './TableRowCell'; +import styles from './TableSelectCell.css'; + +class TableSelectCell extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + isSelected, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: isSelected }); + } + + componentWillUnmount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: null }); + } + + // + // Listeners + + onChange = ({ value, shiftKey }, a, b, c, d) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + className, + id, + isSelected, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +TableSelectCell.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +TableSelectCell.defaultProps = { + className: styles.selectCell, + isSelected: false +}; + +export default TableSelectCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css new file mode 100644 index 000000000..e4cffe1c4 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -0,0 +1,14 @@ +.cell { + @add-mixin truncate; + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + flex-grow: 0; + flex-shrink: 1; + white-space: nowrap; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js new file mode 100644 index 000000000..42999216f --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRowCell.css'; + +function VirtualTableRowCell(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +VirtualTableRowCell.defaultProps = { + className: styles.cell +}; + +export default VirtualTableRowCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css new file mode 100644 index 000000000..e1016aa8a --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css @@ -0,0 +1,11 @@ +.cell { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js new file mode 100644 index 000000000..a773aab58 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRowCell from './VirtualTableRowCell'; +import styles from './VirtualTableSelectCell.css'; + +export function virtualTableSelectCellRenderer(cellProps) { + const { + cellKey, + rowData, + columnData, + ...otherProps + } = cellProps; + + return ( + + ); +} + +class VirtualTableSelectCell extends Component { + + // + // Listeners + + onChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + inputClassName, + id, + isSelected, + isDisabled, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +VirtualTableSelectCell.propTypes = { + inputClassName: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +VirtualTableSelectCell.defaultProps = { + inputClassName: styles.input, + isSelected: false +}; + +export default VirtualTableSelectCell; diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css new file mode 100644 index 000000000..46d49826a --- /dev/null +++ b/frontend/src/Components/Table/Table.css @@ -0,0 +1,16 @@ +.tableContainer { + overflow-x: auto; +} + +.table { + max-width: 100%; + width: 100%; + border-collapse: collapse; +} + +@media only screen and (max-width: $breakpointSmall) { + .tableContainer { + overflow-y: hidden; + width: 100%; + } +} diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js new file mode 100644 index 000000000..5cfbc4c8f --- /dev/null +++ b/frontend/src/Components/Table/Table.js @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, scrollDirections } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Scroller from 'Components/Scroller/Scroller'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import TableHeader from './TableHeader'; +import TableHeaderCell from './TableHeaderCell'; +import TableSelectAllHeaderCell from './TableSelectAllHeaderCell'; +import styles from './Table.css'; + +const tableHeaderCellProps = [ + 'sortKey', + 'sortDirection' +]; + +function getTableHeaderCellProps(props) { + return _.reduce(tableHeaderCellProps, (result, key) => { + if (props.hasOwnProperty(key)) { + result[key] = props[key]; + } + + return result; + }, {}); +} + +class Table extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + selectAll, + columns, + optionsComponent, + pageSize, + canModifyColumns, + children, + onSortPress, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + + + { + selectAll && + + } + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if ((name === 'actions' || name === 'details') && onTableOptionChange) { + return ( + + + + ); + } + + return ( + + {column.label} + + ); + }) + } + + { + !!onTableOptionChange && + + } + + + {children} +
+
+ ); + } +} + +Table.propTypes = { + className: PropTypes.string, + selectAll: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + optionsComponent: PropTypes.func, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool, + children: PropTypes.node, + onSortPress: PropTypes.func, + onTableOptionChange: PropTypes.func +}; + +Table.defaultProps = { + className: styles.table, + selectAll: false +}; + +export default Table; diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js new file mode 100644 index 000000000..5cc60d6f4 --- /dev/null +++ b/frontend/src/Components/Table/TableBody.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableBody extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + {children} + ); + } + +} + +TableBody.propTypes = { + children: PropTypes.node +}; + +export default TableBody; diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js new file mode 100644 index 000000000..81943e919 --- /dev/null +++ b/frontend/src/Components/Table/TableHeader.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableHeader extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + + {children} + + + ); + } +} + +TableHeader.propTypes = { + children: PropTypes.node +}; + +export default TableHeader; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js new file mode 100644 index 000000000..73c4b7ec2 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './TableHeaderCell.css'; + +class TableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + isSortable, + isVisible, + isModifiable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + + + {children} + + ); + } +} + +TableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isSortable: PropTypes.bool, + isVisible: PropTypes.bool, + isModifiable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +TableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default TableHeaderCell; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css new file mode 100644 index 000000000..204773c3d --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css @@ -0,0 +1,48 @@ +.column { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.label { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.notDragable { + padding: 4px 0; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js new file mode 100644 index 000000000..a986be615 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TableOptionsColumn.css'; + +function TableOptionsColumn(props) { + const { + name, + label, + isVisible, + isModifiable, + isDragging, + connectDragSource, + onVisibleChange + } = props; + + return ( +
+
+ + + { + !!connectDragSource && + connectDragSource( +
+ +
+ ) + } +
+
+ ); +} + +TableOptionsColumn.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + connectDragSource: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired +}; + +export default TableOptionsColumn; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js new file mode 100644 index 000000000..b1d016529 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class TableOptionsColumnDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== TABLE_COLUMN) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
+ +
+
+ ); + } +} + +TableOptionsColumnDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css new file mode 100644 index 000000000..9354a35c0 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css @@ -0,0 +1,18 @@ +.columnDragSource { + padding: 4px 0; +} + +.columnPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.columnPlaceholderBefore { + margin-bottom: 8px; +} + +.columnPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js new file mode 100644 index 000000000..80f03e430 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragSource.css'; + +const columnDragSource = { + beginDrag(column) { + return column; + }, + + endDrag(props, monitor, component) { + props.onColumnDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const columnDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().index; + const hoverIndex = props.index; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + props.onColumnDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class TableOptionsColumnDragSource extends Component { + + // + // Render + + render() { + const { + name, + label, + isVisible, + isModifiable, + index, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onVisibleChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +TableOptionsColumnDragSource.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired, + onColumnDragMove: PropTypes.func.isRequired, + onColumnDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + TABLE_COLUMN, + columnDropTarget, + collectDropTarget +)(DragSource( + TABLE_COLUMN, + columnDragSource, + collectDragSource +)(TableOptionsColumnDragSource)); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css new file mode 100644 index 000000000..35544f32b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css @@ -0,0 +1,5 @@ +.columns { + margin-top: 10px; + width: 100%; + user-select: none; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js new file mode 100644 index 000000000..351d827ca --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -0,0 +1,252 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TableOptionsColumn from './TableOptionsColumn'; +import TableOptionsColumnDragSource from './TableOptionsColumnDragSource'; +import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview'; +import styles from './TableOptionsModal.css'; + +class TableOptionsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPageSize: !!props.pageSize, + pageSize: props.pageSize, + pageSizeError: null, + dragIndex: null, + dropIndex: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.pageSize !== this.state.pageSize) { + this.setState({ pageSize: this.props.pageSize }); + } + } + + // + // Listeners + + onPageSizeChange = ({ value }) => { + let pageSizeError = null; + + if (value < 5) { + pageSizeError = 'Page size must be at least 5'; + } else if (value > 250) { + pageSizeError = 'Page size must not exceed 250'; + } else { + this.props.onTableOptionChange({ pageSize: value }); + } + + this.setState({ + pageSize: value, + pageSizeError + }); + } + + onVisibleChange = ({ name, value }) => { + const columns = _.cloneDeep(this.props.columns); + + const column = _.find(columns, { name }); + column.isVisible = value; + + this.props.onTableOptionChange({ columns }); + } + + onColumnDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onColumnDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const columns = _.cloneDeep(this.props.columns); + const items = columns.splice(dragIndex, 1); + columns.splice(dropIndex, 0, items[0]); + + this.props.onTableOptionChange({ columns }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + const { + isOpen, + columns, + canModifyColumns, + optionsComponent: OptionsComponent, + onTableOptionChange, + onModalClose + } = this.props; + + const { + hasPageSize, + pageSize, + pageSizeError, + dragIndex, + dropIndex + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + + + + Table Options + + + +
+ { + hasPageSize && + + Page Size + + + + } + + { + !!OptionsComponent && + + } + + { + canModifyColumns && + + Columns + +
+ + +
+ { + columns.map((column, index) => { + const { + name, + label, + columnLabel, + isVisible, + isModifiable + } = column; + + if (isModifiable !== false) { + return ( + + ); + } + + return ( + + ); + }) + } + + +
+
+
+ } + +
+ + + +
+
+ ); + } +} + +TableOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool.isRequired, + optionsComponent: PropTypes.func, + onTableOptionChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +TableOptionsModal.defaultProps = { + canModifyColumns: true +}; + +export default DragDropContext(HTML5Backend)(TableOptionsModal); diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css new file mode 100644 index 000000000..17d300fb9 --- /dev/null +++ b/frontend/src/Components/Table/TablePager.css @@ -0,0 +1,77 @@ +.pager { + display: flex; + align-items: center; + justify-content: space-between; +} + +.loadingContainer, +.controlsContainer, +.recordsContainer { + flex: 0 1 33%; +} + +.controlsContainer { + display: flex; + justify-content: center; +} + +.recordsContainer { + display: flex; + justify-content: flex-end; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin: 0; + margin-left: 5px; + text-align: left; +} + +.controls { + display: flex; + align-items: center; + text-align: center; +} + +.pageNumber { + line-height: 30px; +} + +.pageLink { + padding: 0; + width: 30px; + height: 30px; + line-height: 30px; +} + +.records { + color: $disabledColor; +} + +.disabledPageButton { + color: $disabledColor; +} + +.pageSelect { + composes: select from 'Components/Form/SelectInput.css'; + + padding: 0 2px; + height: 25px; +} + +@media only screen and (max-width: $breakpointSmall) { + .pager { + flex-wrap: wrap; + } + + .loadingContainer, + .recordsContainer { + flex: 0 1 50%; + } + + .controlsContainer { + flex: 0 1 100%; + order: -1; + } +} diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js new file mode 100644 index 000000000..3c7c5a8f1 --- /dev/null +++ b/frontend/src/Components/Table/TablePager.js @@ -0,0 +1,180 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import styles from './TablePager.css'; + +class TablePager extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isShowingPageSelect: false + }; + } + + // + // Listeners + + onOpenPageSelectClick = () => { + this.setState({ isShowingPageSelect: true }); + } + + onPageSelect = ({ value: page }) => { + this.setState({ isShowingPageSelect: false }); + this.props.onPageSelect(parseInt(page)); + } + + onPageSelectBlur = () => { + this.setState({ isShowingPageSelect: false }); + } + + // + // Render + + render() { + const { + page, + totalPages, + totalRecords, + isFetching, + onFirstPagePress, + onPreviousPagePress, + onNextPagePress, + onLastPagePress + } = this.props; + + const isShowingPageSelect = this.state.isShowingPageSelect; + const pages = Array.from(new Array(totalPages), (x, i) => { + const pageNumber = i + 1; + + return { + key: pageNumber, + value: pageNumber + }; + }); + + if (!page) { + return null; + } + + const isFirstPage = page === 1; + const isLastPage = page === totalPages; + + return ( +
+
+ { + isFetching && + + } +
+ +
+
+ + + + + + + + +
+ { + !isShowingPageSelect && + + {page} / {totalPages} + + } + + { + isShowingPageSelect && + + } +
+ + + + + + + + +
+
+ +
+
+ Total records: {totalRecords} +
+
+
+ ); + } + +} + +TablePager.propTypes = { + page: PropTypes.number, + totalPages: PropTypes.number, + totalRecords: PropTypes.number, + isFetching: PropTypes.bool, + onFirstPagePress: PropTypes.func.isRequired, + onPreviousPagePress: PropTypes.func.isRequired, + onNextPagePress: PropTypes.func.isRequired, + onLastPagePress: PropTypes.func.isRequired, + onPageSelect: PropTypes.func.isRequired +}; + +export default TablePager; diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css new file mode 100644 index 000000000..dcc6ad8cf --- /dev/null +++ b/frontend/src/Components/Table/TableRow.css @@ -0,0 +1,7 @@ +.row { + transition: background-color 500ms; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js new file mode 100644 index 000000000..c76083183 --- /dev/null +++ b/frontend/src/Components/Table/TableRow.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './TableRow.css'; + +function TableRow(props) { + const { + className, + children, + overlayContent, + ...otherProps + } = props; + + return ( + + {children} + + ); +} + +TableRow.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + overlayContent: PropTypes.bool +}; + +TableRow.defaultProps = { + className: styles.row +}; + +export default TableRow; diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css new file mode 100644 index 000000000..70a2238ca --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.css @@ -0,0 +1,4 @@ +.row { + composes: link from 'Components/Link/Link.css'; + composes: row from './TableRow.css'; +} diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js new file mode 100644 index 000000000..7ff679673 --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRow from './TableRow'; +import styles from './TableRowButton.css'; + +function TableRowButton(props) { + return ( + + ); +} + +export default TableRowButton; diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css new file mode 100644 index 000000000..6090e6e9c --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + width: 30px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js new file mode 100644 index 000000000..c889c32ae --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './TableHeaderCell'; +import styles from './TableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function TableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +TableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default TableSelectAllHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css new file mode 100644 index 000000000..3287c5643 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.css @@ -0,0 +1,3 @@ +.tableContainer { + width: 100%; +} diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js new file mode 100644 index 000000000..a13807647 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.js @@ -0,0 +1,167 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { WindowScroller } from 'react-virtualized'; +import { scrollDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import Scroller from 'Components/Scroller/Scroller'; +import VirtualTableBody from './VirtualTableBody'; +import styles from './VirtualTable.css'; + +const ROW_HEIGHT = 38; + +function overscanIndicesGetter(options) { + const { + cellCount, + overscanCellsCount, + startIndex, + stopIndex + } = options; + + // The default getter takes the scroll direction into account, + // but that can cause issues. Ignore the scroll direction and + // always over return more items. + + const overscanStartIndex = startIndex - overscanCellsCount; + const overscanStopIndex = stopIndex + overscanCellsCount; + + return { + overscanStartIndex: Math.max(0, overscanStartIndex), + overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex) + }; +} + +class VirtualTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0 + }; + + this._isInitialized = false; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps, preState) { + const scrollIndex = this.props.scrollIndex; + + if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) { + const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20; + + this.props.onScroll({ scrollTop }); + } + } + + // + // Control + + rowGetter = ({ index }) => { + return this.props.items[index]; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + width + }); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + className, + items, + isSmallScreen, + header, + headerHeight, + scrollTop, + rowRenderer, + onScroll, + ...otherProps + } = this.props; + + const { + width + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + {header} + + + + ); + } + } + + + ); + } +} + +VirtualTable.propTypes = { + className: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + scrollTop: PropTypes.number.isRequired, + scrollIndex: PropTypes.number, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + header: PropTypes.node.isRequired, + headerHeight: PropTypes.number.isRequired, + rowRenderer: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +VirtualTable.defaultProps = { + className: styles.tableContainer, + headerHeight: 38, + onRender: () => {} +}; + +export default VirtualTable; diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css new file mode 100644 index 000000000..12768646d --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.css @@ -0,0 +1,3 @@ +.tableBodyContainer { + position: relative; +} diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js new file mode 100644 index 000000000..de88bd03c --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid } from 'react-virtualized'; +import styles from './VirtualTableBody.css'; + +class VirtualTableBody extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +VirtualTableBody.propTypes = { + className: PropTypes.string.isRequired +}; + +VirtualTableBody.defaultProps = { + className: styles.tableBodyContainer +}; + +export default VirtualTableBody; diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css new file mode 100644 index 000000000..4b757c1f8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.css @@ -0,0 +1,3 @@ +.header { + display: flex; +} diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js new file mode 100644 index 000000000..cf6a0f47b --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.js @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableHeader.css'; + +function VirtualTableHeader({ children }) { + return ( +
+ {children} +
+ ); +} + +VirtualTableHeader.propTypes = { + children: PropTypes.node +}; + +export default VirtualTableHeader; diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js new file mode 100644 index 000000000..bf51062e9 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './VirtualTableHeaderCell.css'; + +export function headerRenderer(headerProps) { + const { + columnData = {}, + dataKey, + label + } = headerProps; + + return ( + + {label} + + ); +} + +class VirtualTableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + isSortable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + +
+ {children} +
+ ); + } +} + +VirtualTableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isSortable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +VirtualTableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default VirtualTableHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css new file mode 100644 index 000000000..f4c825b64 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.css @@ -0,0 +1,14 @@ +.row { + display: flex; + transition: background-color 500ms; + + &:hover { + background-color: #fafbfc; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .row { + overflow-x: visible !important; + } +} diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js new file mode 100644 index 000000000..0a423902e --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRow.css'; + +function VirtualTableRow(props) { + const { + className, + children, + style, + ...otherProps + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRow.propTypes = { + className: PropTypes.string.isRequired, + style: PropTypes.object.isRequired, + children: PropTypes.node +}; + +VirtualTableRow.defaultProps = { + className: styles.row +}; + +export default VirtualTableRow; diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css new file mode 100644 index 000000000..1f3f7fb30 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from 'Components/Table/TableHeaderCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js new file mode 100644 index 000000000..58b246763 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './VirtualTableHeaderCell'; +import styles from './VirtualTableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function VirtualTableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +VirtualTableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default VirtualTableSelectAllHeaderCell; diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css new file mode 100644 index 000000000..c1e5567bd --- /dev/null +++ b/frontend/src/Components/TagList.css @@ -0,0 +1,3 @@ +.tags { + flex: 1 0 auto; +} diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js new file mode 100644 index 000000000..485651bdc --- /dev/null +++ b/frontend/src/Components/TagList.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from './Label'; +import styles from './TagList.css'; + +function TagList({ tags, tagList }) { + return ( +
+ { + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + return ( + + ); + }) + } +
+ ); +} + +TagList.propTypes = { + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default TagList; diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js new file mode 100644 index 000000000..be7e618e3 --- /dev/null +++ b/frontend/src/Components/TagListConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagList from './TagList'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(TagList); diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css new file mode 100644 index 000000000..f7b87f0b9 --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.css @@ -0,0 +1,105 @@ +.tether { + z-index: 2000; +} + +.popoverContainer { + margin: 10px 15px; +} + +.popover { + position: relative; + background-color: $white; + box-shadow: 0 5px 10px $popoverShadowColor; +} + +.arrow, +.arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-width: 11px; + border-style: solid; + border-color: transparent; +} + +.arrow::after { + border-width: 10px; + content: ''; +} + +.top { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: $popoverArrowBorderColor; + border-bottom-width: 0; + + &::after { + bottom: 1px; + margin-left: -10px; + border-top-color: $white; + border-bottom-width: 0; + content: ' '; + } +} + +.right { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: $popoverArrowBorderColor; + border-left-width: 0; + + &::after { + bottom: -10px; + left: 1px; + border-right-color: $white; + border-left-width: 0; + content: ' '; + } +} + +.bottom { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: $popoverArrowBorderColor; + + &::after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + border-bottom-color: $white; + content: ' '; + } +} + +.left { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: $popoverArrowBorderColor; + + &::after { + right: 1px; + bottom: -10px; + border-right-width: 0; + border-left-color: $white; + content: ' '; + } +} + +.title { + padding: 10px 20px; + border-bottom: 1px solid $popoverTitleBorderColor; + background-color: $popoverTitleBackgroundColor; + font-size: 16px; +} + +.body { + overflow: auto; + padding: 10px; +} diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js new file mode 100644 index 000000000..c958fce1b --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.js @@ -0,0 +1,160 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import { tooltipPositions } from 'Helpers/Props'; +import styles from './Popover.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [tooltipPositions.TOP]: { + ...baseTetherOptions, + attachment: 'bottom center', + targetAttachment: 'top center' + }, + + [tooltipPositions.RIGHT]: { + ...baseTetherOptions, + attachment: 'middle left', + targetAttachment: 'middle right' + }, + + [tooltipPositions.BOTTOM]: { + ...baseTetherOptions, + attachment: 'top center', + targetAttachment: 'bottom center' + }, + + [tooltipPositions.LEFT]: { + ...baseTetherOptions, + attachment: 'middle right', + targetAttachment: 'middle left' + } +}; + +class Popover extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false + }; + + this._closeTimeout = null; + } + + componentWillUnmount() { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + } + + // + // Listeners + + onClick = () => { + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } + } + + onMouseEnter = () => { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + + this.setState({ isOpen: true }); + } + + onMouseLeave = () => { + this._closeTimeout = setTimeout(() => { + this.setState({ isOpen: false }); + }, 100); + } + + // + // Render + + render() { + const { + className, + anchor, + title, + body, + position + } = this.props; + + return ( + + + {anchor} + + + { + this.state.isOpen && +
+
+
+ +
+ {title} +
+ +
+ {body} +
+
+
+ } + + ); + } +} + +Popover.propTypes = { + className: PropTypes.string, + anchor: PropTypes.node.isRequired, + title: PropTypes.string.isRequired, + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + position: PropTypes.oneOf(tooltipPositions.all) +}; + +Popover.defaultProps = { + position: tooltipPositions.TOP +}; + +export default Popover; diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css new file mode 100644 index 000000000..d1d798e0f --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.css @@ -0,0 +1,161 @@ +.tether { + z-index: 2000; +} + +.tooltipContainer { + margin: 10px 15px; +} + +.tooltip { + position: relative; + + &.default { + background-color: $white; + box-shadow: 0 5px 10px $popoverShadowColor; + } + + &.inverse { + background-color: $themeDarkColor; + box-shadow: 0 5px 10px $popoverShadowInverseColor; + } +} + +.arrow, +.arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-width: 11px; + border-style: solid; + border-color: transparent; +} + +.arrow::after { + border-width: 10px; + content: ''; +} + +.top { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-bottom-width: 0; + + &::after { + bottom: 1px; + margin-left: -10px; + border-bottom-width: 0; + content: ' '; + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } +} + +.right { + top: 50%; + left: -11px; + margin-top: -11px; + border-left-width: 0; + + &::after { + bottom: -10px; + left: 1px; + border-left-width: 0; + content: ' '; + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } +} + +.bottom { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + + &::after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + content: ' '; + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } +} + +.left { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + + &::after { + right: 1px; + bottom: -10px; + border-right-width: 0; + content: ' '; + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } +} + +.body { + padding: 5px; +} diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js new file mode 100644 index 000000000..43caf87e8 --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.js @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TetherComponent from 'react-tether'; +import classNames from 'classnames'; +import isMobileUtil from 'Utilities/isMobile'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import styles from './Tooltip.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [tooltipPositions.TOP]: { + ...baseTetherOptions, + attachment: 'bottom center', + targetAttachment: 'top center' + }, + + [tooltipPositions.RIGHT]: { + ...baseTetherOptions, + attachment: 'middle left', + targetAttachment: 'middle right' + }, + + [tooltipPositions.BOTTOM]: { + ...baseTetherOptions, + attachment: 'top center', + targetAttachment: 'bottom center' + }, + + [tooltipPositions.LEFT]: { + ...baseTetherOptions, + attachment: 'middle right', + targetAttachment: 'middle left' + } +}; + +class Tooltip extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false + }; + + this._closeTimeout = null; + } + + componentWillUnmount() { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + } + + // + // Listeners + + onClick = () => { + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } + } + + onMouseEnter = () => { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + + this.setState({ isOpen: true }); + } + + onMouseLeave = () => { + this._closeTimeout = setTimeout(() => { + this.setState({ isOpen: false }); + }, 100); + } + + // + // Render + + render() { + const { + className, + anchor, + tooltip, + kind, + position + } = this.props; + + return ( + + + {anchor} + + + { + this.state.isOpen && +
+
+
+ +
+ {tooltip} +
+
+
+ } + + ); + } +} + +Tooltip.propTypes = { + className: PropTypes.string, + anchor: PropTypes.node.isRequired, + tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), + position: PropTypes.oneOf(tooltipPositions.all) +}; + +Tooltip.defaultProps = { + kind: kinds.DEFAULT, + position: tooltipPositions.TOP +}; + +export default Tooltip; diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js new file mode 100644 index 000000000..f43139e4a --- /dev/null +++ b/frontend/src/Components/keyboardShortcuts.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import Mousetrap from 'mousetrap'; +import getDisplayName from 'Helpers/getDisplayName'; + +export const shortcuts = { + OPEN_KEYBOARD_SHORTCUTS_MODAL: { + key: '?', + name: 'Open This Modal' + }, + + SERIES_SEARCH_INPUT: { + key: 's', + name: 'Focus Search Box' + }, + + SAVE_SETTINGS: { + key: 'mod+s', + name: 'Save Settings' + } +}; + +function keyboardShortcuts(WrappedComponent) { + class KeyboardShortcuts extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + this._mousetrapBindings = {}; + this._mousetrap = new Mousetrap(); + this._mousetrap.stopCallback = this.stopCallback; + } + + componentWillUnmount() { + this.unbindAllShortcuts(); + this._mousetrap = null; + } + + // + // Control + + bindShortcut = (key, callback, options = {}) => { + this._mousetrap.bind(key, callback); + this._mousetrapBindings[key] = options; + } + + unbindShortcut = (key) => { + delete this._mousetrapBindings[key]; + this._mousetrap.unbind(key); + } + + unbindAllShortcuts = () => { + const keys = Object.keys(this._mousetrapBindings); + + if (!keys.length) { + return; + } + + keys.forEach((binding) => { + this._mousetrap.unbind(binding); + }); + + this._mousetrapBindings = {}; + } + + stopCallback = (event, element, combo) => { + const binding = this._mousetrapBindings[combo]; + + if (!binding || binding.isGlobal) { + return false; + } + + return ( + element.tagName === 'INPUT' || + element.tagName === 'SELECT' || + element.tagName === 'TEXTAREA' || + (element.contentEditable && element.contentEditable === 'true') + ); + } + + // + // Render + + render() { + return ( + + ); + } + } + + KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`; + KeyboardShortcuts.WrappedComponent = WrappedComponent; + + return KeyboardShortcuts; +} + +export default keyboardShortcuts; diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js new file mode 100644 index 000000000..5e6d9ccf4 --- /dev/null +++ b/frontend/src/Components/withCurrentPage.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +function withCurrentPage(WrappedComponent) { + function CurrentPage(props) { + const { + history + } = props; + + return ( + + ); + } + + CurrentPage.propTypes = { + history: PropTypes.object.isRequired + }; + + return CurrentPage; +} + +export default withCurrentPage; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js new file mode 100644 index 000000000..110da9ab2 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { + history + } = props; + + const scrollTop = history.action === 'POP' ? + scrollPositions[scrollPositionKey] : + 0; + + return ( + + ); + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..94c6bcc67e09602f6d90ac10f449b5c05c2f7021 GIT binary patch literal 162420 zcma&P2S60Z`#wH1yL$%+a&#*mMX;c#h}fdW-b>U7RxBtg2zC@yj9sz!-g`S&#fH5% z1bZ)tH8GmRH0AdG&+OhEkmU3I{m(r2Zk>7Oo%em`op=^Yfs z3?E^MC;h;W1HYd*EGBWvy!bFeDu2THVZvpD+NZr7GQXE=-QP)Z{um+WKDkxqH0x2@ zIbV$CJB}qBhU3(cKdXN5o<2^2%p4=W7?mcDCDf9vH#re=_!WH5CEg^8NY?vBd39_U z(cxSWhtd$-A<_y79MLiw6|zZxUX86PLY@;ROMBus0T=yv+0ELuYepInVlnH8LP&r8 z3Py~CuI2Z{+L8$;M#6+NwA=cjzoLAOOtPG{CUeEDq_dbnhD+~Ay!aD|qut49Hk+ie zmBdeoA*IF1q!pb`>av!kDt$-h3zdj3o=*Z~U?9*3XasBnegN74gMb*ihfJVzNnOzg z*W$@yu`EduHjqyGwq&SiK{;>8VW}FKBz;2;i?e}LoF|d_(spu~`H@@V0Mbly#`Oqt zSoag|?EwanapDJ+j*&dcLGd_=6fctMQbV#q{7A~^L&!JcD-t1IA;rYYq$~ZAY!re? z6;L8{B5i~bgt8W7fY^fc1CJ}kGNh+CjKqolq$}G(>Whgu?nhS9t7I{~WmzWts2=ao z`;aAK3|S>6ldkG}qevXvhjSMa!xj)9+*>9*AVE3@QeVg=RfOxr2-<~hB8}-eB7-_g zW669Rr-|LjXmJ7QAzg;dFOm?NLDq`*NPl4{nX8K-gN2!7wXm5ai#<(*D@q(s)=HB|F?tDo*#+{fO}v;JDK4#moJWw= z`Y|L<-;ea9*<`D*=Y*J-gYE#02Mq;{W_of_xI>onwuytt5osX_k@}EP zQYdLGjv*tUBgaAOONU9MG>0TZhmypJBmw%cPk2v4z;8D(3bOo2THtt%xR}gj{Yg4> zs;ck-@;^($)a%Xg&M-X7b+j2-DW#C1(7`dfWn_l9luQv@TC#O5Nloc9sUbBdQyhkp zFozA~s4fBLo4}Vu_DNSrGrbdZ;0);s9nFx|kaD;lA^k#HO9#n*sUz8?k0t5)nIv6T z)$)gK4>>BnCNj#Kt#3}U#az-IeDy|qwhO(`-X)~EE&%%L0^6EEHo$Dcb5d}a`Dbc z%O_rzz69B!TS8Wg%`7kVLFA_N8`&aWCuPN&q_|#3a&Vc~VCE6PRzkiQ zfpWZlsVF%Bf3s5TgFZ|}pRFNBgs-4)$$s%WvOzbBbdZ`s{;uR8WWL6s9NDMKLYseu zt(+pGbia}SsWvGkwI#d6{$wfW7M&9b6c1beP|K)EHb@&Gd*}n#UvVY;5XWp%TxbdV zSWH^!>XX*GXOLS55+((a%3@>m0V9d}4AM}SNW%12$#LB(_<~=^bg>v2DQzQuQc2PX zeL0V%lU|}HAr9r?eXW!of&X@bzs1M|c@tIz#~TPC*#JJQA*sqC)gWtiA!HnEH-yHK zK0rO%o0JEQV*N;8Hjp%C9Y}fTNh9$G_<<#4fv}!zk^IOO@|uL}Gf6kr8$PKw=`AcF z_23KY2t6T}1teM*44s%ojyUur2VuiibR{6;1kzC&M*@Vmu!EB5yKl)L$sh8|fxkFH zx^dZxpU7azoh;UsA$@g^NhWlNpX0az};4lK?hTbv*yv3-# z-iI_1gJ5S)Bm%a)6=Tgv`i6{w-PIRclPd6m^>u4Cz#rAurH~jd4~NPm(jgrF;|r-N zjwZcmwq-V5MCJ%<$zov~{K*f%82C*`5-*Ij%tsr$03P(n#k+R_LPDXGLiqu7v?3 zNH1M;&>^I~ScxP;560MLdUv|%miE#Qnj9^o-bK_C|4@rVs-EP{AMZ4@E9)Da$5!WU)&%5FTj7O{*n7gE9gVZJNU>gpbvq|cHhYD zx*)h;EC}uw3)aK^Z$a36ae=ycovL5vzS#!VM_V?a?$y9`@KOX=Tfk?}=YHD;l|Syk zISTT}eYg$WzZL|SiwybW^9!$VNwU; z%=%k4s&<7rjcOn0C)+%R>+C=0FI-2|e#Kmc`wjJ&$FGQ4d3>VAs)$F`IGo!ykM%LX zK;KtEUD-TVRc#(rja9i#B2MNy3cIpTwSd|2m9s!stg{GJ-K!aiV!i@9Io^Clj%@@H(i{7-7rZFRnh zd8n`ou|DjZ$32`Ub-rcuQ&wJWe5mn0VsvZV>FCg$bajltyygt}L(FXRV`?3k3vhYx z_?*Yo{P>^#&t^k5zoy29n8WaSjO{&p*^n8R1(%&l`Fx7|5^F4Mr-kY#{^PvVKhFQ<*Z-gQxz79_ejM^>jQg%Ox%`tt zmgg`YEeAbwGo@mdwanYHQ`~RK6MI?eVy+de-b3uBjvL$` zt78S`D2V0NIG+3e1{eppZ|8m+@wz&O;aDBtcx;VvfX@N=b=#Q2<6?W+ct$NBW0P7Y z>QKiO9$WLW`IyGb=W_ubU-SF?8tPEvU_PetIGe}n1$-Cp3)snQOm8S6&0I$(5dkP; zf+Xp6Ob|qg2~3piAI5MfI_U6`B%O{)I!QfI&m8d0L?()O8y|c#oH}U7f+Xri++qUW zlJpW&&m}z`76jZxoua7M>qQ5YDXA~<2N^1GU^+pEt9S(;&X6F0VSG?LxZw9xhQKKP zLt$$DxTZ%;k4yfHh&;JAPx`QO50swF7 z@t#&UXH(>N^g2hqBY&PVqUXHuTfB7jY23t1s9TU6B}V}gWDI##L8nKX(P}ha5=0$! zgtQ@Vy;_z|hedc--9L`U_TfAI#PAYO>IXx?c$-Xb(m;BVne z`|!v4b=x_=S>U6-Vm}wTtPB1t@NoWh{*L<5Lj+(0b)w%@Ey45F=LD#YrixnWc)<3E z_5>sg{&^!rRRDG-G(|%N`B&^mT;5!CTAR4AZRhq!YzJKSTvB>23RNaL2F>H`=ei9M z@h{C@__L@_l@V@Rt;*VX-ospk;1f#4*@m?+ru7=HK_@yofub#&9wNMk0l@Bffl!b20 zXN*6kHk980O9JPgOTa2b-e$a~>N%$n6>Ng@4o$L_uC@fVtBQv-FX8TkqZGd3_f(2Z z7-n>$4r@r{JxRst#~>Ur;zGugbz~RWO-_;vh;UiR6mAN4#j;|k z*hFk6V!c8fBTf}F#e3ol@pp+z2Fb&h`quZI;`@v5Z@zz*X=5y6bTfJxeU1LcU}F_y zn6b98uCcLkgmHp#lW~V}mvNu*sPVX;^ z*0!#rd(qY}teCZ}7tq%GXe*qXwXKuU);YpLVWY55I4WEbve8x|28mU~reb@sub3!~ z6{m|g#QS27_y;=A&6oJr^G)?#=liSgXS9_V4Mq>6kI`r>i?&uX*0Q&Cqj8IIC)#?% zm{F*$-K=eOE6~<{Xe&iqDcYKcwxY)^e^_$qCd>zxSx#f)B64E%!Q7KFUo^J>VeGOya^?tj(#RreR&UUVVkMW+`XUvzlU;^oK} z-@c50QS*i4bM(yfhrpfZna{63pZa{v^U=@ao-KUd`+4}Y1y6TAoBC|Zv+>W8pCvr& z_^j@;T958{taW;(+sJm(y{cw&mhwL+iR7YPSmC2s`?P~=dVvoeL`M`jEp$lx@KZd& z<+Vn;MbmMNMLre;GzTJq901uYtPcI_!~Xi$C0t-{HT>7T|JOCvinV5KSXr< zGuDlDXFXU?){8|GG7GIrV|$s2?PD_A&km5;>>x{LhuC3ugdJtaSO%Fx=Cb4L z1Ut!2vD54fJ4@z~`Rp7!&o00VTw<5W0%ijJpX@L8H~Yfw zvU}_yd&I3z_>SxqItrbH&cuYwaCe~xvdOa0ONb`>h2BCRazN-y4hsE9x)3AuCx?Uq zSSLOr#0mq2LBe1mPKXzV2npn@FjN>u&IyU+Jbc$ha*13fSIAXiIJriy!vl>W*}_qK%__<`ILCJB?tePIfDKpv7u!c<`bc`Qs5rVBIR$7aF0 zXOk!7DQtW$c?YlgBl$^~M?R3B$wy&6JjXBOSMr;%fP97zSVVpo7Lz}OC4ww0rBql( znXp_~A*>WuQGtq75>^ZQg*8-1_0&P^E*uaJQg7-*i_zjjx^M{I@-X~uIk7jqWH(x# zR-hGW2(2Xc5PQ;fNiOl&T;fLE?6_7bCMH5w+i6kE~iG@RC;5ws?)C1eQ4 z#nxgQ;e>Ef94HPF+luXIZCVHZr~|D_zoGTSuCzXFAV!K&!YSdjaE5+M8;S$OSlWm- zhR^CJekXPmW5oWniEv%G1`mEi$bx6PNt@DUVkfb)@J4t`o6{D;JK?=JSd61Bg&)Ou z;V0n(9Z0eIEDoU)#Ab8~!OA?gA2i_Kupb28_75ME_&8;Q3ywMXm}Laup9p_`od(tz zbUm;O=nL%Dz?y*W0Zsx5z$xH7ifsjY!HS8X7l9kN{vBwR2G$IeuLRx2^=_c|fcwA? zzysh3uJ;Cg3OoZQ1J8k1xZWT1wG~rA-vA$RJsyvu{0VTsfORJF5}GYde2G!X9JgEesfr24%ocxDTZ zt7;%=pw)l~T;B^?Qv;tH(ON(QTt5i>sLUd0mzR*rrf8}7#u$a?GFsVG0LT}0Q#Ce2c4jSECQXYfh-2)+p27M#e4Yxj78LDd&>6B0^fwJyA5>LZpt!yj$Ix$eYPTKK zQ3Cbo!%#~hHiFiiu23D=P_$V^U(j_L z2=oUlqZ;~wZqz{1K{o-Lff#_x1t5n&xm-8~0NVh_2ELx~z7#mW&=IUe5#b1~9|g4` z0msKQkh7rZBLR9U30&T9F8P3b=rB&VydGVmRm}0DXy3g9ulEOkf0%4cx?a zehyvX;Qm7d;o$s<=vxkqHAG|@xL*+k4H$ojh=(}{$`U2O0naCcLI=bmxPA-NS%Z)Y z3SY><+btH=fDwy`&=C&qvqXaijABG|)4+Y0=ni<`-ASOH8n|B*y)5Qul7>&V=x;JPLT0in45477>{jG9EO3RK7S zcc9@KFoqJb22c~%KY-TKfH4(&r+{y8{S#v6pr=muaT z&=S}LY{&KLps;tW)T?$Z?!xhIpf#`u*o*5YKuy3tU?3m^T&`^aE~A4er#2{;%OQaG zEtds&q;)}$S(S<@)$QgTSACs6pWGKLVcNdH()$;05a9I-3Lh zj_bET{{a33IstzHf8+Wa&@aGOJkR?g4>05UJ5U9%0E3YgCmQ&8E>R6w)g=-G40yK{ zsGA1tCLxkL;DK^}BE%Q(`r;h{ed1dWfc%7ApsBzV9Ipjk2mFHLpFw{Ge#7x*(9Zz) z6dMpyri}*P_eKH~LA~8TQNIy&^Z3AsI*pzV2%yEGMarv|YZDCEL{IG7lrvmA6P=n>#3j;DcU09+4eU{k52fzQ?b&~^@* z2I{SW&IUzWIp`eFAPofN`Bm1y<0e1I(XS_-nMa5}`jmq%0@Z0C;KScl1BGq-L*D-A zW4Z*iga*0{6f)+Z%Rwt@pesP3TO4#HXtV~p3bY>(gX^maDGQt6pwNe(p)Wsk9@Zfg zh7EAg4TOBf^{*n1AxFicfkKX!JOFx2cbZ5@E0bgU$bFQ~i8@RzQ%sFZ?{gG-^{ix~ zAx2}9fsIWxx{`^7m}mvRN+u!1*wiGHZTekgK$LN&ab}BtGmTA+vAz45#Ih>IjhLBH zp+*zw7&#E%og)2A4Wdfg4`ZUDYF9FeycoQJr)Nf?fI-#*K-Ej3=1L|hq?OSm1htRs z5NS$nT+-B_aa2h^KVuWqsrHekQ;kddMMYII>Fhj$*Kq@jY24~VOu7n{Ob%KJ9V1N* zN}5R2%$dBrj*$U=rqr1;OU^_KZ0Dy6pC6{=pBEYwyZ{L_IZRXAqbQI7zmohyfM0+g zIE-ps$>bQ)s$*mmu;>RCokC2(O)8m+gqX^KIEU;DrqhixJ4PO9fbgd8VFxn3Q{)j6 zEWAyODrpJ;v&QL%4Un7l8gHdbh^fK!!$#6Ia$h;pxa1L1PI%iG*PSbxNJS#kBCH~n zohiN#IoGaI-q)QtR3wLJd{mQarEz$OjUUjyrg|idif&ayJrXDNYg=1AlA6`lUOA+j zKe~JE0RD#F{;orZhSkgZ@N15>LIX<}_>q(Sxgxgmi0tB`2Rm9X$R;;BTyF11N64xC zE76UPlwC{XT0&_$l2jnEq$`Og-;;HuEg6DY(7n#9i>i z4y@Y56XT~rG7(Q*G2*2=MGTlvy6Z*N)#5dLqV4~dlVh|FO=tC3Cd(Dvg$BZKVHq+8 zmxQ;1MeHn25^qcGrG?V(x<0zOdQbfX{Ve@b{U!ZJhmsDX9RnQuI^J+HI>kGgoZb{^ zS7dIH9Yvlv`#Lvtp67hcCCFu{%k83}MOPQS=&ExKcb(;W+aMb17W!~KItLywc5I?sBZGdwdr-+6WO+UE7eySMi_AHgTy=TtFWu^Po@6}w&B zr+C}qvx{#ko>QW1iJm2nlq_3vM#&#bIhER5DyQ_+(q>;r-yq*RzCV_!R3@%WhB3t0 z!noRa+4#XP#IK9rI=@|h7yO?4eev(*e>K1%U~<6yzHkbyN3XTny zgFlvQT5eCbREh{Bfnp^2=r7xB1R31`! zU**ioKUV%68WP$!^oP*3p}CCFV_U^`jomuX zY2f@p!k}S;9uBTEct@OP+?;r)_$@7xalYdSTQ);DTkMkV2Y}|u!KaI1D zuQ|Te_>tq6j^8-Jc|z=jywqtEohR;{xOdW+NzW!bPo6w^&*XDcf~Jg|vTv&Y)X`H< zPIH*ncv}Byo2UCsA3S~k^gm}*nlWd_wHa?_mY5kmbMnkPvx?4&p0y*5rS(p`miEW& z>a+XKK0JrcsX8Zi&i*;i=T@D&cJAwWE#_^TcW3^0^AFF@Tu^MmrG?cOE?bzhD0orq zq8*F<7tdZIEUC1l&yq(=U6yuTnzr=DvTv3xS@v~#aDoAviQo#m3vp|R;8}` zx;kR@)YUiEc&%x_X5pHfYfG&ix^~>!E9>g5JF~vj`kw3GY-qV*`$or&i#M*{`1_`B zH_h90b924TYqyXsNn3@jbG8L;TfFVB?X9+N+iuz+?TFivwX^EZ9lLb9hVDAO+kf|r z-TU`A?5VzI)}EVtUhny`*LkmTZ?(NG_V(C&VsEaguj%SOzkTKRRoz!>U*mnP_wmmW zIar=6|GqzB|EB%N_TSw9?f^MZ>_C+Rbq_Q@(Cfgc1G5ioK9F(X)`9m2*}>ums~l{0 zu;;;%2WKDLbnw{0n+M;g)AVBLq3KQ2d!&y{pPjxueRFz7`mOZ$hu9&{LuC)uJ=E?{ z|3l*rEjqOG(3wLI5B+l3;c%J55r^9zjyaric+TNj+{J_edOhlFGmeW{f|~Z z+Vp6bqXUnQJeqcN_0hdYPanO1^!>4J$8wK-&2Y&G%&48wI-_SsV#W^{b28Rt9Ll(y z@jTo~ZyPb?bIp*Y)ldDedK6&osqm#d$ayaFA z%6KZ|RLxT@Pjx#r;MDL_6Hd)Kwc*sBQ|C`TKK1iya@zfL;OU6dEl>A2op}28nTBVg z&Lp0hc4qY%)0vBBo}T&rtn=A`v$f8)KilW*n6vZF9z6T-?5neXo^w1`=3Mx>*5~@2 zOFWlyZsEBd=T4owckaV^;e3hn)y}s#AANq<`5EWeoj-j3#`)Lh%@@2bgj{HNq0@y^ z7j9m7cj4b^=jhPdvc&uAaU6^y=%YKVSXhs&Y+tt>`uHYrfZl zu2sHP<66CIO|P}R*8kf0Ym2Y#x_0*3gKNKC`+8ltUgWyR^^(^sUvF^zyX*a~kGekR z`sPfJ%#xX5nRPN7Wp>Ss%N&uJk~ujuEpub$&dmLp$1=}mUd_Cf`6%;c=8u`b-H>j$ z-0->)dn55i(v4X+Hr&{8L%wn4#?2e=ZdkIsvnpjZ&gzymJZo0ghO8r5r?W0+-N|~B z^)XwQT{^pbc6fH{?7rD!v*%|o&t9K>Bs)9%N%p(!-*3{JE;ozc^t)O9X4RX`Z}z%5 z;O3Z{vuu z4!&LMcI(?cZztZKcze<9Ew@kH&c2;{`;R;BcPihheW&T2ws*ST>3?U~o$+_(-Pv&G z=$)H)-rr?+OWdt?x8>bFcgNjbc6aC96L+)k=HAV_=Xo#qUhR9G?8=bf=C7mpRXDbMFW zfBd52i)k-jzxeruCC4epJ*PyDe@=y*YB{xYTIcl48JUxovneMd=XTCdFU6OoURHnE z`enbDDK8hj-1YMC%X2TYUOs;L_T}eXlIxsXEH^aw+uSy}QMoa>LvzRF&d6PyyFT}D z?v30>xo>iR%eA~J^2+B`r<~Uy?*xk_t)k( z(i@jIUT;F)RDVw2X{_iTk ztBoj5;=kn+$|2Gdh>kd7eZ!R`$j$~gH}_g5XM>4k@o$~_?WlgcWDyf-5@}+gCHJw? z^`g`hf)nCWFG@zb5O2zud2^!Ih=WQ@PLE}0?ShMTff-CiA6KdB=jY}pxKXUm3w~Ji z@e|6K>#}n-l%JKO)H#<4ib9#$ESbJ+lJw?rZ1h(*mSpb9dQN9O2_wrbf6;m>|1PAC ztSjouIoEN{|AS9w@afF?)JaYiBmM4|{2XPXStc4imOfsRUih z%#hZVDzy%wi^Y;(miUK;`ukO?gc|3Hb?7rpJw?dNiG?GNg)%zh^?l&FBq>AYYK*G9*#v3;qXyfFDr#`8 zin;{ul;BM$?aa{B6jSNYVlolz%@MOqrPUt8cabbpkxz!pH22(NhiZ8fF4+MAwgkv`X5GHVhc|kf}xQ;VF_KR|%P2V=yV$e}}N6WYC+m6<0 z*RS2HKRa5Kk8S!rrgb_h=jjTiKV8v-9*L&w6cy1*vmUtiCp|(a8E&Z}n02!;pGuO2 zkVF*${*4Rk~U9c@i7Borg=7(J|V@W+Wimjm;6`!zJPdQfmsPi9y_TuUzi&K)O zjgDU&yXkV#6USX%o?o?Z8Eh@y@}o3aIu9!=L4rtiQj-iKrtnZxgx2yxCKZVG1@caZ zl{jTVII=-3*gT*7vp|EXGd|qh$g9LE%1Xv%|!f%*$+E+ zPH&dFbK6XPkEAitEqaVe>X&!xhaKBzHpAC+sb|udJ}sh0j_Ugs8kswHgsLSkHAs@brnOQ9~0O zUDTyr`tn)3wydM`x+SDuoHxGRjp13J$F1JcZD(U;VA0Cw4pf~sslj(~UHgm>N(|iG zVY$3MW!|h5rSig~f9-s`pl#$GdC>7u=5O0HqxW3{)-9Q|Rty=trC;SCE!z%6dXD1v zZVgEeJdFg6F^N_`qq-OO@UR@Wu2O$#xN?J*TY_iPlnN|f7fsxWL3Z%)R4odxxWcu0 zRCjle@F~iem#4eCk3NX8w8Z-gGZq}-?e5N4{N0JA`)rMl z-X2|g{M|(M&0}StvZrmErZk97dv$|Oq@hjPv{Cjb7nyyDi%F)# zlvxvEhAR*2)2d4LH@ucsWPw-}dGsQ%JQT*qH8>2NT?b5p>iE3;+ydO-_T0kQD0)n3 zGDc}jP$Q*HR~pfR6bQ7J89*( zEWA^dwPyx)5E_qnSg1_x^`cRzju{j``i!-Y!L#!*p4G()tpmme)gS61554N>3e*(A zEtU^d^DDKlT)BOv<^ff!HpS>fiLYf6d#{d%9(cd7W2sWrCxC{{if7Sr<8+mNZ`29ulB@|;k)lg;u3oCw_VoD8y?y$KlfC|60g>bU#;2+PHbcCboV3y3LgJQm!edX)SCIk!TpL ztK3lT>Aq0%<+GCWxAGTdfAc<+!Em~C5OE6BkB1-<_>pYP<%b~1^`jADs4UpIlCe7) z7ZGa&xJltxjuYEEFk}0=uQSuFz5j z&Jzuldr{g$4#n4jcG&gEhMElU5BYo+F_cmt#K@6<(Ypf0McGd)q?ADpwBJMjVm$nh z2C0K4h8Ek9$$ex1U6?XpMTa$u()K3}yOMk%cgl$AX`iqC@L+V(nPn??A4^XXino~1 zF>TG}X+w|ozn?L1Ur5lA)ML4^$5LkPUAuI_3TS8}^h_)0vlBc-3$5P^MF^_9P@C*1 zTAR%5-15kk@;FF#&=uiLa#0`RjqnOkO&#jy=cX58U%pH-cVrtTnTN0v;q*?7GLI%U z7V`4S3JtnL`r9y4d4r46q`zFkw+xEXSfLO&wdscoPF%QsP(wl8(^D7Dm_Af?bK!C| zxfo>6d=~WvxrF^pF0DQcSK{G^cnjL%u8r8iVct9t(MBHq!)ENhd2ZOq)#u;m-Z=4- z+i4l{;2<3%-XwtZk%hpr zoEJo_K2}>-Ey&guSG2{Iw*@Xg-(ATLM2)ZE_ap~?B>UJ|H@O;QU;7zE>=j@n0#|t6 z1dOF5JlsIa3h)LPNVT>It%@pNmD`58M~9z!Iq1TKY~>5>N1HtwcHZyos&nND-IPD?&dHrXLq8?aX>_+}UNJ4U|A|jex9`g0){C__(FwhyBh~)HGpK#Un<5qJ z7GiY?lm>7gEIOGNjAMiH&I!k*!Cw|jE&F3)L-Vv~S7PTaW!1b1$Be2(N^Na+iW z8JLjB`Joy#M~%FAXwH?#&C5Xi0wTf!Nm;~U)`%66*oX!v5>igYCS3gC(Pzb^Z1u+W zLWvrb?MWxg9a%jmeJ|V4b3kg&)OkNmf)2foQ${HxVnu2^J9k)yJ2wPm7rK-&r^TyZp* z>DAGv&>+thFF(|PZBsAIbG@?I;k@%g9X40FJ)S1_rehKFG5qFngK!Tky8^f=OW-w3 z0_K&WGO?SM2_8eVpFvzaU;%`Cc_)O19Ktt)%wGp_epARB;WGA~=tvcfQFV^N87*Mc zlAg~bF`O3Sl5!qtxX{R4OK6j~pDm=J$;#B;%9Ipv*~#*k& z54Y{Q*k{JjX%&NmDpd*!u1FV+ns>HC?|S3r&KSAr{N-J7$H(_NsK~adJNO=|)X}|` z(nt-`kfg|gjT-ZY!;Tw(?~0+OFfCrPjVbx_QLfASFh0P@29;pk<)&IYe{PK_OPE0} z&Fqu3im=`MGc>in{R~Ylinu5!m`^bx>M^v>N6>Ktj&R3vIDRMMF7gu}ZV7?R!`(eF zteWUf`4|x4!A+d$9!h<759>YFGwwv%On2AOC*#|UXcOktu1oTAM-OF-vi_E``&_Zr z^qWWB<+^2RU5ovqEZgtw{Dh|dZlQg4d_7jAYs({%l^S(R*w~uUgl-a| zD}HOfFe3GrOuCpZzlDi_gEBEQB%pmz*bBOqmc;(v-9O$@`YW_wyoHHX_bHdT)?!s% zd;t%v$2i?oQ-SU54v)KOFw&#|C(%TDS=uGRvARqmXraK$`$$wU@B zk_D&cePPX&$8?zzi;PfvMupt)Dw>^#Ebk zl(Z?_ekWo4ISD%}MH^-ig*ti15!tFx!i`OpimKX0JrPaZ!*LpGY9 z(c#K{<*S^h+&*#yJO|k z%gd9A0bH8IExbAjK1`SHr=`}eQC=KXK2Xos)LZ%axn$a?WSyCMR=K%JYWCF+zjhH* zzhZAWmmTM-a1e=Y^MRaQ1h(uXv5p;e5KPwvKf}v!=xS6Y`8zM9f!eet6 z!8LD`FeUE~ww)a{?}2g-G(WO&!Sj=j@HndW|EFD8O(BI(bpOFZ!I1-%Vt&f26&{+S zl7*>xzq74un>mtKcLi;p4}G}>eeoyV;bKA^(YyKmsm=wtIH7HBVdBS>yi>`^LW30aUQ@|r9>%aCiSk{n zv@Plv5c#VG7s}Dus#MDgqivIu@hIMEhPm1y9J&(3$!acLd$~}Ath~K}HmzkxXFkXH4+T@R2ez!ksVTu)AB?^l}Fu=z{att;j~!NW(v&772X-^zgi z4kS|UKdQFXRzRPtR4vQ3H;h(iH}Vdq#KKIhy{rqvVNv`=LsiO!1XW1nHnUY9b3mBs zU~7*(w@KLr2nDrcT^mG!_dbA5=zcQnqP@%A(!Yn4~R?(zHPX6eL z#S+oJk1){w@J-B+Sg=!17uw`nGg zo0RuA9kVjy+&*P9>uKH~nR2eC-7Rlk?7}XZ(pKz%hlqq0$3gbNq_6B)uDsoP3Z-yL z!;DLFPsB>|r5w4aqnd_9cwHdNh!vguT!ZYdK8cksxoknMs|N)JW8R4F_}79CAFyn> zDdE!&rEcsZ>T!4etMMt8yTu5CZV`C}B`VDrIqet|lvgQ&B#TPd3$JZJP=(VWknYlJ&h+L$& zBr8S5XsOwkBT}=0D02hkwGm~yk-D;E&9w87xsXFFBs}{tQna8T-1zh&Fc^cIr?;_2 zxSP0;u1{u3*T z9isS62FiCNjkUfnRHRLt3#hUz64jj`8zI?MPj-~_YLyWf=dqR_?&tMUnDq6n5S@2a zcp4kqLwr9l618nqF3}IVRM?Kcx|D?EXh9<>n5YB$7$|wnOF!Jasif1Ex)OhT_SYl4 zr=*Hy68^#fkI%5$fi?Jowt_?prjJ!dvgnbL>F-;3nxNKJYOTnu;Ax5_wSwI9X-UCw z8OoDCIM=eT?&g`#@lW=-$~t4SXpH%}igHSHImQLf7g{*Gt{QZdDKeGu5-IDl7 zx)MH?|HR!DvhF8)Xlq^`T9o3TsgN2=+L~`mSE#bE_lxALnzWNjc#8_eUH_bUd%_t9 z>-kgzGjDgdU^PjrcjNgUQFwklXM~hEdf4+*&yuz*9lox7*W5*0nY&rJw{4M*m{;#p z9w}diyYGg^E5)w8N3Zup-(5rBl_srZLxHsjHRony+7`zYFe_Pb;EO`Cr-A1!dG0NL zKt*7VEI=3^H5hgUsM$AdTBpN2+?$1r+pZrj4Lcrp=kLVqH2ICgaEGlEHtgTuVRmBQ zjooRvFM;d+n0kBngjMfv1>AVcrHr+G;g%}p5{Ordl~k9RiW#7z1PP#gS^kq@^^QH3 zmjuqM?4*&xBFf&CUel&b^qBQ*3u~%cZEPc|W z$D^g&Sa2S#xSRF*Lgpd%qC|h=<5`+_>jUS!D>%Oc`TD@B`pAw(KQ1lgHj9CC?qAg5 z*T%Lj*q2K=suRspp#@SIJa3dA#mZjVBZzAXvM%v8Gi$Ef%gaw&8$sreVxhzjTTod2 zna@(j+#i4bO&dEck z_Q@-!>Wb1qxDAQ(T``m7z~FLR;>gO{))opeshXpWNp%+FYHf?^w7Acd1fGSGU5lv6 zD9nlS<4D;{y~n*&fn^pLY{4Z?O`9UDb8+ly;3=|ju2|Z}87P)jvz`psCBDBj^m?5} z6ZS6ICJcX^k;`?gdxbR#T*(w#-n8oLoGXdoA3}gKv|-*iyX)Ej=%- zMi>jJEY}SV2?-9WSW&F~waJn-9(`S|i$~~8&Qs%1YoG94H_pVGb5d)z8H{Q@HW}E$ zBDGsoUF6;3#8d~1i7&~Lbq;Ejq)HLIX~`8HDkJ(^JtHY;C|$Eu38%l!Wqy;DzB-au zek@(8)HKflC;06cya1013TB^doY*?Rntg(d@hINHg-0mvt)P%OLsoWh%iV9UrKmQT^h{7N{ZqpjJVB zq>f_U14AfjQv1wE+FhSSlV?qoos#IpsY5peCyBL9efr#tiB~3@E3?b<)7Zw5=IZR` ztQiw0m`mWtNjAGd8$C!z*~Mx%+&33$qbeX4>#m;<9kfE#+|pF_jhN5gc9Nu%za$Q`qN$-U@GYX>gfp$-UJ z7k&w1K`Z!3aP#9a=H7eYN2^A&g=j@lY&FQ{VTO25$~S+ASbN(;C3<3^i{i?w&1a^bbdAm&+x+RO4hO;E7C_ClR+ z-(AE?sd*X~M>VK}{TEmq!-Oh7kt7?{Cr#KwR50Mj*4se)mdk(ayXEr$ZLfnHN@ACvSOr;b}eJ)+{iX*aAx>el`9dW|30sY6uDPNO=_ z*mNju;K(7oO+J>NSPQ8g;-6k}(E{<0I&rqS1$8&6?ZlQT!#ohKn=eoo+R4K=?&NQs z;#+61xaiLP9p|+k?8tK%> zZSE{~vk&#+TrPv;ufTN}NP8I>4ZGwEg*hr?HZE-`5Y>~}Mj)BEsQYo83b>B4oEM%0 zGK3i;cx)_miur(C@!xPH9=UV<>x>;o@4QnS#!wvfcs z380M^y9*!^w|z__v?LJyKwBg$alMs6$(x16UoU808xg5=8i$25K(?|9DR-ez^nYZ4 z+_VgkMqE?^2CNw%KKI9_U3`VnweH0z{`eCuNPerzYIL$?i)wm3~DPFns)BmYPK2Y-$)aE>yc zPMD27kk@9@@yh(!Y!W?W{$PGUuP8NGFylX)Cq|`}U;;-5j4BYdT~7-2jj9?p#%#V? z#{Yi-kIRKr15(jLJ%WRM5Zdx^;M;qvqZKU^Oe!Ah5vuEL_e~VdAr#>^JKvzCMHry9uS4v8Y^*Zh3*^V zzCz?8#rZBNVsb%r94hNw)Y%r&M)~;~*#quQ^L&_483MwqxrNu@dnm9lsrUBsa`$25 zw(WTHhHl$l;`8Cd;%`b^wVY#5s6LiTuMXvewQH+fyz@tkHoxR5k2|1tS^1Uv*m6t% z+DXfkQq(6VM)_5Dg7=Rjcp<65`Sf^$L1o&UrJgGddJ@ahqq zUj(dR(h%n54}Nue$KS-$r$3k2x%JJP9ov+Q4m9w&xpCvKcWB+#?ZRFieWrYkRsMc* zET-muLTdtsiy31t=koH%EF#JlUJ6>tKeQ(zE7{P7jxz=j7Q0pG5yzh>9y1g zU(2l#F23G6vH7_4$ZAo2<2FofIi-7}=!nQzmbds+#QO5166xaO^}eegHa;HXaGJ6l zf5WZqzh)1%XugousH)+Qk2=5-;JFpmHaK6BvPocAQ?Q)Xzh2PS`gBgOb5 zP{;fM`{OM|gjZ4!@gwczlKugzeqi=wP2v=CA^C=xzhg(Q{<6YN{T&P@xh3s)tl1C* zt^7wIJR#z){Ya!>N~B5hcE{lk*B2!nxqa(s(#lIZTS{csuvzB}xvzYv@4LXKG{EQa zBg4&A_RI(+J&Mf+EZIkU3A+aihLY67ThG(7VKpLZ1Pd6VtUIf$BQSQH(nXG}A$ylsvQJnEPv)3$ z*zw7c^jpbGCXQR=NF&)4<;Oi;V`E)V?0q zT@rtBpuQ|r3_*YJLDII)W*aL^hAh5&3bWSyB{iOnDra{ZkUtN0gZ06%jOr1=5k57H zJ`p}JV*D^ePt|P(>fRb`d(?@8+g=))vw3CbwJB4!b>6y}?%KTL`<*|G-T3|2zb59S zPJEs^>8XW0pERX;hh=NNU%&0q%bh#B>`0rlJ96)i=l8a6>cWy|ewvl`aptU#)2Dxg zR)wH{7Q*hm$uKPh^Txc#+qP_J4-<*AH*X-8;AocYs-3{-)cNQESC29t$c2ho{AU{; z$O757ewnZ(8CHot!`42SV$u9NPTUURiqS`E=0sg23P;WtiThr3^IWA4O;CE$4NA{s zr4L;L3{mRN#owj8X?Bac7O7;=#*x>e*hlomTD$Mk!z=JehHIEQBA_8i*b5b5WrzuQ zB)&E&DM|TN5c15zCH4p2?ytCsy`Z5rNE2j-%QkJs8(P+2s)u~#BzomL6`<_=U z?nT)44qHP=bq_3G@XP-Jm$#2Eg97pGC_b2kBBLBE_DYWL);sS({OQJ1lWy!=m(gUM zvSv@z$U)zCr|i!2zmra_-+Z**vbE=qN16x3jcT_3YWKv+Q+Lz7{bu=9NgJ~1RLxq$ zuCY~nXAN!Bvu*T%xiS4_`-P^(tv{P{eZip_!`t<2T`6q9oR2OeqB=&mu35@u`1gHA z)r2JHN`dShe(_ZV*+li1C$f<5>{QvnKcgoKY;i1G;CQZmXXC+;AT?_dB8^bDGghu_ zv{Lxn!T4R-ALy@Q*oP587R#k;*5cyCF97jtKYUlW=2Jq@X(2X6u8x!Hd=AX2tCq?_ z54f^23@Zl?A=UZo4xtB#gTVn8oYfpgK~+VR?`2n9xtNylsE+NA_8**?La;V#KU2HW zsOCA1yrh=wzyNH0)N8vF)ZJg$Vo%e3mgbR}!g725UUMPd znVPbogd&FzFEjBqJ6$m>Ze+h9W54O#vsrUp|4DPFD(Aa*=}blHGgHJ@?e-oC8$({sSGi!OdvP$^jYk?XKUHBCB$fm`S>yj^{ywf2&A6p2J1ahe0OX%VtH|2#nM3gUzqHb7+9?B57+)!5H+QNw>Gt3?mrq`cHW9Rkj zKY!ubTt2Gv)Yo5c+^CCkK7C&9g++_fnsn;aq*)gvlYtS3uke%d5VFr7xd>fxczkRq z2V5Qm4-5<5i3BEmYy-)l+Wp+Y49X-3mV?!ey-K~=3_flcslj2aPr)&{iCjOGw_*oU z*@0)psE)3+v_e{CKMXD2QS}y)Wlfp;LG1bIJSQTAfslb=1_7jeRkn%9w;v2;&+BRt zKXN(L5b-lXaa4+GbqUUSV~IwrtR* zbIZZ=vQzuNnfxi%*Ib$>&BprTY@^jEk@0xJ`XaH|NLn9@q!nf3*dx?ojE!(&ZC=LO ze3@7p3zW!?K zUIW%-OkCLd<&A46PuetaK!-QFUudbcms-?&sa31Gy|Ob?Yxjt#xF|I{C+($ITQ{cI zbv821AB)^{X)QBP@svD&$21g$K65>u_(%L$an=)>Dk_dG-tTOkBsL=^S7fUx8AB!bEWM1@cU z04!mGil;pSLgSui$nj}R60eEEgW(?wY(V5*_{Oym@a{SHb zGSDmf-{=e`{$?!x<}Tz2z5iD6H($y?rr~eCuq8+<^uLLQB#9QE11fgnljIIc8&tc- z*t)1b8X^+%Sg+m@zpP&_k!>VTJsReUVZ1_<55E+iP>X=z@@FgLyHg0c?6^FyAf|(WF5^xdgxB8!oKy_?f;ZFvt&SzY zsACZlOPA^%Tb0>F1qH0J0PQ<~YmJIFN*@@;45=Del|s{!F%yhuwF2VeQT@;?_p=d? zF7tOXGg;F$Ck`&d{IdMiV_AoPQ24v}evoV4E*7y%A?>&f=%p48Z$v5DsxhJ()rQKw zzw@`AxmEq`1jANKddRJo4HYsMK_sK*TO^U)o~IugMU=)rvIQtADeT^=5-zN;cVy)C$_KKqwf22{Kfxd42^T8E0)=I)Hg}EL*MQXt#Oj#>WqG{e=1DOqjeivx-G^rRt)`pNFz55K_F@McIxzp6elLr)=pEqb=zY)J(9a!qa8)M&? z+OJzfR{6~VA-#vo%TI4FvvMD4(=)oH>!!OfKXtf)CxYr1 zDNrYV2~OCQ29k+PQWFK?M_9V;gaw14h@)Wp0IL-i9w)+k;4eh>WOU-8kboB1OOh+y zcMLy2Ve)6$56<;_`(G8e|B!fL)`j@rx1CBvKIENY8}@Zs*MIIFd-zkHK6G8-+^icj zZ!y0%O`dI^-E!iksHBc&&x)l2TP!gzt*thPPF{v>T2X zFuV{v24K%rwd+Zw!bsg)1ZL2eCBEsrmn!fCxx$U#5qge~hL~}XlD+1YAQ|H3bN*VO+CXHN$q;Ay&x(SfCQ9|lgbf1U? zJ>Wqxlu{^}pvH?Y&Ugw~kc2gbR}lm%pHE0G^NrV_1wzsy^B_(~h+yFW2*K+JjfKNY zlDAe;XCMtm_G{atUyt-RznL?*TS||<{f1^VZ^ov*(sjdm>5Ik<`1q!;Z}?C^52~&I zxW(N?(W?t)R@m@S^ZUz(zE@u^Ug_CKMbZv5D7ahp$;aTnbwD9_0kT3U1mG|&D1?8+ zVd_@C9!ys4LNor4*n{VAn6y8|>)Nqz$7W`Z`35*l0X?X=cDMmO@LKQMyi*`C1@_>t zE`0^|0H|y`6)^=vU%I2kA_u1{3LSN#hKP0vi1DidAruTk1U%0_piBUqA@CnzF+GN< z6J9XNQ|tiM!4fw_4B)wF1O=6ZFh7D)BJu2QV*zbK8D~+-M!cwev0yauA zvU%3Hansq+g*-|MTF!pu0ZLsS#jd%2b6vr@kW`ns(BoaTb%}tzejXkb!8F`1+#Qe~ zYT9J`f&7s4hQmmj2>BHskzxVTO@!%Vp+hMq;gtl(5yOiT`6tqaf&=T;Wy{rjcj@g~ zkS^`1(WQoj7VQkT+QuG((~HQQOo=66A=G4!C5IBaLt8+z zefu)=@-pRaFL!;ppisJmFVbav@_zn`atG81!3}eWD3G=Qi!~$Uh)y9-e1@X@7N~(( zC+?rgz)}5dRClNbi!Ws0kkKHZceRp|7@ZmmRPJn?UpKi}m0F#87QBxd%lbg$Ki!oV zoEk83?CZ+)XQff|Rz~>&t6hT}Hz99JLK_t`RXdBzZed9MiQWxQu$G`IEs)7tM-Aid z!1FUuDOe{#VxRy_s||F6vf=!D`wsmjPn9<0HmL7crBSC|6XuTIP#$c5=;JT1x_o!4 z!D;?kW2Jk$w+|m)@RV)?p`Gt}QJ;`-$nT{p(>yAmNQ%4VwuK4*9)g0=B6grlHPWR9 zrbZ3Y;!#2HKZ4+Yn3~7HPBHccMm6hWz=90B8`n3Qw67E&DC)n%iFw68dkuk;mZ(RQ zmrp_7u{T#gnkh~HbM>@5xjEG{Q*HCJ@zX|+op$^hyB@lj72C<`?q)$tLr~(J?s_M} zPx;-CKmLJ#bb~*H6ahZns|-#t8g)^%Y}-{wa&2+Z1Dx@6vz#?vq{}SHVZE!Hpos@{ z=;bTSwnXs-tr|?GH5qsYP)iNfezS!3o3Z(71?@ZNM3VuB%yWW=Lqs${FoiJFbRf2d z=Mg5I8G0Ax2-PjN9^<}#Yu-06)%$$HnIB!s*_D?@)tvTd=67}Le+@+5Uzuk&u0L~* zpE#?moqQ-*nz&u6vZi3>>}eAgKKZ&=slyL5UVCeP_T;w+qIc%cKQD&A|M>9*cJ}F8tXZ1?M6Ev78m#zo^9h2Z1S8w|+zAn6Lf!{1XX@V|b&wou-90Xj3&&NRm$u(oiF7lv(NBT&H*gpNzzaJ4^+{d;jc(LL9r;?2sOln zeHED-onn?ki82U`l+neBh{lkKQN~iVhdmhJ8SIbJYX13@}2L zeo@XEMv2oziU~hoCj5}fgtzg6xoi%Tx6kzj;5Os)ebpm8yleuqvlF zony~_W{_;B-v z56^FeiA$K;`Ch407Of1LwzmpwTUPCfhO9mD48U4Zy=ng$zImjrgIdhgMeaXqTa{^m zCIk|Ulmi`Ds7XSxQ1_Yqy}C0qZQ#aj&y{#y_L;Hj>`^aQ+ZR-N0(GcdRaRl8>1u#! zr9w~X)V5$Fb=DFx*<;p*1`9qTfRX1MREPE=%0vtu2AvCL(jx38OkybFfl12MDRV|k zCTQaLG6Bk}&GYRAYb_YXX^vMbq{5UcoP)Z7icq}8fCMwkML}nM^)E12o-LeGdRJ&78)B+6Iu}`~#i|F2U11>WkT8yfI`oEwG$nsetB1?SfC!+jL_{NZeCe%Zy z_Byg%R)^UlKPWcjc9N4IBQ=90z7|8AfXaf{eIj$o{8RIjSTWzAP+X2ogy= z3OvdIf3GrL30`t{RraED1-(LJ;1VV!CrTd;+rDkc{npAO--8E>VYm&3wkY3A2kpO7 zp2ElzAMj^o5YWO~V1yj0eOS)k)F(7KF%<2ClM>m6-2;Dq{oWvZ)l)_JGv+9QxVN6! zbl8U5w0Hhg`q+L6z6I(YDEjHi0h0*~+>FRt3YLONL+L8`r_#r3rcPd$+Sl3FereRa zxue^6>Xw34F1ClWI`)IGy^%FV{M5i-coG02(6!hDut?Rz%iw}B$|3fbqdGbogxL~A z4inyioRHT*PC<<&WiS0a$q_QYLOjn*?~WEvI%owlX3U6oDID0STVic!n5QCGCp14WQ)H8StX#XtV}x->a2F5! z7@gr**8#0M)=Knm)V5uh?DTd6s&|O)P`PuvR!i#Q-3Kba%0=v#q4VpB{?&jaL6Z7A zSa;an`AVsd4}GpIxt6^r$~U6b*$;qutBFz6S}t4%7=ce z|62Qc|7w>~uT1&!Wuhup1UDC4Q@-U1dL~pjcahj?1iVhVdmh*g=!qg-o-pM40#T%E zW3CnW%RBmCQoS-;*Yg-f6*4=^}@Uk=Z+yUkhKs347T#7r@NJdks-8@s*Cw zh_=*%Vg*Q&?qyyB?-T;~_}N4mj344(o()r(pOFW|>HwxCnUt*MioIO9Z?qfI&1=@0 zt{s_jX}ni_wJJ5*^hjyk-s>gMz92!l$v?E;7blF)M^J1HkcV*EaB}X=i<@+Ss+ALn z{5WU@KU6fzXlIV27H3Yhf%6agxIQ1pFRhT+kf(E%o6ML0$$V*dz4&=}eug49lD3H4K(kZB^#~G-iC!`06$1Vx2#j8LSEsk=&2Q)nW>2-Gm}6o= zfP3v82pD9jJ8LXCY$$TZ9Y2itOgub)JEH4CZU}v-8;YKg46G!h@E)rXC<`UtRV+&$ zD%w&wagTtAwD-h&0me>Jkfe~sq|x8a6f{vCqQ^hp5&he!g`1D``qNLOblz0{hDTEC zHvBuJ9K?H$g)MRu9*7Ki6l2RZb!Y(%|5Kk9VBrPp;>>%>Xs@|PJ`Ta0mm}xO=(4#- zW+U=aFoPT4=iS53R7u&Cjp5ZfDpQ=&*xv|Xg9Jht)QTIa}TT%Su7SsAj7 zJ-uGGQwtp!#qMS=oBHRFS?uZb@`5HhqSFgd?>M=eptj?MT{r~ZUym1fP{KCRW`L+I z?uoRB*P14UWJsaYl!9jpk(;=6G~T(GI7cts6nDH**mBSFg^4`q^eQSC(H?S+9Td*7 z=h}(1b|Nj<0^e80>uNW87)K8#Ko5v%cuvSkBH|z#+v4kJc|}25Z|Q=oMn`!>L2i1w z95_JsMk8isJBD@M!#cev`$^pPpVPRcuZ$R;2v%yWd@rlugDj=0o0mmZG@dzOTR`{G zdt3Ie2VhK5NK!wRZ9w}7wg!?KJuHF*;4+qXI%aC+0#qPqnVFaEPp!d#EIE|EnD%|?05Os1?zr3bNc6B z&YZqSXP1S25|2fo1D(xXi$G+yh#yPxSd?w+3@EG{vZT?`m&`}(*oZKY-G?oQ(N<8| zfsk;Bjbfm2Hm%bQB6^9E>*!UM#c%(W{R_VfI#Ahn8H z0{6%bmd$s@VAZj;P)I->(IpuALE+$_Xsx_}B2S{@Ap!KIUsmjvuD^Ti`?uHg>MUyN zvKfD@S^MYo6%$!BPs%_)@w<~Bu+sed--iFpPt?jt@6B3#FzN`4UbBv0J2d)3exOG> zSwF)H?U5z;(y;gVc@cCDt#rRA7ZVZM*voy|w>aUv};Ijlq7d z?9I!*?p}G_pe5Utx&@c^dv_1-I`9nYbs8`qR{fL1t}`Fs_>HZfFzYexcrPD;j*k?} zs%RUa+O!IUNNm?_I$M;MaCM2(D%hyr(^(nD%jU1k5^4XShb z!p^Lc{q&Wc{kK$!KRfr#ji&Xu-F{&B@u7U$xYg=g1s}KQxNC9lUX}`et;~DN@52N3 z)pdmB{DipFPgaOD5w>>_WCT|SF?nhRgkDK<^K3pMlMl~k{|bwM3w(<=X74E_3ifyI zO!Vvu%^cyN88uZ98w}NQlA!@kJsT z3(@ESOQ3GWeb8#2ss08TVp;UEf(;rM56XUZe$&Q@zH{adnK5Ht{tp>n&%VO{_;@iJ zJlb)FHGRL^`tDmU*nP5=HBM{Pb!B>NJ$~zK$>qOJWih`+pSH}LSb2hQWy(gBh+p{S5Xr}ATxks$R2X$j6v|X^#IiqXF;9jTt zu|9))kD1(eY2Mq|Z%aO2UIR&8+E!nc$`F3qhJ=sBx>G~^MW`1S+9ZW>8w@lF_$d?( zyP-M4!!>M6Jh=(w;;0c@Fsvte4Ie(C--tPC&*uFbHO)xjH|CVD^N96xArI7+%WH0B z_8eX=v2^>U&9}tY={+c+W{XxeT4X!{e;y!CUkgsBoKPSt{fnA9q6yy9PsPIh0;oVi zTv9u_%tlT~q+P+B27C)^cPD~yWpQ0em#|`vkv|>U!!mQ`JZgcY&+Zc+TzdAl(q`Q% zmn#%EPPV;;Tj2{SLr9_0uvcY4J90~5*yB-;TnbgD>kf}$;U_wkAws0~^do!K@3%dPXE51K{@!4JZ?v>B)NCl6j`ET(A}te>6pWjtmJAawqFmmHoT}w~G=U%r`a?rV1_(>dJ*O%j5!9Mh z(Ky{b4gexlo}QDn$;n1#=X`#12b9wa878MKB1%wCI(nbQ5tuHP)%wt3myT})|< zxdV>^9`PTRyS`Bz-!q?r_m2K>{pz#tj)AVj_)O8YA{w~@o2d%avQiO&C*C2h(sSLa zQd|gSEYIM6GC3_RObo3N8en$|X5tjcWv#=PRDb^uBiBt^bBHZi)U$8h#-&mk^?&ea z6gp{qs!m?@`zO(Sf1h$aI@DhoSEqQZ4mFd)YBHZol~z5TrCSDr!4-k94O*)uwH{d| zDR!uFhb6_Piv)tXm>2@VQzN@U3{jH!AlefI$Gni5w!~;*B?3*L*;C43es;*X%&*@< za+MYNLP{t|JG*h+xwD(rpOG&&{b5n`;@_#^NyHsi&UNX`pMT_sAO7V5Y4!7-vfaehzx;_&W)1T;<%M@L~M+?I;C`pnNVJMNzN7o4!%(6!>JtB{s zT({cq`TLHdZlUAqjm-8DbFhecg|>OTkYD`rlC&8&W&|&5_rp4h!yE0X258uGNxoY!f{GgU zPs--};jF9%JqAgip33Z=bzwBWUXYo2^15_rPKBDm0jx4(yI4^BH=5m_aq-VUIlRZn zIe#sE$S(%)B&k|J;qfC+>Qo`AJdQWB`-+`)Qf)#K*hE4GSIBa?EZeg_2gyIu>SqBH>yOpP0-0aR|A-_ls5Iw@5|U;zU||@4;r)r?CK_ec;WZc$5=J~>9I4v<}+^=y|NID z#;;~iaK$=W`Xlj2CM)FPug5LpspN9@_*rHpCbL%MSQ z{o9#r{emS6&VWks$O zU2-V@IN$CFXX%?i4r2XT*jS2T6(RwlOtBw@h1gOp?zXN;ii>m9Z3E#m=}$bu2ZG%= zJpfbH;x>wfU_Wj+hnH~R=-R-3As|z5crq@M5>&rtsffz8TEy_%S^QSH=1G+!O4WF& z^YDbP!y>+_G0c9nL{I;wZCs07??|spgIYK7?@_WxIuB~qhTR>~1?N;7ycY}J3xW?c zT=mxa@F4zI8K9q&U^~y+aq|rkuX+cHBLr3=09`16n4nOX$y`-2wn&_@umB(u zNV{t6mkjkU$HhmJ$S`xNBX(=2tP{E_kDlG<8h&G{L8%r^=T< zdC2(}1qG}E|LtO$>x=Jh{`k#>-_IUpk^HA)XMU$!R;%z2`#ShOCD4N*M0AXL0VfK} zO}9ApZ~^g{X@~aWqO=nEDMSTK(h!gl1CZpFjY4un2NJ-DI+;6Sv??mi?||}9mb=cs zy6@#*m}AtJi`n*Lrw(r3lFi#GeeBI$cgw{7e0{>XA$rCBqux zOpOwKvgop*#rE^_MZ82S1NV%Plb#tPd>hRe$*Rg%p1z|_$nr0JVrGL`C|_ zbXtIe8ZFDvy@52EuuTG^sVqW?0dh{3BnKtNYt;%04yL*eVOl8%*@*dHj^qW`d6X-^ zY)JbRQ)-q^ORp19PL5*f+fN+XfYB1`*?433OwX@j3MPH5PnCVd* z5%?^5A4}hH{OD#mP+I4j-S5cw_kLi6Ed)VgTr0NEo4HM*dx_}r``B}+4N1x8y$yYP zmhhy$J*&5&Dq36;Qy@*35+;i7Zr~b{;3U=UcKqoAE^~eQ?xCl5LGJ3!S7bl8@A?-9y4I+Mtf zii*fOiXDvFBofPpHWjCH|? z&4!`>Fi0ie)`JkkxzGEWhG%Y(XpeU#^csishv9WY!z>{BSfo z#(X3{q#?iOJ$V6dMG`d}-7{(4N2qOvMp7Y3bk?&h*MghYbD-iFEC=oZtp)gqL>fSO z2>~Q&>A|#9C13RxAq@CQNbUes?JlDv^m_~f76^$E#$SWMD+4xX?chPHr4rm{2|Mr1 z+Re@{<~~x%RnmUfE8^d;=VtkZ;(RPu z)zb@sE|BFIJ?T}g)xGK?I^JXb)bkM$i^M=>OjcIG``Ou)VReshiJ?zKTVv%0J~0q; zP!yB5Y58DFD%Vh0W}*|q$P%x_2U~TIawE&N!mMd%DKFA3SXDAILKrxPRiQJfX;oq& z`soXSsz1>g8GK5XoJnVb3gAaC7;6IV-#|i>qP1-80@o+y241KiT_m(Y_A8MMTvz8x zMdU5d>Nh)9q@Pz#mB~s1BB-qkPuq9ce-@g&60rV7#XMW;=35-?D75?nny8?@I4n}* zA2;?z^(!Lkje)PzP)Z1eRxs3fNm!WXPLoKx(P4#Qm>4qnUX=F4R1ETM%Au)>NPOB5 zAB~SMYol~rc%WRX><$*Nd?}3o*vyi<`GXZJSkSJLnWesB%I_b2`ZX{3{o{J}l58>m zYvU&Vc&T*d$|B~wc@rzLIG>*{Xu%v`e!?VQ#tXjuw9w|uX^C8)kMm?KP12&f3g!&wXHCV7m>fy#>PPmVqQ8M^ZAW5+)tu9KG5`TRS!j;HV4P)FK} zbsT^!RmIMsCFyoq_AAU%ozX0pP`?Y$W#MW6P(@^Sh<~hx^m3NeH0vn=97szFO^MIL zC-V(n_3)j)t%J=8R{9@4T+es@?Zb)XP=F;_R-tJgeI;-C40!@U<5|ppUXKxQ2EDHT zgk`fC-;(C;@dC|lA&qhR!t)c#(kuv|75|O$mZASdov~GU`(pZsuDhdn1fFTS+v%FA z>F!ppX}a!~$@;Vt2BQcfRS}q|7%X!6WDNtfhDW_bKrB^1MN2B*Vy5ZYK{Un`cpc>R z4Y@}63d2mpAsfrbCDm<8GBA^j9b{?Cmz%-1to^+4;i@jEPBX)Wy+pfcqk3z7aNyDU zx$Py(>h%Tx|5#&{zpw9aYa{(I7kc;u=;1Q9$*L@(r`iRvpg%nQc5Nj^^3bGX`R9N%1eb&oHQPNpC+BJzgKhe||x6OfxObPz#~DO(g1 zCC304f~kuTnz-`i;+c2k*<)vJy4Fj*?w;c-t7e6GhL2orgv*SaIi3b*Y zOo#?O4&ia;gMes;FN>luBT5O8E%c>j3>?5vn4J^DsPej8Vssz{)bUDLhkS~6=clHz zH|8Qv_X|5xaB)Ap4E~;alE$b>qpF|Hy>RDgJNfB+r!@C%thzV2wzMM<{%8eY;=Q%b z5V%cxKQD23Iv&8dP2PlGCM1u4kid+Dix<@5-eLo|`^IsQUvBOL$xNNklLBmTw<+!I z@WTHv4hgOV#~Z1ED2LSU0<(?zdfw;A3)p^MGA`}qIrF!CSi0fKg0mYoo<7IlJ1zgt zAAVPS(Y-0G)cszp9a|V5@mli^Lz{#bzjdNRIuD6FcyCo~?*ceRCee7}v*eT1Zw4 zlk_Fmx0%u^`Eo%Oyn|$`$xkV-2>OMP=cmbCczz1xZc$Agi~Fp)S> z(7Pzm*E~Oby4mwfPOb%q57$=sf5F7pvNR=WLU6E@Gd!c<@+frMd7Td`7SzAlF4mH* zE~v!2vzPcOwn^@f0R4E9#SySlY*=fkZN4Uih54bcOqc`u6H5TpLlH7AbVFH)D#POo zbbEYx!vb

z$Vn&;Vc5lmIIN^?!5y+8zCT?{Q#in+Z5+O$g-AK^1p)cIVdZkl z(Qe8$z!x>895_0sTy8nAxKVPY29zbNiy9d!!VSRsSU@8l7mz~nFX%lM7X!dlEjeuA z#L-(aZtpmf->X~x^`ramXTCjQ)~b06w;X;Vm+Uhzqw5Uk`bBLg#MJq4;m!k#`b-(s zcSx^v2TpY}xcO_Gs@3PyH!93c{fXru4WCa;MOS^q7x1~q=ac(pX1We#peq=9hK~ax zpl$&w^~C*A2q99`Hbmni3x^AF_`*;0U>TmTh*wh_Bq|&Ys5(B!k?R0`O|t&Q)$ymc zjvKd`&0WTuvR~Gks^cHJs2&_hZuuLmF@XG1Qx|a|y7blQ&Vb-$-4-XwY_P`o#-?@g3lp@u%m@AO%kY7cbhORb=D|m5vPJK z`kHjLC)Q-SZ2AIKn`jED+O;p<;tOI!?t>!-CQ*9G-dUW=sc<=VmJ+T3^`<7xPbuXze59WVfsqu?l$$_V1-* z2`#(!Tn^;h47m?M4qUV@{gJ8J!pCOvY4Ujavx3UBo0|+nk|P=z7d83QW2%R&IEFt1?Ci* z0wE{3`Z|sS2Cfv3CMT4HA!8zzObTsq*szNS2eiy+HT3O#AC|v=aNCSFX}d1?Dr48o zO|9La_ng%QZ0Xz{4eIuuvqB0%=Ceiu`+=880kDk{Q)J&3l10w{?rugzRM zrX7A*>kxKxd*-z z+h^uTKuPvPCi^z?=~w#pw2s_s<{x50L<$7X<*q*!4{I zP3B~up-(RwYtW7XpDqD|ZBzE+JI()Rn#GT7j_!$C)?^&{|BL?*gUH=yj51ZAla`!{ zVfn0f=Kk(q9ONDOHaw{{X;E%AviKQqtV~^VO#X~#@Zyt444#H=v%&(lzbZ~l^Z&gx z{~xy(6$f~<`2YIZS;j`{4uH6=x@Do-!vAak-+|>n=l@4}c95YhU-})=a)Q6 zWQg5=H^NaZCB{2COC_ksOF`WXE0t4?L-Z%Ylhj>a;~oZy ziQ=e%9L91`1F5E<6>eS*%Vo$@uVZ`gQeFaUKtDN-pO`?DRG?r&@FP1P;7JCV` ztDYHK@gfpn#htMzOsbpk<@7IDF|-A^$p7@^A7x~)U7qFuZ5IDBk>@{+ptb;!*EvQj zxh{#RTcS6BL{lHcY#q(xH)h@G*<8#0w^(2vi!?~YX366>3^CP97A6O*Gb4kI9g#!i z_W5fi63hjX03QF|Ozz~5r?P-yS(o$pZ{r5D&iumoTr$sC9&gRwFST{VA}2rE^G%5z zK~W4V{1H8QXhLBuB2`*PbeprR=D}HxPY+g!+b%*nRCAC}7Eu|0dWbDqmZAW$<~bIs zWEWv92VhSug^Pr_p>@%-blMQE1Yx(~F(hG9u+)Noq>VdVKmVor zew7+`>OF2cO+7qtAcG|-m${Q>9~S)JZn+^BUoxLYAS#zr&z-~lq&k4B;(kuI+)s;= zG^B-xkN)lDETIiuEs>CyQzEwn*mYQmoD#&YB^uz1noq_jQ7tFrWKaOUl zVxh1wsSZsdq2a8bKQ@N5u14JN7ar`TzB+p(jcASM(e4qgSC6Nj{1Ugb9Q@unkdEO% zkBn1&1bPH^Jy3j_@hj4R`i0c@D~{I#s<(x?g*oM2j6y%BwI&L@X*y5{G~*`Hk)qUt z96&IrtYa#Yp~7^vC7)-t-yPKVvpn9Gr-TO1UXmO~9)+}u6u`pq(k#}a)0~C-n0%Qx z;$QzPyZsB-UEXkfTHj2Dox=KXtj11*V2e&xnKmH~x;NOpQ(@!kJGJ=ux^HIPHVf3d zekw(TgRem+j1V?KCyeXpL^#d>QV1@Fu}=s{v1@WnbR`|bN|Np0?Cp!6Ir%HX6*$gn z0ig#Uwe8CR02Be|^Ff05O(uHnm2_R!^Zah=O3vQ`yPl^5L zLRL+Ppj%20C4xlD%o5MtQh}Pmy)=Tya0dZIah4!MzSe-G3CXNyI4m8JEa?tCJ}nmFG2pyxaI?`M%_Ak; zNFt$RMPDgTO3grHCI>o(vCoc-+E=w&-q^fPkz!l$=9Fbnd5b1aSi;aQ?e6)T=>rF* ze}7IX{p`-NJ?auA?AhHV>fWVF80};Y<}lpArlM&l|AZ|UJJFfm(qZ)mR<{y;ra9 z`A4PGuExt>ZL(>Obkx;s?#4B5N$(?Tb_0C-sE#IC&QKoM`K@xMJ@H`M_F=aUXL&L^A2au z;NIeS(&UBnvS5qO>a=daCIEl?Q)6YB z+iD6lZ8_T}wUmhx<*>zaI%^6_=ua$^&`l22ucV-aAM8TE94eqn@uPT)Ujr(wa+gpE z@(61KTkuhyrh=l|*T82(Db&5I$NVkotaH3tOe@$2V;GE&fQ{-WUdKiRpa>1h(NP1f$>o&~ z#$DevQ3}hDf+o6Z0FKVS;&Jp(B=ws~r^%L&bZ110Aa-b8mwqz;&APNPL0Ff`o5#BR zU%ak(FR5Txrn)ejogTo>WV_yH6PCx9XI+uaz&~NKo@D8hC)4U~Lc?DH)q;^v%hsqS z7Nk>6hz9+MMZQ@Kn->EoL$yw&=oo|2Zp5)o{SANzaF7p=0V2eLpe=@Vp-+jM!Z2fq zDWYZ8lkH}Wh`>7qvESiYSy@tf&{}FH4|>{8r#OY|?aqQF8qGg+M@?w5Uyf;4I?ctd z^e6x8u51DBv>ZtelVV3(0WVSL0QDZ4uZ9Qx+l^tuW+Qav^Pv-3Bu;BSzlt{I`PyNf zl*Ylca32}#!nZ=?&k%=(?mXBXVJJ&MPmNVe3q9q;upV8ui!h{qW{ zB0fqkBGn`AT#KE0<91Ue^;lE2l<_gL-rhLLa=^XKSHmk*BrSxV z6Xo6ZwO~#CBrR(0h5Hl7s;fEiL>K#*ci^_Xov{=JB*3yM_8X4MBkE>mEdq%S{l^oI zK@Es@Md?>s%e||aj#`?xb+`Mdv!ww%HY-aB5GO7dWu60{zGRwY95|bHs;8?9(*Ip? z@Dg=ac2KQXG^F}cHC9yfkSnXBb%g@c?{5^8UX=dCa+xhsNzW|`y6B}psx3-rKx?-Q zWK4xSN2U&VACG855oL~#LtZ`45Zcg7H(6lo;BEv*+sm4`qRZ;Z$~dVn?*dKsyWpAf z#3Isb7Vc1{z)f4NxQ@Q4E?((7IWo_B=szS>iy7 zIGtZ~0j-va6V4_kVfHKoS0#ZS3^!SoOO_$FgL`S%Z_YULZdLyT*h%=b8u`u|2?zXZ zl&O(W1D)zBCWz^SmGYgHs38-)G66@85k>7@1e<`6&}uzl0Y;%zJRV`O8j7cwG?ff(rnX_0*{g{2~Zj48gqX+0U*ijYts=95LT1CsCRMGGU@b9X= zz{S(OI+6gUI8nS^cy{p#c?t0i;$au9i{FDUYDzrC3gdI=`&@jlu8T*A&`6=EMFoO_7dTq+U~-83>%yJl)eX`RG~m>wER->#m= zzhq@TxH-80?Z=mEtn1pY%77UQSed2m_U@WG3DxM=H3G_|I`n2~tEaWiDOc~^IlGnY z74@^=7M+!nq4A z@u;~J%^ASN2Uy(xh{=|;%~OkN9u+_q(Dr!Lb*~_SE{t^fqed1LQYI31c4ST@F;66g zt?-bS6PX)HLDJ^otlNri(qf0FjxQuE+IGVIi* z8!M>&DJnIJisLhsTKw$)8C6Wn&ZArOW0*%7M zC8f_CV#XU*Vt6mOXrl7Z-8My=cCN-%I8$y=oBCw~Bg(d~z%TUTQ?r@>tXa~?LA}Fz z79CMA7b*#|Wo+R{<))(!IH3Y&h_2&ehO#X%9*Aq$AP&ikK-UP^qLx;@gXdY#VvW4mETn?b6f;w7=7b&pT$Rv85_pG zySwvu{zb@`F=4FyBkZknVK(^uDExtVTPIc4x+bGHhd$xT5<(L6roG`8U{eH$N?%lf z#~QoA?LgI=VNjtYjQCX>3#~P7BC|?Db0&dD6B~4($x|3M*!idTGEUvTdpzU4pQKWA zwrrV`F>C9#nHf@*Z&}5#5hKF*7q^Ok&p!_tH9CyN-{MWeALbR`@n`tBabbV%E$;j^ zYz#@VorU)tgS~11!BG!!vE8b#HkB8#5NI6tmrUyEPbb;`hm?zv-lViS2_ zN;T!>Z_n_b&wk&%ckk}so?#(pzwf_eA-}t1*QPlSpUqmkC6|?2vWpGum{Pgy=8}WQ zmhBwR&(&`}I5lZtByO;X#yWmSG`2h@yLo9o1zek0H_iXgqq5|%#OI@~i6-Tg$22IT z>2P`aSfsAN%6|)YiVzT@f^JgACPibk0;Jo&3E&o#l^#kw53%Nd7s&nfwbw?BAUQ3f zxzHZbkkg=qhPnkM;>?uLx$plXs_TrY=RnNP@=4$gplojqr~%pUzV(q;@<>- z3%X0vLz(YGAE19531+dczB*+J^yg&0NZF0pDRo$9dVe73DiQnArJi67y=#gdm!j#_ z+T}IdBZ&PPsVN*Ub#f58N(4bE&?h?eB_>M0X5HYwzQs4(VFBLRMc+Sg;+I<|Tk}P% zOAs5$%C0)js%Qk(J z3f=|_3ioo2e(R+=_HU-m8HYJt%(V@_J52vwSp)=aBo^Y66`-aWFGDfc4p19-Ucfp@}K-G9Qr(|<7u-_YMyNmL$oNKfxjH94sozttz-3?_|B(eT6%}- zHIu8lp3qO}JAPC9z2vI);-0@S{a(j3?Qd~H<%Y6`SrzmSF^cILmw1(!M zmr8KPBsiln%sl~^3%!a=FH}Qc>2TZ)IX*a1X36%@Slo>yJ4=oYw=*w$e5@QVCkM0e zU@w_hD)MHpm+KbAgH);*Ys0rb8&iz$V_oFc5^LBL4OF2GPBde2l>!$xIxF`IT-^9{ zJwN_7@{$|<*lT<}4=kG9r}@9=fxQ#3qUi?(^8!~i`#`!Wv6q|iD+Mla`>ko;~=_`LZBU1QAFb*-gn3SVbm#60C-NzBcRr+62wX9kXPOBQhfqMdon-q1wM*7$N2VhPj8iPA6TnQ&h!BbP3KAZz7=hyQ$?zdB=3Zy{!c5_sqp`QrCKu@bP~cW7F1o(O69t)Z)QDX0sXcQih66IEL}b^J5yQ> zgw5KAS5r@o4enOv?O8hxyPjZusBZ=be1=7b=yeYk(_93Pbh3JmjN%Kr#8LfyMPm|o zL7!H{=dS8Af-Wl4CMd!N5b=j<)qNy1MZjw5KhYNfnKoq^Pht00^OoIG=VxcnPwmad zBO2e(6(BkJ4OYt44$lU%;`~QxDOSD_D{p{3grIA+-~r1$6rQwBW__{Z*My{@n2evV z@RL1>XLqvMoPSo=`5~Ta>hZ{YL>veE@S`}2dmpfX-1~r_#6F7gt!&O(zVDUPnd8RI zOl{53antpuE1&&(|GqZy@VDRCHSFP4<_Z2u=tdexXQItn!G~VG4cGYwt42OP_$c0hOho5btNl;kGtUs}Ei)Xz)zGA)qxyHhf3D2)ybOHL?>ZR-3 z&B)(Zn+7*u<*J#2Z}0Lc%4bq zM#BmGp4r)QS!rJC(*rci(=97N)5)M| zF-z`SC!<)E{^TE4rBB0j18d!wS`A<8M%1ojY~=s6pT`8f%#KG?k@3esFwv)WvTe^WO`XR&ATOA9oZh^Y7q%dP?vCXtQiD-kw_-` zD&KE53*{}^zyP_4MJmU;2MA#8kj##mp!PqlT_^Sb?b=1hFcQ*9x{#)|%b%}hHWt!n z>(Hp#i=?ZkHlQ*sQ?4zI_~VPT;~62{qMXxq9&$bL)MaKB-nOrR|6BnRw->7wYu6z< zR-~qeCIP6+izv!S>Q97F*V$Ru=c=Sc(L11-4+4lL5P>DKkIbXFO9$A#iV?{|KC-6g z84b{e0frtOG={ELVz}mhBgT^K?pg|4CNwZa@(Kf}IVc>FW6`+T5o%xY>5h4C=kdF% z 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff new file mode 100644 index 0000000000000000000000000000000000000000..ec6bf5749e52ad1cf959fc5129672b44f2f6d1ab GIT binary patch literal 89300 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 z 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%0Dh+`o+a49c{bs_0^sWpgeA?4;$ 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 MLV_d_fRm8?ABWrtUjP6A literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2882017887433f9a4e4e621c60187d8a96f6d355 GIT binary patch literal 62832 zcmZU(18}B6*CzbLwrv{|+qP}n#uMAg#I~JGj0q>UZF}OK_x=8Vx3>1&Roz`(U8nop zeb86;@l=#x1%Lql(UTJZdspAwtF3`UCjq>LFFh0S{R{ZpIcq>GS(Pq?;0LR~A;j(J&+r&OU`= zpk*WlytN(^k-AT0mQKWQ4^2$d=#*HqYn_vURU^D?!F7TVJuS+BctnF|R$Ghinc|Wd z=Zayk%(QkbLm{L%9*0hY=C^&-OT9(Bq#hWD;L+cyu$Tm`mQW>GE^Q<2UM+9{M#uCT z#kSqfQ$xDoy)(ZtqL3AFk2T|mBIvjT7t*-@t(y#u@Ra(bmxP9s8y>{+FWoPR12OLk z{b5$RT=~pfrmi+F!>0E36;y1(rlp!QJxKGQ1~;z^X?&+QVue<6n%vSTXs|-y_DkA? zL>w3Sc;2W9Omr)aOu-Qb6&(EMa%?M(H_j0wA^MD133_f9M}#^5naWhA^`izgqCyD; zKD}ms8n^L0xizzKLGYkg8BM%I7&$dOIy<&_Mo7%q!hyJLw91-cqN8lmg?KC!H`)no zwZ%LA{HgQ@R$9HxS^vuYF*UC8_T0@}Wq`_DAvT(SSSx390#}UozLa+j3a)0JrTbfDJ+y|-?`7_m?C=r#_DKvFX#-rkwmkNb;FFgnBPk zsu2()lVVpYGEq@DyemWt4hA1P3{E~_!S{W*BiZ-tz4Rl|w*)%egeQCKt?vfVAurTy zr$L+da35Tje}^!b1)C4dqVXgY0Jxbbi4tmNXTVMK7eoTDFt&Wc?( zH#@A8)QHhw(uHex?EU0-4g7rkfq%V^x7SRv!5eiN;2@~=8;Zq6kvG4~j{U9m^V~G( z-UV}gEHi~5)P!I&^Hqp>3zkzGR2IXeL57XbnV;VWb;Iw%Yi-*QRSlc+iHuyQstk^N zSEtc{sB9K07U-E!sgyVX;{GETN7>u%(WbL6dkhtRQ>Q-kk44#)c5x17QRN*PxM8v(8dD-Ua1P@%r5T`wV< zJz0~^R*kaSR8+O*BMJ+JNs|A( z3z{>w0jtztzZG-+{u;mjV*$*@E65<_6wt!}iY;rk zwf`{pQcZV_?b)!cpF#KdjTSl&UU_Hu`DvplcJtnELBT7XboqO^8Q%V5Xh9*s+wS4p zuGt~c1*Or32a;@7>q$XUDm2!`%~+MQl;~y0@iZ6!05Vgtm6BV&1z(NCiw#VOwC*%J zxe1;gxYCF7Sxkj*X9zq&!U>-}euGPhvn1j-i9S9?9w}}rFO@Sa^K#amBjxYht|_v5 zHX#{?4MR6jhoC-ru@4p`^|488n5!cmt2`j<0#q~&$_pO>$r2>398$c5 z^+&1iDqkd6-?z*L0E7aNt9_*271us#Sy>srf@FH`eo(?)vd_vnqusREW5e9efXq~b zTeqjH!!kTa*{1C*nrw2;P!eLjBg9_HgXcY5r`p7M-V6;2rzEH7Q2&!J!aR3}E^Pur- zR=}}daGa0Zk*#%DHLI-g zFsa7F(Q+$pQuk6XzNe3Vw8p@Cb`ia#w?xgzqM9m`h;!w#ozQ75CD3LtPNdqNRGUvJ ze0yNAxp#%4n`wZ?q4M(j7iQ)nk=&#_NW&KPDnW~rEHaT)Cz6M`NI~OGxV#ps4p0vU zZkz<(tpA|Fir6P5osW!^T0lEkImp}y6jybA$=j~RArvKxV<#mQ21bIz4DbiMgxlCR zTT%m+;n#?_ykCLd**XKf7&ZBhyLEjU>U>EVaF`Q zBFXBA?(c~VN0m{aPc5Y%N_sikYdqp4%)Az6dQCC`$#swT)c6E8D#x=_7DxU&_|O_G zp7{5y?%2Lb&3D$BZnbbiHj|SCZ~47YwJqI%nb8?FFo(~QtQU$1?_LKG?z5kyFF1od z$gkFcn5QC^Ud^nep_HW6+npyLIdnqA4nS-q*WRn;Rq-dmf+XNO6ZAg2`dT7yT;N~= zVBh&a6oR$~ULuK^BYYvINYD?hdkL5K5o{l}xIy;}ZiYmKE<`Pe*|unppppX6hXg zL1<-9tZOXGvPPLPDcVwx;xy&0rI>To0Q-%h|1tMH@0%nF9DM6tb9ij4?1 zPD2xWfF^)Pt|JVl2q2u?OeVGlM#|pB)|QQqs`_$z1!XOc3sUcHN&!mQ>tgdJ2U-wa#Jfcj%m^px8@$g z8(@G&zQgX|`0fj<>8tyaq}!Ezom%}6M>|W~%NnQbZ06oaq2eDw<&sSMVZB}Q$`Nea zNXRf8Vd{+dJY~Wqz0RzP%=J3XR!OR^1gJ9}(}lq-Cao!5s3s$1@7r0>*_n}_>T&{N z#^4q?;0rm=zJzijYlTdVvBT!!?51GGg3aVzmarkyl-@>EUYaD&0Jna@XUZLpF% zYUhbV5hKL%%<50+c)v{E`aH+rjPY1Mzb8+Rxn@E(JSq^$@B;) zLN$Ei3?Va7Oa_wC@!Bdbt()+DxqZ1dCR%BnZZN#pCC9(YD2pqrv8|1)$|^Bj_tyr{ zM00=5x?dRA%m7qHDKBp$>3ZU5>eqbU$A9~>>G?PH52P8UlUT}T{lYN0qHU=T% zD@gL}>Y0JwQq##;xuHcyc2px^+tY{K4mp^u!=QIH*51DEE` zhf8*)0R$pBk;z*hm-v2GU22PJ76xmUQVN?eSQ9{yR*Pz0-JT=UM&Ih@l0IFyyey49 zY_n7L;c8aE{LWIVqB+XasKi;yQm^KID!jqfe=ldchiu_2F_MVk7Jo2dr;we{P^+n2 zPh4FL+$apuhZ;~88(pBp4-i@}6HrLj5-K59h~6(He@(lmuBiMdCjKEV!>UxqW7>Mo zQz7iC!Q&1wC+hK5wuZ;NNK!n^oh<|Hu&f6?sSRuXJ5w!pWkojw{$=~S?CsE?_c8`b zWBzH~V#h9*XdNNbto)S7Fc($rG?CWt%lZUf~laL8kh( zvTrqha0D3GxPj}F4Vpox$oW*BAxTZop232a*^S77u|(GScxJW*ABDNl*)=VhqYBML zAWD&}yPo!#{6&_|edqeG!+wajhH2BRMdlBq*P#K;^Z;lnOnU9cfN6@rvN_bvBY)(1rMKI)Z(7{fUlG3!@55O|OnD*- zc77b+wf`mLHR=#d6iNoZU$X@+msq7|M7q>&TbbHAJGnU9KGpOhcWhnZ?~i5Dy}+Kt zA3AHwX=e=CM;1l3Q7vPLyl%Fzsax4IC|oj?5&o>UU%xcCFX(ID(fHhKBSFQSHV%{5 ze||ch1Z-&k!qAKW0dXH(?e>gl-V;h~_z8+ktR(|dv#QbQP#uOY4k1e&Ttp4~jG=|Z zAf_YX>Xw8}$$vl0#?(#gNJ!_}VIQ0~J3&8T^D|zTq>DVUSb@#3B6aW zezF49mXtC#>+HoID?Fto6U zS^0Ju=FtN)UfaJthC>As%MG))GeA0{l1nF1n&u5@k?K7zF3^g`N%%v;XVK^mWi}Z7 zjz{Ap?7l{QD3IzUC^y1pLin$82+yhegkN?!a$gt6j(d_&yUDH9@ zF~8R8rtr$lWe}ne^5kTWgj4I<`nN+eFVzyvX>8v)Is4F&1u8U`uQf}KAa4*Ng}-Vm zwPLP7JVF!(e?9b!y}T6yTBJOs1yK^b%A|Bc-6p@yVJptSb~$$n2|P*;Mm6vOH2w2N zp_8|O;|h%Z(Wu{Qz0!EZ3_(^aV-KuT_N!?K|9{xmx+*AO0svK2L9~F~dpc$V_Yg`6 z*&2*D{E*&`m9cIO9Q)q>+mIt9sGUrWN$~*KagEo_win4+QV3WLbK3(s|Jn&Vqs&#Y ziz+np-CMouU8E!lW5MgHKu@_v8kA^0d`zH{YQ8|5!Ql_6J2D=H9Jj>jW8t zoD%e*Fp{9b>bs)5w8t`d!_se%YC1v6RJt1uN=oXG+J6KRpy|VI@$p-{F@MsBFyb_I zA!TDb|E>^>)n>Z}ibANGP)=$X8DB-q+BrGh+xe4*Lg3i>O7CH$0L??iD4U8<5faLZ z#9#UTgfpP?K1VQ`S^WmoZ5q*PaQ??xOtaVHg2M?TQ;MWw|BqUUYS@$7&Xx73pkK_Z zV!7+K*$`|kf4<0Ri!77tYBs4JBA@)Buxi^=0;g%JQ7$K6|~ z6325Fj?cpBD0~_G+Gm76_8yLVftARqrqDnnvYGY#eW#9XOR$ONW&IzCpZ}2^V{#@J zY9>0wdx_YX1bR?R&bRG1u>z2sX5MV9D&`}-R!6ek-4UtDEuA4>G~N|l9tkV@SrwkW zIn8EVox+BcW;Go~UGzsu61$~8Bx_`*Oh|goBEz1XR)fiC6dsa+XOy>4lgvZLT`0Zx zOixX;(@~L+5IG4|`D)*dWD@j=taUV^{iuTj?{;mz@#<3KDTZO~(LIHR9*Um!FRuUY zhNpqHT6*Ju{&)}nj$JE0&~-Lax@x>u%v)4~O|uM#UDo_)qV)Hqmq>c|iGjo&^#uA7 z*gi3SA~m}Pfx#qSHqIg|d(5Q|wN>bUozujLF^65@|Mp29|Cdi<^f|XqE0ib}0E3)L zt^L2`P$K_k1R|IMpdPiLiyd&k#5vlpoFd!9gbpHkp-nqFEI_Hy0lit~`bY~|7XgV-s zXtLbxAYplOz#3rr#QT!e)otwIyA&0L7n6!pmE|3`sH92MXVAVh*?w|Vt>-!WTTkyC ze#EP;ews6i*ha88kJtLGI5bpm=%cN>ow1pH$SbgDP-G)2YHEu=`TX5bRg(dqASM(^ zc&53OUBj_)n_s%O=m;eFF$?0&uJa(UC%cyU%iNni`Di0k*4ai_#+2rPzu4MoUMJ(q z#jhfNqL-dGKvLi4x0?jyRL;Q+`&e-f)xDj=^4@vUtykIIt1rn36tu3MZ0Zk|KOP+? zR%2=13=z~QG;yUlofRw43c(@SBp$ zdo~m%Dt}dO@grHh2Ydw4f5hTld>L(>SVv19L91KoR%#SiPPZdeNHiYjAjap8?OQUx z84Kbj&+*h*zdhHuyN-RGbflvV$t>!0RRxGHFzg7R`-J+$Qsw!y@wE$&maTy47kvs2 zCP6{7S`Z;4mC@O}JSCX6j3U1C5n6HugSVlok%i?AG1r{s7>>j_IqpI-)S(pqX~}hb zM_lYlF~C>DSQ22*??zD>rHQ{j4z>$C~$VA+eLPRGvFN=JnOVaffv?f%mg_9Nat^b}D@217%ae3&S=sLSbXY0EHC zO#nqM&bv*%#rImc?R0|!RV$ZabZ^p?UcG>Jmj^d&UvMR&(E`&SyL<$rx&5dui46WrjP4Gh?lA{>d=EwYK1GP8?%f-A@(UVB z7dMEab`bpET(N6zxffotoRi+$U_9w+*BehVK@BXq+~)9xYUdRw2t^RNtAoZm-aSFi zKN?(-nge*AzoM8f(WojnlaDf;*q*F!IN+)r_h#?I+uFC~$r1>N)5L=$G;JirWtdj@~0mi{eB);pO`sCQLFE0N>FztF%vY0Thi1| z!k$QDsO!e0{K~KC+ZjTW_81x7NcBKM%3B@WAS5;3vO3Oi<(9!n)_4Zlq#wno@oZjx zijYazTF{={zKyT(16PuaEzxaO2;0g#_D5`Guud(QP4uC7bcZJd8PP_0(;Lm2(Qew9 zBw5Bb=enx+Shzuywid$VEeDrEJ*74|F4n1LADhNg=tiAuMr~Qh8ROvH zHSq|%aHrcaJK^eD^*oJ0D-U?r{X&8WHqt`p7>7RBDcmMm@%(dF&CPRawV zI%HE(&RC}iVcP??;=`{$yofs+1QJ9b?vX46lYCZLD}?UsQy;8#VR%u-4kdAr zZ^8rx39{X&zK#U$_YgDdRv%;7*@ey)5`t-EXVqWW2aUk8#;Ia2g|Ho;8X)qd8(Hrg z3q~H1dG+N6MY1VFj=Qcu7%kluv#?Mu^G5Mf;plk6*>y#@z}^9?rOX| ze$JhepS<+OHFvd*tjI7Ub%E?{gBjhzM_Wp&swm%&b04z0H8<=T_9yad8;4yD181KQ z!TPO?zm~xy5I)#^qxUg-!N@4kIB_cN*`gw$uUxOvFi_Jg6SeenTjnq}b$)b2pcBn$x%g+^pUl?ic@Ll1j>+7XCifJ?=uLjU*~FfesQA z*NupuSRD)yO@Q)3cErA2?dl3r#tV{^$}0{celHkabA>j9j|x(jSK?(C!IYGL$i@cp zPw?JnPEU?NQhQwXUBvI~dD)5W?M9F2zTPT81`#2#3NCyfkU>N*qk4^k{r6q*gSo)3 zILH%hWW~L=7FJV#hFZaYlWTXppa_?N0`Hzs3P31EO>fBoxyw&{C;Ha{;j-{0?*-&# z&i{FdQ}E-y`~we>yzz3K3;T6O07?QNh)@B4%Nzi$(4k&(QV|IKF!y;uJQ5G__wMe~ z*}CRJxY32_<3LoXu!f&d~LgLGjY< z&CJFegw7+U<8IeI<0lmPg>M9!AddQyc?sW`wJ8MYNo>hK$OKEvs{D)P6O6{@PY==< zfaXtVx#t2z7&M6`oJ-q1zTrPqfX!o95!(wJ8f?&Ua#_F57}}v;17*_4wPv*9Vn*;! zNkWmEFzV8`)i9fw*sfJ}f9|^tW2Vdhj4Y8#J8of{Ub1!Aa9a32noip{qo|gbJ@e>X zsY8_iZ;aQgLA)lRO!vnRkM!rPrwHsSsCKIA;2vY^A`%F9s0jD7WP0C1TB+vf)fk?| ze%dXl@Q&ropKW%(+#OhgLXwQ%Y2DbDU|||b%D5Ef^T!YK7@7j{W^)tkZelWC8_D(6 z>E0I~pRD5WAE^azyfLNPjBa~+6`!?s?f_;$Q@pgHGu3)Lgx6$A*yuXSP6jL3Ih(f= zI3L?g4z#g9zr7ViTqI$=dynnbJwWfah8l)hFO+dE+;-Hw8hw-^u2(yr)O=FE=FGJ0 zYaAS9inG;M=%anCK$oer3?CEr(Gy06`!;CIV9s{RON^s^Xmq1?ow7eb6z=s3&sJqlx^D^dMa zu6$}_pPmCJTX4~d>vGiiBjcEz0?`ql_LR{j-vOgxm%OEV+3Cgq|Mp5oUE>!v_ZFyQ>MGw6~i@p2`=-j%|ssD7O0<1kw3%|98i z!Dwth^0#S*)wW~Ol;9Sb znlGli!kV>_-=fRuRwmnJdZpbTX@6G#jbfv>b_<+2z6syT{M&j*MVVX1L3zEu*v=nP zhqvCQY;!HoE|3s4mHj+W9@%ScmlFQ!&9K{uc*ca7?S)G3lFY60C-+)gI!t!&zan|a z&{xk@gw*C`Jhg9%$xbq~QLJsZPfvTstmcX#9sKpSUJ5r+6L(8lUQ*DsVBbPJ5uP42 zHwS(_7SH6xMXWrpC}%0m`T<*ceMfD2yzWL^cx4sdFtsn!-3~f_&k~_+uCE{3M0fQ( zLLFdLaMgg^%|EQr>c?;leYKy}DQq>a#MI4%3ipj9{_>gKQtdyntT{QNvMDek0y8!| zy!4tuy?+t&U5#!pSWD7)fBUgu{vEn3Z>(Zeet=M6dU`+?zU$>J9!{=AJ-eIQ?wG%+ zm<^?gGcQp1!{{?GQS_uTQAxj#kg z{flFhHG4tTzb~P#_XMo@FRuN~q?=jXGlAV=BKouvQAT*Kmu6w{om(!0;ykN({$nG|N4B0i37SVBNY zws$x#!3&7xC@8y<*s;&I3l9~81Q8geF5&uUf;V>t`1p+| zu2NuLvHo9`pC)zj=be^($wyq$PPH~uWy6cFtlJA+L7WQt0q>U!Cp0) za9|V79FP7sa;G)F=9`(Y^iN~8zI`gwd|>AkFRA1ixb5pe+9jmB;Npb)Ac=zCg2n?c zxMvaM`oR7`{r>fy+;^h6m6>qJ#Q)Bt_Y-lZJ^Q)5G@U22t^oV0Y{$^t`rBa+&lHy3 zM3dcf)zFN1vpLwm|7y=*pqSxD*q-?_R4jngX5W#_i9|H^=i7@Fa-nh&Q>;QpvZ4J* z(6=rV`zmj$o3qFpmi&iBV*stsC-#OSh(0Jahfp~`o&ni6Y z?@=IzGKVrMcTsW;Ol30tjK}2Nv8gR^l`dfq5h8b<)@QydJXPnow%YP%7OPw>r5t>{G36~sYq^{Yz97h6@cB8rG8c{+! z?>LGJB3!4-fwDIS$W2Lu4E<78QXV3$>P_-wUB`LWg+s>~W;ms%`^8+fVjoCII6NMU zPFxyVf0!OKU;Hg;6}3firP+Jj4Qc_ub% z&y>J#;_(@El1Iu>U-wpc1yLpVA@fiMi9HTTBvmi%{!!Eb4a~$CaJ<4z-ZM35SB^e6 z^7%*u;XeXmuUdK%2;Ugw?7?r)dnwAs+~7sf0}14ZA@A21O?^5t28kqOQU3-H1S%=6 zsAW$vvC>xZU1AM>kg5z|%n?s~sHs6mRXCLVhQhNW%OwVq|4~H+2Z;#c;$rf$y1Eh^ zn6w5aPS}eiPWo0v@^HbY-cJ(UrhebvsvB_x8-f!70~ION=29d&_9-K*D3DuF!|Id4 zYt5)==qu^1!RsI8cRhzgG%4%N8qJyy8xETeQHo$g)lS447`0%vqqU;7Wwd0pC$uKC z&tW=o#VT-XM=-|{Nkqdz@h%&N0vZhCNmjO^mE61Lhvp~i-jCHFp_M~O^+eN+(^1lu zHHy(lBZ)z&a-2khK_Ot#sYlZ%G4ph4BA1itCPUjbIPMJjksNF9CU_>pQ{cY6u=GH= z{+vNW;v}?CMVdH>H=}d?fg^m7=zk{QJ&2WD&sW3oa5_7?6=pF+|4AhChiIw^rLkD5 z(KN4ja+ zEd2$s7KaQy6w0J{*AjLc=E8E}hR@?hkU3tX16sKKNbuET6{94-3GX}ycNRg8Av+|E z2iSo^zj6C)-CyW~SNor|7|X{S6#@w07r+3`*-PhooiOwZ@!_|IYnqZ(06s^g$I4!V z@q#z>Y7f$o!TP`%tk@;V@+VQWJW+~)*2bTU=RMx*GZY#?|L=Tz2|bK|=u*H@gjn=Tuym32z99@8w^>A+3!*P^Tb>_*uNrVn_H< zKh0qp8UPFx7;vk2wW0?dibB*``JKi=CVjv3kS0JT7_}r7zcQI;qe5BlgIRruT-sWB zJ{6Hd^i0_$6{Izewdio%cWusO9kPtZPb-?4ga-Xccza#8t)$(hf%ICd2BCjh2;VAE zvoopv)jmh*9e3Gu;gLdDMN-yvK{No4eag$oCeEV^D=pzpVrNpQ(=O{;ePB|koq6^p zFP=`dPNbAlJ!@fQj}WA*$*Ntg5M7UWR|1x>&RtJA+vEq~Pyrz10UF&@2`Hr9wfxd> zX{7A^+cnJC5e55Ty`z}*XXpSrZ$9tBXHFa4`qFOpk;YN!Np}8+obj6ViDut|7N}ss z?L*5;LLd!@Ot5(Y^f@I27ZBzz*_rCtHXsZPWgi_D8XXO#@r@RhFNqu|#a(zG9m@$0 zJx39Z{g*bJ-VM`L!>>bim=Sx;wMn)Bd;bkiI6lml1^X-%n}0o)F9`C0CBSMz>8SCi z)@5%PwtY?iSS8}Ofsjye0PgHUO6jw$-skxpHjo4x7+3JRLEfTN1cAcfXB9jIt;537 zq5@v)qB5m2wgj@(+zoamx#6GmR~$p2 z;Y#jipbIy;dj7t+#Lx?NzNYEre80i%K%cu643Gy5bOs8+aIhoc#loXj%TqiIrTXeQ z#K9Nr-qWEun7aG}4M(Gw>I0st4L{E{``(u7SEGMGMvu_UagpMlh#oju<-jv&Ss9DK zAuxkV^h0BVkQx2sUB^rnE#^cK!x{q{LmPujC$lu8z2BSnw;7+LOTwN& zv}`cM9!}zG1pqR}AyagV&C?{y`a8DgH2>UcTeWV5HF%IA$B&skIJ(5~toua;d;)&G z%mZ51(vV)if)Y=wvm+5?JdVfWS&9w|I-X=+3BDEwQ2Aq<4Efe95ImEKLzcPa%n6dy z=pK5Fw?i#b@CQ7nQb6!|TL>yjhf3lI)NLx)! z{Ay{_DUsV_i+cpxnhO2aaJ>J%j7+VC(b;3n@7ID^NDqTM=b1SYTZz*$m3Or0S12d*V8Q_-!j%Lus`g8r2>L z9Zj9?cW=jz9luV$zsIATO1zn|Bd^ z;|N7Z9>z(|OxCj>4lHlXmvx&^zWKlX{PE9~?V=#k`yvaAe$y`O-1y{Rf|Yf?dXe0( z|FdLxI%C-JwxLQ^2d59-TlGkErdF~p+Sjov9$Xo#1^Wim8eBH3<^8LReApej?HO~| z*3m$q@Xd-4NE_BmdIPtY(?L@V#DmR=a!6(y+&1q+)tm~%h+HP~U_2TW9Y>A!e1Uih z513o{Bg&YRQw*di2{9Kch^Q$GHD6K?nJNuAPl~XqD>r?40dlKZ$IV~@5t?4sZI)yg zURNMuyP{U<{*laCW=4Yg=03)vRNT?(rQeN4D(Rz(d1>2y5$m?MvQeW9tJ9Y*A(sK% z#krVClp5tqm3-Heg?f2iI)8x1_jk3;Y@#ulgtC1>{bjqO{aTypN}j(64wg!AH9z}v zh#$+aH6pP*%p0`4OJ~q>(77Wa<^F zO$in(Teh!UZ2C#%g*wX*(YO~ zVCCmIyRkV}mq2vwQn%UC^C((Tii)Ry!DeF-(c39=brjv*{*J_uBsKtwNhiZZ{e%CM z`r&fg_9GRc{m9ht@j^wjE$&YUaq?3Pwf0v8nRbkZ)aetgg{SkyGeIO|6(jQ5Pg_9o zNsZd#jV9BXLLvnt&8Xzu+kG3ON00K23#9O3>hZjaZ}=sL$wS=!Fj@C-Tte%c-P zRb2$3H!Lfq0L&IjmCyrgE~?zI>sfapMnWMl%fhC zk?uU5tJ_jsXAz z*e9D7h$q}Z`${B2S_B)oiH!AmG~S0U9US&n3huC=)#tJ{s*-ZbA4`IgQyedEqM;j2 z3!3Qrm_Stom1JpiBDCZB_RW+hjR%k7GB^b))z%6&PVR@exu=5x$Ufqd2}+TPWlT2F z(a^)P08au#^=%S*b#R0=I-6{#+QkDv-PB7!mRem=PxI6^4#^osMW8c+Y^A)Nlt)je ztp&+XX7Pu}8~4h%JYOCMGy4vRn!`8asKaW}WNth%V{V+~By~RxT3`9XUC2|@?iej$ zr%0nDazs!INfXBhdMrUcs5E%CQhl#BC9Os$Lu1-b%Ft0Wy-Gh{RrghBthE{?ieCn% z-Tdj*l#KgbIt3t`(D&z9R<=u{k}j_Ok&AC7*LB)j*O=qC)v_1%7P1*@dA!biknyxf zzls#;O4gYItCtZ^M@7Fq1)0(ne-U6{qN)&-eQ>~?tJ?!r>?kX>En;dmYO8;y@vCYe zwrwyQ&4j6T;fY3cxEMIsH>Uz8-V6>M`=P3!3EY$(1Ci{2tKXe`R<*R$M9Mey%TiP_eSOzr>cF{z}DaUbTc8EwSgGR)H>6`mk%qBq3N# zygNP}U0#JGb&seN%jD1p*j#pS%HP~q<`ji}IFLe*l6J|t@uiL`O^~aJGO&xV!G{oz zARgkSB7ZcX|9b&GEbFu!+{Es|RS`bOo*kw%zbO(Q8I0v*Is2&_gnWaOnlH>VJ!9nT zK|qLHi1U?yyh4^DBzGolEcdjCiX2uOXGr?MkZG4-+7{AF@k`dfOB6c&}I5YMNO z2(o~_b1Gw)uZnPnlmC;`mG1k*DN6#2klN7Ss2l4Lnltlf??0(WQG zzMfVKE)D`*Ys(7)BLQa?y(DTkz#SsNZCo_i zS|#}@>u9H{vpS&Q(KwoJE^=G*w>@f6@_pmp2nXL35`Qt{kQvBmK`RG8%qTUI`G?gr^R68ibb2w+Y zEM!U#!jKcNrmd)St%%Z@I_h#YI||&<#%s8KE+)Uybl7^nI%K?l?Rjp3avH6cXr-@9 z>I?d&YW>|azu`Zhi$2hmwpjJtgsY2F(+gd(kgT$sQr14DwaA0p`8<8)CgA6HFWY4&=N@Q^2m%RN=7|;d1JK!m@w*PH;V|g8M zjS%T#E`^Eyad*nN1J+>)_Av0hwFU6JilB^ZrS#I8ba9|kvffbpL42zVt^tuZ@+1rjl z(pJfy!{>&O@yz3`ty?$-%LH4?M7YxMY0x`jld=j=p+NVSA z1J3VFQ-6)zJ=qS}X61Bgx14`NX0Zw+YDQeO|6%KaGxF{>W<5d3qQ# zYhOa+GbS)0M-PW#QYK9t>OgTUjf(Sa#uuq(IPh;VP3#Pwz@8B0sf8f%JAIM121$%;aLstIl~6VsOSW##>bhT3cL%$)e<#=JeC1c~38B@) zP%(RH#cHy~w9}m82Y%8tma#(&%YZ7AKv7Z+(aK-Q!IF@nZ*C~F(fnXvsnR7R)|&^u z#;wjHb;1$TwbLFLYF+vhptJ1Ki3FmrOCK0u^$Z^_t_17R|Jma()Yd|TkJbDU( z^06+~EHsTIaBw#e*xnxDAnP zFJ*+Dq9qJdy*Wo%vqvw(J!<~$64pcKoMubul&fgOPP=4q(;E<6~Xge+?#D;_C z`|eQF3N5K$FmP}M`>zbMatT24kjLTnF)QjKci^oOHM zW`f}7q)~$>5w|qbk1kb6^nm#3)TY>!U+;G1qssw zgXD+#%iszI4uBO>1i=F!25o{;&2sGmIR-GT_?$!ugP^hi-9SE2qTAvqV!$B`IDUj8pc1xEI1L`Oy*1kto1&TKH| z+5RFfR(tpHc8y_0cbZiN-gp)&iHr1DMkLf_M(Q= zqz<0PK&K9IX$Xg{*yR0G6L!zH|Ka5PvZ=zb7|gIZgb|CV7iLRG??m7b#J?G;Tm2D^ zJqtd%%drUpWfhJIPoPt1Uvt4$2xbcZy<^K)_!Y?*i?djA&GI}4=8rokDF}Zl5-f}j zBx8mr^KHym#>Ufr=f}>^Mw9RNzYT%9g|8J~@Wh7ms>_ySD$rejM%_N2A?OKt!Iu3C zxHv_(>rc@3`^>+E`vmRVLmI*T8WeoQvje5e>Bj}f)~z9tIcW$-oe4PkVL%PqIC63* zd14&4>H=B72isQ7qluOpG4SlhV~O>5W5f9iwBQ4}t#95j>t9NE#Fw`2wmoR603g{F z7T^iW%^+`y@H~bC&RyIOCNvFRN(kwKd9n$L1j=3S8W1cQtQU#-+>$dXbO)X|Id}hV zo}ee3NC(S@ayT(t%3q6cYnCV(QXa&Iz=v_DuY?5Q-Tth;(F63GRdp2*Q(6IgW%fcG zqe7p7cm+O)2cHD{ao=JDV+9qn+>$!6gL$!VWv#uS&VAhWpyNi&U|G@pHIUd-0KcZa zb#c<$UE1vc%OAL7A1CX9G~qctgD{Bk@klgC@Nr1o4)SqM+>Z3|Puvdm@l4!?Z{!TQ zA-lzNdX}Jf1p6*#+(`GCOVHYeaY!0)-rd>_rU*s?z6vsbBl$%p7xNM9R)vQUYfBXM zfk6TsDRD`aj=Uh(Iw-&59Ld2Xi^*o2?qXTy<@Lb_wnNIhZ{>j@P)48~FrSbd>awPK z(sg(KEP0u2&L~sH{UpkAkG~fJZL#4=`az;X;G(2=?^6t0^+>PNpcc|R%k)()?JvMh z1%`RFe?3RigT9qLsiOs~kSj$x{kS0|M};JV%T#WcltO!#oP2B4y6$5SqYZO`sM07O zkkJEBD1b|&qB&LD6vo!GiVPZQAn_j2Kl5v%Yq=c@Rn{(I_$>&b0FruoB!uh0>6lbj zRRtQkLpMIVN2u3gf!2;PdIuO7m3q=?$c16JW%zJW5d)Z2A7vD%Vz{PA!JH)h!GA-! zMXi~(QC@njIbm60yV{C;F3jV)*9fRU4_O(}JnOs{M}E*dg$cbnR7Re+wjliu!#kY3 z#)j!~2c=d5nA3pz(cmZ9A$%b7)_=jp8w`#o;|qizF#rrs9yrGT?%!HU1_9ua@%Z=l z;aG&Ut@g%7q*7C9EQhSHj!ej~gly$bL{tARDW#+Kdo$0+rcwOVudy+KHIb%+kyda| zk1FMmXS;j`heQ`*usHo902Z^S~FVCiROzcguW8Pkq?#p z*DTo*J$9i>4^AC1Hu8BvF*L-Fqx8Kq#&d>#T_3aOl%D+p5BV>6N9B(lQ*&5%xm&DE zzk)qe2mZLPggLW>_Ve$0@`;ZKCf>pVP(z)?)9>c-jD%5iY#VU7yv+LBo5A%1=g@X) zI{Nq+S|15wr5~Rg1Dlc7^z9*V{cazlUbkXsy0(9!NSyZUl_Xnzewmg;kCW~S88R^5 z;_Jmc^}|Tj^rr)nP*)8v=GFAp5qXffuHH}af&^zaAq15Rdnp^@3p<5P?j=gw_xc%< z_D`m`e2LF%5t0F^$5X14T?NY>PwO^@E~H5f2ThPpn8a?O#=89=MH83+p~^Y8mGp~C zjUEQ&q?Yf+yq5|VB##aUkIMOur_>4y_2906b!=bKc>L6#1%iII#WE>ggM(O4Q!+HM zTs#qyHu(lC>9n}s8)*8Be!HeTB?s_idPWgc{M4vngQ<-Qw8v7mvLkqAP?gp~ zK6r1i0UE!Xmh-B`UM}0k!6C3je+cKP_bVC!Q7(S@5VW#|skzSzXPwJ9E=00BUwDZ_ zZ33SwoS1@hUgRQ;dqjO=&G^QMWepM49Eo<*^ykLVjJ)S&+cUHM3;zNIP|ZD*Xc9Z$ zm?4c*?M_v@E+Sh$Fb>aAn7jOFdV;)E zN7KkUzM&mdjaEHKBw(ok6g1SoMh|)gQ^ponjPQ)13ca|TkF+wyeRQ<;Q;T%ZfwfH; zocm0>6{xa#8zg;Fuk^5B8R#^q{sjEkamN3%OF@|V(EXSv8Q0s68ofE!aN$8GPD^p^ z%TM3lH&hc&OEo;zEHoHe6fHUUeO-m2HS8ow z^@VKnzED{@&2EP#_#q$^HNb*LHJeQOWRPJ@c~@WO&pTm1yo%lcaYv|j+FR2$*%;p@5!czY7HL3PcYuLt7*RZ_aXO%%C@LcI6FZZoNgEx z%hY%77!C_>d+UQkq*nBUG#<=4HtSPa*s(YBukV`> z)8h!=&gjTz@3Uk&wAu5N@+)X_tN?1|yDaj!)%aBPKka5E=Pqhd@_fa)>4pp>Ky9tV z*fmAGA_w7VBcWAVY&*m>#jQ$KWCfHzYt&xqZ|krmj}L0p*myfqWh4s6;yAF>+CaN8 z(l#92c-{+yHK_6;N~FgU8g3kd*J0W4az*PUZPvU+?O>6ea}vNMTsyrWlrbpLV_u&` zX9`(Jc4@;a%5aAWO*!~p_*XjS1*qaL5iPX}f@SGZlXiw8Q5D4jv~=w_P*HJt)7vC< zzQ(0(i3?`|uy^!vP5qovj8*B*Pi-TyrPxXiu%a+OKXo)sxeq9w>L&i&Yf$tZ-D(Hv>#K#OsV(Hi9D-K#OzMa$ku4G z-~JJE8YQnUE@NMhd7)SKMXf6s3}kWe^e0_iHX)m0y3^WhotO7{kcV`%%H|xM{qmQd zy;+=ndW;0``>hRC_v}-(d6IMD!wAL00B*Um^P<3diM}&)nOTkf$aI zb~M0mHAf7W|*g_FB%!>hKbWXK}tP)ns8WiL(6N}QCcQ)nC24pkp|J*uE zNP#<|;9bOr9#Erlexv!JqA|9bKzH)!XI-A66&14GnCpg}dwGH1vZLHpzp46P3W5>F z9#pm6%2%(@P+TI)nA1`V(u;*O`X4Ct%Rt$2B> zjKo9VVSAq$2Nnw<&Sao`#Fx>nhYNznrw<)Ygj%>iA+q)W;JJt8+xmpW`jtX6A2;ce z_iYlrtYgmRiJaq?Hn!sFtxYy$hj_%&b-Y-Dc<;7RdqrL@I`g1f@5!t$iJkf~F{Q(1 z0Nxe<6MR)XZL&B`P8QMeNqBlpB&{fvbYwya+a0!3={bcAR+TbVlU1IIp=&PJR0hdT zCOzbZnkH9w<`sPTpV;I-RT@ZSrjgxRrEG1825QxRDT4>;{U%Mlw%w+xAkQr%0O?Ap zp`8LE>`(fws*8h+?`@R>>B~DW#Zi$woCnP6Sl<|+(emsZ)kt$%G19jKaHG^yPqrB1 z+Yhl?fq{mU{st}~n`{IorvZ-8xwIgoat+>b+V7AY!M?SnU)ByWu~`xb9r~Hgu)AW2 z41!>L0u0rO?mdDMx2iRo#2#lMplv`G@6D^{s z_cOe9Z%lEF7b8`{`qBGCl456SwfhK)9B4Q-TzY^dw@m%W28LpA4V;LF93OF9g}cJI zuny7CgAHegTFk-)9^7(c|uVSy`Sq}WBH}} zMc!@ZkNF&tMI{&8<)t~D@-6hh)hPhUJqU-ikG`UC+i@%-2_(xU4RW9xadq7;xCH|13}GB ze0ZN*0Sf@Cb*=lKmZf=13ekb7b2)@fYu-of;%AjOy<^uT*DnTCZQ5kxp)3zhWj4 z+FzjVgpQog)~#nGd{i#RXgZ>Y5qt#Q+oitaI!{uLGU{}t7{u{)xxOl})si+`@XU3V z7sA?B6+#V7+6ALOHVo_YAIJcnFAN&1eDGhzV+T9_fSvWsje?uR z+G~B4#nC-bEDUq^CtV3mN3+>WNe$GAy^6Lu@+p^%TfajtAI(bW<5UN*n}f7=bG@qj z0@^dB_mCAtt{#%9j%k6?9mj>JHrGC{3=WrcB+R}+S(!hUgp3S&(*j9!RVzWa#Msf% zJmHpo?psS;<(q8i_OfexTnl{Xbe4vXYzgo|rPy2zVPx5rVZ$s})Lb57Yv|iHvCcMa zwpx0kP!garZ80I+GA>=zOADW*FQ(185JGO{CbDjE!@Vy&!In`LoXpqR_TcDUy?6F& z=L32gha3IFo*wgO$~b;cdT*gO3LJ>H9?fQN%dd@W+I9tJyj`a^qeS}X99Gvd zp#|mqSb2BWrEg$7l}&XdCsP%69Fn-K4G5bgw&TIb&^yja6c#aJC3mauTXY=J4gih- zDgi(nc4-Xn!z|mO%`TN@z^_m>BQ$<>oVx#Y97teK<2*Ab42ij5aq)|MEN+=gW91Jw ztiXmI^E~G0M+yjgPCieLlDBN4Y-&;9kCKb+F>f_S2g9ZtSBUc2L3lW!*=-Mcba}Gd zn**UnhFl+GZZb+s_&CpzA^J@Ixg=I0Cd8-m!ZmYF9AsKMYX`W&<>rB#XGBjQI9|=Y z;_g4oes2D3hmF1UoC7#aFCXH8Jm)6O*z~#k@zLI^r^!zCsv9;7qO1wTmQl({BZC!R z9W;y^I=ThTD{P6JZ`cr-Ni2TwQ{rJ(o+J$`LhEVcp-atQ0QB{<=b~?iZTasF}wPK9pr*pe2{rB`JkLR z^>~sOLU3@@$jv0OnO9{_EE~w>$WeE}@*1nR&0rKsTE1Ik?R#iSb#%^z<`_(AF?0Qx z3+Hr|FD<*}t)Tu@^sG-dUt^%hUb9h6HR`dz)zxuRjDmAfxfHle~n+W+LQFeAh{mFgP8>S1sEe`8etF0hbRJe=^UF74k zGUkQ~-uQX5_j2O|pzPRDIzB8p*#-~C;5kjzV}38k0F;lxPT5N(+*>QiVb>=2`E7;)QNYKzPsGvi`?1(c)b z^ex8+l{q0{E!fA%j60BP|8uj!gSEN2nZ1vhpU^2TSWcj#?4BkEOw*E!Ue6rU(ZQLD z1|*8T zt&G!{OSFf%@sPq}ZamI6E6i3K&{t-~x-_n-7Ukv`_+NWFPI~Me4|IR3TH=uSKV$`T zH|E~lOZ-lal0bBrw%8wHg2H#3Y$OoE>sqzXptS!IAS`2H6j5pt$T}`2M?NzOQH5MZ zK3RcQpc(PXm-~e3L7QKBeL%9hGGbF;&HcTJq3u@^-s9;Qxga;yU7cxNi6oKA=8yo| z4YGM>#$m1~ub!5p?Job=%oRpPMVIPTNanI@6ZJ475^xr>q7M zuJX2a-MP(i45K>=1SygAl=g^F4pZ<0NYXFmniiat{d{#*m9M7EA440r;DOSc6rR3M zejmA~=&#+w{LSK7N9&}*j>)D&M7&9yc=GHr3K`Ty3$K>6-Yi#}IhlMSh<+c^uF1?! zvj7Hid~%t1Bqf`f98G4wu0~>a?5WR^2Fe+e%ZKpY zBOAG?fwtqcL6`#9^D-_9;5{a@3n_b+ei5aE$8^{MxjZ~e^Q?UT0S}rB`CFIT`Ao=q zBLd!Kpq$}eJ?r`xq|j2u0sy#WID*EUBsTh>kUTP3JLyCBO2LpX%g2;mCX0|Osg>3% zz~dljCT9|JNJ4Q)M;y<`zrm-Mxc-VeRz-3D21~7@5MV>+KE*72@I{^_5s7u=kRw{c z91p$^+oQpz)|O2`^DANoT}XDv6`jcRrvs8ZV^FVnh7Th*fTa|2guNGkBb;|^EEw#g zYdjF`24H@mtD!XZc*(-LuO9i}DR*VULo)q+KmovXST`F$qR;U)2MtZnTxjD3=F@D7 zIGEwNi#-8HYVojq0(5tTK88SyP@vC+Lvr(hl!vY0z2bu#0T=5A`XsmE7Z{i@YLK}O zyBIhdYzm+k%W8hEW$;b-I{Z&y(R;=~K&WC%pQ$$AI-6-wq5m9R%{*AK<=*b*qY+E< zNrg3ma~G*BX`|5J&N*QJt=81!l5^KWnl|k_XWu=9yGb2F2|u&%&p`Xn=&4jZ^o2tT z%b)eVIPYzUhl_#K>ctF-a<@noIEPW!5{;(K&pm>OWkq{bW1^AM)`-q{_X<7GZVJtp zL1b2cmrg-J;CdToWp9uxp;GoG=dhpACN>*MCAeCCdxvH#&YS)BE34~>IEhy@(mpNn z^7W^IaGZEUa{cio;gHg#Zx3ub>NAofG|EOW!eFY?oGtw^xpDZx_Biz&7N;11cOvaC zDXIyC-3Xyx19?@dS-sT@QM*;|_7OjFO`z)#1Q0Y`w)sXM5($gT6%=xym>QU>GJ~@2 z9izD=vU9hg75ZqBvVAF^q~)q8SKXM|FO=|9HZ%7PUCRwpSGVWXPPRRqURWMtcKuN< z4}F)#J8v4;cm4E?N*VIOmP0BGwQmd~dB#nI*;GHk#s- zGOBKfMBJ`0RY3Eq;QhDh9e(XyIxh4hIn2t}dqJRmJ!(U@@*Gw&TAQT8@^jzCETc#x z2wNQv+V6x-%2}Fi?Ic_AW-8^H;_t~3LhbWPja@~yBlEyuIjPSjw5~>LyOmbVw(TQB z1--f^Al%sBJ|8L6RzzqSevbw$I9kQ$CI4O*HAjk>oJw?spfA&Md4lQC(K)GEVkDVU zyD1F=sF#JpCe+WmY;uuGAG@QFo-~X{=7mcPGGGT^49smTK5osWSG}lubTSmOJK-Gt ztH7=YV%9*wfjRHTQG|;$kOageE|vtyxxgs|XYn8Ky%Gc}lG*vkZYUtcJ>6u6%=1)n zeUW23X5i8<-bb(qBB$p z=tB;A@t2y*YUCziAH)a>&q)?+oi^Qd-d>r-9UmciD$Adg4s z(crM%drOUN=y6|78NmDwZx@k-J1wq5FFcpqL863ik86(>Oa}5(e-c>RRpdx{bKY8ls^34!$nuka`z-X|3D6-bLa%Ok`PM&K(0U8FddepR z!fcxm+fDgL@0hTJ;_|DF8nQGt-Yft};r%oFJ8K|9+yRAo1Bl&n3988-SkW^XP;gjU z+r8l!O1vHg@D)$rlVhp3qILPE&_%5V`2#W$USZ-O1`W}9820Lhi)tkAMJKx!=BJ3aqY z+AUB$J2fAh)<+CBmip>1Bn2`=RCE|y6hMp;GK^0>@J`P=$-Eq_eli4plQbFtg1G5u zDl0K4fSnZiZQV!QA~8NAnyZRb3yQ+_zi+85O$aGS$5!VAr(vo_^dsv~-#te<*Tdaw zwEZrBalCr;c*-(;IoWx*$zVha>Df3rKiZxa>r)Trbvnw!DT`;H&Ka`IvBJD4sMxK4*T}v(b|ZSSB^;JA0|wu@BbgH8@YhbUX6nB z_qaE3&^!i(BMMiCzTrp@BT%nCoPoLd9@WOBRJWmNrOoh7m9Rg$+i6#(4g(YaKv>7^ zrbORE9T%OXr}cJAORh_0c2lFS{JETJ+LRQKcan3)Rc_9=Z($4E;p+OU?}pXp-t`Lv zWBEo>4+6mM@e$C+FXpCJpF-{t(lhe2Q_B+2JJGv-_p$~uvwng9zHx&|`4s zh-x~!k_JfO#7ViW!SPE5I3%3rvKYVQU5N#0yG4DhptAMLfPBCb$Z)Dp`VSfE&qG4 zvPI=;a+Iwn#)l=RQmWHSdlElon0dIepfmLr`x(u(4saO}7W5NVmzXC)EuK<5Jg+f6 zTmp*B#?&NcU=C~kH`uI|w!LJ0;rG14!XZmNu; z_rZ~pf&%iBaZeDvFT;+(W7Cg$vLE}0+rI7e_MSb`;^&pVEz?s1Ae1Db2ee&J0!^W! z0*?nZKT>ikSBiBo#I6S>L#SX0WKH{Ym!9~Tm^iPSNn&RZH8=@6@D(3Y)pH(1h>JH5dY0`|2QBq8)mvLV zTTiDSSk>zYI&0Fy0?5h9ZY*32gcR(DFZL~!=KFr^R9WRa+UPyaNWBASI@T5-_BXR} zljE=AJoZ66bA3~kvFz`Pd3$Sn4d1*1Yl-5@1$tA(Px`l~JkaW^~M)S zQJf(QH82XbFlN8HPL&Q4dd~1oX6=jx=Rskk_XPtDcrcG8>nSMX;ewkM_GC~`usAR) z@63^B-`Ith0%pGhTr3j>wYhOJy}vuux39z&58Zn>I6}Y2s-}g*vuN}EWvTVPfxmCI zL)l5w^?C7eb!7!$_ubgspy9|zMcRATbTmx>?}Y*fAz6g`fQ~+v0}}JWS$N5_ZlEQH zd%^BMX{H7p_i3tf1V_V6Y8+6}x1VXdrenB*5~V1FXwt?824BL{MN71&3)}Ep5&ph%rNZ zczp)#@;X&Wx?(}K^sy5!feNzjY&gZOUHr{(f6HyUW~3K6+*dX8%y!zN@@oIz0Ga1x zmxsRSFS!w%aU$#bNDsav-MH_DZGXM5&&CnAOrzcxWT^zm=vDeAa;f}V!P?#)U@fePcEr6PNkdK zOx!t=pDHMXn4(i~E2FRC_fbu9oDgrP4eM}IQ?-H@w~4NJj4)&rV$KAak;l`j!pQ8^}RG_EqC(SD=YCj}T&gXo!^Ye#Hc zZ`WaH)qB0xyRD>by_`geJZ;(}24Mb3*8GIEbEp(%66gWtrIv-@z@?ciPUFBRC?h-2 z-@XSYlKBFV6Usi5gVsEYoI%E@W{@M2EiA`5SBjrvefaxI3h;Bbh%m|alt=Gr>prck z4Nv3B*pN#(cG2BEzwaRx^c*grVct3Eju1d>q8Q_y#^&4Rgj=n!p}f6#+YSp;#_oIG zT_*e{(!Q-d;r3bkT*gQ{LJL47sb#Xse!o~oNxQL=-|8+{P&<*Fro3INyAorc#o}7A zI>|55a!J4fqvNAe{c2KhFuBr7+oe+*Bfw{m=MpPt%EEz&PWP1LN@M^&IoJu^{?Run zpkywqtZ}mn4@eMLe+|hzAmHEkldddmV6LIgdFV@9iR8dYeRfbcVL+2^Cp)ySt>B5o)+o=nbqV_Gn zz=Ds;vE(8t=li&M$SiQbTpIa<_pRAbTlq|*3h8r}NpLq>AztzEQvv^}P_L`c4x@wD z>csYe=udR5gA6&)nk)H|dCG6hn_V_%;OTx93N1*7Ds0W)nHPMi`TV zHIm*&`7&QbiWJGOk*i2k3)L_%8u*^V+licK78;q`uB{p!EyWbYM0vT_k&~E(+@oSV z#eQ$R&b+gKk{y?rq7&GejyXKL?3+)_*9jWT%MK~bCgc@0KF}$AGMc8_p?XBI(AWdz5SWGy((#_PTkJ_RK1u}yI~HU z>`)Qb4V(Zf9v@45Q%kLNC|1-m+x|bz&uvx1q6P+?tfXc6P^ z;7S-HvD`u@&@!aVx(D1gPCI>n35aa2%E+k=bo6%(Ytrnze@dzWPO6CCwPJG)Clfs* zx;4)~F;I_{@o@;E7dVeS$!0!I{9XuL8Z%yTU2g^D%7%q_$QE=hNrMRy}7&K4c`9P znoxiFP}LwmoL#*0TawpWq(_!hjyIrj)Y3wYd7EL(>&3b%^_PR=NDWvo&z05_YwL@% zA5D=n#Py~NNH@hf5|{Tq=w6_x6t|25{7Wy@L%e1tq(nzE!n^FE5`5g6pn z0Tw4p6uj*;>Et$MoJSG}=FF3r2Hz9fZGxQ!Hju3hVK3)hF4bAA@i}BP$oT zKr|i`fp+3h+cCoI2sF#VH_Su1=WZRm1C#5O>A~i@Md1QSp}vU9K@BJH%#VoAd=^@A z^>XP)-RFaR6C5-WKmeUDm0&VHUh_F!dT$FE^E_tjqVJB*H$ui){y%#JBE(|7{kNLm zJye^2*K~8Ix&C6KVTV=mxi=nhutQEk=E1wKp7ptsY}vG~s*mLEm!dcYw2+%3qhd^| zQA8LX4hwL~;;)5W{lmUH5`h^=fa<*wIKcR^fET6x} zb!Nn~bQzcyX=G}hC*744H|u9m_=cMCQ0Hq#a>zHRjf20eLRqJ^Jk^=@)G8#emK2^c zSJn^V2u|{-^T^HdvPhN*-zcwg7h4*0oFkX*64z+^$ZgAG!Jg)&!rEYG%b;o>g=+JV zMT0B%K|!Ogdytq(0?I+Z*|)s?0uiI#nqJl}Re9g1ZA>QpGzURS{iU8zpAT)7! zZk4o(C4JYU!!*v;+%J({V_XBlAccuQLB=K8X;@hJ#Ca-I;gN!aj>t)**|!(8@9LED z$iHqOa)E~%k*|-WN>chij15sBhEA!56aazA@*AaD8^CL90~th; z8qFvVm!r$T9z50!&q@x0+*n#d8R%qRTr}s(my@oxxv*1F`pcqv=;UfmY1qq??C|@F zx)G_#ZwuWI6P&tH#<5ZWJv^=&WZ!u)wf!h6l?^;BH|EFvHKxL^oW~0;5y;w8?|7x5 zQsB7nFlnD(^8yp-k=^nlTkKbh4&c{JQE=#N@bi56jGGZ2r&mv3QvV9pQVz=|9b6#z z{GcC!uft^1TykcD2$RfqO%zZl$F(&b#}Aa-JAZCJ{qsJ0vD3S{=Rzv(S}n1LL~P8- zEQ?a`l0WcG;t)MLL$E3}#&ZddKPWUs?qxPx+L}Ncg^c>~8u)YE(@)1g-(Ql6T>qzb z{&YS1@IlI!FGn_qXkZ zej!4$@%I7keVrkapj=YG1pIY4SJEOO4%O$e5^|By4jFf4g+57$3XiriFs8bU`iALA zY5CX0oKa@c$b`9YT|nm#Z7%f*RXqW+?>rRR5TXCgwHFt;5lni-ywWVxPEmwT=Z z&)hmXZ|ak>y~9EnOe27S_L7&$*j?BD`wowi@0Y8~ODhYNrOTM!dfNU{*NJQAYv)e| zxP6H>Xasgf%G}piPoLBN3D$J{sEBG&^t2@u1as;C!7;loq~Qbu{4ENzIQrv!?4_#Q zDw};f|Ltwm6fH+vE!nmhKF>0;M$1~99(7D6_BdkkDw3&q}c6c`&V2aNGE{U_`g^xl)Bc6ao6RPpvgQdqLyYO&R_kPiN=8)8NaTN z{&~`fBxKrBv8}mv16i0U{%8#lNhS-Nel>T^X-?dQ(07k%rj^;jW`L;GB&5FEuAC2s zu)EOTjo7C7$#{s-x`ARp$pm%_vgI5P?`4ZY#*Y;HV}kg%Xjo&AZudTPSY2E5(24qB z2u=hW>|3x}Jxd($Cs)+1x6krRtIe2A28(f$@*h42uQAvASv4-zl z;vbF50O{U)i-HY}^EVFc-W9&=k`Z1(-pDgZ}O3=3tkp_F|VforRka||rcI9MJ^G%s8Av)A~@UCdgFn>E}|SI+1R zv}z#!#2Ol|)zHTBVXS^5iz1PF3W4(D!zEay^3opwK+M|3thCxhxTSO@dApC620ya! zI;z9#Db0@#DhcQs6XrT2nDtIoM`j4hv>9HY?K;E)CCA zsIV#2Z}mr1+`b}DwkUk&AxG1I z&;vOGTg|3ile08gQ$215wq`M-v-YGU6&-TaB_E$@MLj89YQ7o=s-p2|6HawubzWwo z#=qr*T}RiGWN(tStNyyTn>_=*#l@E0nv@FH^dz9X82t)HNQY!#~aPSiT zIMOnkX@@_Ty&=a~V1In0rnn3AhzR&~d9>nxsQ=y)$q`|_NZXehlVfyM$em>gy88IU zhBEk`M)uq4Pd^JOPp`84t7;8b{aPT<5nsRn`C7&IOzwU#EC^$zDrpvk24&ardnN}c zyUEJUS753r8+CNcy*?(>lNMN}zj@rAHr_1P0)7RJ&@GynLEuvcBX(^Ard_5)563}Gl)tI3^`94cDk3(r!Md?vBuOmEpg7jF z>qpsitfNJw=LWU5P5&jyFAse;CAI|YH!w>Ds8tB3P(w-WiB%~!uC*DhmU+jx+af9O zgB@n3JSEEFTwE?~X*%n7Tz39qat!Lu0wGZc} zZ`n2CDpQXwF;gm5)3Ie%P32s4QDDHc(|IqMpuk598p&%^n zQi$VD?5d!|FU|`9+G>?^@qaf`TYZXGbT}Ac`9*HMlVc9_7IavQ`>2;ZdE?EeVhdy) zMvokEG2X?wH-R1S{f@I{?iac>X3lB>5Zh(^fk#?lJGen2#VWsP{W{#Sm3zYwrkWdh zS}pQ_Lt)gY9DNvLoc-qaY4fn?*@YL98rk|@gg!NmuwCE5qhrgx?Q6SKbu{oT)OL|0 zV!|^ZIcpmiInLi?iy>9}#^(<;>k@XU3)Ld}d-^K5nUcIiA54+}M^C^Fm!3OmglAgV zdm;5ODm+43kQ!It*Aro9k*N`;?naIcvt)1X@Mh2Yn8l)AK3dx+^Iv#d+B~N5KEqOSsPg+NV4qa}jOekors?N?c%05noA<_wZ-WWCw`)wLl-8ATQ_ayxZu75rhNx}9)3FPi22Rg_C5kR+(Zlw ztjo;;M`$E@S!pgw&Htv~hRl+%y`UQ-SHletI zomrJj*of)v{O}P^Hybpw#lW++GP@Eplebfm%>Ut}yRavM9EqtdtUyMeG>LB~r9Ov3 zODHMTsRKkWOk&3D65M~Ab)*$VC_MeSA=`XzRL+O)H?g=a2vx5GCi0^N`&@z}=xr;VpuHzD8C zu+1RGSbAe~@6*y(U_*seZ_A@oz0F?@;%}=UGz*tDz8TEzHs5G~y-sxi4PrAAxXUE;mui8Y#=-XIrTps=*(+X&3xK ze4658?qBP_G~kelw!r(}(`z?*kNK9pt>aaLY;Bq1;7Nr|z6@a%u3-@aB>LliNnkP*yj18oZifMhfXkGT`+$4uVzam z`jQCm#SZY`mnl{8^h>V1f#0a!r4tX036uUz^m^%fP7YZJ>ghoUk@E_lng zdET_kLc1MwO90f~{tRniyUd$w;41hMz6N{=%RP$kzE{B!VY(SH3(Q7V2tW0#yv?DS?^sYSZiCoO9uiUAO0_bDj9wmtmk^G8pwK>Rz zQ^x$qP$!YOO)u1Jx=W90=R%@;}_!U*|zVe+PJRC$%!`VFVwTr6t}%lJWDQ| z1=Qc;TR z;c1_$1oP{lSYdO9Z1=YVo^u{Hf<&6}srAdfbu7mN~~3Zt2_!oQaYtnrv-= zcHKs60TX=#)54)a;`;$>xiz`*MNP`@!6rwb0_fIq45yn*d&C^5yw7~u5M=>!cA!V} zt3gjiM?Z(Qz&j=ystd;Sh62|fdqsNL^D2EYKqG1Ko_)#*KU|RqKJMMY{Jod`8!GV? zHYe0RkgWgrF*v0P?blv$6c-5z-W$EY@W1$ZeOeYd;Zm$S|GYU+S}-DpV#hsu6Y zN;FWfLn@;ZO*6=+%Dj^q=1V(2tu*|_Np{m)O2O2uWVHsM%AeA&gjxlk?Lv3Sct2%w z6I7Ss-}*`WFVvasrBmmccHlebT1qGVpY;YkT>>57M<Qp?Igw>L^Ju5cNYZbd_$ z9LIMaDv573Xc~uQN7kUz0kPs`>Jz}E>KJseAS#+#QUb0N^)e37$FLRN)yJN7ZX8y< zb9$~Y>Y<{cNMgHC2p_9QFDWKAH&FMvTqsRMi*)i}eI*M4dN!8p9H$TmIaZu$MDo$$ zc}V9qtTG_By4Wo*wiI+hDN8A%u(lNEQBMXHDpA9sPN%>%?W1|f9z(MTi-_jvP% zl7%HgvTIyB>l(q}ALn15aGtB~o8MG6#L_ecpJwQ;m`WDL;H$^N=XOt51CFOpwr6H< z#&u>Xe;OymtcSIwp%^!y^8a6d&>#I#15wCSgnqgP%88>9;FRvN>Jf)9$`Q@^9F7{h z2#!85J#YHd!lD_!*?drXc~cn1p9&G1}onye8-Vo1WvJ;AQCZeO(8g%;F|+3w8IvF zTaiY0bF-cW2j!%4xZxl42(k0(w?VzUp`jD51K?)U@WE}jkgpEyqMuw(?JXB8pJ)?X zn(t|@iviE^-Q7}2hqxPrMq)I~Nlb&%#82ER=h|oD(Tt$f{;)P*L}r$1)^t=3__e?D zBhbCf;X*1R=tU1U1a;bsD?kcIw9hUl`DsUIGW1`_H{gF3Oi86evHFxqe@zQglu=>) zCvLT~c7wEksC$&yaMTx(^GfdN#drWCICn&^X(LLIdFe|Dn-tyqvL|_7uF3#Ck@%T* za>1#d20a?SJI-9yvHYxc-K03NbA`Ed{Cs)mQs|Ap*Q_moVEqNL@&GIsKm{wUg|`}( zWta1_owXXs{%VsP!fAJkJM3?9xl}(_McME)J)%h*SU-WPye5bBbYi#FV~n2V#jv}_ zWW37Va5sLHAIAp7q`bJq3EP^oXSZ9V{8ExAd7Ag45QLt8IA~JAlO#&oeR@{( z$ERS6`|Ecbc?Ckj3Es6ktNaV}yG=X^!o*{|FGW_bHiYo?A6AGBXCF^LqzF@rc?XV5 z#l*RL0lZ1VU~J$-#mElm5o~;Pr^00#ZZ!&I!jonW&y(`hk-aKpYIPN;8r~aTgP95n z&TnF85}oCJv6Xe{&%5b9Y%DrJtLSg?u7(^#T>v89iA(;K77YApq3jA$JHg?AG>@o` zsjaEyHqU_u6gr#diB|oF7F2WU<-q;!3dYYNf%26ayz*_Q4xS_QFK}^4ieq$kHY8N= z{S9oJ28%$gVh^mRIbPSeG21|cm+Q>63e~ODO*FdrRLKRT7QkiMVL9JRMzlL@*GKz% zKZEi1f>=Sx)D~PhNkibfRS0f(o?Twho;8N@gqzb|wyilx{15RWo%7G`hgeo}TWeLz z_T#@Pe^y*uwR8$?grUc{o`}$I!PRaGEL)z+cTDd|_e!qaAJlCtJ4nDjI>V0K7?lav z8?MzZq7iP4JPI=ynGF&ec^siP(u?rj&)K6$HcWe%$MU8;Chq+IfS4t2)nr9(t}{;~ zv_D4FU#CPT(})#l#sM8QS?>~4I6dbVjDIewV7o~?%ziEcWS04{iR*mUiD$1kvIF8Q zjjSyVtnXjcNnnRXB(eepHb_eg18c9HPl>Fsh`p9J$WS9osMJ;b`aCQT8d-`3&PJik z?bKeUjc>bkFXJu~;s*!0Qg6r?xe}-zREg}&Mpyi-FhXOiPYXjQdwPrIf8%gGQGDIK zSs7_?MuO_Vb#bbo4A0zK21lvFP^a%bmJn<=M+NSA>YlJsppQqC{wY3)!HGQUN(*vdElb^qTGR z)qAFLZMNSBitwO=RpooV%R4)?p^-y_WE%qMquw?x`NR3?4$JHW(U~i+9a3_wrVtVc zCXLp#gfId0b^~;hnB2Uxmt0k1sL9f-Cd0B?H-_`G)M)7qHn1CVdeR}{omw9>cj3E% zcC3fM1NcWRorjnwCkwg@FwjLhxLJFfR8rEX3tr0!4v+VU`@lvxVh=4RWFfwRb>~4U zjt8sixa22~Pz>;TE{qtb+P24=?)O=_za+cPnPxN_ALU9Hbb|QTCfOz{ zIWBy#c1Y><+7dr--c)s41`{y#%5Or_A&OjmB)ILvilD`_~4Y4|3 z&vn0xgpqw19bq1%T4&rUEdG~xc&8%8QM_>PCulO4Kba!Zt1SF5n#7EMCG^fam&?); zv+3y;ALW$jR{+lLkjF9Z6~W)501&8?aHWYg&LS*(I7j13Cp1grg|(LOWw_u~ud9N4G`e&+or+R;S3fFimQUJ1q``S6iN@Odfr-%!B{ zbz=|UYw#Pf-nMvMeB}$s>5x29N;&wGKcSL2?fzsly#r@IC}BEfecc0YOh9JR-k$-w zV7Hkvd5D2N#+ufFi-0O1F10)yDttsIW}hK-Kd@&I*W&zwu4aVGRn2Le_aYpjRTImZ zQW}e7M`k1U3th+ae@`y_FZjulxfDzPNq(}NizK4TLtr13+G$wRs=Sz8a#gP zhcQa=Em-DZZxOZgXTOU*e^RW(i*OlMl?YWv->l*Un<~h*+x-|QB7l6$sx}8`^j+Fx zQ>(!1JWDHW=B7*8j(o6AXyMg{e26zVYlPfer;l0ZFT?@hKykqnYQ(O4f%Flwx$H2> zBkR+Xq;4>~@BMGm%*ZY~yQf#0cZ zGHV0V=0E|5_|GRA5CbV~&@d?JrPxK4U~&Zf8LJtbDceC{IRh=Ei13=?bclWsm_To{ zak~N+(QzEI*r*mvT7ynP@|0dWfrQ*GXCdBD&^v>!WvJqWZOwYF+dN5sf2w4gPgv!Lzv9`|fQe&JS?NPZ(GX&Od^lffT5` zaN{FcEkZo8pPy8lqM?b|j9HqM(eeIfXU1MDxuSExffbNcXsSjRMh$1$3D(AXsk_Bq zP)z79kKK#^2Y_ZTogsea!Qu7JxnPc6bpB&tYF0R@QC%zntTa9w0Y6HH)W-0OQ|kFy z+Rs5h&aXTBRRCt0+L?p*Od*NoU%ZBt-Mt`Wp@hFI5;^HwQk0nVZG99BTVvjJs-K0? z8-Z!B4X4J|v?w-7T(Cuy8={94ojoDJDU$A1AW(+yW#~5x4=*Pj3fK*&+u-j7l{-ph zNFqWVf3=EqhAsC`K6eB(v6`_qrt)IXSr?xF$hne4Cs)Q0PScoLpxoPfAhr2_2FY8qz}2PD3`oMG z%Wl`}c`8BX*xY;!?lbPj0&5;fE^Y z%TM~WzE}^k8J|Y!8F~RN;w&U~L2=xC!#Z&G7IX+)qWst;MMunrAvjZO-_BTQW(8Bj z8;!`DKCm(XJ3z$0SQQo!A6;me1&pbL7J}oH&N+cpOZm#!pf{(ES}q(Gx&QfxZsrVC zZ{3+lGyBv1ej9T(~|%(F+w&J#iw zfqZ>jnAdnvFSf=IVs^VgsgQZ~3i~-g9>`Zumo`PYuYTOyLRWnhHZxt!!9YwONb@gC2Y;!TUaEKz@!4T z6!6RlVgQqdSDMgB6{IRbAH%%U^DqG-tTtQ^o`xl+^^k(qb5irM0qIyhm^Mu4Is2!u zmlP-tNgo?Ap$yVFj3Ejh_h1&tOs3z~VD7O%MslPNjhPS>z)Fn7oTQJTFiWBDuegF9 zAA#n?^XvC`@+^!}!47j|Wy;ASS?kHT;5?oZt|CDF6;PUoi}wakv9L$FKVHH92kH;Q zwR#;kMG0*dr$_uU7Yel=basnlHh1-w7)Vw6)91$fp7An{BlH=&NJbqWB_gSCYNwzF zo&H;YL8ssmYors#T*E|bZsxPV1?)5AOSvU;nyWL040|}Q`G~Pke7x!F=cxgOF0#(o zY>W&-575GX(n8K1_JAvLctwhjULS;fAeI-VWlBSGRx)29*OFb|dbqzWhM#>h2l5ju z5lC(q7)l#{Efr8<{(Umn2`_Yi>*4k`HDb$_V_DqV{oYpE+^)hL;)%}v-N$oqUikcj z_FlxEB*{_Lw`(6fmYEb#5lc<ual)WJ{^Ojp`6Tr>X1RbddLdXHX9c8h)shoJHgv;1q) z*1>xG^@;73Ed9RhEP{FOZ5nk3)>SA()XCDV8DGh`Ralm*<-wpH*fG;jR*Az8+hz*q z(90Vo2E`*#uX0F5h|eUnO;***B84t@r1b`}i@kWaqi;)tJ(Lj+ZgnA`JC)pNo;pzm zvZ<4iNeq)hNW8#Vf0zA8kc32bo|vC>-nfB_gG}Ho=a%O25s{?oMU6yeT;99`VoE4{ z(1&`%%f-^af~KAxt%Fro@=BtyhUe#+qDP~m!CM#D&w2aMhPOZ15mSj5E&5~nk^MiH zK7%&8`~SRq4T>$ai~a2~(M(E;tt-ib`e98Cq)qevoszl>{M55Li#8(z%xQ2ku@#m^ z^`fv+vFMNhHwLw~*^@Pu8X(nu_Y8V@hh1tt1>mvk~-!tYl7{ zAh+Mn*HL~A;UxxrFb|YB1c?PaTyV*Fsd%UnQSM~l&?HbbMC8vPSSA{`-b|(2P1^XOAs$KwIMe$oZ3R)J}?iIVHC~9cM$I)fK!pT75 zfBXp(l`vVk2T#l27u@l&Q3|^VXmtMNDFUT?EMv?bWra?h6P&*i?)-Ed6AA0>n5(*c zv~43Zzx(d-!|O2@LUhp9p8){LbzQ)g;nue*DmkU({i+9U7)t(U*vIfylvSj$CG#S; zSeb>Cdw#(GR?c6wz6q-xC^r7&*#iF{>+y-D9{4p`;g@_mhVT8*e=^zHfBx~>l^EdH z#F5obsD%D&;;*T8-*E!^E4ao^)+#0FLYgC|WP0tz@VyUir>E!ql3V`{M}@Pxx?fJV zV--!$KWm1Yr8+N9ECeRC{T>tDp$@0zhd!<2FkzXk{)Ao1wHf8nLx0*fUEnH}W&X

Z=XtEWT1KTfsvcBh(5e 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8c082c8de090865264d37594e396c4d6c0099fe4 GIT binary patch literal 162876 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..464d206236bfe401e3d9f5f3849c68d4e347bbbc GIT binary patch literal 89732 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

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

imCl?nAg7CQ)=23ee*SK&-grQ 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⩔(V3B$#|xuSp(J4ReX@mv(MW~Ja0|$yoE4k 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..9a117be5a8582b097fc5f0d2a3eceb3a4e5fb34a GIT binary patch literal 11319 zcmb_?Wmr^S^zIA;Lk=h%(nxos#Lyt!NJvVzbj{G+D2PaxbeAA0(jna-4Wg95z%X<9 z-T&8n@27h{oW0I^-@W!)YwvxYeb#g0v^A9>c+_|R005$@qM!=^U_6Q#036U`Gf>7d z3;=)t+Uoj>kB_Ts>wo_DH8wu|*q)sHo?lpc6i!c1|409Sy#N0F|3d$dUH(5RVc|*t zH)|F)UV29M0l~2;>7R^Do&JaR$j|tY|0u^NWxex@cog0`_&U1>t7}^&rRGG%rh9sa zIk^V8dW2+t$X8J}Ut8adh)#WE2EY?s+(W#4!tI>{9u0bU!{7Nv2sF-n`$Ystr@yiG zw0irlqOyUjZkFfkwnFWAT|*mp^>oMA-m02r*{YH5o&k0HAl1rYfsPHO`YFIT`h|?n zEt@z4RSi=V6`wedE9=Pj7 zP}fmPPLH@?L`d5Xr{mz2mIYq(wwG_XuA!Z8U^GeI4{>?J#^%o2`c@5HYp8-D#_(Nh zTlbTa$@<3j&aOToO~;!QcMynRsFLUGSRuIJg8v#-xpoO&OXx(MFY5Ry986O2EK{ z^wU7sw*hv?$oJWW6d4_+mTnW1GeqS-r>15J>Q;Am_ZWk6o_i#_d4>-C7^|*rp-gM# zvGCvC{zaA2B&%eUo|$)abX-teNe?d!42hGFGr(`y_&K}qv~V;zHHR*|Brfs8^vqmY zMcwE83WkupzWyHs^=qNwN&EW;Ik~0Sok!1Ns~#s}ePfHuI)pm0fz2hB(=LqcV;{Fw zP;qI^!1obb2VeZgP5kDz$ea9imCY3?-uySUKI(~vsizM6ZTJSg@7rnE$d7t=s4|&)g^RFq7*E)lhT{8aU za`slXpFb6r|GBAQx;p;IFPU!(FFDyk=)^d+&iNumTi;KqtRQOiUJ6Zp+@2O*QYikq zbN!{M@z4M~zx?yLM>-tK!_Vq$77auKw zu^44Ul16s>Ig=bFZd?INpp~^`Px(7rtIy$tSaIyk)WGi*xUx@p{b!y)h~tKnbOZ!g zBWalAx}NYwW)N$(`kXpW-AnXLO$EpBa}^41k?;8US#v?}T)eqyG{%LUO**(F z+lm%mrGobiryyTP%SE*NTndXy^E!X65QGc&neX#?`@8D9Tvz@WC~Z2whkmb~PmtV0 zh;lyHv-x}V6C-@Ctl$g3L%G*T{$Knp0Ue$-vTq1R&ac&+Sv|x=V7zx(AB636_jWVy zD;qHJ8o*_X_BwkKS-h3cRqWpTB!esImTOt@Wd%oEO`a81X1o?|%3&DkD4%T8Yq#|Qtt@J_u7wa`kLtqzX|FP|t03vZiDwrUG=xfC?jA7gCZd-s5CRV;!* zVEpW@p2n!R@U|zE-T8~Wn#IG8gp>$ose9`W)t1@{$fA;?lkZXm-7e`bl(>%_jCnq( zdP6W=H%-j^e)GX*ekUxoC&#yC>i*~U=N;DAm<0~~a%xF`N|nl%yyNUInC0@gA}23G zKa50>W$P=ZQp49Xl(nCQVa+l8jxr9z#psl}v%>PP@YarSJlp;prp*0(rTvJJ*PTR` z+u58@q>N#MDWt%-cr{dDX$68-H-4=NnV=KBY80O;-CL#mHJtw{k+XQBLI!1n#TCbB zKyCf!M+}EcuR()I^^f=NozxINs}gnPn~=fOBY`=qY4h-yp7XAnLH_tljvR*u*=taM z8hiYhV=P<0b+`Eq>8$QLdP1Q#p$JoMLSeBIB6)l8!?0pUq+#BEu*JQy()%T4_4gTi zzOpEVtbM1yw6Icf@Al`dSGplx#~k?f95WxCavw^Dit8Ry>BVHe;2j^@_a+jd9dVBA z@oS#PsQ~DSP#_Q zo1$7)(*I&F@<{ANO6VAfZtY18z#*y3F!6VH&x>A6^i7xEx zff$!EIKgWh-}%Rvnatd3~DGpB7thOfymQFX)%8y8isLu&SO zod9^>CxR0eQRq+P-@ywRY{2K$xL5TE8<6b?x`#3PA9npWy|D+l58z0cAsnJ2g63*K zxHQ%5X#?@_RvIwVvVEfne|w# zLWz(kLTEn9g*0dWkHf4?GADPTDhqH}127Ib!i6^Ku`({7>MdbQsSs4fHsTcSxe(-g?at z$cVRbY;G$?><0XD4tl#M6C7vNea^~45;mcrwEDPEh!3Vh!t=jj1#>euei5hNUbJw5 z25+)W<$o&gdU!fmxU1g2pxOWozvuysc-%Pq_7F-wm_TiX4X=L7{Z3fMqzqfa#KEL|l8`!Ref`~9 z3`_vlo8pnB?(6P+IQe67b5wPYz<2q!>9mgGDKEXAYevSs2nVew>$L;Pxh-PBiImU% zTJf4lj@aa7I~M3kI9z)r@EnE8ih)f(j&597F3MRWpbZtF3h9vH3?xrelHAhVj01Gx zZQ;j!-uFFa=A;$>@Y39za?-)Mnq}HPtM=?@^}=O|*MhJKu7Ww!nO!KTQmzHKIvqPv z6~#KGj+!fwVZXY7GON zpv-s+I1?q}9Y~)Z;M+F^jf=&=7mYJoKHUyCo6EKb*7OHFt?d3<6FCq znRC9JQt3;CUeiK>g8s`3=Y0p^E#thw$>^arp2$!Ok-D5rK8i;xrmS*Vij7a$u8 z4V;8eH;?Cns`GdVrP$r}Jxpt3H5o-K9#us=xjcd>SAaQm}Il)EWMM)4ZFs`!to z@e9yqrlL9H{cb=N-qT0SOyF~mn=$564O`kCcrYo=72sy9Mc2jw(y>F1Dd8hd?!qSm z#hCo4`8)e(6XX^fpZ*6tHN7YY)_g{Q3QV7J&A`WOX4=zfcVBbGKhd#9CWyQ9z|8Tn zV1P9z=x@;!X-r>w#ia3DimJ!R*-bF;T%sGm>gnzPX>obFW*=&5pEnM=`%76eElvd2 zj;gjnphoTfE492J95d6H7l?Dd1-~(A;A%5tV-N7-YxbSWwgt>lOH=bFSe&mbd9gFzW zQHw=#mzl3XPe*{=WgT>H`<#6zRT}kUANgg5p~?iu9I?^=9VAe;<_H%ls*vS(XCR$?ExM)Q7U2G%STon|GTl@veGy;%H3& z=wYA8XS#=z4a7ck=uMw+Fmbs~eC4IFyiF`DOON(?t%K@Z>bJ<` z=lXe`j9^O(E7GF{4GmD8wU#FOdV!HXpwRRtw|x;W&0jaGe+G-Xe$hYw(PKQ7AE*d_ z?Vdq4Hujdf9>B9&@Aiu7OIFv8*^!)Zskqq9%eZsC4;PEwU{)VD#l~te%hQ17UL&u- zAvfJ}uOG{CitJi4*L5OW+qYR?CwH|Xm`QU%dUJqNr!|h|p)9??yWbCjjWKi&bMe0+ zjuj25J*}r=)|n_BG9QE~40BMJQDT9nV2|$#Y^>{De!Ny~Fk%};7otgR^3SHW;=@-H zbZ;3mLhlAAV*twX+8zF(X9MsuGfQ1E#BsLzUlpgSrq}XEd?6wu{r4+RxnPEues9ZZxV|m@B6P!Sd>y=`)UEv?lv%1%nW1Dx(1>04&*xkTJgmbN> z3j0|`&$owdZPXlLNtfF7U8vPGQoaQUL~VZH_7H?EYxGsn**4WQ$l4XhIDs&Bdxn;g zZ>O-lnN~GatFj)A55iDbng?2tJC${C>n5v(luNH28ZqS~4f|53>Yc!fHCgx(%q>rZ zpNTf1czhKkJ^zEjt`p{?+DYRD88&ehMgLZ4+e1}*>Sr(;Yh zgbB#2qz%6G5!*URb%Gj}jUYp4d^3paI<5#SxW>N7Kq!NjVW>9#Y)3@N5P#bS(B~z2 zj#F>jp+wk4PjVmWXGVk0_J4#GrrTBYknC=i+YA~>^hHR?_&skI=vAmF+fhuaLvO5S zKN0gg=uf-U7aA|*J*LuiM@sjpJ#eIK2icntJFj(*6mdA67{0vEe_8_E^#2CL1zzy^ zJ`~42FjQQZtgsTCgE5C?=Aoz)l?K@-(nh5d{-D0nyvMb87n3|?{5I#=L%wCoXAdc5HvgnX7O7VRF=>9gCGK)$A*0>B&RLTP20<1a>32N>b9yVdPUn$YU55v0K9@M5J*%UY_?;ZJcrs>_mi3;4_40?t zZv8=T-NMXn{hmWTkZzdXQ!~CpSznV$g1fO{(AKiFHYeo!Ogi zG5?s5dj}aFmrK4b%Nd5r3rhUy38RO6O1-3`bc17GEK)PTf?e?>>I^U?Yq&Zr$*KR- zDtBdZeuAkAc5DS?WdVJN62*E!2<8oy_mZuG5o>4RV|-zqWb8A5uNPVt9y`O=JOP(I zIUWHM>53%Z)&i`@3Z=e(N-2cIzbkF!q)X*8>DQwcV-RB@9}(^iL@{5x<@cD}W_bl1 zw0&1ypkZiI>3DDUblL3Jy|z0#GmUJx5vDaAmxz$t`DdTeyclQ^Z6sAhNR0LXw z@@`+C^MuK&WURZq>xuF2(@l#kd;@5W{lN2gZr}L1-s%E5Mc$hUkviQ=bd+JvpReD` z8mIf=&vW#s)CLeaIlrpyh#WP`XW(A#pwh5&+g+`+mTZ@Y@Az84! z4nvxhS%6%wK~A61ZOY6lW1OZ!PU(3^MfWNVu(PS6ccm^W>0+qpp>kNGP9-r~ubP|u zbpK^Or;Er9n0hdjs$I!j4PdSymz4Tr4Ax=3BMovxmzSK7tO5{w0tOSIHIYKBvO9ELDeu*SoFXBo}~H8v18xbO!qxN-s0hR$ufo^9;MT&46)t zBAJH<3w~;tqYVW zyewM78F}z(6soS%?mI7xacX@Dc{-VLGXrUBsLWOW1ewwk9d-V5(D01+m^;R7E{UW( ztRMn=m|IJjq=^`bb5F$>+LC~Hmb);GrD!A(wH|9vwfKE5wH3U78@~sT0tBOIq%2MvwzCw8XQI)e=&jq zZ&>LJ3#c-(Vta4D;nA_ub@DG0u+C=4CFA(*u@+Eq#l|cG3P{eikK_X~7ICu7&wo1t zG%NuAG1fv}YJ2?1$A*Q;OKRICSKl?DaXR~_MD?m8yi0s@caxu+yCIty?5WXq<4 z#3Jb9k}>?Fv$NwHTXD$U0(|%m*#uk+!{b_uO4>!jqfXhKL7p>^7bFxXIzMIm28e@W z@BQSEheRQ<3BenFvd8sQ&&h}>P79?3(#+!0S#ekm11}7rErr1gVX>n+ufn&i@n5}o zWp)*{7?0rE9Bpw4Wx>gyVNJYM4oZo=d5kN#QC!jhyG^F$ZoINIH7e7nkgUOz{P`^<4@jjB z)0K(WL{`Y!hU8T?xA^Phc?{tQ*1@6B%qM)}%i+4$BorFA&v03CjwWUSIg)&nII{|J z!VL`=&&SDg$8Q!WcD!eD@%XDYNG#k*CqLM{H*R71-nh$qjN?S;cxbushTm~1~dQXc{iLc zw>84PC{ZB%DUmz2(2FO6^Fgz+bRB=@No3b0XmM9}zu<UJ^g)J7*B_ z3VG8_F`YcyaJI_6^I^*-+}<7hZ^v6;mw$AaI5-z7!4{XWom(6IS)R(1TPbp0e0O=Q zkVo8{usc#$6GwF4|2pv2UEG{#Gg@~NU$jxu6rP+5R?siV#OPCGeO1ZC8`vK-7;Os- zRxcY22v$%xwcZgP{m7Dic9mK|5u3!R6~a3_n%(|#P%+3qZE5&=tKNTtm`Drnbjn`S zav}@V?ayNQa+F%j%-iO2q9Wg=LTsRDHa#wml9fKCPW0PGVuJnU_tZmn2NjpWq7oZ! z)Z>{fAR#UW2;hfjx5xez#bc%n6^tIU-^l~Q=>`>~U#Sg~^$D!v)m~$rPZPqY!r-*V z6VC6fCv8OB>F`5o;ZttYSeb2x?XDUx3@`T!d+mW+B-Ykgf#+jsGz~e*T9H>+TTN6R zbZl|Z|>s9`4W1UXu-iOF~+f&0O`#nE(N{#sGtvn#S#s1gR*+JxS#NU&DQ5+eWUNB zs0#4q@fUZvdy4;FTA0l$2`6#Zw&2GE6X8ZgzJ~;vKX@XCc`dCIP!-_!{E%!KnP{9H zmU7v@cKEj9azAEg1Gsfb=TE3HHcG}z`9ArDd#Gfq3?Toe*T)$DzUo&8zV^Y4yj4=z zOpql<4uzl5ZQpNhf7h=gA)vw`=r0Cro?#QFy#C*U!z2qC_a+!;0YvK0k_@Y9Ii88| z(&g`Vt9sHF4&4v-S~r)88;G;F0rGeIsGhom9*Mb8CQ{sVZ1Z5mHtTcQRF}b;cr6Bw)B9D|-9GH{Jp8n>awZv!=)4;IAW!r`w+0BL-0wa%+gRez4lL z_2;gDmn@wElDw6;9Tknw=Ch@gjNf}P&_T}DvSZ7%SJiflI4ac7vlf0$y^IC>pCzBM zX(#=NwiM;mulFwDcbsVC7M?Vn{j~z0Jhid$-|pqj(iqw z)`V+t`5?|}phDHr4VdS8#gcUu&IVy#BrO8jxg$B}J?>uK3>M>_=TT8ot&@AZo!R1i9k}mtOLIoT$_0#>k^P3n<<3itDqWMR_v6lK^{rJ& zbA=+@yc9E{3*Oyp4;y%!^oN^@8PdIO^&aoSRWsCDl5o6#fB7z+$W2o@34v@OoH!pE zUpm-wq#dSBm}$R~KOfHg0P_J!3b0w?NY+So*w9dm29)1&;=bmbz7U%MSV(siib>W4 zka&Fu3v=>n;dqJxseWFdl|i>e_|!P4W(Kj}{CQ}WLMjoAWe#IzZ;p3Me{s=ZZ#Q?` ze1(W$Q-vf?o3xL|NFNR^oqk@gH#BGEKEe`wo;fqftaxQ!dqyr3l$J33=7XiJ{aF=- zggXhfH}ufZT}g@D0rANlI^^bM?2Ug`$0ormfSrUelyn=xu$3R%)CIMs%|?gA%!fVi zH3(tGP2?ZXLUcnME_mlDFntEHtV1KS?ov}4vOvP{9p(Z)Kt>NxXa-L6r9Yo1_kw5*eu<<5&eSm0 zSA^|8DJFvdGTWZY7TEk@feO$kw8Kw-*l2`e+G30` zTM;GUOvCjk*FM{NLA9nkI-eWI$|g>bq7rvHgT52)&=pH*E7n)Qp$yvekT#6k11o}E zMjo#P+vZLoLvFMDKS=9O%Hi`e8f{(GQE%*|RT+@q0p@>dCxO6(BXBP3+YPMZK;;b1 zkuQkL%f!b}@-5f`?9bm}{7OV~I#}j%K^4#tlDwwXDvp2r% zb|Fb4Y=fbce)+F;+&Mn{vAkpc7*CwbBC$7b4K|zI*c-r{nJgdv99-U*ZBxH-)s*22 z?J_r_A`Tz^>q(&LPon<(fQ$ysV*AwYyRd3a+a@17UVxi&o!Z^dT*v;=*odKfSaY9k z6<&3MI~^Jyel248MiSiOLBCuA4hQj!{ifBjb z^Ux*jZyyKBaqlIXZ;@sZ{%8Xc-4cI2hcSLatRYx^kWwMV^dkuxR7$p&Nor^GdBfKq zok=ES=V~5`z7IL^`~BjZzX7TRi8M$^$zN`Q5bCHI#<7h|x z8KtAujQSPd!q>w6AHl-MVDc_4-AC*GgRS^*e6>ccP1G+`EpmOtR@ZvDl6Gt=FZZ?p z$F(Rwi2D83a3EB<{?{JR_PhErQsiCZbWU?_f2Eqx&HJ>^zJHB9-OP`ok6MK-6UOOZ zu>dl=4j-lM%~b@nABISNVJ`TT&>3kLS;f#n{s+5B_qk!P3^ek-PqCRRaM( z^hKaBtmX=yO{5y{Ud9Qry+)$QJly?C!(9<_h~s-GnQD*P(fZ54I!c&T&{cvfC#0$Y zx-x=}MUNiHnE0Ufl-pQ|*N%(>&L>*S(u2i_0PUcB5d8>fe2L1Ld_wg%+ups2EVi7ia)iZZL`tejP4&qhndDDS44%?w7Qrwjr}In5C?>yaorG_Zt2c z8s0Q&iI=pxIhv^u5+_4g;!+%_Jvc5-b#d$svh0bDSRdWH$lo?wih!kYf;mc*lGh)f6UgDlGtwiW-g9IK zTSx6ZP4h7)b+e!%eY{&KFs)nmBL!%mzKRv?NHKgutaUe?EhVR7MmtpWZ`a5|yCSLy zp>?%3mM=3_UMO%7Wv3BV3YNs?{NC)v58JfDgG6UxYT{0$W06x&AHUVE%s4_S5AV~z z%#a%r72c1Zo3|SG?*x8^HfSBH3eD~2Z$HSOOJ1JH%YU1%BmCq9#n!I5w6wpVpV}WR zmnu_NmM6rSC|cg9o^F<4Up%m3yV%@H`)skpYddU4OP6I?2Gr;b18q1INTadWrXI5E zL%SNlYos~2cGPFo;ze~ANHq!idm&d(!sGBz(IdBXZ_rA({Dx63jI53s-P&PjT@7Tr zsu~c4I8b2`I*O}1qS!d}PkA#tB&GSxD}hska2pdM;WSu?DeAmj&~*Re7CE9sQ=h`# zh%a8DR#J&94?6!H<2iCN9eWiZm%%mz_v_W+;~a%qsqmn0^%#XL?-X)pz(|VvdDRB$>`z+l|ZNB-|qrH97AX4Iw865!$DHb9dPp5lC4D22#c{EAG=K$4#v%7iA_MxMI%=9bqI-lK35gO3NCaJP|UB9?m@Mlmo7tY8I&hwl`V z7FM9Eu^lYqoi<2VmPO>H(HfjN1?G1r?}h*&nR0u6fe$x7Yu?X)>YeJ597h|A!xSOL ztim5(-&R>*D5I4*(MQ|JQW_5x2VV5cDI^f3!HTXSWnG7#&htf}fo2b;;;`9ou_FEf z!GF-;Z>V(gNefp{YyBiWlQb>uq=h3$8cE-~YTxkao9I^CvrV*9{?of^$=sesFfw`b zZfX|Q%Aea)1V*OK-ocWb)DG87R_eLPSmjw!cwqiB3Q zp;>sMOjuip3EkwT97y_Nf2!xl!@rxyl_s`j(S3LM*V7%vncE|Qz9Gt6dcfyA)&jAs zznOJ=!rAFl&i}g3FM4_g0m94p>3|dYff2$!-X8YAIOk2PVx`o5B^a5vFr|~t2zi5> zWP#QSXv2-dT5QITtdxFW=Z-zJ|0I7HPf4t5<#RMcCTH5_OG>Wo9ittHxZ$g`XU4ny zJKt6y?JwQMd$<{tR2b^8B-+J!)ol2=0Cl*T+3$4E06xyxr!D4CU#y1AiMdP4Kh~mf zzL#!VQv2bci7RG~6)W-s+e-N3T}&-Pk@qtkNSm_=Y3+PPh@;ZK*I%bsZ?eerQeb$J zLZtnG&iUC042@9o(fd%{mBoSY+lN}mJ5?48Iw3M;cW-YmpIo;O(Von*TFu@y|8buy zAlKItKA|bHM^zu+nT0Gd+_u|Gy}89LyP1-IAd*dK8<2TGH+8?9(0CGy0dR374aYYY zmDPDoIqvm5$mS>73wI=BqPACPmq@}C@{o_ zcv4kEA9Nu!nn7hkof+^di4Hr$75{CrAhAq%n46h5+lp+b+b8ZhE`s2Su_DE!f>jD!<(xL*J&5CV7^`T}JenLIU#-2;1r*@4BmnerGKPXSEIRcT}YB jP5y%Fc^3+9KG4jOALppIJN@?uFhEsNQ=wMQGW>r5-$@hX literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/apple-touch-icon.png b/frontend/src/Content/Images/Icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1986ca67669255ed5a5a8bd1d31d6eca24308e46 GIT binary patch literal 3817 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 + + + + + #00ccff + + + diff --git a/frontend/src/Content/Images/Icons/favicon-16x16.png b/frontend/src/Content/Images/Icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..94e3bc54e33055aae963070636bd8e4a31323363 GIT binary patch literal 723 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GXl47^?z&LR|m<|Nr{+>$h*;zC!|U z-n{WkO1gjlAw=xivu8<3Wm#Db{#mvD*>$0zS(h$dJ8|O7`}gmWG(njV!H*w5K7IQ9 z(W6H}`Aq=@Euo=>opa}2ym$qo=<2l_?d{W$wSV~V0VtJ~SN-DU%h#{p6qPlF#O4`9 z6eOpYo;`Q*(&ek^*%kUxMSfxFckkT~h|H2}UTWzQo|0Z#)6iYl*prr3VeJ_s+rG*- zB=!1@TYL8%HVVp=uU}wgA876#tx!AH)Hi9@?gLSAxeE0Qn2&x}sGlDZlU-QaXzCr$ zbmAAong4l(b)^+8#sR5}r~bqz7s+(4VLbI$wryotROY$!m#jRZRZ6FrdBw>$F0you zJbLWZt5>fS>gJpJBs#fAghgdV#^$*D#41%zkB-lK@#2M>U&6}On}DIZZO2~kkW~HX z;_STYz=(_+H*ep6@GvYs4;bui`(rhLk)c--sPmSmi(^Q|Emz;>q@xT1-iGTx zTirf-c3bZ4s%`fBF3sfRNsQ=w-reP$;F%d|IWs6wlffbM>x&tt67yHAWDPL4G__6s zvbp#(_g4q!$B!<3I`!(-uVc@yed{~XpnB`>ZF>tF8w)Et#iWZaF3Z`|pFbC8pI&wH zV8V`F`*!ZNwcm5uTkdStp8AR}Y`Rr7Ruy%Xm9`be@ll=k?CtpX?c>{5U&ni4+qeB! z>*w1G@ccY9am5YBlDMSm6XN>+|NqyoU%v-~cNpO9 z+qXb5pd_-)hYuefKYk1meDUH%Q&a!xGZ*eXc-Yw3n=@mzyHBjEXH@pg)eX)4_EC9` z;n@u>6PB;sFmdWUplNU3ya8&28w+H>xyV)k*+3~E06GsM4rJfBdHei@%W2tF?mjW@ z-Z9k;J=wF?xQ1uhMCTXISl!t>ZRYGHD^_jn>Y57H3UWCRyn6Kt!T^#GHv?sXY;XUB zOP8-ccG!y8NbsM(1dPS?{_n3LaPMkb<&AP41rBjRp(tH9E+PkLAU$}DXwmra* zId|^TwjF!tEm#o{lA<3~q*OiI+&y~I)OkynuUD*@!+P+eQDBBaWFhmBuc}4;y%T2D zG<3^!tY$s%!6+!xFf3QOd|G&P)~naAf!7;%=dirOx|a4y;!}4^^=<0tnmT*lGUI^M>9ZDX+OkWfbn>zl z>v!%xpj~v#BEu5)n0z_ zdeJ4u{;8IZAzEpTN>#HQ-NGL}dc11&CjH1ljqDDk%9(6?-x`KwU%qnP#WTtvERS{n zd)4BJZ2R8YIfWcQaYnUxf@;wOXOGB;nC$Gl>bl0B#+Lr5_*`H-`UWLky?W!`{Rc^D zCBUdTa`a?QL9MBGykAg~Mo!1;*KbUGlQgp1fss3H<|1HvFrHfJ2u#F5B|(0{47@>e zkKO%p@5I6|euc$9e=S$$?3>JOedV%q*57{x4uAjJJ$llwv5N0|T=4Aq2aZ4a^Xkk5 zE5^rqh1)J0{rPK`^!Hzu#gpeAMK9l0@|3@MUt6)D!{n6#9aPPy1mg^l8 zAH8v}{Qcw5P5;_=@(lMKdbX+0G?aNF`=)!sg$?^YuDe^kpoXszJ%h*Mn|e^{pN?9mQi@3=#* zmf1Xi}nJ*cPT&lTzMKy&T z!n$J28e}fcbGz(%iZv-nB5;F7yAWT!#!5~Bq+ zQKcPRPZiTTLpT?BZ4BUD;8r331``Ck5S~z_%c>KTlDF?X?#un-bM}tukG(&JhyC~_YoGM-v#|AqJ5K(8wA|0-hy99r z`1iQ1z4*<^cOI$MpZ?fyI;Cp+7oAwQL!U%{te4R=4_l|S^lEm*@mr-*^Y8uQo%OhP zw%KI0<+{g~B^U$h*;zC!|U z-n{WkO1gjlAw=xivu8<3Wm#Dbxmjs>S*f9+S(h$dJ8|O7`}gmWG(njV!H*w5K7IQ9 z(W6HN`5A@z8KI$topa}2ym$qo=<2l_?d{W$wSV~V0VtJ~SN-DU%h#{p6qPka#b!o_ z`y{8Ao;`Q*(&ek^*%h%7KEYwhckkT~i%gx|;Ns{Kn37&v)6iYl*prr3;pQ1Kv&A_e zBW;8p8MP;5lf62`wsIl1H!_$9aor{xe;L&5J zUcGwd%PqX4~z!Ak|4ie2DRp`&$qQ|@>YKTQOV2m=@U<9qrvZQ2mV~| z(Eq2}UGm`rC-1DEzvc=mOgZ*O(dO8#oj{#8JY5_^DsH*@Hai_)5U6#|+nHLuBWL&B zO}V#!{qjuT$L`M(IO*w>D<&+H=1uh6my)W@Fk$lTdxAVxyFYzm6e;=sceT>vua4c_`!^-v*)e828!M}4!vB}fdGY1V9DThXpI+T!Ub|wCWc=>EyX!0V)K=96 zu5NhMd+_@9a;0BC{`^|UxZujaKa=$9|CfGcxNzZy2+yqOC_#_e%cR@Z&187MP@!({ UqcfpkGtiF=p00i_>zopr0L#^XH2?qr literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..5d3557ea882102e87b09c0c94a0f866379620a97 GIT binary patch literal 1385 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 G==*;n$-ox? literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3f63c9d506d86cd3e9fde0ff3e4d6683f48067b8 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json new file mode 100644 index 000000000..d14732f60 --- /dev/null +++ b/frontend/src/Content/Images/Icons/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "", + "icons": [ + { + "src": "/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..86be23dc432646a94e707500a184b23884249384 GIT binary patch literal 3592 zcmcImcQ72@_FqJa5+#;M5WOc#A_z9x5+zu@6Bf~<1*=Q2yGksg*VUFJ5~7RVd+#-( z_pnR!x4-$$y!p+%d4Ik;bI<+U&-u(bXU@HM<|4E;RjA1ClLG(%D%F=tI`}p2|0Wp` z-VUeGAprm)fVPGn7{A^Bk5T`#WqW()Kj{AzVQJ%z4{c=TgtwZy*8fhEl#2d0{%;^9 zJs5p|pp zcK%&o-_SL(H!yKDv2=N*W1W#zlvnWeM`b-_>8z}R?h|dhd#I{gt;alWG4$zek;uf- z@6{^mW*mMg{B~hNR(?_n`izNnw_1+cJ9>KihK7g7-+G1M`&0I#HV0kO+SZM4eoo%! z>6tn6*KW`^@5t&lL5lk9{^>lfQ3O4gqI%AcO+4r47aN+|JG%O^bBiUEP0Gq^oZJHN z_bnr?1o@9; z5d)V`DLJvIH1diSQ6o1g1wB=F9TNN-@bJ>n)CExgCEC2SARv(*T1^||s8^poi zMrZo(-i^4Y`q*T-Pp#yewe9wpow($F6t-fm=IdZk|7F|Cqhc#TU0)zRIXPHWNnX!) zwqrgrnBMk&^B-RCgcP|jRm`aACOIv{=3s9Z25UGylKH{z%oeT}#{PzmjD(P#fC$b8 zcH~1466V4QM?XlnW8h(%3x45GR)0J>*xi>t-rW@nx&eS*91WTla?!x|M^F-rPruu1 zbXBYl!!-@LJF(2uxa*B`bLyqg>xE9)1q4?_r+17N)qBAX)cs&X$<*zq`JP_%x0WW_ z#^8^nso?A6X8^Jk`3z8>NngPAnZXnhl{SFlGMNiS{}?l;~=nL-~sduXL>;-rP4%37d0A=KIPS6tCF0^O75~Jm%Yo0mft?7=WrO=j)A5+an}v>7MpUO z&=dqxLrEa%VqY<8LITDL3=?k3)#`%VYB*UXMLCZ6?)o<*B^iQRC?F$9soL9q;~Q?5 zeVYX2eIUmJu4!)l4lEtNuH2nre1Y4~z0*j#$D{Kq9w=p~$=Ou1Xc|%?b)0lCfvf2V zqn@HY6Dk=8zV7+Ou_Ap;PA;46EXi|1HZ!u!?Fdkfa)juWm!ph$BoI=PxPrS&o3LA= zub253J=u?i=xkk5H#uxBk}~EIbyZdZz7o}Mvu~j+u%7A5iO}+|k$z`RFc}<4X51a9kzOtz(w^4RArsAa{Fn)+?MnxD<+3!WjcT09sQ}vMaXZby z60tfCA5XVa5;Z{L#f2jK4ug+XDPI?r8W$+i9&`PA zUM!rp;WJXiD$D&qb;Us&$6KsRu?oIqbz$$5Rn)m^*)xfEdRId6H5}c*h?DgVS%nqX zE0Z&64;dVsTUPkgj;>RANpEu0D!H3}`t0`I%**-xs2yWnWfm{zlw5s!&oBd)@BD+Q zmzjeny}oJBaNw{jfJtDsSa?Nv=IjX&oElcL2Njah$guI4z@#0=PqrP-&RJ8la)bu- zpT{G7w1tjLm`^o&Qhk2rEdSljFh8>ZS7Mly?am!PqjUGHQH>kNwb}e(HwM5wJ*Lz% zP=KINV!wt0qS((H8F}`}bGSNltVe^C6mW^q09K*3>XQXQ8Qn_R&k|~z9DQseK*|p@ z^pS2`y2HsGT)bKG`<$MyHeCjd!2-Mw8qC=|B^;(Ia_z7yGU$80ObtL(?LB}BVd`fe zbP;*SFa$fUMtK_`LDQvx6;TwO)IqYoqfzmJ^Taxi3pQd>Qc`1JMF`IO``oc$OtppR zn$*M{8Wk=qcPs_QQ^;Jj^0mM=Ge_)$*OKl(yMLm=;+W$*?q1=g?V>E;LBjA~`r{5< zv7y5Wl4hFpU-TtMqaYOsNtl9USz=C(^6S)4v@o2LBp$&{L@Y(#Cq|#i85%nkgCI0v3|i7Lk8_MhOmYrzN0mFPB5Zb&P{)(IZPOAe9-eOPFf zX+7bLy~0RrI5kn9P4p{X8>vSI%=hRwVHrKdQoU@ zGA;vbQi1Xjr1~wHAjH@J{idC7V5Z>q>SrX?7l%oBqh4>)rW$t5X7cyF#eqPkbL

O9=GJkLJ?%>kXBOY{)Z}yITWttn(umkLt@I1> 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/mstile-310x150.png b/frontend/src/Content/Images/Icons/mstile-310x150.png new file mode 100644 index 0000000000000000000000000000000000000000..b6308fce5c01342170aa24b0a651b7b31f53346b GIT binary patch literal 3734 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 A_W%F@ literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/mstile-310x310.png b/frontend/src/Content/Images/Icons/mstile-310x310.png new file mode 100644 index 0000000000000000000000000000000000000000..dc2c2bf6ce33d3d4befb7566718d7cc6ff54cd01 GIT binary patch literal 6632 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 R(4B1vz!+f-8Tw99{{|L%=1Bkm literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg new file mode 100644 index 000000000..6fc7fb969 --- /dev/null +++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg @@ -0,0 +1,38 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1ae774675b362b5c58dd104403761ccf5797c7 GIT binary patch literal 163647 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

  • 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg new file mode 100644 index 000000000..b0a721893 --- /dev/null +++ b/frontend/src/Content/Images/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6a88711c3a074dc5d13cac9f26ea834cff972dcc GIT binary patch literal 2553 zcmbW3S6CAW7KL#D!AOy=z<{E(5ormbNeM*=H6%gD3{qsI1P}-%(t;q60cjG-Ku`k# z5)?tHY8*xvrAdh-lSm+d(t8VPsLQAOxZghP`Odljy>I97X2RSY!~u!`0RaJVsG}YH zkkrF`6%{<3?XIg;hX{bVAno;Z^}b7APhVeOPwzVpHv@eG1B3tIa5FS8{2`zr5coZZ z00BWiWCSw$A+Qk`{5^*-1{)iH&mm0k0BM$hz%e(d9RzucvpjmYjQoqVz(k5lZ>WMV z%R3Y!ABY({P*-rRqMtmQ;Wo%GUf8+7rwM4UZ;<(TyA=7L|L%Pmk}&Fjxzk~!mBnc_ zO4^Nt`Pap8`CzlaFqPFjWk;;HWJy-Pq|t>lSIB1<7b`mRF~qIkNS(2>%m^?LWJRFP z6v6xlY2>DcJ8&>IV9N-`QCy~!HQ*Ho`%c6vfRZ22x-aA{&AYgxan$(%(~cxLUWfUn z^QLWxC+*b$c^?q;B*r@!i$u-h7c2{7Pu+*_P=U4~MvewVrV$ly5|Ccmp25@&v`ium zi2fsXDY&LQBl+i3yXH#GtPRaMvxm%|9Vkl(n3Z_-el0GbFZc?=^6|c{%A7hux)FNK zmA}iLCg{MbXW_Ni(d?w?l{O30zEo!wxx(+nK43R`d^eEppL!{m@o-==Oel0@H zBxZ7n0GmaurJxH_CH!Km8B{uBJQbm`$7ptkEJu?mYPIN4!_5ZrP}_2Di_$(`lf%bHdmd^l=*SD3p{nWf7_C8+?7cJhUcaTaAJe{}XR9u?!#=I1Ia>$! zb53NmaV6%4l=%j39@$aUXw2<3Iz3$Lp{&L!w0u{1QaVP}k`nxBb6zmwU0iGrjOGQ` zD)=eceNfYMRn}KS3jNaDK3xnxv72Au?yH9BH7At0ri~Gm%;mB`N5Yk*H+lqwrV--_ zsdjT*45PB99N-;>#Z0J~tT$}l*N4)Jt|Q)`7M=(tB0XJRtv|0^UAfGH!V*-`l4s6; z7XQ{N72~UX8$l!gR{I1&TCr?q6l6trmgXyw8pGo-y_0-gmGu=YQ0LL6xT!WbtaCA` zftEg`VVTuiqk8Apj8;jKrdKpZU@lDH`Zwi_L4#-0Nue1Whn+$c*DR&u^j1S<@@8*} z%TWLED*@-37O7wiID0bM8{?d0-6ylXcFU7^+-$W8sl!k-qu|3Po7UL(;IC$54!n<;%G_;xVtmQqQ5F)*lugp+LhFM zo{D-^k=j#Ef84ZJxS1tSkXQ zwaYEpkIW%0Gx12<6#FOR(LEwzG9zQxr_#jTIKor+kesw)NqSls&R8PORA(d%1LsPw zT5zGk#93rc)2SQHPHk6vcm5c>-Hma}j11|5Oy4Mix`ByXicMdJ=2dE)PdZtEw5xNn zB-!1<{iuoG-_%{z>0wmd4OIVNT?x=VGd8(W>0)DJ0XkECSy)TQyr@80PR*&d5S4Eu zcfY~Um0VppFL>uQU{TvcO$D~F{gD@0*TVCe6nk8S3q08#M2oY*LPioqO02un z5u!>_n36eNRoz)Lm6>19X>+dHe1#~rC1xDeH%bb~Yre@BgE#Eui-t}stMM;Yt+m~MKqpfE&XrmNZ4OrHl>>7*n zd~dU;ri4k&xffiG!9Jh{In@nP2RyHLAj?kpi(HG$nJ!DMlH1d ziEIZXLCOiK9%V8wnm^=vNef!k`>ASA(bM>9MYMPmc7_0_1!jO-urq}?Zj#f6jjO~Q zd)(t)sfHCO8E(Eajny>NbvN6z-5mecHLSq>v$x!vvmn&(-k;pirdk@lQG75KEW~wp zRl1xk15^biqoAdNWd{SfRiN?YHWvn}M5$EOskDVceEjB#ahcUtZvBk(0j2H11^Q3_ cpZ8P~@*{VT+(LRC{&E6PdpEm!8{gD_0~9CC`Tzg` literal 0 HcmV?d00001 diff --git a/frontend/src/Episode/EpisodeDetailsModal.js b/frontend/src/Episode/EpisodeDetailsModal.js new file mode 100644 index 000000000..b2f379808 --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector'; + +class EpisodeDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EpisodeDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeDetailsModal; diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.css b/frontend/src/Episode/EpisodeDetailsModalContent.css new file mode 100644 index 000000000..450cdce9d --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContent.css @@ -0,0 +1,44 @@ +.seriesTitle { + margin-left: 5px; +} + +.separator { + margin: 0 5px; +} + +.tabs { + margin-top: -32px; +} + +.tabList { + margin: 0; + padding: 0; +} + +.tab { + position: relative; + bottom: -1px; + display: inline-block; + padding: 6px 12px; + border: 1px solid transparent; + border-top: none; + list-style: none; + cursor: pointer; +} + +.selectedTab { + border-color: $borderColor; + border-radius: 0 0 5px 5px; + background-color: rgba(239, 239, 239, 0.4); + color: $black; +} + +.tabContent { + margin-top: 20px; +} + +.openSeriesButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.js b/frontend/src/Episode/EpisodeDetailsModalContent.js new file mode 100644 index 000000000..f1b612501 --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContent.js @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import episodeEntities from 'Episode/episodeEntities'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector'; +import EpisodeHistoryConnector from './History/EpisodeHistoryConnector'; +import EpisodeSearchConnector from './Search/EpisodeSearchConnector'; +import SeasonEpisodeNumber from './SeasonEpisodeNumber'; +import styles from './EpisodeDetailsModalContent.css'; + +const tabs = [ + 'details', + 'history', + 'search' +]; + +class EpisodeDetailsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + selectedTab: props.selectedTab + }; + } + + // + // Listeners + + onTabSelect = (index, lastIndex) => { + this.setState({ selectedTab: tabs[index] }); + } + + // + // Render + + render() { + const { + episodeId, + episodeEntity, + episodeFileId, + seriesId, + seriesTitle, + titleSlug, + seriesMonitored, + seriesType, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + episodeTitle, + airDate, + monitored, + isSaving, + showOpenSeriesButton, + startInteractiveSearch, + onMonitorEpisodePress, + onModalClose + } = this.props; + + const seriesLink = `/series/${titleSlug}`; + + return ( + + + + + + {seriesTitle} + + + - + + + + - + + {episodeTitle} + + + + + + + Details + + + + History + + + + Search + + + + +
    + +
    +
    + + +
    + +
    +
    + + + {/* Don't wrap in tabContent so we not have a top margin */} + + +
    +
    + + + { + showOpenSeriesButton && + + } + + + +
    + ); + } +} + +EpisodeDetailsModalContent.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeEntity: PropTypes.string.isRequired, + episodeFileId: PropTypes.number, + seriesId: PropTypes.number.isRequired, + seriesTitle: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + seriesMonitored: PropTypes.bool.isRequired, + seriesType: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDate: PropTypes.string.isRequired, + episodeTitle: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + showOpenSeriesButton: PropTypes.bool, + selectedTab: PropTypes.string.isRequired, + startInteractiveSearch: PropTypes.bool.isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +EpisodeDetailsModalContent.defaultProps = { + selectedTab: 'details', + episodeEntity: episodeEntities.EPISODES, + startInteractiveSearch: false +}; + +export default EpisodeDetailsModalContent; diff --git a/frontend/src/Episode/EpisodeDetailsModalContentConnector.js b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js new file mode 100644 index 000000000..5b378fbfb --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeDetailsModalContent from './EpisodeDetailsModalContent'; + +function createMapStateToProps() { + return createSelector( + createEpisodeSelector(), + createSeriesSelector(), + (episode, series) => { + const { + title: seriesTitle, + titleSlug, + monitored: seriesMonitored, + seriesType + } = series; + + return { + seriesTitle, + titleSlug, + seriesMonitored, + seriesType, + ...episode + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchCancelFetchReleases() { + dispatch(cancelFetchReleases()); + }, + + dispatchClearReleases() { + dispatch(clearReleases()); + }, + + onMonitorEpisodePress(monitored) { + const { + episodeId, + episodeEntity + } = props; + + dispatch(toggleEpisodeMonitored({ + episodeEntity, + episodeId, + monitored + })); + } + }; +} + +class EpisodeDetailsModalContentConnector extends Component { + + // + // Lifecycle + + componentWillUnmount() { + // Clear pending releases here so we can reshow the search + // results even after switching tabs. + + this.props.dispatchCancelFetchReleases(); + this.props.dispatchClearReleases(); + } + + // + // Render + + render() { + const { + dispatchClearReleases, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EpisodeDetailsModalContentConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeEntity: PropTypes.string.isRequired, + seriesId: PropTypes.number.isRequired, + dispatchCancelFetchReleases: PropTypes.func.isRequired, + dispatchClearReleases: PropTypes.func.isRequired +}; + +EpisodeDetailsModalContentConnector.defaultProps = { + episodeEntity: episodeEntities.EPISODES +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector); diff --git a/frontend/src/Episode/EpisodeLanguage.js b/frontend/src/Episode/EpisodeLanguage.js new file mode 100644 index 000000000..52c8b3390 --- /dev/null +++ b/frontend/src/Episode/EpisodeLanguage.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; + +function EpisodeLanguage(props) { + const { + className, + language, + isCutoffNotMet + } = props; + + if (!language) { + return null; + } + + return ( + + ); +} + +EpisodeLanguage.propTypes = { + className: PropTypes.string, + language: PropTypes.object, + isCutoffNotMet: PropTypes.bool +}; + +EpisodeLanguage.defaultProps = { + isCutoffNotMet: true +}; + +export default EpisodeLanguage; diff --git a/frontend/src/Episode/EpisodeNumber.css b/frontend/src/Episode/EpisodeNumber.css new file mode 100644 index 000000000..1c5072d02 --- /dev/null +++ b/frontend/src/Episode/EpisodeNumber.css @@ -0,0 +1,7 @@ +.absoluteEpisodeNumber { + margin-left: 5px; +} + +.warning { + margin-left: 8px; +} diff --git a/frontend/src/Episode/EpisodeNumber.js b/frontend/src/Episode/EpisodeNumber.js new file mode 100644 index 000000000..f8183be8a --- /dev/null +++ b/frontend/src/Episode/EpisodeNumber.js @@ -0,0 +1,138 @@ +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import SceneInfo from './SceneInfo'; +import styles from './EpisodeNumber.css'; + +function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) { + return alternateTitles.filter((alternateTitle) => { + if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) { + return true; + } + + return seasonNumber === alternateTitle.seasonNumber; + }); +} + +function EpisodeNumber(props) { + const { + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + alternateTitles: seriesAlternateTitles, + seriesType, + showSeasonNumber + } = props; + + const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles); + + const hasSceneInformation = sceneSeasonNumber !== undefined || + sceneEpisodeNumber !== undefined || + (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) || + !!alternateTitles.length; + + return ( + + { + hasSceneInformation ? + + { + showSeasonNumber && seasonNumber != null && + + {seasonNumber}x + + } + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + title="Scene Information" + body={ + + } + position={tooltipPositions.RIGHT} + /> : + + { + showSeasonNumber && seasonNumber != null && + + {seasonNumber}x + + } + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + + { + unverifiedSceneNumbering && + + } + + { + seriesType === 'anime' && !absoluteEpisodeNumber && + + } + + ); +} + +EpisodeNumber.propTypes = { + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string, + showSeasonNumber: PropTypes.bool.isRequired +}; + +EpisodeNumber.defaultProps = { + unverifiedSceneNumbering: false, + alternateTitles: [], + showSeasonNumber: false +}; + +export default EpisodeNumber; diff --git a/frontend/src/Episode/EpisodeQuality.js b/frontend/src/Episode/EpisodeQuality.js new file mode 100644 index 000000000..65a3f3dbc --- /dev/null +++ b/frontend/src/Episode/EpisodeQuality.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getTooltip(title, quality, size) { + const revision = quality.revision; + + if (revision.real && revision.real > 0) { + title += ' [REAL]'; + } + + if (revision.version && revision.version > 1) { + title += ' [PROPER]'; + } + + if (size) { + title += ` - ${formatBytes(size)}`; + } + + return title; +} + +function EpisodeQuality(props) { + const { + className, + title, + quality, + size, + isCutoffNotMet + } = props; + + return ( + + ); +} + +EpisodeQuality.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + quality: PropTypes.object.isRequired, + size: PropTypes.number, + isCutoffNotMet: PropTypes.bool +}; + +EpisodeQuality.defaultProps = { + title: '' +}; + +export default EpisodeQuality; diff --git a/frontend/src/Episode/EpisodeSearchCell.css b/frontend/src/Episode/EpisodeSearchCell.css new file mode 100644 index 000000000..5e99b51eb --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCell.css @@ -0,0 +1,6 @@ +.episodeSearchCell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; + white-space: nowrap; +} diff --git a/frontend/src/Episode/EpisodeSearchCell.js b/frontend/src/Episode/EpisodeSearchCell.js new file mode 100644 index 000000000..d0f8c4114 --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCell.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeDetailsModal from './EpisodeDetailsModal'; +import styles from './EpisodeSearchCell.css'; + +class EpisodeSearchCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeId, + seriesId, + episodeTitle, + isSearching, + onSearchPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + ); + } +} + +EpisodeSearchCell.propTypes = { + episodeId: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + episodeTitle: PropTypes.string.isRequired, + isSearching: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default EpisodeSearchCell; diff --git a/frontend/src/Episode/EpisodeSearchCellConnector.js b/frontend/src/Episode/EpisodeSearchCellConnector.js new file mode 100644 index 000000000..e4df0a324 --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCellConnector.js @@ -0,0 +1,50 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import EpisodeSearchCell from './EpisodeSearchCell'; + +function createMapStateToProps() { + return createSelector( + (state, { episodeId }) => episodeId, + (state, { sceneSeasonNumber }) => sceneSeasonNumber, + createSeriesSelector(), + createCommandsSelector(), + (episodeId, sceneSeasonNumber, series, commands) => { + const isSearching = commands.some((command) => { + const episodeSearch = command.name === commandNames.EPISODE_SEARCH; + + if (!episodeSearch) { + return false; + } + + return ( + isCommandExecuting(command) && + command.body.episodeIds.indexOf(episodeId) > -1 + ); + }); + + return { + seriesMonitored: series.monitored, + seriesType: series.seriesType, + isSearching + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchPress(name, path) { + dispatch(executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: [props.episodeId] + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell); diff --git a/frontend/src/Episode/EpisodeStatus.css b/frontend/src/Episode/EpisodeStatus.css new file mode 100644 index 000000000..3833887df --- /dev/null +++ b/frontend/src/Episode/EpisodeStatus.css @@ -0,0 +1,4 @@ +.center { + display: flex; + justify-content: center; +} diff --git a/frontend/src/Episode/EpisodeStatus.js b/frontend/src/Episode/EpisodeStatus.js new file mode 100644 index 000000000..ee8ff9dad --- /dev/null +++ b/frontend/src/Episode/EpisodeStatus.js @@ -0,0 +1,128 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import isBefore from 'Utilities/Date/isBefore'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import ProgressBar from 'Components/ProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import EpisodeQuality from './EpisodeQuality'; +import styles from './EpisodeStatus.css'; + +function EpisodeStatus(props) { + const { + airDateUtc, + monitored, + grabbed, + queueItem, + episodeFile + } = props; + + const hasEpisodeFile = !!episodeFile; + const isQueued = !!queueItem; + const hasAired = isBefore(airDateUtc); + + if (isQueued) { + const { + sizeleft, + size + } = queueItem; + + const progress = (100 - sizeleft / size * 100); + + return ( +
    + + } + /> +
    + ); + } + + if (grabbed) { + return ( +
    + +
    + ); + } + + if (hasEpisodeFile) { + const quality = episodeFile.quality; + const isCutoffNotMet = episodeFile.qualityCutoffNotMet; + + return ( +
    + +
    + ); + } + + if (!airDateUtc) { + return ( +
    + +
    + ); + } + + if (!monitored) { + return ( +
    + +
    + ); + } + + if (hasAired) { + return ( +
    + +
    + ); + } + + return ( +
    + +
    + ); +} + +EpisodeStatus.propTypes = { + airDateUtc: PropTypes.string, + monitored: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + episodeFile: PropTypes.object +}; + +export default EpisodeStatus; diff --git a/frontend/src/Episode/EpisodeStatusConnector.js b/frontend/src/Episode/EpisodeStatusConnector.js new file mode 100644 index 000000000..7169dd31b --- /dev/null +++ b/frontend/src/Episode/EpisodeStatusConnector.js @@ -0,0 +1,53 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeStatus from './EpisodeStatus'; + +function createMapStateToProps() { + return createSelector( + createEpisodeSelector(), + createQueueItemSelector(), + createEpisodeFileSelector(), + (episode, queueItem, episodeFile) => { + const result = _.pick(episode, [ + 'airDateUtc', + 'monitored', + 'grabbed' + ]); + + result.queueItem = queueItem; + result.episodeFile = episodeFile; + + return result; + } + ); +} + +const mapDispatchToProps = { +}; + +class EpisodeStatusConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +EpisodeStatusConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeFileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector); diff --git a/frontend/src/Episode/EpisodeTitleLink.css b/frontend/src/Episode/EpisodeTitleLink.css new file mode 100644 index 000000000..6022be8a4 --- /dev/null +++ b/frontend/src/Episode/EpisodeTitleLink.css @@ -0,0 +1,8 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + &:hover { + color: $linkHoverColor; + text-decoration: underline; + } +} diff --git a/frontend/src/Episode/EpisodeTitleLink.js b/frontend/src/Episode/EpisodeTitleLink.js new file mode 100644 index 000000000..eac76eaae --- /dev/null +++ b/frontend/src/Episode/EpisodeTitleLink.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import styles from './EpisodeTitleLink.css'; + +class EpisodeTitleLink extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onLinkPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeTitle, + ...otherProps + } = this.props; + + return ( +
    + + {episodeTitle} + + + +
    + ); + } +} + +EpisodeTitleLink.propTypes = { + episodeTitle: PropTypes.string.isRequired +}; + +EpisodeTitleLink.defaultProps = { + showSeriesButton: false +}; + +export default EpisodeTitleLink; diff --git a/frontend/src/Episode/History/EpisodeHistory.js b/frontend/src/Episode/History/EpisodeHistory.js new file mode 100644 index 000000000..5a7007ba8 --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistory.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import EpisodeHistoryRow from './EpisodeHistoryRow'; + +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class EpisodeHistory extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress + } = this.props; + + const hasItems = !!items.length; + + if (isFetching) { + return ( + + ); + } + + if (!isFetching && !!error) { + return ( +
    Unable to load episode history.
    + ); + } + + if (isPopulated && !hasItems && !error) { + return ( +
    No episode history.
    + ); + } + + if (isPopulated && hasItems && !error) { + return ( + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + ); + } + + return null; + } +} + +EpisodeHistory.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +EpisodeHistory.defaultProps = { + selectedTab: 'details' +}; + +export default EpisodeHistory; diff --git a/frontend/src/Episode/History/EpisodeHistoryConnector.js b/frontend/src/Episode/History/EpisodeHistoryConnector.js new file mode 100644 index 000000000..cf28b79dc --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryConnector.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchEpisodeHistory, clearEpisodeHistory, episodeHistoryMarkAsFailed } from 'Store/Actions/episodeHistoryActions'; +import EpisodeHistory from './EpisodeHistory'; + +function createMapStateToProps() { + return createSelector( + (state) => state.episodeHistory, + (episodeHistory) => { + return episodeHistory; + } + ); +} + +const mapDispatchToProps = { + fetchEpisodeHistory, + clearEpisodeHistory, + episodeHistoryMarkAsFailed +}; + +class EpisodeHistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId }); + } + + componentWillUnmount() { + this.props.clearEpisodeHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EpisodeHistoryConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + fetchEpisodeHistory: PropTypes.func.isRequired, + clearEpisodeHistory: PropTypes.func.isRequired, + episodeHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector); diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.css b/frontend/src/Episode/History/EpisodeHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.js b/frontend/src/Episode/History/EpisodeHistoryRow.js new file mode 100644 index 000000000..409449f3b --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryRow.js @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './EpisodeHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'seriesFolderImported': return 'Series Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'episodeFileDeleted': return 'Episode File Deleted'; + case 'episodeFileRenamed': return 'Episode File Renamed'; + default: return 'Unknown'; + } +} + +class EpisodeHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + date, + data + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + return ( + + + + + {sourceTitle} + + + + + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +EpisodeHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default EpisodeHistoryRow; diff --git a/frontend/src/Episode/SceneInfo.css b/frontend/src/Episode/SceneInfo.css new file mode 100644 index 000000000..3efb78509 --- /dev/null +++ b/frontend/src/Episode/SceneInfo.css @@ -0,0 +1,17 @@ +.descriptionList { + composes: descriptionList from 'Components/DescriptionList/DescriptionList.css'; + + margin-right: 10px; +} + +.title { + composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css'; + + width: 80px; +} + +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + margin-left: 100px; +} diff --git a/frontend/src/Episode/SceneInfo.js b/frontend/src/Episode/SceneInfo.js new file mode 100644 index 000000000..3eef9351f --- /dev/null +++ b/frontend/src/Episode/SceneInfo.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SceneInfo.css'; + +function SceneInfo(props) { + const { + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + alternateTitles, + seriesType + } = props; + + return ( + + { + sceneSeasonNumber !== undefined && + + } + + { + sceneEpisodeNumber !== undefined && + + } + + { + seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined && + + } + + { + !!alternateTitles.length && + + { + alternateTitles.map((alternateTitle) => { + return ( +
    + {alternateTitle.title} +
    + ); + }) + } +
  • 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 XLbG$9VI_3Jf0k7RS1XPO@mT->QjP?B literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot new file mode 100644 index 0000000000000000000000000000000000000000..7a03fb5122f353d598a801d5660b51fda9cf6b4b GIT binary patch literal 23691 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fdd309d716629f4e5339d5e5508225ed857a3ede GIT binary patch literal 205748 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ăESk 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 G(fmJpf?fdt literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..0289699c05a703d05e7268ffe84c7110161dfec9 GIT binary patch literal 27392 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%0D)V9?yyD2Coq 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css new file mode 100644 index 000000000..3db8ae6b0 --- /dev/null +++ b/frontend/src/Content/Fonts/fonts.css @@ -0,0 +1,38 @@ +@font-face { + font-weight: 300; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Ubuntu Mono'; + src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype'); +} + +/* + * text-security-disc + */ + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'text-security-disc'; + src: url('text-security-disc.woff') format('woff'), url('text-security-disc.ttf') format('truetype'); +} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf new file mode 100644 index 0000000000000000000000000000000000000000..86038dba89d7e762d5dbc458529c0d20514a3d54 GIT binary patch literal 12392 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 literal 0 HcmV?d00001 diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png new file mode 100644 index 0000000000000000000000000000000000000000..deeb83f8fd8fe3b12d27cc6e821bfafb07fba27d GIT binary patch literal 103643 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

    + } + /> + } + + ); +} + +SceneInfo.propTypes = { + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string +}; + +export default SceneInfo; diff --git a/frontend/src/Episode/Search/EpisodeSearch.css b/frontend/src/Episode/Search/EpisodeSearch.css new file mode 100644 index 000000000..2f7ddfd19 --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearch.css @@ -0,0 +1,16 @@ +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from 'Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/Episode/Search/EpisodeSearch.js b/frontend/src/Episode/Search/EpisodeSearch.js new file mode 100644 index 000000000..f3ab8fdec --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearch.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import styles from './EpisodeSearch.css'; + +function EpisodeSearch(props) { + const { + onQuickSearchPress, + onInteractiveSearchPress + } = props; + + return ( +
    +
    + +
    + +
    + +
    +
    + ); +} + +EpisodeSearch.propTypes = { + onQuickSearchPress: PropTypes.func.isRequired, + onInteractiveSearchPress: PropTypes.func.isRequired +}; + +export default EpisodeSearch; diff --git a/frontend/src/Episode/Search/EpisodeSearchConnector.js b/frontend/src/Episode/Search/EpisodeSearchConnector.js new file mode 100644 index 000000000..c25eecc4f --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearchConnector.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import EpisodeSearch from './EpisodeSearch'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases, + (releases) => { + return { + isPopulated: releases.isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class EpisodeSearchConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isInteractiveSearchOpen: props.startInteractiveSearch + }; + } + + componentDidMount() { + if (this.props.isPopulated) { + this.setState({ isInteractiveSearchOpen: true }); + } + } + + // + // Listeners + + onQuickSearchPress = () => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: [this.props.episodeId] + }); + + this.props.onModalClose(); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchOpen: true }); + } + + // + // Render + + render() { + const { episodeId } = this.props; + + if (this.state.isInteractiveSearchOpen) { + return ( + + ); + } + + return ( + + ); + } +} + +EpisodeSearchConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + isPopulated: PropTypes.bool.isRequired, + startInteractiveSearch: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector); diff --git a/frontend/src/Episode/SeasonEpisodeNumber.js b/frontend/src/Episode/SeasonEpisodeNumber.js new file mode 100644 index 000000000..e4d391002 --- /dev/null +++ b/frontend/src/Episode/SeasonEpisodeNumber.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import EpisodeNumber from './EpisodeNumber'; + +function SeasonEpisodeNumber(props) { + const { + airDate, + seriesType, + ...otherProps + } = props; + + if (seriesType === 'daily' && airDate) { + return ( + {airDate} + ); + } + + return ( + + ); +} + +SeasonEpisodeNumber.propTypes = { + airDate: PropTypes.string, + seriesType: PropTypes.string +}; + +export default SeasonEpisodeNumber; diff --git a/frontend/src/Episode/Summary/EpisodeAiring.js b/frontend/src/Episode/Summary/EpisodeAiring.js new file mode 100644 index 000000000..54ca64325 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeAiring.js @@ -0,0 +1,86 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function EpisodeAiring(props) { + const { + airDateUtc, + network, + shortDateFormat, + showRelativeDates, + timeFormat + } = props; + + const networkLabel = ( + + ); + + if (!airDateUtc) { + return ( + + TBA on {networkLabel} + + ); + } + + const time = formatTime(airDateUtc, timeFormat); + + if (!showRelativeDates) { + return ( + + {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel} + + ); + } + + if (isToday(airDateUtc)) { + return ( + + {time} on {networkLabel} + + ); + } + + if (isTomorrow(airDateUtc)) { + return ( + + Tomorrow at {time} on {networkLabel} + + ); + } + + if (isInNextWeek(airDateUtc)) { + return ( + + {moment(airDateUtc).format('dddd')} at {time} on {networkLabel} + + ); + } + + return ( + + {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel} + + ); +} + +EpisodeAiring.propTypes = { + airDateUtc: PropTypes.string.isRequired, + network: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default EpisodeAiring; diff --git a/frontend/src/Episode/Summary/EpisodeAiringConnector.js b/frontend/src/Episode/Summary/EpisodeAiringConnector.js new file mode 100644 index 000000000..508467efb --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeAiringConnector.js @@ -0,0 +1,20 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import EpisodeAiring from './EpisodeAiring'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'showRelativeDates', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(EpisodeAiring); diff --git a/frontend/src/Episode/Summary/EpisodeSummary.css b/frontend/src/Episode/Summary/EpisodeSummary.css new file mode 100644 index 000000000..f8238ed8a --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummary.css @@ -0,0 +1,48 @@ +.infoTitle { + display: inline-block; + width: 100px; + font-weight: bold; +} + +.overview, +.files { + margin-top: 20px; +} + +.filesHeader { + display: flex; + font-weight: bold; +} + +.filesHeader { + display: flex; + margin-bottom: 10px; + border-bottom: 1px solid $borderColor; +} + +.fileRow { + display: flex; +} + +.path { + @add-mixin truncate; + + flex: 1 0 1px; +} + +.size, +.quality { + flex: 0 0 125px; +} + +.actions { + flex: 0 0 40px; + text-align: center; +} + +@media only screen and (max-width: $breakpointMedium) { + .size, + .quality { + flex: 0 0 80px; + } +} diff --git a/frontend/src/Episode/Summary/EpisodeSummary.js b/frontend/src/Episode/Summary/EpisodeSummary.js new file mode 100644 index 000000000..ef1b7cc67 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummary.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Popover from 'Components/Tooltip/Popover'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeAiringConnector from './EpisodeAiringConnector'; +import MediaInfo from './MediaInfo'; +import styles from './EpisodeSummary.css'; + +class EpisodeSummary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveEpisodeFileModalOpen: false + }; + } + + // + // Listeners + + onRemoveEpisodeFilePress = () => { + this.setState({ isRemoveEpisodeFileModalOpen: true }); + } + + onConfirmRemoveEpisodeFile = () => { + this.props.onDeleteEpisodeFile(); + this.setState({ isRemoveEpisodeFileModalOpen: false }); + } + + onRemoveEpisodeFileModalClose = () => { + this.setState({ isRemoveEpisodeFileModalOpen: false }); + } + + // + // Render + + render() { + const { + qualityProfileId, + network, + overview, + airDateUtc, + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + } = this.props; + + const hasOverview = !!overview; + + return ( +
    +
    + Airs + + +
    + +
    + Quality Profile + + +
    + +
    + { + hasOverview ? + overview : + 'No episode overview.' + } +
    + + { + path && +
    +
    +
    + Path +
    + +
    + Size +
    + +
    + Quality +
    + +
    +
    + +
    +
    + {path} +
    + +
    + {formatBytes(size)} +
    + +
    + +
    + +
    + + } + title="Media Info" + body={} + position={tooltipPositions.LEFT} + /> + + +
    +
    +
    + } + + +
    + ); + } +} + +EpisodeSummary.propTypes = { + episodeFileId: PropTypes.number.isRequired, + qualityProfileId: PropTypes.number.isRequired, + network: PropTypes.string.isRequired, + overview: PropTypes.string, + airDateUtc: PropTypes.string.isRequired, + mediaInfo: PropTypes.object, + path: PropTypes.string, + size: PropTypes.number, + quality: PropTypes.object, + qualityCutoffNotMet: PropTypes.bool, + onDeleteEpisodeFile: PropTypes.func.isRequired +}; + +export default EpisodeSummary; diff --git a/frontend/src/Episode/Summary/EpisodeSummaryConnector.js b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js new file mode 100644 index 000000000..e17f7b750 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js @@ -0,0 +1,59 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteEpisodeFile } from 'Store/Actions/episodeFileActions'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import EpisodeSummary from './EpisodeSummary'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createEpisodeFileSelector(), + (series, episode, episodeFile = {}) => { + const { + qualityProfileId, + network + } = series; + + const { + airDateUtc, + overview + } = episode; + + const { + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + } = episodeFile; + + return { + network, + qualityProfileId, + airDateUtc, + overview, + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteEpisodeFile() { + dispatch(deleteEpisodeFile({ + id: props.episodeFileId, + episodeEntity: props.episodeEntity + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary); diff --git a/frontend/src/Episode/Summary/MediaInfo.js b/frontend/src/Episode/Summary/MediaInfo.js new file mode 100644 index 000000000..af023266b --- /dev/null +++ b/frontend/src/Episode/Summary/MediaInfo.js @@ -0,0 +1,33 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function MediaInfo(props) { + return ( + + { + Object.keys(props).map((key) => { + const title = key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()); + + const value = props[key]; + + if (!value) { + return null; + } + + return ( + + ); + }) + } + + ); +} + +export default MediaInfo; diff --git a/frontend/src/Episode/episodeEntities.js b/frontend/src/Episode/episodeEntities.js new file mode 100644 index 000000000..fe21d4ed0 --- /dev/null +++ b/frontend/src/Episode/episodeEntities.js @@ -0,0 +1,13 @@ +export const CALENDAR = 'calendar'; +export const EPISODES = 'episodes'; +export const INTERACTIVE_IMPORT = 'interactiveImport.episodes'; +export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet'; +export const WANTED_MISSING = 'wanted.missing'; + +export default { + CALENDAR, + EPISODES, + INTERACTIVE_IMPORT, + WANTED_CUTOFF_UNMET, + WANTED_MISSING +}; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js new file mode 100644 index 000000000..35d00caf2 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EpisodeFileEditorModalContentConnector from './EpisodeFileEditorModalContentConnector'; + +function EpisodeFileEditorModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +EpisodeFileEditorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorModal; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css new file mode 100644 index 000000000..49e946826 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css @@ -0,0 +1,8 @@ +.actions { + display: flex; + margin-right: auto; +} + +.selectInput { + margin-left: 10px; +} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js new file mode 100644 index 000000000..1d65457d7 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js @@ -0,0 +1,285 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import SelectInput from 'Components/Form/SelectInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SeasonNumber from 'Season/SeasonNumber'; +import EpisodeFileEditorRow from './EpisodeFileEditorRow'; +import styles from './EpisodeFileEditorModalContent.css'; + +const columns = [ + { + name: 'episodeNumber', + label: 'Episode', + isVisible: true + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + } +]; + +class EpisodeFileEditorModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmDeleteModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.items !== this.props.items) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + const selectedIds = getSelectedIds(this.state.selectedState); + + return selectedIds.reduce((acc, id) => { + const matchingItem = this.props.items.find((item) => item.id === id); + + if (matchingItem && !acc.includes(matchingItem.episodeFileId)) { + acc.push(matchingItem.episodeFileId); + } + + return acc; + }, []); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.onDeletePress(this.getSelectedIds()); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onLanguageChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onLanguageChange(selectedIds, parseInt(value)); + } + + onQualityChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onQualityChange(selectedIds, parseInt(value)); + } + + // + // Render + + render() { + const { + seasonNumber, + isDeleting, + items, + languages, + qualities, + seriesType, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmDeleteModalOpen + } = this.state; + + const languageOptions = _.reduceRight(languages, (acc, language) => { + acc.push({ + key: language.id, + value: language.name + }); + + return acc; + }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]); + + const qualityOptions = _.reduceRight(qualities, (acc, quality) => { + acc.push({ + key: quality.id, + value: quality.name + }); + + return acc; + }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + + const hasSelectedFiles = this.getSelectedIds().length > 0; + + return ( + + + Manage Episodes {seasonNumber != null && } + + + + { + !items.length && +
    + No episode files to manage. +
    + } + + { + !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } +
    + + +
    + + Delete + + +
    + +
    + +
    + +
    +
    + + +
    + + +
    + ); + } +} + +EpisodeFileEditorModalContent.propTypes = { + seasonNumber: PropTypes.number, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string.isRequired, + onDeletePress: PropTypes.func.isRequired, + onLanguageChange: PropTypes.func.isRequired, + onQualityChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorModalContent; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js new file mode 100644 index 000000000..c20e752e3 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js @@ -0,0 +1,151 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import EpisodeFileEditorModalContent from './EpisodeFileEditorModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seasonNumber }) => seasonNumber, + (state) => state.episodes, + (state) => state.episodeFiles, + (state) => state.settings.languageProfiles.schema, + (state) => state.settings.qualityProfiles.schema, + createSeriesSelector(), + ( + seasonNumber, + episodes, + episodeFiles, + languageProfilesSchema, + qualityProfileSchema, + series + ) => { + const filtered = _.filter(episodes.items, (episode) => { + if (seasonNumber >= 0 && episode.seasonNumber !== seasonNumber) { + return false; + } + + if (!episode.episodeFileId) { + return false; + } + + return _.some(episodeFiles.items, { id: episode.episodeFileId }); + }); + + const sorted = _.orderBy(filtered, ['seasonNumber', 'episodeNumber'], ['desc', 'desc']); + + const items = _.map(sorted, (episode) => { + const episodeFile = _.find(episodeFiles.items, { id: episode.episodeFileId }); + + return { + relativePath: episodeFile.relativePath, + language: episodeFile.language, + quality: episodeFile.quality, + ...episode + }; + }); + + const languages = _.map(languageProfilesSchema.languages, 'language'); + const qualities = getQualities(qualityProfileSchema.items); + + return { + items, + seriesType: series.seriesType, + isDeleting: episodeFiles.isDeleting, + isSaving: episodeFiles.isSaving, + languages, + qualities + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchLanguageProfileSchema(name, path) { + dispatch(fetchLanguageProfileSchema()); + }, + + dispatchFetchQualityProfileSchema(name, path) { + dispatch(fetchQualityProfileSchema()); + }, + + dispatchUpdateEpisodeFiles(updateProps) { + dispatch(updateEpisodeFiles(updateProps)); + }, + + onDeletePress(episodeFileIds) { + dispatch(deleteEpisodeFiles({ episodeFileIds })); + } + }; +} + +class EpisodeFileEditorModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchLanguageProfileSchema(); + this.props.dispatchFetchQualityProfileSchema(); + } + + // + // Render + + // + // Listeners + + onLanguageChange = (episodeFileIds, languageId) => { + const language = _.find(this.props.languages, { id: languageId }); + + this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, language }); + } + + onQualityChange = (episodeFileIds, qualityId) => { + const quality = { + quality: _.find(this.props.qualities, { id: qualityId }), + revision: { + version: 1, + real: 0 + } + }; + + this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, quality }); + } + + render() { + const { + dispatchFetchLanguageProfileSchema, + dispatchFetchQualityProfileSchema, + dispatchUpdateEpisodeFiles, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EpisodeFileEditorModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, + dispatchUpdateEpisodeFiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeFileEditorModalContentConnector); diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css new file mode 100644 index 000000000..f86e1de6b --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css @@ -0,0 +1,3 @@ +.absoluteEpisodeNumber { + margin-left: 5px; +} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js new file mode 100644 index 000000000..62cedb8af --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import padNumber from 'Utilities/Number/padNumber'; +import Label from 'Components/Label'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import styles from './EpisodeFileEditorRow'; + +function EpisodeFileEditorRow(props) { + const { + id, + seriesType, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + relativePath, + airDateUtc, + language, + quality, + isSelected, + onSelectedChange + } = props; + + return ( + + + + + {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + + + {relativePath} + + + + + + + + + + + + + ); +} + +EpisodeFileEditorRow.propTypes = { + id: PropTypes.number.isRequired, + seriesType: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + relativePath: PropTypes.string.isRequired, + airDateUtc: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorRow; diff --git a/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js new file mode 100644 index 000000000..38713c011 --- /dev/null +++ b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; + +function createMapStateToProps() { + return createSelector( + createEpisodeFileSelector(), + (episodeFile) => { + return { + language: episodeFile ? episodeFile.language : undefined + }; + } + ); +} + +export default connect(createMapStateToProps)(EpisodeLanguage); diff --git a/frontend/src/EpisodeFile/MediaInfo.js b/frontend/src/EpisodeFile/MediaInfo.js new file mode 100644 index 000000000..75b264d58 --- /dev/null +++ b/frontend/src/EpisodeFile/MediaInfo.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import * as mediaInfoTypes from './mediaInfoTypes'; + +function MediaInfo(props) { + const { + type, + audioChannels, + audioCodec, + videoCodec + } = props; + + if (type === mediaInfoTypes.AUDIO) { + return ( + + { + !!audioCodec && + audioCodec + } + + { + !!audioCodec && !!audioChannels && + ' - ' + } + + { + !!audioChannels && + audioChannels.toFixed(1) + } + + ); + } + + if (type === mediaInfoTypes.VIDEO) { + return ( + + {videoCodec} + + ); + } + + return null; +} + +MediaInfo.propTypes = { + type: PropTypes.string.isRequired, + audioChannels: PropTypes.number, + audioCodec: PropTypes.string, + videoCodec: PropTypes.string +}; + +export default MediaInfo; diff --git a/frontend/src/EpisodeFile/MediaInfoConnector.js b/frontend/src/EpisodeFile/MediaInfoConnector.js new file mode 100644 index 000000000..bbb963cf4 --- /dev/null +++ b/frontend/src/EpisodeFile/MediaInfoConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import MediaInfo from './MediaInfo'; + +function createMapStateToProps() { + return createSelector( + createEpisodeFileSelector(), + (episodeFile) => { + if (episodeFile) { + return { + ...episodeFile.mediaInfo + }; + } + + return {}; + } + ); +} + +export default connect(createMapStateToProps)(MediaInfo); diff --git a/frontend/src/EpisodeFile/mediaInfoTypes.js b/frontend/src/EpisodeFile/mediaInfoTypes.js new file mode 100644 index 000000000..5e5a78e64 --- /dev/null +++ b/frontend/src/EpisodeFile/mediaInfoTypes.js @@ -0,0 +1,2 @@ +export const AUDIO = 'audio'; +export const VIDEO = 'video'; diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js new file mode 100644 index 000000000..11cca7d1b --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +function createRouteMatchShape(props) { + return PropTypes.shape({ + params: PropTypes.shape({ + ...props + }).isRequired + }); +} + +export default createRouteMatchShape; diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js new file mode 100644 index 000000000..80b53eb44 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/locationShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +const locationShape = PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string.isRequired, + state: PropTypes.object, + action: PropTypes.string, + key: PropTypes.string +}); + +export default locationShape; diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js new file mode 100644 index 000000000..cd672de27 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/settingShape.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; + +const settingShape = { + value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]), + warnings: PropTypes.arrayOf(PropTypes.string).isRequired, + errors: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export const arraySettingShape = { + ...settingShape, + value: PropTypes.array.isRequired +}; + +export const boolSettingShape = { + ...settingShape, + value: PropTypes.bool.isRequired +}; + +export const numberSettingShape = { + ...settingShape, + value: PropTypes.number.isRequired +}; + +export const stringSettingShape = { + ...settingShape, + value: PropTypes.string +}; + +export const tagSettingShape = { + ...settingShape, + value: PropTypes.arrayOf(PropTypes.number).isRequired +}; + +export default settingShape; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js new file mode 100644 index 000000000..f381959c6 --- /dev/null +++ b/frontend/src/Helpers/Props/align.js @@ -0,0 +1,5 @@ +export const LEFT = 'left'; +export const CENTER = 'center'; +export const RIGHT = 'right'; + +export const all = [LEFT, CENTER, RIGHT]; diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js new file mode 100644 index 000000000..72722ab63 --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -0,0 +1,50 @@ +import * as filterTypes from './filterTypes'; + +export const ARRAY = 'array'; +export const DATE = 'date'; +export const EXACT = 'exact'; +export const NUMBER = 'number'; +export const STRING = 'string'; + +export const all = [ + ARRAY, + DATE, + EXACT, + NUMBER, + STRING +]; + +export const possibleFilterTypes = { + [ARRAY]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } + ], + + [DATE]: [ + { key: filterTypes.LESS_THAN, value: 'is before' }, + { key: filterTypes.GREATER_THAN, value: 'is after' }, + { key: filterTypes.IN_LAST, value: 'in the last' }, + { key: filterTypes.IN_NEXT, value: 'in the next' } + ], + + [EXACT]: [ + { key: filterTypes.EQUAL, value: 'is' }, + { key: filterTypes.NOT_EQUAL, value: 'is not' } + ], + + [NUMBER]: [ + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.GREATER_THAN, value: 'greater than' }, + { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' }, + { key: filterTypes.LESS_THAN, value: 'less than' }, + { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ], + + [STRING]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }, + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ] +}; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js new file mode 100644 index 000000000..cacc24fda --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -0,0 +1,11 @@ +export const BOOL = 'bool'; +export const BYTES = 'bytes'; +export const DATE = 'date'; +export const DEFAULT = 'default'; +export const INDEXER = 'indexer'; +export const LANGUAGE_PROFILE = 'languageProfile'; +export const PROTOCOL = 'protocol'; +export const QUALITY = 'quality'; +export const QUALITY_PROFILE = 'qualityProfile'; +export const SERIES_STATUS = 'seriesStatus'; +export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js new file mode 100644 index 000000000..a3ea11956 --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypePredicates.js @@ -0,0 +1,45 @@ +import * as filterTypes from './filterTypes'; + +const filterTypePredicates = { + [filterTypes.CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return itemValue.some((v) => v === filterValue); + } + + return itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.EQUAL]: function(itemValue, filterValue) { + return itemValue === filterValue; + }, + + [filterTypes.GREATER_THAN]: function(itemValue, filterValue) { + return itemValue > filterValue; + }, + + [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue >= filterValue; + }, + + [filterTypes.LESS_THAN]: function(itemValue, filterValue) { + return itemValue < filterValue; + }, + + [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue <= filterValue; + }, + + [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return !itemValue.some((v) => v === filterValue); + } + + return !itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) { + return itemValue !== filterValue; + } +}; + +export default filterTypePredicates; diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js new file mode 100644 index 000000000..77809b8ce --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypes.js @@ -0,0 +1,21 @@ +export const CONTAINS = 'contains'; +export const EQUAL = 'equal'; +export const GREATER_THAN = 'greaterThan'; +export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual'; +export const IN_LAST = 'inLast'; +export const IN_NEXT = 'inNext'; +export const LESS_THAN = 'lessThan'; +export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual'; +export const NOT_CONTAINS = 'notContains'; +export const NOT_EQUAL = 'notEqual'; + +export const all = [ + CONTAINS, + EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL, + NOT_CONTAINS, + NOT_EQUAL +]; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js new file mode 100644 index 000000000..7865dcd6e --- /dev/null +++ b/frontend/src/Helpers/Props/icons.js @@ -0,0 +1,201 @@ +// +// Regular + +import { + faBookmark as farBookmark, + faCalendar as farCalendar, + faCircle as farCircle, + faClock as farClock, + faClone as farClone, + faDotCircle as farDotCircle, + faFile as farFile, + faFileArchive as farFileArchive, + faFileVideo as farFileVideo, + faFolder as farFolder, + faObjectGroup as farObjectGroup, + faHdd as farHdd, + faKeyboard as farKeyboard, + faObjectUngroup as farObjectUngroup +} from '@fortawesome/free-regular-svg-icons'; + +// +// Solid + +import { + faArrowCircleLeft as fasArrowCircleLeft, + faArrowCircleRight as fasArrowCircleRight, + faBackward as fasBackward, + faBars as fasBars, + faBolt as fasBolt, + faBookmark as fasBookmark, + faBookReader as fasBookReader, + faBug as fasBug, + faBroadcastTower as fasBroadcastTower, + faCalendarAlt as fasCalendarAlt, + faCaretDown as fasCaretDown, + faCheck as fasCheck, + faChevronCircleDown as fasChevronCircleDown, + faChevronCircleRight as fasChevronCircleRight, + faChevronCircleUp as fasChevronCircleUp, + faCheckCircle as fasCheckCircle, + faCircle as fasCircle, + faCloudDownloadAlt as fasCloudDownloadAlt, + faCloud as fasCloud, + faCog as fasCog, + faCogs as fasCogs, + faCopy as fasCopy, + faDesktop as fasDesktop, + faDownload as fasDownload, + faEllipsisH as fasEllipsisH, + faExclamationCircle as fasExclamationCircle, + faExclamationTriangle as fasExclamationTriangle, + faExternalLinkAlt as fasExternalLinkAlt, + faEye as fasEye, + faFastBackward as fasFastBackward, + faFastForward as fasFastForward, + faFileInvoice as farFileInvoice, + faFilter as fasFilter, + faFolderOpen as fasFolderOpen, + faForward as fasForward, + faHeart as fasHeart, + faHistory as fasHistory, + faHome as fasHome, + faInfoCircle as fasInfoCircle, + faLaptop as fasLaptop, + faLevelUpAlt as fasLevelUpAlt, + faMedkit as fasMedkit, + faMinus as fasMinus, + faPause as fasPause, + faPlay as fasPlay, + faPlus as fasPlus, + faPowerOff as fasPowerOff, + faQuestion as fasQuestion, + faQuestionCircle as fasQuestionCircle, + faRedoAlt as fasRedoAlt, + faRetweet as fasRetweet, + faRss as fasRss, + faRocket as fasRocket, + faSave as fasSave, + faSearch as fasSearch, + faSignOutAlt as fasSignOutAlt, + faSitemap as fasSitemap, + faSpinner as fasSpinner, + faSort as fasSort, + faSortDown as fasSortDown, + faSortUp as fasSortUp, + faStop as fasStop, + faSync as fasSync, + faTags as fasTags, + faTh as fasTh, + faThList as fasThList, + faTrashAlt as fasTrashAlt, + faTimes as fasTimes, + faTimesCircle as fasTimesCircle, + faUser as fasUser, + faUserPlus as fasUserPlus, + faVial as fasVial, + faWrench as fasWrench +} from '@fortawesome/free-solid-svg-icons'; + +// +// Icons + +export const ACTIONS = fasBolt; +export const ACTIVITY = farClock; +export const ADD = fasPlus; +export const ALTERNATE_TITLES = farClone; +export const ADVANCED_SETTINGS = fasCog; +export const ARROW_LEFT = fasArrowCircleLeft; +export const ARROW_RIGHT = fasArrowCircleRight; +export const BACKUP = farFileArchive; +export const BUG = fasBug; +export const CALENDAR = fasCalendarAlt; +export const CALENDAR_O = farCalendar; +export const CARET_DOWN = fasCaretDown; +export const CHECK = fasCheck; +export const CHECK_INDETERMINATE = fasMinus; +export const CHECK_CIRCLE = fasCheckCircle; +export const CIRCLE = fasCircle; +export const CIRCLE_OUTLINE = farCircle; +export const CLEAR = fasTrashAlt; +export const CLIPBOARD = fasCopy; +export const CLOSE = fasTimes; +export const CLONE = farClone; +export const COLLAPSE = fasChevronCircleUp; +export const COMPUTER = fasDesktop; +export const DANGER = fasExclamationCircle; +export const DELETE = fasTrashAlt; +export const DOWNLOAD = fasDownload; +export const DOWNLOADED = fasDownload; +export const DOWNLOADING = fasCloudDownloadAlt; +export const DRIVE = farHdd; +export const EDIT = fasWrench; +export const EPISODE_FILE = farFileVideo; +export const EXPAND = fasChevronCircleDown; +export const EXPAND_INDETERMINATE = fasChevronCircleRight; +export const EXTERNAL_LINK = fasExternalLinkAlt; +export const FATAL = fasTimesCircle; +export const FILE = farFile; +export const FILTER = fasFilter; +export const FOLDER = farFolder; +export const FOLDER_OPEN = fasFolderOpen; +export const GROUP = farObjectGroup; +export const HEALTH = fasMedkit; +export const HEART = fasHeart; +export const HISTORY = fasHistory; +export const HOUSEKEEPING = fasHome; +export const INFO = fasInfoCircle; +export const INTERACTIVE = fasUser; +export const KEYBOARD = farKeyboard; +export const LOGOUT = fasSignOutAlt; +export const MEDIA_INFO = farFileInvoice; +export const MISSING = fasExclamationTriangle; +export const MONITORED = fasBookmark; +export const NETWORK = fasBroadcastTower; +export const NAVBAR_COLLAPSE = fasBars; +export const NOT_AIRED = farClock; +export const ORGANIZE = fasSitemap; +export const OVERFLOW = fasEllipsisH; +export const OVERVIEW = fasThList; +export const PAGE_FIRST = fasFastBackward; +export const PAGE_PREVIOUS = fasBackward; +export const PAGE_NEXT = fasForward; +export const PAGE_LAST = fasFastForward; +export const PARENT = fasLevelUpAlt; +export const PAUSED = fasPause; +export const PENDING = farClock; +export const PROFILE = fasUser; +export const POSTER = fasTh; +export const QUEUED = fasCloud; +export const QUICK = fasRocket; +export const REFRESH = fasSync; +export const REMOVE = fasTimes; +export const RESTART = fasRedoAlt; +export const RESTORE = fasHistory; +export const REORDER = fasBars; +export const RSS = fasRss; +export const SAVE = fasSave; +export const SCHEDULED = farClock; +export const SCORE = fasUserPlus; +export const SEARCH = fasSearch; +export const SERIES_CONTINUING = fasPlay; +export const SERIES_ENDED = fasStop; +export const SETTINGS = fasCogs; +export const SHUTDOWN = fasPowerOff; +export const SORT = fasSort; +export const SORT_ASCENDING = fasSortUp; +export const SORT_DESCENDING = fasSortDown; +export const SPINNER = fasSpinner; +export const SUBTRACT = fasMinus; +export const SYSTEM = fasLaptop; +export const TAGS = fasTags; +export const TBA = fasQuestionCircle; +export const TEST = fasVial; +export const UNGROUP = farObjectUngroup; +export const UNKNOWN = fasQuestion; +export const UNMONITORED = farBookmark; +export const UPDATE = fasRetweet; +export const UNSAVED_SETTING = farDotCircle; +export const VIEW = fasEye; +export const WARNING = fasExclamationTriangle; +export const WIKI = fasBookReader; diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js new file mode 100644 index 000000000..3f4f94f6f --- /dev/null +++ b/frontend/src/Helpers/Props/index.js @@ -0,0 +1,29 @@ +import * as align from './align'; +import * as inputTypes from './inputTypes'; +import * as filterBuilderTypes from './filterBuilderTypes'; +import * as filterBuilderValueTypes from './filterBuilderValueTypes'; +import filterTypePredicates from './filterTypePredicates'; +import * as filterTypes from './filterTypes'; +import * as icons from './icons'; +import * as kinds from './kinds'; +import * as messageTypes from './messageTypes'; +import * as sizes from './sizes'; +import * as scrollDirections from './scrollDirections'; +import * as sortDirections from './sortDirections'; +import * as tooltipPositions from './tooltipPositions'; + +export { + align, + inputTypes, + filterBuilderTypes, + filterBuilderValueTypes, + filterTypePredicates, + filterTypes, + icons, + kinds, + messageTypes, + sizes, + scrollDirections, + sortDirections, + tooltipPositions +}; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js new file mode 100644 index 000000000..78a8aa1af --- /dev/null +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -0,0 +1,39 @@ +export const AUTO_COMPLETE = 'autoComplete'; +export const CAPTCHA = 'captcha'; +export const CHECK = 'check'; +export const DEVICE = 'device'; +export const KEY_VALUE_LIST = 'keyValueList'; +export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect'; +export const NUMBER = 'number'; +export const OAUTH = 'oauth'; +export const PASSWORD = 'password'; +export const PATH = 'path'; +export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; +export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect'; +export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; +export const SELECT = 'select'; +export const SERIES_TYPE_SELECT = 'seriesTypeSelect'; +export const TAG = 'tag'; +export const TEXT = 'text'; +export const TEXT_TAG = 'textTag'; + +export const all = [ + AUTO_COMPLETE, + CAPTCHA, + CHECK, + DEVICE, + KEY_VALUE_LIST, + MONITOR_EPISODES_SELECT, + NUMBER, + OAUTH, + PASSWORD, + PATH, + QUALITY_PROFILE_SELECT, + LANGUAGE_PROFILE_SELECT, + ROOT_FOLDER_SELECT, + SELECT, + SERIES_TYPE_SELECT, + TAG, + TEXT, + TEXT_TAG +]; diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js new file mode 100644 index 000000000..fd2c17f7b --- /dev/null +++ b/frontend/src/Helpers/Props/kinds.js @@ -0,0 +1,23 @@ +export const DANGER = 'danger'; +export const DEFAULT = 'default'; +export const DISABLED = 'disabled'; +export const INFO = 'info'; +export const INVERSE = 'inverse'; +export const PINK = 'pink'; +export const PRIMARY = 'primary'; +export const PURPLE = 'purple'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + DANGER, + DEFAULT, + DISABLED, + INFO, + INVERSE, + PINK, + PRIMARY, + PURPLE, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js new file mode 100644 index 000000000..997354f9d --- /dev/null +++ b/frontend/src/Helpers/Props/messageTypes.js @@ -0,0 +1,11 @@ +export const ERROR = 'error'; +export const INFO = 'info'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + ERROR, + INFO, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js new file mode 100644 index 000000000..5e4a4fe08 --- /dev/null +++ b/frontend/src/Helpers/Props/scrollDirections.js @@ -0,0 +1,5 @@ +export const NONE = 'none'; +export const HORIZONTAL = 'horizontal'; +export const VERTICAL = 'vertical'; + +export const all = [NONE, HORIZONTAL, VERTICAL]; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js new file mode 100644 index 000000000..d7f85df5e --- /dev/null +++ b/frontend/src/Helpers/Props/sizes.js @@ -0,0 +1,7 @@ +export const EXTRA_SMALL = 'extraSmall'; +export const SMALL = 'small'; +export const MEDIUM = 'medium'; +export const LARGE = 'large'; +export const EXTRA_LARGE = 'extraLarge'; + +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js new file mode 100644 index 000000000..ff3b17bb6 --- /dev/null +++ b/frontend/src/Helpers/Props/sortDirections.js @@ -0,0 +1,4 @@ +export const ASCENDING = 'ascending'; +export const DESCENDING = 'descending'; + +export const all = [ASCENDING, DESCENDING]; diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js new file mode 100644 index 000000000..bca3c4ed4 --- /dev/null +++ b/frontend/src/Helpers/Props/tooltipPositions.js @@ -0,0 +1,11 @@ +export const TOP = 'top'; +export const RIGHT = 'right'; +export const BOTTOM = 'bottom'; +export const LEFT = 'left'; + +export const all = [ + TOP, + RIGHT, + BOTTOM, + LEFT +]; diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js new file mode 100644 index 000000000..ed6ba080d --- /dev/null +++ b/frontend/src/Helpers/dragTypes.js @@ -0,0 +1,3 @@ +export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; +export const DELAY_PROFILE = 'delayProfile'; +export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js new file mode 100644 index 000000000..1c10b2f0e --- /dev/null +++ b/frontend/src/Helpers/elementChildren.js @@ -0,0 +1,149 @@ +// https://github.com/react-bootstrap/react-element-children + +import React from 'react'; + +/** + * Iterates through children that are typically specified as `props.children`, + * but only maps over children that are "valid components". + * + * The mapFunction provided index will be normalised to the components mapped, + * so an invalid component would not increase the index. + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @return {object} Object containing the ordered map of results. + */ +export function map(children, func, context) { + let index = 0; + + return React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return func.call(context, child, index++); + }); +} + +/** + * Iterates through children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for context. + */ +export function forEach(children, func, context) { + let index = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + func.call(context, child, index++); + }); +} + +/** + * Count the number of "valid components" in the Children container. + * + * @param {?*} children Children tree container. + * @returns {number} + */ +export function count(children) { + let result = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + ++result; + }); + + return result; +} + +/** + * Finds children that are typically specified as `props.children`, + * but only iterates over children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @returns {array} of children that meet the func return statement + */ +export function filter(children, func, context) { + const result = []; + + forEach(children, (child, index) => { + if (func.call(context, child, index)) { + result.push(child); + } + }); + + return result; +} + +export function find(children, func, context) { + let result = null; + + forEach(children, (child, index) => { + if (result) { + return; + } + if (func.call(context, child, index)) { + result = child; + } + }); + + return result; +} + +export function every(children, func, context) { + let result = true; + + forEach(children, (child, index) => { + if (!result) { + return; + } + if (!func.call(context, child, index)) { + result = false; + } + }); + + return result; +} + +export function some(children, func, context) { + let result = false; + + forEach(children, (child, index) => { + if (result) { + return; + } + + if (func.call(context, child, index)) { + result = true; + } + }); + + return result; +} + +export function toArray(children) { + const result = []; + + forEach(children, (child) => { + result.push(child); + }); + + return result; +} diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js new file mode 100644 index 000000000..512702c87 --- /dev/null +++ b/frontend/src/Helpers/getDisplayName.js @@ -0,0 +1,3 @@ +export default function getDisplayName(Component) { + return Component.displayName || Component.name || 'Component'; +} diff --git a/frontend/src/Hotkeys/Hotkeys.js b/frontend/src/Hotkeys/Hotkeys.js new file mode 100644 index 000000000..9aa64914b --- /dev/null +++ b/frontend/src/Hotkeys/Hotkeys.js @@ -0,0 +1,34 @@ +var $ = require('jquery'); +var vent = require('vent'); +var HotkeysView = require('./HotkeysView'); + +$(document).on('keypress', function(e) { + if ($(e.target).is('input') || $(e.target).is('textarea')) { + return; + } + + if (e.charCode === 63) { + vent.trigger(vent.Commands.OpenFullscreenModal, new HotkeysView()); + } +}); + +$(document).on('keydown', function(e) { + if (e.ctrlKey && e.keyCode === 83) { + vent.trigger(vent.Hotkeys.SaveSettings); + e.preventDefault(); + return; + } + + if ($(e.target).is('input') || $(e.target).is('textarea')) { + return; + } + + if (e.ctrlKey || e.metaKey || e.altKey) { + return; + } + + if (e.keyCode === 84) { + vent.trigger(vent.Hotkeys.NavbarSearch); + e.preventDefault(); + } +}); diff --git a/frontend/src/Hotkeys/HotkeysView.js b/frontend/src/Hotkeys/HotkeysView.js new file mode 100644 index 000000000..67575c726 --- /dev/null +++ b/frontend/src/Hotkeys/HotkeysView.js @@ -0,0 +1,6 @@ +var vent = require('vent'); +var Marionette = require('marionette'); + +module.exports = Marionette.ItemView.extend({ + template: 'Hotkeys/HotkeysViewTemplate' +}); diff --git a/frontend/src/Hotkeys/HotkeysViewTemplate.hbs b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs new file mode 100644 index 000000000..b0b181603 --- /dev/null +++ b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs @@ -0,0 +1,45 @@ + diff --git a/frontend/src/Hotkeys/hotkeys.less b/frontend/src/Hotkeys/hotkeys.less new file mode 100644 index 000000000..3e06b5c18 --- /dev/null +++ b/frontend/src/Hotkeys/hotkeys.less @@ -0,0 +1,23 @@ +.hotkeys-modal { + h3 { + margin-top: 0px; + margin-botton: 0px; + } + + .hotkey-group { + &:first-of-type { + margin-top: 0px; + } + + &:last-of-type { + margin-bottom: 0px; + } + + margin-top: 25px; + margin-bottom: 25px; + + .hotkey { + font-size: 22px; + } + } +} diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js new file mode 100644 index 000000000..31ea74d23 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectEpisodeModalContentConnector from './SelectEpisodeModalContentConnector'; + +class SelectEpisodeModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectEpisodeModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEpisodeModal; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js new file mode 100644 index 000000000..8f8906839 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectEpisodeRow from './SelectEpisodeRow'; + +const columns = [ + { + name: 'episodeNumber', + label: '#', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'airDate', + label: 'Air Date', + isVisible: true + } +]; + +class SelectEpisodeModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onEpisodesSelect = () => { + this.props.onEpisodesSelect(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const errorMessage = getErrorMessage(error, 'Unable to load episodes'); + + return ( + + + Manual Import - Select Episode(s) + + + + { + isFetching && + + } + + { + error && +
    {errorMessage}
    + } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } + + { + isPopulated && !items.length && + 'No episodes were found for the selected season' + } +
    + + + + + + +
    + ); + } +} + +SelectEpisodeModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onEpisodesSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEpisodeModalContent; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js new file mode 100644 index 000000000..016cc7a66 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { + updateInteractiveImportItem, + fetchInteractiveImportEpisodes, + setInteractiveImportEpisodesSort, + clearInteractiveImportEpisodes +} from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import SelectEpisodeModalContent from './SelectEpisodeModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport.episodes'), + (episodes) => { + return episodes; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportEpisodes, + setInteractiveImportEpisodesSort, + clearInteractiveImportEpisodes, + updateInteractiveImportItem +}; + +class SelectEpisodeModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchInteractiveImportEpisodes({ seriesId, seasonNumber }); + } + + componentWillUnmount() { + // This clears the episodes for the queue and hides the queue + // We'll need another place to store episodes for manual import + this.props.clearInteractiveImportEpisodes(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportEpisodesSort({ sortKey, sortDirection }); + } + + onEpisodesSelect = (episodeIds) => { + const episodes = _.reduce(this.props.items, (acc, item) => { + if (episodeIds.indexOf(item.id) > -1) { + acc.push(item); + } + + return acc; + }, []); + + this.props.updateInteractiveImportItem({ + id: this.props.id, + episodes: _.sortBy(episodes, 'episodeNumber') + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectEpisodeModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportEpisodes: PropTypes.func.isRequired, + setInteractiveImportEpisodesSort: PropTypes.func.isRequired, + clearInteractiveImportEpisodes: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectEpisodeModalContentConnector); diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js new file mode 100644 index 000000000..ba455121a --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; + +class SelectEpisodeRow extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + isSelected + } = this.props; + + this.props.onSelectedChange({ id, value: !isSelected }); + } + + // + // Render + + render() { + const { + id, + episodeNumber, + title, + airDate, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + {episodeNumber} + + + + {title} + + + + {airDate} + + + ); + } +} + +SelectEpisodeRow.propTypes = { + id: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + airDate: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default SelectEpisodeRow; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css new file mode 100644 index 000000000..86418a2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css @@ -0,0 +1,24 @@ +.recentFoldersContainer { + margin-top: 15px; +} + +.buttonsContainer { + margin-top: 30px; +} + +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from 'Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js new file mode 100644 index 000000000..78df1f53e --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import PathInputConnector from 'Components/Form/PathInputConnector'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RecentFolderRow from './RecentFolderRow'; +import styles from './InteractiveImportSelectFolderModalContent.css'; + +const recentFoldersColumns = [ + { + name: 'folder', + label: 'Folder' + }, + { + name: 'lastUsed', + label: 'Last Used' + }, + { + name: 'actions', + label: '' + } +]; + +class InteractiveImportSelectFolderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: '' + }; + } + + // + // Listeners + + onPathChange = ({ value }) => { + this.setState({ folder: value }); + } + + onRecentPathPress = (folder) => { + this.setState({ folder }); + } + + onQuickImportPress = () => { + this.props.onQuickImportPress(this.state.folder); + } + + onInteractiveImportPress = () => { + this.props.onInteractiveImportPress(this.state.folder); + } + + // + // Render + + render() { + const { + recentFolders, + onRemoveRecentFolderPress, + onModalClose + } = this.props; + + const folder = this.state.folder; + + return ( + + + Manual Import - Select Folder + + + + + + { + !!recentFolders.length && +
    + + + { + recentFolders.map((recentFolder) => { + return ( + + ); + }) + } + +
    +
    + } + +
    +
    + +
    + +
    + +
    +
    +
    + + + + +
    + ); + } +} + +InteractiveImportSelectFolderModalContent.propTypes = { + recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onQuickImportPress: PropTypes.func.isRequired, + onInteractiveImportPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js new file mode 100644 index 000000000..92d729800 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.interactiveImport.recentFolders, + (recentFolders) => { + return { + recentFolders + }; + } + ); +} + +const mapDispatchToProps = { + addRecentFolder, + removeRecentFolder, + executeCommand +}; + +class InteractiveImportSelectFolderModalContentConnector extends Component { + + // + // Listeners + + onQuickImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + + this.props.executeCommand({ + name: commandNames.DOWNLOADED_EPSIODES_SCAN, + path: folder + }); + + this.props.onModalClose(); + } + + onInteractiveImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + this.props.onFolderSelect(folder); + } + + onRemoveRecentFolderPress = (folder) => { + this.props.removeRecentFolder({ folder }); + } + + // + // Render + + render() { + if (this.path) { + return null; + } + + return ( + + ); + } +} + +InteractiveImportSelectFolderModalContentConnector.propTypes = { + path: PropTypes.string, + onFolderSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + addRecentFolder: PropTypes.func.isRequired, + removeRecentFolder: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css new file mode 100644 index 000000000..edb55b075 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css @@ -0,0 +1,5 @@ +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js new file mode 100644 index 000000000..403bce33d --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import styles from './RecentFolderRow.css'; + +class RecentFolderRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.folder); + } + + onRemovePress = (event) => { + event.stopPropagation(); + + const { + folder, + onRemoveRecentFolderPress + } = this.props; + + onRemoveRecentFolderPress(folder); + } + + // + // Render + + render() { + const { + folder, + lastUsed + } = this.props; + + return ( + + {folder} + + + + + + + + ); + } +} + +RecentFolderRow.propTypes = { + folder: PropTypes.string.isRequired, + lastUsed: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired +}; + +export default RecentFolderRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css new file mode 100644 index 000000000..5bad6c050 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -0,0 +1,73 @@ +.filterContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filterText { + margin-left: 5px; +} + +.footer { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + justify-content: space-between; + padding: 15px; +} + +.leftButtons, +.centerButtons, +.rightButtons { + display: flex; + flex: 1 0 33%; + flex-wrap: wrap; +} + +.centerButtons { + justify-content: center; +} + +.rightButtons { + justify-content: flex-end; +} + +.importMode { + composes: select from 'Components/Form/SelectInput.css'; + + width: auto; +} + +.errorMessage { + color: $dangerColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + .leftButtons, + .centerButtons, + .rightButtons { + flex-direction: column; + } + + .leftButtons { + align-items: flex-start; + } + + .centerButtons { + align-items: center; + } + + .rightButtons { + align-items: flex-end; + } + + a, + button { + margin-left: 0; + + &:first-child { + margin-bottom: 5px; + } + } + } +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js new file mode 100644 index 000000000..f0eaf0965 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -0,0 +1,402 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import InteractiveImportRow from './InteractiveImportRow'; +import styles from './InteractiveImportModalContent.css'; + +const columns = [ + { + name: 'relativePath', + label: 'Relative Path', + isSortable: true, + isVisible: true + }, + { + name: 'series', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'season', + label: 'Season', + isVisible: true + }, + { + name: 'episodes', + label: 'Episode(s)', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + kind: kinds.DANGER + }), + isVisible: true + } +]; + +const filterExistingFilesOptions = { + ALL: 'all', + NEW: 'new' +}; + +class InteractiveImportModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + invalidRowsSelected: [], + isSelectSeriesModalOpen: false, + isSelectSeasonModalOpen: false + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onValidRowChange = (id, isValid) => { + this.setState((state) => { + if (isValid) { + return { + invalidRowsSelected: _.without(state.invalidRowsSelected, id) + }; + } + + return { + invalidRowsSelected: [...state.invalidRowsSelected, id] + }; + }); + } + + onImportSelectedPress = () => { + const { + downloadId, + showImportMode, + importMode, + onImportSelectedPress + } = this.props; + + const selected = this.getSelectedIds(); + const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; + + onImportSelectedPress(selected, finalImportMode); + } + + onFilterExistingFilesChange = (value) => { + this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); + } + + onImportModeChange = ({ value }) => { + this.props.onImportModeChange(value); + } + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectSeasonPress = () => { + this.setState({ isSelectSeasonModalOpen: true }); + } + + onSelectSeriesModalClose = () => { + this.setState({ isSelectSeriesModalOpen: false }); + } + + onSelectSeasonModalClose = () => { + this.setState({ isSelectSeasonModalOpen: false }); + } + + // + // Render + + render() { + const { + downloadId, + allowSeriesChange, + showFilterExistingFiles, + showImportMode, + filterExistingFiles, + title, + folder, + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + importMode, + interactiveImportErrorMessage, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + invalidRowsSelected, + isSelectSeriesModalOpen, + isSelectSeasonModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + const importModeOptions = [ + { key: 'move', value: 'Move Files' }, + { key: 'copy', value: 'Copy Files' } + ]; + + return ( + + + Manual Import - {title || folder} + + + + { + showFilterExistingFiles && +
    + + + + +
    + { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
    +
    + + + + All Files + + + + Unmapped Files Only + + +
    +
    + } + + { + isFetching && + + } + + { + error && +
    {errorMessage}
    + } + + { + isPopulated && !!items.length && !isFetching && !isFetching && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } + + { + isPopulated && !items.length && !isFetching && + 'No video files were found in the selected folder' + } +
    + + +
    + { + !downloadId && showImportMode && + + } +
    + +
    + { + allowSeriesChange && + + } + + +
    + +
    + + + { + interactiveImportErrorMessage && + {interactiveImportErrorMessage} + } + + +
    +
    + + + + +
    + ); + } +} + +InteractiveImportModalContent.propTypes = { + downloadId: PropTypes.string, + allowSeriesChange: PropTypes.bool.isRequired, + showImportMode: PropTypes.bool.isRequired, + showFilterExistingFiles: PropTypes.bool.isRequired, + filterExistingFiles: PropTypes.bool.isRequired, + importMode: PropTypes.string.isRequired, + title: PropTypes.string, + folder: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + interactiveImportErrorMessage: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onFilterExistingFilesChange: PropTypes.func.isRequired, + onImportModeChange: PropTypes.func.isRequired, + onImportSelectedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContent.defaultProps = { + allowSeriesChange: true, + showFilterExistingFiles: false, + showImportMode: true, + importMode: 'move' +}; + +export default InteractiveImportModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js new file mode 100644 index 000000000..3a7b03fb6 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -0,0 +1,203 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportModalContent from './InteractiveImportModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport'), + (interactiveImport) => { + return interactiveImport; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportItems, + setInteractiveImportSort, + setInteractiveImportMode, + clearInteractiveImport, + executeCommand +}; + +class InteractiveImportModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + interactiveImportErrorMessage: null, + filterExistingFiles: true + }; + } + + componentDidMount() { + const { + downloadId, + folder + } = this.props; + + const { + filterExistingFiles + } = this.state; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } + + componentDidUpdate(prevProps, prevState) { + const { + filterExistingFiles + } = this.state; + + if (prevState.filterExistingFiles !== filterExistingFiles) { + const { + downloadId, + folder + } = this.props; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } + } + + componentWillUnmount() { + this.props.clearInteractiveImport(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportSort({ sortKey, sortDirection }); + } + + onFilterExistingFilesChange = (filterExistingFiles) => { + this.setState({ filterExistingFiles }); + } + + onImportModeChange = (importMode) => { + this.props.setInteractiveImportMode({ importMode }); + } + + onImportSelectedPress = (selected, importMode) => { + const files = []; + + _.forEach(this.props.items, (item) => { + const isSelected = selected.indexOf(item.id) > -1; + + if (isSelected) { + const { + series, + seasonNumber, + episodes, + quality, + language + } = item; + + if (!series) { + this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' }); + return false; + } + + if (isNaN(seasonNumber)) { + this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' }); + return false; + } + + if (!episodes || !episodes.length) { + this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' }); + return false; + } + + if (!quality) { + this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' }); + return false; + } + + if (!language) { + this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' }); + return false; + } + + files.push({ + path: item.path, + folderName: item.folderName, + seriesId: series.id, + episodeIds: _.map(episodes, 'id'), + quality, + language, + downloadId: this.props.downloadId + }); + } + }); + + if (!files.length) { + return; + } + + this.props.executeCommand({ + name: commandNames.INTERACTIVE_IMPORT, + files, + importMode + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + interactiveImportErrorMessage, + filterExistingFiles + } = this.state; + + return ( + + ); + } +} + +InteractiveImportModalContentConnector.propTypes = { + downloadId: PropTypes.string, + folder: PropTypes.string, + filterExistingFiles: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportItems: PropTypes.func.isRequired, + setInteractiveImportSort: PropTypes.func.isRequired, + clearInteractiveImport: PropTypes.func.isRequired, + setInteractiveImportMode: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContentConnector.defaultProps = { + filterExistingFiles: true +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css new file mode 100644 index 000000000..89f43cebc --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css @@ -0,0 +1,18 @@ +.relativePath { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality, +.language { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.label { + composes: label from 'Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js new file mode 100644 index 000000000..d95af5d72 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -0,0 +1,370 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; +import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; +import styles from './InteractiveImportRow.css'; + +class InteractiveImportRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isSelectSeriesModalOpen: false, + isSelectSeasonModalOpen: false, + isSelectEpisodeModalOpen: false, + isSelectQualityModalOpen: false, + isSelectLanguageModalOpen: false + }; + } + + componentDidMount() { + const { + id, + series, + seasonNumber, + episodes, + quality, + language + } = this.props; + + if ( + series && + seasonNumber != null && + episodes.length && + quality && + language + ) { + this.props.onSelectedChange({ id, value: true }); + } + } + + componentDidUpdate(prevProps) { + const { + id, + series, + seasonNumber, + episodes, + quality, + language, + isSelected, + onValidRowChange + } = this.props; + + if ( + prevProps.series === series && + prevProps.seasonNumber === seasonNumber && + !hasDifferentItems(prevProps.episodes, episodes) && + prevProps.quality === quality && + prevProps.language === language && + prevProps.isSelected === isSelected + ) { + return; + } + + const isValid = !!( + series && + seasonNumber != null && + episodes.length && + quality && + language + ); + + if (isSelected && !isValid) { + onValidRowChange(id, false); + } else { + onValidRowChange(id, true); + } + } + + // + // Control + + selectRowAfterChange = (value) => { + const { + id, + isSelected + } = this.props; + + if (!isSelected && value === true) { + this.props.onSelectedChange({ id, value }); + } + } + + // + // Listeners + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectSeasonPress = () => { + this.setState({ isSelectSeasonModalOpen: true }); + } + + onSelectEpisodePress = () => { + this.setState({ isSelectEpisodeModalOpen: true }); + } + + onSelectQualityPress = () => { + this.setState({ isSelectQualityModalOpen: true }); + } + + onSelectLanguagePress = () => { + this.setState({ isSelectLanguageModalOpen: true }); + } + + onSelectSeriesModalClose = (changed) => { + this.setState({ isSelectSeriesModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectSeasonModalClose = (changed) => { + this.setState({ isSelectSeasonModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectEpisodeModalClose = (changed) => { + this.setState({ isSelectEpisodeModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectQualityModalClose = (changed) => { + this.setState({ isSelectQualityModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectLanguageModalClose = (changed) => { + this.setState({ isSelectLanguageModalOpen: false }); + this.selectRowAfterChange(changed); + } + + // + // Render + + render() { + const { + id, + allowSeriesChange, + relativePath, + series, + seasonNumber, + episodes, + quality, + language, + size, + rejections, + isSelected, + onSelectedChange + } = this.props; + + const { + isSelectSeriesModalOpen, + isSelectSeasonModalOpen, + isSelectEpisodeModalOpen, + isSelectQualityModalOpen, + isSelectLanguageModalOpen + } = this.state; + + const seriesTitle = series ? series.title : ''; + const episodeNumbers = episodes.map((episode) => episode.episodeNumber) + .join(', '); + + const showSeriesPlaceholder = isSelected && !series; + const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber); + const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length; + const showQualityPlaceholder = isSelected && !quality; + const showLanguagePlaceholder = isSelected && !language; + + return ( + + + + + {relativePath} + + + + { + showSeriesPlaceholder ? : seriesTitle + } + + + + { + showSeasonNumberPlaceholder ? : seasonNumber + } + + + + { + showEpisodeNumbersPlaceholder ? : episodeNumbers + } + + + + { + showQualityPlaceholder && + + } + + { + !showQualityPlaceholder && !!quality && + + } + + + + { + showLanguagePlaceholder && + + } + + { + !showLanguagePlaceholder && !!language && + + } + + + + {formatBytes(size)} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
      + { + rejections.map((rejection, index) => { + return ( +
    • + {rejection.reason} +
    • + ); + }) + } +
    + } + position={tooltipPositions.LEFT} + /> + } +
    + + + + + + + + 1 : false} + real={quality ? quality.revision.real > 0 : false} + onModalClose={this.onSelectQualityModalClose} + /> + + +
    + ); + } + +} + +InteractiveImportRow.propTypes = { + id: PropTypes.number.isRequired, + allowSeriesChange: PropTypes.bool.isRequired, + relativePath: PropTypes.string.isRequired, + series: PropTypes.object, + seasonNumber: PropTypes.number, + episodes: PropTypes.arrayOf(PropTypes.object).isRequired, + quality: PropTypes.object, + language: PropTypes.object, + size: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onValidRowChange: PropTypes.func.isRequired +}; + +InteractiveImportRow.defaultProps = { + episodes: [] +}; + +export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css new file mode 100644 index 000000000..941988144 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css @@ -0,0 +1,7 @@ +.placeholder { + display: inline-block; + margin: -8px 0; + width: 100%; + height: 25px; + border: 2px dashed $dangerColor; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js new file mode 100644 index 000000000..b6744d156 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './InteractiveImportRowCellPlaceholder.css'; + +function InteractiveImportRowCellPlaceholder() { + return ( + + ); +} + +export default InteractiveImportRowCellPlaceholder; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js new file mode 100644 index 000000000..0ea6fd9cb --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector'; +import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector'; + +class InteractiveImportModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ folder: null }); + } + } + + // + // Listeners + + onFolderSelect = (folder) => { + this.setState({ folder }); + } + + // + // Render + + render() { + const { + isOpen, + folder, + downloadId, + onModalClose, + ...otherProps + } = this.props; + + const folderPath = folder || this.state.folder; + + return ( + + { + folderPath || downloadId ? + : + + } + + ); + } +} + +InteractiveImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + folder: PropTypes.string, + downloadId: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModal.js b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js new file mode 100644 index 000000000..938d26a6d --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector'; + +class SelectLanguageModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectLanguageModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectLanguageModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js new file mode 100644 index 000000000..ff99ce6bf --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function SelectLanguageModalContent(props) { + const { + languageId, + isFetching, + isPopulated, + error, + items, + onModalClose, + onLanguageSelect + } = props; + + const languageOptions = items.map(({ language }) => { + return { + key: language.id, + value: language.name + }; + }); + + return ( + + + Manual Import - Select Language + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to load languages
    + } + + { + isPopulated && !error && +
    + + Language + + + +
    + } +
    + + + + +
    + ); +} + +SelectLanguageModalContent.propTypes = { + languageId: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onLanguageSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectLanguageModalContent; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js new file mode 100644 index 000000000..56e95b861 --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js @@ -0,0 +1,87 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import SelectLanguageModalContent from './SelectLanguageModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + (languageProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = languageProfiles; + + return { + isFetching, + isPopulated, + error, + items: schema.languages ? [...schema.languages].reverse() : [] + }; + } + ); +} + +const mapDispatchToProps = { + fetchLanguageProfileSchema, + updateInteractiveImportItem +}; + +class SelectLanguageModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.fetchLanguageProfileSchema(); + } + } + + // + // Listeners + + onLanguageSelect = ({ value }) => { + const languageId = parseInt(value); + const language = _.find(this.props.items, + (item) => item.language.id === languageId).language; + + this.props.updateInteractiveImportItem({ + id: this.props.id, + language + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectLanguageModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchLanguageProfileSchema: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector); diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js new file mode 100644 index 000000000..d3e31d2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectQualityModalContentConnector from './SelectQualityModalContentConnector'; + +class SelectQualityModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectQualityModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js new file mode 100644 index 000000000..642e0433e --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class SelectQualityModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + qualityId, + proper, + real + } = props; + + this.state = { + qualityId, + proper, + real + }; + } + + // + // Listeners + + onQualityChange = ({ value }) => { + this.setState({ qualityId: parseInt(value) }); + } + + onProperChange = ({ value }) => { + this.setState({ proper: value }); + } + + onRealChange = ({ value }) => { + this.setState({ real: value }); + } + + onQualitySelect = () => { + this.props.onQualitySelect(this.state); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onModalClose + } = this.props; + + const { + qualityId, + proper, + real + } = this.state; + + const qualityOptions = items.map(({ id, name }) => { + return { + key: id, + value: name + }; + }); + + return ( + + + Manual Import - Select Quality + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to load qualities
    + } + + { + isPopulated && !error && +
    + + Quality + + + + + + Proper + + + + + + Real + + + +
    + } +
    + + + + + + +
    + ); + } +} + +SelectQualityModalContent.propTypes = { + qualityId: PropTypes.number.isRequired, + proper: PropTypes.bool.isRequired, + real: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onQualitySelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModalContent; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js new file mode 100644 index 000000000..20a49c768 --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import SelectQualityModalContent from './SelectQualityModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + return { + isFetching, + isPopulated, + error, + items: getQualities(schema.items) + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + updateInteractiveImportItem +}; + +class SelectQualityModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + // + // Listeners + + onQualitySelect = ({ qualityId, proper, real }) => { + const quality = _.find(this.props.items, + (item) => item.id === qualityId); + + const revision = { + version: proper ? 2 : 1, + real: real ? 1 : 0 + }; + + this.props.updateInteractiveImportItem({ + id: this.props.id, + quality: { + quality, + revision + } + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectQualityModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector); diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModal.js b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js new file mode 100644 index 000000000..9de9ee493 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeasonModalContentConnector from './SelectSeasonModalContentConnector'; + +class SelectSeasonModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectSeasonModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeasonModal; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js new file mode 100644 index 000000000..267174491 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectSeasonRow from './SelectSeasonRow'; + +class SelectSeasonModalContent extends Component { + + // + // Render + + render() { + const { + items, + onSeasonSelect, + onModalClose + } = this.props; + + return ( + + + Manual Import - Select Season + + + + { + items.map((item) => { + return ( + + ); + }) + } + + + + + + + ); + } +} + +SelectSeasonModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeasonSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeasonModalContent; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js new file mode 100644 index 000000000..b84fdf148 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import SelectSeasonModalContent from './SelectSeasonModalContent'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + if (!series) { + return { + items: [] + }; + } + + return { + items: series.seasons.slice(0).reverse() + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem +}; + +class SelectSeasonModalContentConnector extends Component { + + // + // Listeners + + onSeasonSelect = (seasonNumber) => { + this.props.ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + seasonNumber, + episodes: [] + }); + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectSeasonModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + seriesId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeasonModalContentConnector); diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.css b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css new file mode 100644 index 000000000..c43d879f4 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css @@ -0,0 +1,4 @@ +.season { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.js b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js new file mode 100644 index 000000000..d6cf5aea1 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeasonRow.css'; + +class SelectSeasonRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onSeasonSelect(this.props.seasonNumber); + } + + // + // Render + + render() { + const seasonNumber = this.props.seasonNumber; + + return ( + + { + seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}` + } + + ); + } +} + +SelectSeasonRow.propTypes = { + seasonNumber: PropTypes.number.isRequired, + onSeasonSelect: PropTypes.func.isRequired +}; + +export default SelectSeasonRow; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js new file mode 100644 index 000000000..1a1ceffca --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector'; + +class SelectSeriesModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModal; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css new file mode 100644 index 000000000..c22d502f5 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from 'Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js new file mode 100644 index 000000000..1b4cb52fe --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import TextInput from 'Components/Form/TextInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectSeriesRow from './SelectSeriesRow'; +import styles from './SelectSeriesModalContent.css'; + +class SelectSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filter: '' + }; + } + + // + // Listeners + + onFilterChange = ({ value }) => { + this.setState({ filter: value.toLowerCase() }); + } + + // + // Render + + render() { + const { + items, + onSeriesSelect, + onModalClose + } = this.props; + + const filter = this.state.filter; + + return ( + + + Manual Import - Select Series + + + + + + + { + items.map((item) => { + return item.title.toLowerCase().includes(filter) ? + ( + + ) : + null; + }) + } + + + + + + + + ); + } +} + +SelectSeriesModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeriesSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModalContent; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js new file mode 100644 index 000000000..07dab9f0b --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import SelectSeriesModalContent from './SelectSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + createAllSeriesSelector(), + (items) => { + return { + items: items.sort((a, b) => { + if (a.sortTitle < b.sortTitle) { + return -1; + } + + if (a.sortTitle > b.sortTitle) { + return 1; + } + + return 0; + }) + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem +}; + +class SelectSeriesModalContentConnector extends Component { + + // + // Listeners + + onSeriesSelect = (seriesId) => { + const series = _.find(this.props.items, { id: seriesId }); + + this.props.ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + series, + seasonNumber: undefined, + episodes: [] + }); + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectSeriesModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector); diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.css b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css new file mode 100644 index 000000000..f2573d585 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css @@ -0,0 +1,4 @@ +.series { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.js b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js new file mode 100644 index 000000000..49af64ecf --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeriesRow.css'; + +class SelectSeriesRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onSeriesSelect(this.props.id); + } + + // + // Render + + render() { + return ( + + {this.props.title} + + ); + } +} + +SelectSeriesRow.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + onSeriesSelect: PropTypes.func.isRequired +}; + +export default SelectSeriesRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css new file mode 100644 index 000000000..5e647332f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.css @@ -0,0 +1,9 @@ +.filterMenuContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filteredMessage { + margin-top: 10px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js new file mode 100644 index 000000000..862bc0c55 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns = [ + { + name: 'protocol', + label: 'Source', + isSortable: true, + isVisible: true + }, + { + name: 'age', + label: 'Age', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: 'Peers', + isSortable: true, + isVisible: true + }, + { + name: 'languageWeight', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'qualityWeight', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'preferredWordScore', + label: React.createElement(Icon, { + name: icons.SCORE, + title: 'Preferred word score' + }), + isSortable: true, + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + title: 'Rejections' + }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + } +]; + +function InteractiveSearch(props) { + const { + isFetching, + isPopulated, + error, + totalReleasesCount, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + type, + longDateFormat, + timeFormat, + onSortPress, + onFilterSelect, + onGrabPress + } = props; + + return ( +
    +
    + +
    + + { + isFetching && + + } + + { + !isFetching && !!error && +
    + Unable to load results for this episode search. Try again later +
    + } + + { + !isFetching && isPopulated && !totalReleasesCount && +
    + No results found +
    + } + + { + !!totalReleasesCount && isPopulated && !items.length && +
    + All results are hidden by the applied filter +
    + } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } + + { + totalReleasesCount !== items.length && !!items.length && +
    + Some results are hidden by the applied filter +
    + } +
    + ); +} + +InteractiveSearch.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + type: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js new file mode 100644 index 000000000..601f337d6 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as releaseActions from 'Store/Actions/releaseActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import InteractiveSearch from './InteractiveSearch'; + +function createMapStateToProps(appState, { type }) { + return createSelector( + (state) => state.releases.items.length, + createClientSideCollectionSelector('releases', `releases.${type}`), + createUISettingsSelector(), + (totalReleasesCount, releases, uiSettings) => { + return { + totalReleasesCount, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + ...releases + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchReleases(payload) { + dispatch(releaseActions.fetchReleases(payload)); + }, + + onSortPress(sortKey, sortDirection) { + dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); + }, + + onFilterSelect(selectedFilterKey) { + const action = props.type === 'episode' ? + releaseActions.setEpisodeReleasesFilter : + releaseActions.setSeasonReleasesFilter; + + dispatch(action({ selectedFilterKey })); + }, + + onGrabPress(guid, indexerId) { + dispatch(releaseActions.grabRelease({ guid, indexerId })); + } + }; +} + +class InteractiveSearchConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + searchPayload, + isPopulated, + dispatchFetchReleases + } = this.props; + + // If search results are not yet isPopulated fetch them, + // otherwise re-show the existing props. + + if (!isPopulated) { + dispatchFetchReleases(searchPayload); + } + } + + // + // Render + + render() { + const { + dispatchFetchReleases, + ...otherProps + } = this.props; + + return ( + + + ); + } +} + +InteractiveSearchConnector.propTypes = { + searchPayload: PropTypes.object.isRequired, + isPopulated: PropTypes.bool.isRequired, + dispatchFetchReleases: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js new file mode 100644 index 000000000..dcbcf340f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases.items, + (state) => state.releases.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'releases' + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchSetFilter(payload) { + const action = props.type === 'episode' ? + setEpisodeReleasesFilter: + setSeasonReleasesFilter; + + dispatch(action(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css new file mode 100644 index 000000000..98503e496 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -0,0 +1,38 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality, +.language { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.language { + width: 100px; +} + +.preferredWordScore { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 55px; + font-weight: bold; + cursor: default; +} + +.rejected, +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.age, +.size { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js new file mode 100644 index 000000000..4f8c5089f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import Peers from './Peers'; +import styles from './InteractiveSearchRow.css'; + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return 'Added to downloaded queue'; + } else if (grabError) { + return grabError; + } + + return 'Add to downloaded queue'; +} + +class InteractiveSearchRow extends Component { + + // + // Listeners + + onGrabPress = () => { + const { + guid, + indexerId, + onGrabPress + }= this.props; + + onGrabPress(guid, indexerId); + } + + // + // Render + + render() { + const { + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + quality, + language, + preferredWordScore, + rejections, + downloadAllowed, + isGrabbing, + isGrabbed, + longDateFormat, + timeFormat, + grabError + } = this.props; + + return ( + + + + + + + {formatAge(age, ageHours, ageMinutes)} + + + + + {title} + + + + + {indexer} + + + + {formatBytes(size)} + + + + { + protocol === 'torrent' && + + } + + + + + + + + + + + + {preferredWordScore > 0 && `+${preferredWordScore}`} + {preferredWordScore < 0 && preferredWordScore} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
      + { + rejections.map((rejection, index) => { + return ( +
    • + {rejection} +
    • + ); + }) + } +
    + } + position={tooltipPositions.LEFT} + /> + } +
    + + + { + downloadAllowed && + + } + +
    + ); + } +} + +InteractiveSearchRow.propTypes = { + guid: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + indexer: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + seeders: PropTypes.number, + leechers: PropTypes.number, + quality: PropTypes.object.isRequired, + language: PropTypes.object.isRequired, + preferredWordScore: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.string).isRequired, + downloadAllowed: PropTypes.bool.isRequired, + isGrabbing: PropTypes.bool.isRequired, + isGrabbed: PropTypes.bool.isRequired, + grabError: PropTypes.string, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +InteractiveSearchRow.defaultProps = { + rejections: [], + isGrabbing: false, + isGrabbed: false +}; + +export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js new file mode 100644 index 000000000..66f7cc9f5 --- /dev/null +++ b/frontend/src/InteractiveSearch/Peers.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getKind(seeders) { + if (seeders > 50) { + return kinds.PRIMARY; + } + + if (seeders > 10) { + return kinds.INFO; + } + + if (seeders > 0) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +function getPeersTooltipPart(peers, peersUnit) { + if (peers == null) { + return `unknown ${peersUnit}s`; + } + + if (peers === 1) { + return `1 ${peersUnit}`; + } + + return `${peers} ${peersUnit}s`; +} + +function Peers(props) { + const { + seeders, + leechers + } = props; + + const kind = getKind(seeders); + + return ( + + ); +} + +Peers.propTypes = { + seeders: PropTypes.number, + leechers: PropTypes.number +}; + +export default Peers; diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js new file mode 100644 index 000000000..647f4ddf8 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector'; + +function OrganizePreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +OrganizePreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModal; diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js new file mode 100644 index 000000000..ace733c86 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import OrganizePreviewModal from './OrganizePreviewModal'; + +const mapDispatchToProps = { + clearOrganizePreview +}; + +class OrganizePreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearOrganizePreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalConnector.propTypes = { + clearOrganizePreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector); diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css new file mode 100644 index 000000000..7de056fcc --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.episodeFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js new file mode 100644 index 000000000..6962486df --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -0,0 +1,203 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CheckInput from 'Components/Form/CheckInput'; +import SeasonNumber from 'Season/SeasonNumber'; +import OrganizePreviewRow from './OrganizePreviewRow'; +import styles from './OrganizePreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class OrganizePreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onOrganizePress = () => { + this.props.onOrganizePress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + seasonNumber, + renameEpisodes, + episodeFormat, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Organize & Rename {seasonNumber != null && } + + + + { + isFetching && + + } + + { + !isFetching && error && +
    Error loading previews
    + } + + { + !isFetching && isPopulated && !items.length && +
    + { + renameEpisodes ? +
    Success! My work is done, no files to rename.
    : +
    Renaming is disabled, nothing to rename
    + } +
    + } + + { + !isFetching && isPopulated && !!items.length && +
    + +
    + All paths are relative to: + + {path} + +
    + +
    + Naming pattern: + + {episodeFormat} + +
    +
    + +
    + { + items.map((item) => { + return ( + + ); + }) + } +
    +
    + } +
    + + + { + isPopulated && !!items.length && + + } + + + + + +
    + ); + } +} + +OrganizePreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + seasonNumber: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + renameEpisodes: PropTypes.bool, + episodeFormat: PropTypes.string, + onOrganizePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModalContent; diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js new file mode 100644 index 000000000..cbb150407 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import { fetchNamingSettings } from 'Store/Actions/settingsActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizePreviewModalContent from './OrganizePreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.organizePreview, + (state) => state.settings.naming, + createSeriesSelector(), + (organizePreview, naming, series) => { + const props = { ...organizePreview }; + props.isFetching = organizePreview.isFetching || naming.isFetching; + props.isPopulated = organizePreview.isPopulated && naming.isPopulated; + props.error = organizePreview.error || naming.error; + props.renameEpisodes = naming.item.renameEpisodes; + props.episodeFormat = naming.item[`${series.seriesType}EpisodeFormat`]; + props.path = series.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchOrganizePreview, + fetchNamingSettings, + executeCommand +}; + +class OrganizePreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchOrganizePreview({ + seriesId, + seasonNumber + }); + + this.props.fetchNamingSettings(); + } + + // + // Listeners + + onOrganizePress = (files) => { + this.props.executeCommand({ + name: commandNames.RENAME_FILES, + seriesId: this.props.seriesId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchOrganizePreview: PropTypes.func.isRequired, + fetchNamingSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector); diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css new file mode 100644 index 000000000..1b3c8ca47 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.css @@ -0,0 +1,20 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; +} diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js new file mode 100644 index 000000000..340232a98 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './OrganizePreviewRow.css'; + +class OrganizePreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + existingPath, + newPath, + isSelected + } = this.props; + + return ( +
    + + +
    +
    + + + + {existingPath} + +
    + +
    + + + + {newPath} + +
    +
    +
    + ); + } +} + +OrganizePreviewRow.propTypes = { + id: PropTypes.number.isRequired, + existingPath: PropTypes.string.isRequired, + newPath: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default OrganizePreviewRow; diff --git a/frontend/src/Season/SeasonNumber.js b/frontend/src/Season/SeasonNumber.js new file mode 100644 index 000000000..8db967243 --- /dev/null +++ b/frontend/src/Season/SeasonNumber.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; + +function SeasonNumber(props) { + const { + seasonNumber, + separator + } = props; + + if (seasonNumber === 0) { + return `${separator}Specials`; + } + + if (seasonNumber > 0) { + return `${separator}Season ${seasonNumber}`; + } + + return null; +} + +SeasonNumber.propTypes = { + seasonNumber: PropTypes.number.isRequired, + separator: PropTypes.string.isRequired +}; + +SeasonNumber.defaultProps = { + separator: '- ' +}; + +export default SeasonNumber; diff --git a/frontend/src/SeasonPass/SeasonPass.js b/frontend/src/SeasonPass/SeasonPass.js new file mode 100644 index 000000000..7907f9249 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPass.js @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoSeries from 'Series/NoSeries'; +import SeasonPassFilterModalConnector from './SeasonPassFilterModalConnector'; +import SeasonPassFooter from './SeasonPassFooter'; +import SeasonPassRowConnector from './SeasonPassRowConnector'; + +const columns = [ + { + name: 'status', + isVisible: true + }, + { + name: 'sortTitle', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'monitored', + isVisible: true + }, + { + name: 'seasonCount', + label: 'Seasons', + isSortable: true, + isVisible: true + } +]; + +class SeasonPass extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onUpdateSelectedPress = (changes) => { + this.props.onUpdateSelectedPress({ + seriesIds: this.getSelectedIds(), + ...changes + }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
    Unable to load the calendar
    + } + + { + !error && isPopulated && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    +
    + } + + { + !error && isPopulated && !items.length && + + } +
    + + +
    + ); + } +} + +SeasonPass.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default SeasonPass; diff --git a/frontend/src/SeasonPass/SeasonPassConnector.js b/frontend/src/SeasonPass/SeasonPassConnector.js new file mode 100644 index 000000000..990c5f264 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassConnector.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setSeasonPassSort, setSeasonPassFilter, saveSeasonPass } from 'Store/Actions/seasonPassActions'; +import SeasonPass from './SeasonPass'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('series', 'seasonPass'), + (series) => { + return { + ...series + }; + } + ); +} + +const mapDispatchToProps = { + setSeasonPassSort, + setSeasonPassFilter, + saveSeasonPass +}; + +class SeasonPassConnector extends Component { + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.setSeasonPassSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setSeasonPassFilter({ selectedFilterKey }); + } + + onUpdateSelectedPress = (payload) => { + this.props.saveSeasonPass(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeasonPassConnector.propTypes = { + setSeasonPassSort: PropTypes.func.isRequired, + setSeasonPassFilter: PropTypes.func.isRequired, + saveSeasonPass: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassConnector); diff --git a/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js new file mode 100644 index 000000000..ba3308cea --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeasonPassFilter } from 'Store/Actions/seasonPassActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seasonPass.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seasonPass' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeasonPassFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/SeasonPass/SeasonPassFooter.css b/frontend/src/SeasonPass/SeasonPassFooter.css new file mode 100644 index 000000000..c18eb660f --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFooter.css @@ -0,0 +1,14 @@ +.inputContainer { + margin-right: 20px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.updateSelectedButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + height: 35px; +} diff --git a/frontend/src/SeasonPass/SeasonPassFooter.js b/frontend/src/SeasonPass/SeasonPassFooter.js new file mode 100644 index 000000000..7ce8dc491 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFooter.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import MonitorEpisodesSelectInput from 'Components/Form/MonitorEpisodesSelectInput'; +import SelectInput from 'Components/Form/SelectInput'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './SeasonPassFooter.css'; + +const NO_CHANGE = 'noChange'; + +class SeasonPassFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + monitor: NO_CHANGE + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = prevProps; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + monitor: NO_CHANGE + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onUpdateSelectedPress = () => { + const { + monitor, + monitored + } = this.state; + + const changes = {}; + + if (monitored !== NO_CHANGE) { + changes.monitored = monitored === 'monitored'; + } + + if (monitor !== NO_CHANGE) { + changes.monitor = monitor; + } + + this.props.onUpdateSelectedPress(changes); + } + + // + // Render + + render() { + const { + selectedCount, + isSaving + } = this.props; + + const { + monitored, + monitor + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE; + + return ( + +
    +
    + Monitor Series +
    + + +
    + +
    +
    + Monitor Episodes +
    + + +
    + +
    +
    + {selectedCount} Series Selected +
    + + + Update Selected + +
    +
    + ); + } +} + +SeasonPassFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default SeasonPassFooter; diff --git a/frontend/src/SeasonPass/SeasonPassRow.css b/frontend/src/SeasonPass/SeasonPassRow.css new file mode 100644 index 000000000..a053c6bef --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRow.css @@ -0,0 +1,20 @@ +.status, +.monitored { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 1px; + white-space: nowrap; +} + +.seasons { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/SeasonPass/SeasonPassRow.js b/frontend/src/SeasonPass/SeasonPassRow.js new file mode 100644 index 000000000..6da75ded9 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRow.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import SeasonPassSeason from './SeasonPassSeason'; +import styles from './SeasonPassRow.css'; + +class SeasonPassRow extends Component { + + // + // Render + + render() { + const { + seriesId, + status, + titleSlug, + title, + monitored, + seasons, + isSaving, + isSelected, + onSelectedChange, + onSeriesMonitoredPress, + onSeasonMonitoredPress + } = this.props; + + return ( + + + + + + + + + + + + + + + + + { + seasons.map((season) => { + return ( + + ); + }) + } + + + ); + } +} + +SeasonPassRow.propTypes = { + seriesId: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + seasons: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onSeriesMonitoredPress: PropTypes.func.isRequired, + onSeasonMonitoredPress: PropTypes.func.isRequired +}; + +SeasonPassRow.defaultProps = { + isSaving: false +}; + +export default SeasonPassRow; diff --git a/frontend/src/SeasonPass/SeasonPassRowConnector.js b/frontend/src/SeasonPass/SeasonPassRowConnector.js new file mode 100644 index 000000000..f2139743f --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRowConnector.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { toggleSeriesMonitored, toggleSeasonMonitored } from 'Store/Actions/seriesActions'; +import SeasonPassRow from './SeasonPassRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return _.pick(series, [ + 'status', + 'titleSlug', + 'title', + 'monitored', + 'seasons', + 'isSaving' + ]); + } + ); +} + +const mapDispatchToProps = { + toggleSeriesMonitored, + toggleSeasonMonitored +}; + +class SeasonPassRowConnector extends Component { + + // + // Listeners + + onSeriesMonitoredPress = () => { + const { + seriesId, + monitored + } = this.props; + + this.props.toggleSeriesMonitored({ + seriesId, + monitored: !monitored + }); + } + + onSeasonMonitoredPress = (seasonNumber, monitored) => { + this.props.toggleSeasonMonitored({ + seriesId: this.props.seriesId, + seasonNumber, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeasonPassRowConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + toggleSeriesMonitored: PropTypes.func.isRequired, + toggleSeasonMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassRowConnector); diff --git a/frontend/src/SeasonPass/SeasonPassSeason.css b/frontend/src/SeasonPass/SeasonPassSeason.css new file mode 100644 index 000000000..603d98ecd --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassSeason.css @@ -0,0 +1,24 @@ +.season { + display: flex; + align-items: stretch; + overflow: hidden; + margin: 2px 4px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: #eee; + cursor: default; +} + +.info { + padding: 0 4px; +} + +.episodes { + padding: 0 4px; + background-color: $white; + color: $defaultColor; +} + +.allEpisodes { + background-color: #e0ffe0; +} diff --git a/frontend/src/SeasonPass/SeasonPassSeason.js b/frontend/src/SeasonPass/SeasonPassSeason.js new file mode 100644 index 000000000..152221b8c --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassSeason.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import padNumber from 'Utilities/Number/padNumber'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import styles from './SeasonPassSeason.css'; + +class SeasonPassSeason extends Component { + + // + // Listeners + + onSeasonMonitoredPress = () => { + const { + seasonNumber, + monitored + } = this.props; + + this.props.onSeasonMonitoredPress(seasonNumber, !monitored); + } + + // + // Render + + render() { + const { + seasonNumber, + monitored, + statistics, + isSaving + } = this.props; + + const { + episodeFileCount, + totalEpisodeCount, + percentOfEpisodes + } = statistics; + + return ( +
    +
    + + + + { + seasonNumber === 0 ? 'Specials' : `S${padNumber(seasonNumber, 2)}` + } + +
    + +
    + { + totalEpisodeCount === 0 ? '0/0' : `${episodeFileCount}/${totalEpisodeCount}` + } +
    +
    + ); + } +} + +SeasonPassSeason.propTypes = { + seasonNumber: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + statistics: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + onSeasonMonitoredPress: PropTypes.func.isRequired +}; + +SeasonPassSeason.defaultProps = { + isSaving: false, + statistics: { + episodeFileCount: 0, + totalEpisodeCount: 0, + percentOfEpisodes: 0 + } +}; + +export default SeasonPassSeason; diff --git a/frontend/src/Series/Delete/DeleteSeriesModal.js b/frontend/src/Series/Delete/DeleteSeriesModal.js new file mode 100644 index 000000000..486ba3764 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector'; + +function DeleteSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteSeriesModal; diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Delete/DeleteSeriesModalContent.css new file mode 100644 index 000000000..dbfef0871 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.css @@ -0,0 +1,12 @@ +.pathContainer { + margin-bottom: 20px; +} + +.pathIcon { + margin-right: 8px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Delete/DeleteSeriesModalContent.js new file mode 100644 index 000000000..b183ffdeb --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.js @@ -0,0 +1,144 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteSeriesModalContent.css'; + +class DeleteSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteSeriesConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeletePress(deleteFiles); + } + + // + // Render + + render() { + const { + title, + path, + statistics, + onModalClose + } = this.props; + + const { + episodeFileCount, + sizeOnDisk + } = statistics; + + const deleteFiles = this.state.deleteFiles; + let deleteFilesLabel = `Delete ${episodeFileCount} Episode Files`; + let deleteFilesHelpText = 'Delete the episode files and series folder'; + + if (episodeFileCount === 0) { + deleteFilesLabel = 'Delete Series Folder'; + deleteFilesHelpText = 'Delete the series folder and it\'s contents'; + } + + return ( + + + Delete - {title} + + + +
    + + + {path} +
    + + + {deleteFilesLabel} + + + + + { + deleteFiles && +
    +
    The series folder {path} and all it's content will be deleted.
    + + { + !!episodeFileCount && +
    {episodeFileCount} episode files totaling {formatBytes(sizeOnDisk)}
    + } +
    + } + +
    + + + + + + +
    + ); + } +} + +DeleteSeriesModalContent.propTypes = { + title: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + onDeletePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +DeleteSeriesModalContent.defaultProps = { + statistics: { + episodeFileCount: 0 + } +}; + +export default DeleteSeriesModalContent; diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js new file mode 100644 index 000000000..73033f957 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { deleteSeries } from 'Store/Actions/seriesActions'; +import DeleteSeriesModalContent from './DeleteSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return series; + } + ); +} + +const mapDispatchToProps = { + deleteSeries +}; + +class DeleteSeriesModalContentConnector extends Component { + + // + // Listeners + + onDeletePress = (deleteFiles) => { + this.props.deleteSeries({ + id: this.props.seriesId, + deleteFiles + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeleteSeriesModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired, + deleteSeries: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeleteSeriesModalContentConnector); diff --git a/frontend/src/Series/Details/EpisodeRow.css b/frontend/src/Series/Details/EpisodeRow.css new file mode 100644 index 000000000..2ff09f37c --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRow.css @@ -0,0 +1,32 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.monitored { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 42px; +} + +.episodeNumber { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.size { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.language, +.audio, +.video, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js new file mode 100644 index 000000000..b6e951aa8 --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRow.js @@ -0,0 +1,284 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import EpisodeNumber from 'Episode/EpisodeNumber'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector'; +import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector'; +import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes'; + +import styles from './EpisodeRow.css'; + +class EpisodeRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onMonitorEpisodePress = (monitored, options) => { + this.props.onMonitorEpisodePress(this.props.id, monitored, options); + } + + // + // Render + + render() { + const { + id, + seriesId, + episodeFileId, + monitored, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + airDateUtc, + title, + unverifiedSceneNumbering, + isSaving, + seriesMonitored, + seriesType, + episodeFilePath, + episodeFileRelativePath, + episodeFileSize, + alternateTitles, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'monitored') { + return ( + + + + ); + } + + if (name === 'episodeNumber') { + return ( + + + + ); + } + + if (name === 'title') { + return ( + + + + ); + } + + if (name === 'path') { + return ( + + { + episodeFilePath + } + + ); + } + + if (name === 'relativePath') { + return ( + + { + episodeFileRelativePath + } + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'audioInfo') { + return ( + + + + ); + } + + if (name === 'videoCodec') { + return ( + + + + ); + } + + if (name === 'size') { + return ( + + {!!episodeFileSize && formatBytes(episodeFileSize)} + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); + } +} + +EpisodeRow.propTypes = { + id: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + monitored: PropTypes.bool.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string, + title: PropTypes.string.isRequired, + isSaving: PropTypes.bool, + unverifiedSceneNumbering: PropTypes.bool, + seriesMonitored: PropTypes.bool.isRequired, + seriesType: PropTypes.string.isRequired, + episodeFilePath: PropTypes.string, + episodeFileRelativePath: PropTypes.string, + episodeFileSize: PropTypes.number, + mediaInfo: PropTypes.object, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired +}; + +EpisodeRow.defaultProps = { + alternateTitles: [] +}; + +export default EpisodeRow; diff --git a/frontend/src/Series/Details/EpisodeRowConnector.js b/frontend/src/Series/Details/EpisodeRowConnector.js new file mode 100644 index 000000000..d8453cef3 --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRowConnector.js @@ -0,0 +1,24 @@ +/* eslint max-params: 0 */ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeRow from './EpisodeRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeFileSelector(), + (series = {}, episodeFile) => { + return { + seriesMonitored: series.monitored, + seriesType: series.seriesType, + episodeFilePath: episodeFile ? episodeFile.path : null, + episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null, + episodeFileSize: episodeFile ? episodeFile.size : null, + alternateTitles: series.alternateTitles + }; + } + ); +} +export default connect(createMapStateToProps)(EpisodeRow); diff --git a/frontend/src/Series/Details/SeasonInfo.css b/frontend/src/Series/Details/SeasonInfo.css new file mode 100644 index 000000000..3f4c3ac62 --- /dev/null +++ b/frontend/src/Series/Details/SeasonInfo.css @@ -0,0 +1,11 @@ +.title { + composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css'; + + width: 90px; +} + +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + margin-left: 110px; +} diff --git a/frontend/src/Series/Details/SeasonInfo.js b/frontend/src/Series/Details/SeasonInfo.js new file mode 100644 index 000000000..f89542b49 --- /dev/null +++ b/frontend/src/Series/Details/SeasonInfo.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SeasonInfo.css'; + +function SeasonInfo(props) { + const { + totalEpisodeCount, + monitoredEpisodeCount, + episodeFileCount + } = props; + + return ( + + + + + + + + ); +} + +SeasonInfo.propTypes = { + totalEpisodeCount: PropTypes.number.isRequired, + monitoredEpisodeCount: PropTypes.number.isRequired, + episodeFileCount: PropTypes.number.isRequired +}; + +export default SeasonInfo; diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.css b/frontend/src/Series/Details/SeriesAlternateTitles.css new file mode 100644 index 000000000..1af1ae68b --- /dev/null +++ b/frontend/src/Series/Details/SeriesAlternateTitles.css @@ -0,0 +1,3 @@ +.alternateTitle { + white-space: nowrap; +} diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.js b/frontend/src/Series/Details/SeriesAlternateTitles.js new file mode 100644 index 000000000..18d016579 --- /dev/null +++ b/frontend/src/Series/Details/SeriesAlternateTitles.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './SeriesAlternateTitles.css'; + +function SeriesAlternateTitles({ alternateTitles }) { + return ( +
      + { + alternateTitles.map((alternateTitle) => { + return ( +
    • + {alternateTitle} +
    • + ); + }) + } +
    + ); +} + +SeriesAlternateTitles.propTypes = { + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default SeriesAlternateTitles; diff --git a/frontend/src/Series/Details/SeriesDetails.css b/frontend/src/Series/Details/SeriesDetails.css new file mode 100644 index 000000000..f161e524a --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetails.css @@ -0,0 +1,155 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 425px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.poster { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 368px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.title { + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from 'Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.alternateTitlesIconContainer { + align-self: flex-end; + margin-left: 20px; +} + +.seriesNavigationButtons { + white-space: nowrap; +} + +.seriesNavigationButton { + composes: button from 'Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.details { + margin-bottom: 8px; + font-weight: 300; + font-size: 20px; +} + +.runtime { + margin-right: 15px; +} + +.detailsLabel { + composes: label from 'Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.path, +.sizeOnDisk, +.qualityProfileName, +.network, +.links, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + margin-top: 8px; + min-height: 0; + font-size: $intermediateFontSize; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .poster { + display: none; + } +} diff --git a/frontend/src/Series/Details/SeriesDetails.js b/frontend/src/Series/Details/SeriesDetails.js new file mode 100644 index 000000000..af693dcb9 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetails.js @@ -0,0 +1,695 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Measure from 'Components/Measure'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; +import SeriesAlternateTitles from './SeriesAlternateTitles'; +import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector'; +import SeriesTagsConnector from './SeriesTagsConnector'; +import SeriesDetailsLinks from './SeriesDetailsLinks'; +import styles from './SeriesDetails.css'; +import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class SeriesDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageEpisodesOpen: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false, + isSeriesHistoryModalOpen: false, + isInteractiveImportModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {}, + overviewHeight: 0 + }; + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageEpisodesPress = () => { + this.setState({ isManageEpisodesOpen: true }); + } + + onManageEpisodesModalClose = () => { + this.setState({ isManageEpisodesOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onSeriesHistoryPress = () => { + this.setState({ isSeriesHistoryModalOpen: true }); + } + + onSeriesHistoryModalClose = () => { + this.setState({ isSeriesHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (seasonNumber, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false); + + return getExpandedState(newState); + }); + } + + onMeasure = ({ height }) => { + this.setState({ overviewHeight: height }); + } + + // + // Render + + render() { + const { + id, + tvdbId, + tvMazeId, + imdbId, + title, + runtime, + ratings, + path, + statistics, + qualityProfileId, + monitored, + status, + network, + overview, + images, + seasons, + alternateTitles, + tags, + isSaving, + isRefreshing, + isSearching, + isFetching, + isPopulated, + episodesError, + episodeFilesError, + hasEpisodes, + hasMonitoredEpisodes, + hasEpisodeFiles, + previousSeries, + nextSeries, + onMonitorTogglePress, + onRefreshPress, + onSearchPress + } = this.props; + + const { + episodeFileCount, + sizeOnDisk + } = statistics; + + const { + isOrganizeModalOpen, + isManageEpisodesOpen, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen, + isSeriesHistoryModalOpen, + isInteractiveImportModalOpen, + allExpanded, + allCollapsed, + expandedState, + overviewHeight + } = this.state; + + const continuing = status === 'continuing'; + + let episodeFilesCountMessage = 'No episode files'; + + if (episodeFileCount === 1) { + episodeFilesCountMessage = '1 episode file'; + } else if (episodeFileCount > 1) { + episodeFilesCountMessage = `${episodeFileCount} episode files`; + } + + let expandIcon = icons.EXPAND_INDETERMINATE; + + if (allExpanded) { + expandIcon = icons.COLLAPSE; + } else if (allCollapsed) { + expandIcon = icons.EXPAND; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + +
    + +
    + {title} +
    + + { + !!alternateTitles.length && +
    + + } + title="Alternate Titles" + body={} + position={tooltipPositions.BOTTOM} + /> +
    + } +
    + +
    + + + +
    +
    + +
    +
    + { + !!runtime && + + {runtime} Minutes + + } + + +
    +
    + +
    + + + + + + + + + + + { + !!network && + + } + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + { + !!tags.length && + + + + + Tags + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + } +
    + + +
    + +
    +
    +
    +
    +
    + +
    + { + !isPopulated && !episodesError && !episodeFilesError && + + } + + { + !isFetching && episodesError && +
    Loading episodes failed
    + } + + { + !isFetching && episodeFilesError && +
    Loading episode files failed
    + } + + { + isPopulated && !!seasons.length && +
    + { + seasons.slice(0).reverse().map((season) => { + return ( + + ); + }) + } +
    + } + + { + isPopulated && !seasons.length && +
    + No episode information is available. +
    + } + +
    + + + + + + + + + + + + + + + ); + } +} + +SeriesDetails.propTypes = { + id: PropTypes.number.isRequired, + tvdbId: PropTypes.number.isRequired, + tvMazeId: PropTypes.number, + imdbId: PropTypes.string, + title: PropTypes.string.isRequired, + runtime: PropTypes.number.isRequired, + ratings: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + qualityProfileId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + network: PropTypes.string, + overview: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + seasons: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + isSaving: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isSearching: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + episodeFilesError: PropTypes.object, + hasEpisodes: PropTypes.bool.isRequired, + hasMonitoredEpisodes: PropTypes.bool.isRequired, + hasEpisodeFiles: PropTypes.bool.isRequired, + previousSeries: PropTypes.object.isRequired, + nextSeries: PropTypes.object.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesDetails.defaultProps = { + statistics: {}, + tag: [], + isSaving: false +}; + +export default SeriesDetails; diff --git a/frontend/src/Series/Details/SeriesDetailsConnector.js b/frontend/src/Series/Details/SeriesDetailsConnector.js new file mode 100644 index 000000000..25bf13de1 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsConnector.js @@ -0,0 +1,271 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesDetails from './SeriesDetails'; + +const selectEpisodes = createSelector( + (state) => state.episodes, + (episodes) => { + const { + items, + isFetching, + isPopulated, + error + } = episodes; + + const hasEpisodes = !!items.length; + const hasMonitoredEpisodes = items.some((e) => e.monitored); + + return { + isEpisodesFetching: isFetching, + isEpisodesPopulated: isPopulated, + episodesError: error, + hasEpisodes, + hasMonitoredEpisodes + }; + } +); + +const selectEpisodeFiles = createSelector( + (state) => state.episodeFiles, + (episodeFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = episodeFiles; + + const hasEpisodeFiles = !!items.length; + + return { + isEpisodeFilesFetching: isFetching, + isEpisodeFilesPopulated: isPopulated, + episodeFilesError: error, + hasEpisodeFiles + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state, { titleSlug }) => titleSlug, + selectEpisodes, + selectEpisodeFiles, + createAllSeriesSelector(), + createCommandsSelector(), + (titleSlug, episodes, episodeFiles, allSeries, commands) => { + const sortedSeries = _.orderBy(allSeries, 'sortTitle'); + const seriesIndex = _.findIndex(sortedSeries, { titleSlug }); + const series = sortedSeries[seriesIndex]; + + if (!series) { + return {}; + } + + const { + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + hasEpisodes, + hasMonitoredEpisodes + } = episodes; + + const { + isEpisodeFilesFetching, + isEpisodeFilesPopulated, + episodeFilesError, + hasEpisodeFiles + } = episodeFiles; + + const previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries); + const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries); + const isSeriesRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_SERIES, seriesId: series.id })); + const seriesRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_SERIES }); + const allSeriesRefreshing = ( + isCommandExecuting(seriesRefreshingCommand) && + !seriesRefreshingCommand.body.seriesId + ); + const isRefreshing = isSeriesRefreshing || allSeriesRefreshing; + const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.SERIES_SEARCH, seriesId: series.id })); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, seriesId: series.id })); + const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES }); + const isRenamingSeries = ( + isCommandExecuting(isRenamingSeriesCommand) && + isRenamingSeriesCommand.body.seriesIds.indexOf(series.id) > -1 + ); + + const isFetching = isEpisodesFetching || isEpisodeFilesFetching; + const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; + const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => { + if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && + (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { + acc.push(alternateTitle.title); + } + + return acc; + }, []); + + return { + ...series, + alternateTitles, + isSeriesRefreshing, + allSeriesRefreshing, + isRefreshing, + isSearching, + isRenamingFiles, + isRenamingSeries, + isFetching, + isPopulated, + episodesError, + episodeFilesError, + hasEpisodes, + hasMonitoredEpisodes, + hasEpisodeFiles, + previousSeries, + nextSeries + }; + } + ); +} + +const mapDispatchToProps = { + fetchEpisodes, + clearEpisodes, + fetchEpisodeFiles, + clearEpisodeFiles, + toggleSeriesMonitored, + fetchQueueDetails, + clearQueueDetails, + executeCommand +}; + +class SeriesDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + const { + id, + isSeriesRefreshing, + allSeriesRefreshing, + isRenamingFiles, + isRenamingSeries + } = this.props; + + if ( + (prevProps.isSeriesRefreshing && !isSeriesRefreshing) || + (prevProps.allSeriesRefreshing && !allSeriesRefreshing) || + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingSeries && !isRenamingSeries) + ) { + this.populate(); + } + + // If the id has changed we need to clear the episodes/episode + // files and fetch from the server. + + if (prevProps.id !== id) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const seriesId = this.props.id; + + this.props.fetchEpisodes({ seriesId }); + this.props.fetchEpisodeFiles({ seriesId }); + this.props.fetchQueueDetails({ seriesId }); + } + + unpopulate = () => { + this.props.clearEpisodes(); + this.props.clearEpisodeFiles(); + this.props.clearQueueDetails(); + } + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleSeriesMonitored({ + seriesId: this.props.id, + monitored + }); + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES, + seriesId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.SERIES_SEARCH, + seriesId: this.props.id + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesDetailsConnector.propTypes = { + id: PropTypes.number.isRequired, + titleSlug: PropTypes.string.isRequired, + isSeriesRefreshing: PropTypes.bool.isRequired, + allSeriesRefreshing: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingSeries: PropTypes.bool.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired, + toggleSeriesMonitored: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector); diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.css b/frontend/src/Series/Details/SeriesDetailsLinks.css new file mode 100644 index 000000000..0f65b9154 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from 'Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.js b/frontend/src/Series/Details/SeriesDetailsLinks.js new file mode 100644 index 000000000..9cacdb1a3 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsLinks.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import styles from './SeriesDetailsLinks.css'; + +function SeriesDetailsLinks(props) { + const { + tvdbId, + tvMazeId, + imdbId + } = props; + + return ( +
    + + + + + + + + + { + !!tvMazeId && + + + + } + + { + !!imdbId && + + + + } +
    + ); +} + +SeriesDetailsLinks.propTypes = { + tvdbId: PropTypes.number.isRequired, + tvMazeId: PropTypes.number, + imdbId: PropTypes.string +}; + +export default SeriesDetailsLinks; diff --git a/frontend/src/Series/Details/SeriesDetailsPageConnector.js b/frontend/src/Series/Details/SeriesDetailsPageConnector.js new file mode 100644 index 000000000..bf440a532 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsPageConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import NotFound from 'Components/NotFound'; +import SeriesDetailsConnector from './SeriesDetailsConnector'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + createAllSeriesSelector(), + (match, allSeries) => { + const titleSlug = match.params.titleSlug; + const seriesIndex = _.findIndex(allSeries, { titleSlug }); + + if (seriesIndex > -1) { + return { + titleSlug + }; + } + + return {}; + } + ); +} + +const mapDispatchToProps = { + push +}; + +class SeriesDetailsPageConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if (!this.props.titleSlug) { + this.props.push(`${window.Sonarr.urlBase}/`); + return; + } + } + + // + // Render + + render() { + const { + titleSlug + } = this.props; + + if (!titleSlug) { + return ( + + ); + } + + return ( + + ); + } +} + +SeriesDetailsPageConnector.propTypes = { + titleSlug: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsPageConnector); diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.css b/frontend/src/Series/Details/SeriesDetailsSeason.css new file mode 100644 index 000000000..f417c415a --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeason.css @@ -0,0 +1,112 @@ +.season { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 24px; +} + +.seasonNumber { + margin-right: 10px; + margin-left: 5px; +} + +.episodeCountTooltip { + display: flex; +} + +.expandButton { + composes: link from 'Components/Link/Link.css'; + + flex-grow: 1; + margin: 0 20px; + text-align: center; +} + +.left { + display: flex; + align-items: center; + flex: 0 1 300px; +} + +.left, +.actions { + padding: 15px 10px; +} + +.actionsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + flex: 0 0 45px; +} + +.actionsMenuContent { + composes: menuContent from 'Components/Menu/MenuContent.css'; + + white-space: nowrap; + font-size: $defaultFontSize; +} + +.actionMenuIcon { + margin-right: 8px; +} + +.actionButton { + composes: button from 'Components/Link/IconButton.css'; + + width: 30px; +} + +.episodes { + padding-top: 15px; + border-top: 1px solid $borderColor; +} + +.collapseButtonContainer { + padding: 10px 15px; + width: 100%; + border-top: 1px solid $borderColor; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + background-color: #fafafa; + text-align: center; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +.noEpisodes { + margin-bottom: 15px; + text-align: center; +} + +@media only screen and (max-width: $breakpointSmall) { + .season { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.js b/frontend/src/Series/Details/SeriesDetailsSeason.js new file mode 100644 index 000000000..f63ba9c30 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeason.js @@ -0,0 +1,519 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import getToggledRange from 'Utilities/Table/getToggledRange'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; +import SeasonInteractiveSearchModalConnector from 'Series/Search/SeasonInteractiveSearchModalConnector'; +import EpisodeRowConnector from './EpisodeRowConnector'; +import SeasonInfo from './SeasonInfo'; +import styles from './SeriesDetailsSeason.css'; + +function getSeasonStatistics(episodes) { + let episodeCount = 0; + let episodeFileCount = 0; + let totalEpisodeCount = 0; + let monitoredEpisodeCount = 0; + let hasMonitoredEpisodes = false; + + episodes.forEach((episode) => { + if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) { + episodeCount++; + } + + if (episode.episodeFileId) { + episodeFileCount++; + } + + if (episode.monitored) { + monitoredEpisodeCount++; + hasMonitoredEpisodes = true; + } + + totalEpisodeCount++; + }); + + return { + episodeCount, + episodeFileCount, + totalEpisodeCount, + monitoredEpisodeCount, + hasMonitoredEpisodes + }; +} + +function getEpisodeCountKind(monitored, episodeFileCount, episodeCount) { + if (episodeFileCount === episodeCount && episodeCount > 0) { + return kinds.SUCCESS; + } + + if (!monitored) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +class SeriesDetailsSeason extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageEpisodesOpen: false, + isHistoryModalOpen: false, + isInteractiveSearchModalOpen: false, + lastToggledEpisode: null + }; + } + + componentDidMount() { + this._expandByDefault(); + } + + componentDidUpdate(prevProps) { + const { + seriesId, + items + } = this.props; + + if (prevProps.seriesId !== seriesId) { + this._expandByDefault(); + return; + } + + if ( + getSeasonStatistics(prevProps.items).episodeFileCount > 0 && + getSeasonStatistics(items).episodeFileCount === 0 + ) { + this.setState({ + isOrganizeModalOpen: false, + isManageEpisodesOpen: false + }); + } + } + + // + // Control + + _expandByDefault() { + const { + seasonNumber, + onExpandPress, + items + } = this.props; + + const expand = _.some(items, (item) => { + return isAfter(item.airDateUtc) || + isAfter(item.airDateUtc, { days: -30 }); + }); + + onExpandPress(seasonNumber, expand && seasonNumber > 0); + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageEpisodesPress = () => { + this.setState({ isManageEpisodesOpen: true }); + } + + onManageEpisodesModalClose = () => { + this.setState({ isManageEpisodesOpen: false }); + } + + onHistoryPress = () => { + this.setState({ isHistoryModalOpen: true }); + } + + onHistoryModalClose = () => { + this.setState({ isHistoryModalOpen: false }); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchModalOpen: true }); + } + + onInteractiveSearchModalClose = () => { + this.setState({ isInteractiveSearchModalOpen: false }); + } + + onExpandPress = () => { + const { + seasonNumber, + isExpanded + } = this.props; + + this.props.onExpandPress(seasonNumber, !isExpanded); + } + + onMonitorEpisodePress = (episodeId, monitored, { shiftKey }) => { + const lastToggled = this.state.lastToggledEpisode; + const episodeIds = [episodeId]; + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(this.props.items, episodeId, lastToggled); + const items = this.props.items; + + for (let i = lower; i < upper; i++) { + episodeIds.push(items[i].id); + } + } + + this.setState({ lastToggledEpisode: episodeId }); + + this.props.onMonitorEpisodePress(_.uniq(episodeIds), monitored); + } + + // + // Render + + render() { + const { + seriesId, + monitored, + seasonNumber, + items, + columns, + isSaving, + isExpanded, + isSearching, + seriesMonitored, + isSmallScreen, + onTableOptionChange, + onMonitorSeasonPress, + onSearchPress + } = this.props; + + const { + episodeCount, + episodeFileCount, + totalEpisodeCount, + monitoredEpisodeCount, + hasMonitoredEpisodes + } = getSeasonStatistics(items); + + const { + isOrganizeModalOpen, + isManageEpisodesOpen, + isHistoryModalOpen, + isInteractiveSearchModalOpen + } = this.state; + + return ( +
    +
    +
    + + + { + seasonNumber === 0 ? + + Specials + : + + Season {seasonNumber} + + } + + + {episodeFileCount} / {episodeCount} + + } + title="Season Information" + body={ +
    + +
    + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> +
    + + + + { + !isSmallScreen && +   + } + + + { + isSmallScreen ? + + + + + + + + + + Search + + + + + + Interactive Search + + + + + + Preview Rename + + + + + + Manage Episodes + + + + + + History + + + : + +
    + + + + + + + + + +
    + } + +
    + +
    + { + isExpanded && +
    + { + items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    : + +
    + No episodes in this season +
    + } +
    + +
    +
    + } +
    + + + + + + + + +
    + ); + } +} + +SeriesDetailsSeason.propTypes = { + seriesId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + seasonNumber: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool, + isExpanded: PropTypes.bool, + isSearching: PropTypes.bool.isRequired, + seriesMonitored: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + onMonitorSeasonPress: PropTypes.func.isRequired, + onExpandPress: PropTypes.func.isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default SeriesDetailsSeason; diff --git a/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js new file mode 100644 index 000000000..12b9a3166 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js @@ -0,0 +1,118 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { toggleSeasonMonitored } from 'Store/Actions/seriesActions'; +import { toggleEpisodesMonitored, setEpisodesTableOption } from 'Store/Actions/episodeActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesDetailsSeason from './SeriesDetailsSeason'; + +function createMapStateToProps() { + return createSelector( + (state, { seasonNumber }) => seasonNumber, + (state) => state.episodes, + createSeriesSelector(), + createCommandsSelector(), + createDimensionsSelector(), + (seasonNumber, episodes, series, commands, dimensions) => { + const isSearching = isCommandExecuting(findCommand(commands, { + name: commandNames.SEASON_SEARCH, + seriesId: series.id, + seasonNumber + })); + + const episodesInSeason = _.filter(episodes.items, { seasonNumber }); + const sortedEpisodes = _.orderBy(episodesInSeason, 'episodeNumber', 'desc'); + + return { + items: sortedEpisodes, + columns: episodes.columns, + isSearching, + seriesMonitored: series.monitored, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + toggleSeasonMonitored, + toggleEpisodesMonitored, + setEpisodesTableOption, + executeCommand +}; + +class SeriesDetailsSeasonConnector extends Component { + + // + // Listeners + + onTableOptionChange = (payload) => { + this.props.setEpisodesTableOption(payload); + } + + onMonitorSeasonPress = (monitored) => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.toggleSeasonMonitored({ + seriesId, + seasonNumber, + monitored + }); + } + + onSearchPress = () => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.executeCommand({ + name: commandNames.SEASON_SEARCH, + seriesId, + seasonNumber + }); + } + + onMonitorEpisodePress = (episodeIds, monitored) => { + this.props.toggleEpisodesMonitored({ + episodeIds, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesDetailsSeasonConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + toggleSeasonMonitored: PropTypes.func.isRequired, + toggleEpisodesMonitored: PropTypes.func.isRequired, + setEpisodesTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsSeasonConnector); diff --git a/frontend/src/Series/Details/SeriesTags.js b/frontend/src/Series/Details/SeriesTags.js new file mode 100644 index 000000000..3876c9273 --- /dev/null +++ b/frontend/src/Series/Details/SeriesTags.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function SeriesTags({ tags }) { + return ( +
    + { + tags.map((tag) => { + return ( + + ); + }) + } +
    + ); +} + +SeriesTags.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default SeriesTags; diff --git a/frontend/src/Series/Details/SeriesTagsConnector.js b/frontend/src/Series/Details/SeriesTagsConnector.js new file mode 100644 index 000000000..e89bc1800 --- /dev/null +++ b/frontend/src/Series/Details/SeriesTagsConnector.js @@ -0,0 +1,30 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import SeriesTags from './SeriesTags'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createTagsSelector(), + (series, tagList) => { + const tags = _.reduce(series.tags, (acc, tag) => { + const matchingTag = _.find(tagList, { id: tag }); + + if (matchingTag) { + acc.push(matchingTag.label); + } + + return acc; + }, []); + + return { + tags + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesTags); diff --git a/frontend/src/Series/Edit/EditSeriesModal.js b/frontend/src/Series/Edit/EditSeriesModal.js new file mode 100644 index 000000000..7c34f9586 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditSeriesModalContentConnector from './EditSeriesModalContentConnector'; + +function EditSeriesModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSeriesModal; diff --git a/frontend/src/Series/Edit/EditSeriesModalConnector.js b/frontend/src/Series/Edit/EditSeriesModalConnector.js new file mode 100644 index 000000000..2dfa43e31 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSeriesModal from './EditSeriesModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditSeriesModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'series' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditSeriesModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditSeriesModalConnector); diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.css b/frontend/src/Series/Edit/EditSeriesModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.js b/frontend/src/Series/Edit/EditSeriesModalContent.js new file mode 100644 index 000000000..c237cc824 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContent.js @@ -0,0 +1,223 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal'; +import styles from './EditSeriesModalContent.css'; + +class EditSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmMoveModalOpen: false + }; + } + + // + // Listeners + + onSavePress = () => { + const { + isPathChanging, + onSavePress + } = this.props; + + if (isPathChanging && !this.state.isConfirmMoveModalOpen) { + this.setState({ isConfirmMoveModalOpen: true }); + } else { + this.setState({ isConfirmMoveModalOpen: false }); + + onSavePress(false); + } + } + + onMoveSeriesPress = () => { + this.setState({ isConfirmMoveModalOpen: false }); + + this.props.onSavePress(true); + } + + // + // Render + + render() { + const { + title, + item, + isSaving, + showLanguageProfile, + originalPath, + onInputChange, + onModalClose, + onDeleteSeriesPress, + ...otherProps + } = this.props; + + const { + monitored, + seasonFolder, + qualityProfileId, + languageProfileId, + seriesType, + path, + tags + } = item; + + return ( + + + Edit - {title} + + + +
    + + Monitored + + + + + + Use Season Folder + + + + + + Quality Profile + + + + + { + showLanguageProfile && + + Language Profile + + + + } + + + Series Type + + + + + + Path + + + + + + Tags + + + +
    +
    + + + + + + + + Save + + + + +
    + ); + } +} + +EditSeriesModalContent.propTypes = { + seriesId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isPathChanging: PropTypes.bool.isRequired, + originalPath: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSeriesPress: PropTypes.func.isRequired +}; + +export default EditSeriesModalContent; diff --git a/frontend/src/Series/Edit/EditSeriesModalContentConnector.js b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js new file mode 100644 index 000000000..c1e6a8e9f --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js @@ -0,0 +1,120 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { setSeriesValue, saveSeries } from 'Store/Actions/seriesActions'; +import EditSeriesModalContent from './EditSeriesModalContent'; + +function createIsPathChangingSelector() { + return createSelector( + (state) => state.series.pendingChanges, + createSeriesSelector(), + (pendingChanges, series) => { + const path = pendingChanges.path; + + if (path == null) { + return false; + } + + return series.path !== path; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.series, + (state) => state.settings.languageProfiles, + createSeriesSelector(), + createIsPathChangingSelector(), + (seriesState, languageProfiles, series, isPathChanging) => { + const { + isSaving, + saveError, + pendingChanges + } = seriesState; + + const seriesSettings = _.pick(series, [ + 'monitored', + 'seasonFolder', + 'qualityProfileId', + 'languageProfileId', + 'seriesType', + 'path', + 'tags' + ]); + + const settings = selectSettings(seriesSettings, pendingChanges, saveError); + + return { + title: series.title, + isSaving, + saveError, + isPathChanging, + originalPath: series.path, + item: settings.settings, + showLanguageProfile: languageProfiles.items.length > 1, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetSeriesValue: setSeriesValue, + dispatchSaveSeries: saveSeries +}; + +class EditSeriesModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetSeriesValue({ name, value }); + } + + onSavePress = (moveFiles) => { + this.props.dispatchSaveSeries({ + id: this.props.seriesId, + moveFiles + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditSeriesModalContentConnector.propTypes = { + seriesId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetSeriesValue: PropTypes.func.isRequired, + dispatchSaveSeries: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSeriesModalContentConnector); diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js new file mode 100644 index 000000000..84fca1612 --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector'; + +function DeleteSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteSeriesModal; diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css new file mode 100644 index 000000000..950fdc27d --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: $dangerColor; +} diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js new file mode 100644 index 000000000..79c854cad --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteSeriesModalContent.css'; + +class DeleteSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteSeriesConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles); + } + + // + // Render + + render() { + const { + series, + onModalClose + } = this.props; + const deleteFiles = this.state.deleteFiles; + + return ( + + + Delete Selected Series + + + +
    + + {`Delete Series Folder${series.length > 1 ? 's' : ''}`} + + 1 ? 's' : ''} and all contents`} + kind={kinds.DANGER} + onChange={this.onDeleteFilesChange} + /> + +
    + +
    + {`Are you sure you want to delete ${series.length} selected series${deleteFiles ? ' and all contents' : ''}?`} +
    + +
      + { + series.map((s) => { + return ( +
    • + {s.title} + + { + deleteFiles && + + - + + {s.path} + + + } +
    • + ); + }) + } +
    +
    + + + + + + +
    + ); + } +} + +DeleteSeriesModalContent.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteSeriesModalContent; diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js new file mode 100644 index 000000000..0513842b7 --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import { bulkDeleteSeries } from 'Store/Actions/seriesEditorActions'; +import DeleteSeriesModalContent from './DeleteSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + (seriesIds, allSeries) => { + const selectedSeries = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(selectedSeries, 'sortTitle'); + const series = _.map(sortedSeries, (s) => { + return { + title: s.title, + path: s.path + }; + }); + + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles) { + dispatch(bulkDeleteSeries({ + seriesIds: props.seriesIds, + deleteFiles + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteSeriesModalContent); diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js new file mode 100644 index 000000000..c970392ec --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeSeriesModalContentConnector from './OrganizeSeriesModalContentConnector'; + +function OrganizeSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +OrganizeSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModal; diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js new file mode 100644 index 000000000..10a459d52 --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './OrganizeSeriesModalContent.css'; + +function OrganizeSeriesModalContent(props) { + const { + seriesTitles, + onModalClose, + onOrganizeSeriesPress + } = props; + + return ( + + + Organize Selected Series + + + + + Tip: To preview a rename... select "Cancel" then any series title and use the + + + +
    + Are you sure you want to organize all files in the {seriesTitles.length} selected series? +
    + +
      + { + seriesTitles.map((title) => { + return ( +
    • + {title} +
    • + ); + }) + } +
    +
    + + + + + + +
    + ); +} + +OrganizeSeriesModalContent.propTypes = { + seriesTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModalContent; diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js new file mode 100644 index 000000000..dabd1c58a --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizeSeriesModalContent from './OrganizeSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + (seriesIds, allSeries) => { + const series = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(series, 'sortTitle'); + const seriesTitles = _.map(sortedSeries, 'title'); + + return { + seriesTitles + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class OrganizeSeriesModalContentConnector extends Component { + + // + // Listeners + + onOrganizeSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.RENAME_SERIES, + seriesIds: this.props.seriesIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +OrganizeSeriesModalContentConnector.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector); diff --git a/frontend/src/Series/Editor/SeriesEditor.js b/frontend/src/Series/Editor/SeriesEditor.js new file mode 100644 index 000000000..511ed8070 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditor.js @@ -0,0 +1,289 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoSeries from 'Series/NoSeries'; +import OrganizeSeriesModal from './Organize/OrganizeSeriesModal'; +import SeriesEditorRowConnector from './SeriesEditorRowConnector'; +import SeriesEditorFooter from './SeriesEditorFooter'; +import SeriesEditorFilterModalConnector from './SeriesEditorFilterModalConnector'; + +function getColumns(showLanguageProfile) { + return [ + { + name: 'status', + isSortable: true, + isVisible: true + }, + { + name: 'sortTitle', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'languageProfileId', + label: 'Language Profile', + isSortable: true, + isVisible: showLanguageProfile + }, + { + name: 'seriesType', + label: 'Series Type', + isSortable: false, + isVisible: true + }, + { + name: 'seasonFolder', + label: 'Season Folder', + isSortable: true, + isVisible: true + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: true + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: true + } + ]; +} + +class SeriesEditor extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isOrganizingSeriesModalOpen: false, + columns: getColumns(props.showLanguageProfile) + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + const hasFinishedDeleting = prevProps.isDeleting && + !isDeleting && + !deleteError; + + if (hasFinishedDeleting) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + seriesIds: this.getSelectedIds(), + ...changes + }); + } + + onOrganizeSeriesPress = () => { + this.setState({ isOrganizingSeriesModalOpen: true }); + } + + onOrganizeSeriesModalClose = (organized) => { + this.setState({ isOrganizingSeriesModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + isDeleting, + deleteError, + isOrganizingSeries, + showLanguageProfile, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + columns + } = this.state; + + const selectedSeriesIds = this.getSelectedIds(); + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
    Unable to load the calendar
    + } + + { + !error && isPopulated && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    +
    + } + + { + !error && isPopulated && !items.length && + + } +
    + + + + +
    + ); + } +} + +SeriesEditor.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSaveSelected: PropTypes.func.isRequired +}; + +export default SeriesEditor; diff --git a/frontend/src/Series/Editor/SeriesEditorConnector.js b/frontend/src/Series/Editor/SeriesEditorConnector.js new file mode 100644 index 000000000..fb9cb478f --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { setSeriesEditorSort, setSeriesEditorFilter, saveSeriesEditor } from 'Store/Actions/seriesEditorActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesEditor from './SeriesEditor'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + createClientSideCollectionSelector('series', 'seriesEditor'), + createCommandExecutingSelector(commandNames.RENAME_SERIES), + (languageProfiles, series, isOrganizingSeries) => { + return { + isOrganizingSeries, + showLanguageProfile: languageProfiles.items.length > 1, + ...series + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetSeriesEditorSort: setSeriesEditorSort, + dispatchSetSeriesEditorFilter: setSeriesEditorFilter, + dispatchSaveSeriesEditor: saveSeriesEditor, + dispatchFetchRootFolders: fetchRootFolders, + dispatchExecuteCommand: executeCommand +}; + +class SeriesEditorConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.dispatchSetSeriesEditorSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.dispatchSetSeriesEditorFilter({ selectedFilterKey }); + } + + onSaveSelected = (payload) => { + this.props.dispatchSaveSeriesEditor(payload); + } + + onMoveSelected = (payload) => { + this.props.dispatchExecuteCommand({ + name: commandNames.MOVE_SERIES, + ...payload + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesEditorConnector.propTypes = { + dispatchSetSeriesEditorSort: PropTypes.func.isRequired, + dispatchSetSeriesEditorFilter: PropTypes.func.isRequired, + dispatchSaveSeriesEditor: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesEditorConnector); diff --git a/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js new file mode 100644 index 000000000..07a5230b2 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesEditorFilter } from 'Store/Actions/seriesEditorActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seriesEditor.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seriesEditor' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeriesEditorFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.css b/frontend/src/Series/Editor/SeriesEditorFooter.css new file mode 100644 index 000000000..5b509936b --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooter.css @@ -0,0 +1,57 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedSeriesLabel { + text-align: left; + } +} diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.js b/frontend/src/Series/Editor/SeriesEditorFooter.js new file mode 100644 index 000000000..b4edc9a83 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooter.js @@ -0,0 +1,353 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import LanguageProfileSelectInputConnector from 'Components/Form/LanguageProfileSelectInputConnector'; +import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; +import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; +import SeriesTypeSelectInput from 'Components/Form/SeriesTypeSelectInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal'; +import TagsModal from './Tags/TagsModal'; +import DeleteSeriesModal from './Delete/DeleteSeriesModal'; +import SeriesEditorFooterLabel from './SeriesEditorFooterLabel'; +import styles from './SeriesEditorFooter.css'; + +const NO_CHANGE = 'noChange'; + +class SeriesEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + languageProfileId: NO_CHANGE, + seriesType: NO_CHANGE, + seasonFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false, + isDeleteSeriesModalOpen: false, + isTagsModalOpen: false, + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + languageProfileId: NO_CHANGE, + seriesType: NO_CHANGE, + seasonFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'rootFolderPath': + this.setState({ + isConfirmMoveModalOpen: true, + destinationRootFolder: value + }); + break; + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + case 'seasonFolder': + this.props.onSaveSelected({ [name]: value === 'yes' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onApplyTagsPress = (tags, applyTags) => { + this.setState({ + savingTags: true, + isTagsModalOpen: false + }); + + this.props.onSaveSelected({ + tags, + applyTags + }); + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteSeriesModalOpen: true }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onTagsPress = () => { + this.setState({ isTagsModalOpen: true }); + } + + onTagsModalClose = () => { + this.setState({ isTagsModalOpen: false }); + } + + onSaveRootFolderPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder }); + } + + onMoveSeriesPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ + rootFolderPath: this.state.destinationRootFolder, + moveFiles: true + }); + } + + // + // Render + + render() { + const { + seriesIds, + selectedCount, + isSaving, + isDeleting, + isOrganizingSeries, + showLanguageProfile, + onOrganizeSeriesPress + } = this.props; + + const { + monitored, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + rootFolderPath, + savingTags, + isTagsModalOpen, + isDeleteSeriesModalOpen, + isConfirmMoveModalOpen, + destinationRootFolder + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const seasonFolderOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'yes', value: 'Yes' }, + { key: 'no', value: 'No' } + ]; + + return ( + +
    + + + +
    + +
    + + + +
    + + { + showLanguageProfile && +
    + + + +
    + } + +
    + + + +
    + +
    + + + +
    + +
    + + + +
    + +
    +
    + + +
    +
    + + Rename Files + + + + Set Tags + +
    + + + Delete + +
    +
    +
    + + + + + + +
    + ); + } +} + +SeriesEditorFooter.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onSaveSelected: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default SeriesEditorFooter; diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.css b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.js b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js new file mode 100644 index 000000000..fc77ece44 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './SeriesEditorFooterLabel.css'; + +function SeriesEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
    + {label} + + { + isSaving && + + } +
    + ); +} + +SeriesEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +SeriesEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default SeriesEditorFooterLabel; diff --git a/frontend/src/Series/Editor/SeriesEditorRow.css b/frontend/src/Series/Editor/SeriesEditorRow.css new file mode 100644 index 000000000..d53a30f6d --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRow.css @@ -0,0 +1,5 @@ +.seasonFolder { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/Series/Editor/SeriesEditorRow.js b/frontend/src/Series/Editor/SeriesEditorRow.js new file mode 100644 index 000000000..2d9330c2b --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRow.js @@ -0,0 +1,124 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import TagListConnector from 'Components/TagListConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import SeriesStatusCell from 'Series/Index/Table/SeriesStatusCell'; +import styles from './SeriesEditorRow.css'; + +class SeriesEditorRow extends Component { + + // + // Listeners + + onSeasonFolderChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + // + // Render + + render() { + const { + id, + status, + titleSlug, + title, + monitored, + languageProfile, + qualityProfile, + seriesType, + seasonFolder, + path, + tags, + columns, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + + + + + + + {qualityProfile.name} + + + { + _.find(columns, { name: 'languageProfileId' }).isVisible && + + {languageProfile.name} + + } + + + {titleCase(seriesType)} + + + + + + + + {path} + + + + + + + ); + } +} + +SeriesEditorRow.propTypes = { + id: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + languageProfile: PropTypes.object.isRequired, + qualityProfile: PropTypes.object.isRequired, + seriesType: PropTypes.string.isRequired, + seasonFolder: PropTypes.bool.isRequired, + path: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +SeriesEditorRow.defaultProps = { + tags: [] +}; + +export default SeriesEditorRow; diff --git a/frontend/src/Series/Editor/SeriesEditorRowConnector.js b/frontend/src/Series/Editor/SeriesEditorRowConnector.js new file mode 100644 index 000000000..3d1ee2e71 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRowConnector.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import SeriesEditorRow from './SeriesEditorRow'; + +function createMapStateToProps() { + return createSelector( + createLanguageProfileSelector(), + createQualityProfileSelector(), + (languageProfile, qualityProfile) => { + return { + languageProfile, + qualityProfile + }; + } + ); +} + +function SeriesEditorRowConnector(props) { + return ( + + ); +} + +SeriesEditorRowConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps)(SeriesEditorRowConnector); diff --git a/frontend/src/Series/Editor/Tags/TagsModal.js b/frontend/src/Series/Editor/Tags/TagsModal.js new file mode 100644 index 000000000..0f6c2d7ec --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContentConnector from './TagsModalContentConnector'; + +function TagsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagsModal; diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.css b/frontend/src/Series/Editor/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.js b/frontend/src/Series/Editor/Tags/TagsModalContent.js new file mode 100644 index 000000000..ccc1120db --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContent.js @@ -0,0 +1,187 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './TagsModalContent.css'; + +class TagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + tags: [], + applyTags: 'add' + }; + } + + // + // Lifecycle + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onApplyTagsPress = () => { + const { + tags, + applyTags + } = this.state; + + this.props.onApplyTagsPress(tags, applyTags); + } + + // + // Render + + render() { + const { + seriesTags, + tagList, + onModalClose + } = this.props; + + const { + tags, + applyTags + } = this.state; + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' } + ]; + + return ( + + + Tags + + + +
    + + Tags + + + + + + Apply Tags + + + + + + Result + +
    + { + seriesTags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + const removeTag = (applyTags === 'remove' && tags.indexOf(t) > -1) || + (applyTags === 'replace' && tags.indexOf(t) === -1); + + return ( + + ); + }) + } + + { + (applyTags === 'add' || applyTags === 'replace') && + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(t) > -1) { + return null; + } + + return ( + + ); + }) + } +
    +
    +
    +
    + + + + + + +
    + ); + } +} + +TagsModalContent.propTypes = { + seriesTags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onApplyTagsPress: PropTypes.func.isRequired +}; + +export default TagsModalContent; diff --git a/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js new file mode 100644 index 000000000..9d4907971 --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagsModalContent from './TagsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + createTagsSelector(), + (seriesIds, allSeries, tagList) => { + const series = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const seriesTags = _.uniq(_.concat(..._.map(series, 'tags'))); + + return { + seriesTags, + tagList + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onAction() { + // Do something + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent); diff --git a/frontend/src/Series/History/SeriesHistoryModal.js b/frontend/src/Series/History/SeriesHistoryModal.js new file mode 100644 index 000000000..2d46c5f6f --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesHistoryModalContentConnector from './SeriesHistoryModalContentConnector'; + +function SeriesHistoryModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +SeriesHistoryModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesHistoryModal; diff --git a/frontend/src/Series/History/SeriesHistoryModalContent.js b/frontend/src/Series/History/SeriesHistoryModalContent.js new file mode 100644 index 000000000..ec593cd8c --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModalContent.js @@ -0,0 +1,137 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SeasonNumber from 'Season/SeasonNumber'; +import SeriesHistoryRowConnector from './SeriesHistoryRowConnector'; +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class SeriesHistoryModalContent extends Component { + + // + // Render + + render() { + const { + seasonNumber, + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress, + onModalClose + } = this.props; + + const fullSeries = seasonNumber == null; + const hasItems = !!items.length; + + return ( + + + History {seasonNumber != null && } + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to load history.
    + } + + { + isPopulated && !hasItems && !error && +
    No history.
    + } + + { + isPopulated && hasItems && !error && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } +
    + + + + +
    + ); + } +} + +SeriesHistoryModalContent.propTypes = { + seasonNumber: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesHistoryModalContent; diff --git a/frontend/src/Series/History/SeriesHistoryModalContentConnector.js b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js new file mode 100644 index 000000000..8c8d4e5b2 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchSeriesHistory, clearSeriesHistory, seriesHistoryMarkAsFailed } from 'Store/Actions/seriesHistoryActions'; +import SeriesHistoryModalContent from './SeriesHistoryModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesHistory, + (seriesHistory) => { + return seriesHistory; + } + ); +} + +const mapDispatchToProps = { + fetchSeriesHistory, + clearSeriesHistory, + seriesHistoryMarkAsFailed +}; + +class SeriesHistoryModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchSeriesHistory({ + seriesId, + seasonNumber + }); + } + + componentWillUnmount() { + this.props.clearSeriesHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.seriesHistoryMarkAsFailed({ + historyId, + seriesId, + seasonNumber + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesHistoryModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchSeriesHistory: PropTypes.func.isRequired, + clearSeriesHistory: PropTypes.func.isRequired, + seriesHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryModalContentConnector); diff --git a/frontend/src/Series/History/SeriesHistoryRow.css b/frontend/src/Series/History/SeriesHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Series/History/SeriesHistoryRow.js b/frontend/src/Series/History/SeriesHistoryRow.js new file mode 100644 index 000000000..ecf176091 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRow.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeNumber from 'Episode/EpisodeNumber'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './SeriesHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'seriesFolderImported': return 'Series Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'episodeFileDeleted': return 'Episode File Deleted'; + case 'episodeFileRenamed': return 'Episode File Renamed'; + default: return 'Unknown'; + } +} + +class SeriesHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + date, + data, + fullSeries, + series, + episode + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + const EpisodeComponent = fullSeries ? SeasonEpisodeNumber : EpisodeNumber; + + return ( + + + + + + + + + {sourceTitle} + + + + + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +SeriesHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + fullSeries: PropTypes.bool.isRequired, + series: PropTypes.object.isRequired, + episode: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default SeriesHistoryRow; diff --git a/frontend/src/Series/History/SeriesHistoryRowConnector.js b/frontend/src/Series/History/SeriesHistoryRowConnector.js new file mode 100644 index 000000000..840e522f0 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import SeriesHistoryRow from './SeriesHistoryRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + (series, episode) => { + return { + series, + episode + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryRow); diff --git a/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js new file mode 100644 index 000000000..29f2a15c8 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import SeriesIndexFilterModalConnector from 'Series/Index/SeriesIndexFilterModalConnector'; + +function SeriesIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +SeriesIndexFilterMenu.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +SeriesIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default SeriesIndexFilterMenu; diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js new file mode 100644 index 000000000..83aabf604 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, sortDirections } from 'Helpers/Props'; +import SortMenu from 'Components/Menu/SortMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; + +function SeriesIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + Title + + + + Network + + + + Quality Profile + + + + Language Profile + + + + Next Airing + + + + Previous Airing + + + + Added + + + + Seasons + + + + Episodes + + + + Episode Count + + + + Latest Season + + + + Path + + + + Size on Disk + + + + ); +} + +SeriesIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default SeriesIndexSortMenu; diff --git a/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js new file mode 100644 index 000000000..2fa6f6f98 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; + +function SeriesIndexViewMenu(props) { + const { + view, + isDisabled, + onViewSelect + } = props; + + return ( + + + + Table + + + + Posters + + + + Overview + + + + ); +} + +SeriesIndexViewMenu.propTypes = { + view: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + onViewSelect: PropTypes.func.isRequired +}; + +export default SeriesIndexViewMenu; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js new file mode 100644 index 000000000..0b7a28ba0 --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesIndexOverviewOptionsModalContentConnector from './SeriesIndexOverviewOptionsModalContentConnector'; + +function SeriesIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +SeriesIndexOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviewOptionsModal; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js new file mode 100644 index 000000000..3cc19475c --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js @@ -0,0 +1,305 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class SeriesIndexOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showMonitored: props.showMonitored, + showNetwork: props.showNetwork, + showQualityProfile: props.showQualityProfile, + showPreviousAiring: props.showPreviousAiring, + showAdded: props.showAdded, + showSeasonCount: props.showSeasonCount, + showPath: props.showPath, + showSizeOnDisk: props.showSizeOnDisk, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showMonitored, + showNetwork, + showQualityProfile, + showPreviousAiring, + showAdded, + showSeasonCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showNetwork !== prevProps.showNetwork) { + state.showNetwork = showNetwork; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showPreviousAiring !== prevProps.showPreviousAiring) { + state.showPreviousAiring = showPreviousAiring; + } + + if (showAdded !== prevProps.showAdded) { + state.showAdded = showAdded; + } + + if (showSeasonCount !== prevProps.showSeasonCount) { + state.showSeasonCount = showSeasonCount; + } + + if (showPath !== prevProps.showPath) { + state.showPath = showPath; + } + + if (showSizeOnDisk !== prevProps.showSizeOnDisk) { + state.showSizeOnDisk = showSizeOnDisk; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeOverviewOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOverviewOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showMonitored, + showNetwork, + showQualityProfile, + showPreviousAiring, + showAdded, + showSeasonCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.state; + + return ( + + + Overview Options + + + +
    + + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Monitored + + + + + + Show Network + + + + + + Show Quality Profile + + + + + + Show Previous Airing + + + + + + Show Date Added + + + + + + Show Season Count + + + + + + Show Path + + + + + + Show Size on Disk + + + + + + Show Search + + + +
    +
    + + + + +
    + ); + } +} + +SeriesIndexOverviewOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showNetwork: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showPreviousAiring: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showSeasonCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangeOverviewOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviewOptionsModalContent; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..5feffa506 --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexOverviewOptionsModalContent from './SeriesIndexOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + return seriesIndex.overviewOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setSeriesOverviewOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexOverviewOptionsModalContent); diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.css b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css new file mode 100644 index 000000000..6311d9be1 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css @@ -0,0 +1,96 @@ +$hoverScale: 1.05; + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.content { + display: flex; + flex-grow: 1; +} + +.poster { + position: relative; +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; + color: $defaultColor; + + &:hover { + color: $defaultColor; + text-decoration: none; + } +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + line-height: 32px; +} + +.title { + @add-mixin truncate; + composes: link; + + flex: 1 0 1px; + font-weight: 300; + font-size: 30px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; +} + +.overview { + composes: link; + + flex: 0 1 1000px; + overflow: hidden; + min-height: 0; +} + +@media only screen and (max-width: $breakpointSmall) { + .overview { + display: none; + } +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.js b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js new file mode 100644 index 000000000..c89f76430 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexOverviewInfo from './SeriesIndexOverviewInfo'; +import styles from './SeriesIndexOverview.css'; + +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const titleRowHeight = 42; + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +class SeriesIndexOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + title, + overview, + monitored, + status, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + qualityProfile, + overviewOptions, + showSearchAction, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount + } = statistics; + + const { + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + const link = `/series/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
    +
    +
    +
    + { + status === 'ended' && +
    + } + + + + +
    + + +
    + +
    +
    + + {title} + + +
    + + + { + showSearchAction && + + } + + +
    +
    + +
    + + + + + +
    +
    +
    + + + + +
    + ); + } +} + +SeriesIndexOverview.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + rowHeight: PropTypes.number.isRequired, + qualityProfile: PropTypes.object.isRequired, + overviewOptions: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired +}; + +SeriesIndexOverview.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + } +}; + +export default SeriesIndexOverview; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css new file mode 100644 index 000000000..5dc53762f --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css @@ -0,0 +1,12 @@ +.infos { + display: flex; + flex: 0 0 250px; + flex-direction: column; + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .infos { + margin-left: 0; + } +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js new file mode 100644 index 000000000..844219f04 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js @@ -0,0 +1,266 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SeriesIndexOverviewInfoRow from './SeriesIndexOverviewInfoRow'; +import styles from './SeriesIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.seriesIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored' + + }, + { + name: 'network', + showProp: 'showNetwork', + valueProp: 'network' + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId' + }, + { + name: 'previousAiring', + showProp: 'showPreviousAiring', + valueProp: 'previousAiring' + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added' + }, + { + name: 'seasonCount', + showProp: 'showSeasonCount', + valueProp: 'seasonCount' + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path' + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk' + } +]; + +function isVisible(row, props) { + const { + name, + showProp, + valueProp + } = row; + + if (props[valueProp] == null) { + return false; + } + + return props[showProp] || props.sortKey === name; +} + +function getInfoRowProps(row, props) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText + }; + } + + if (name === 'network') { + return { + title: 'Network', + iconName: icons.NETWORK, + label: props.network + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name + }; + } + + if (name === 'previousAiring') { + const { + previousAiring, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Previous Airing: ${formatDateTime(previousAiring, longDateFormat, timeFormat)}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'added') { + const { + added, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'seasonCount') { + const { seasonCount } = props; + let seasons = '1 season'; + + if (seasonCount === 0) { + seasons = 'No seasons'; + } else if (seasonCount > 1) { + seasons = `${seasonCount} seasons`; + } + + return { + title: 'Season Count', + iconName: icons.CIRCLE, + label: seasons + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk) + }; + } +} + +function SeriesIndexOverviewInfo(props) { + const { + height, + nextAiring, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + return ( +
    + { + !!nextAiring && + + } + + { + rows.map((row) => { + if (!isVisible(row, props)) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props); + + return ( + + ); + }) + } +
    + ); +} + +SeriesIndexOverviewInfo.propTypes = { + height: PropTypes.number.isRequired, + showNetwork: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showPreviousAiring: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showSeasonCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + monitored: PropTypes.bool.isRequired, + nextAiring: PropTypes.string, + network: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + seasonCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default SeriesIndexOverviewInfo; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css new file mode 100644 index 000000000..bae40ed1f --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css @@ -0,0 +1,10 @@ +.infoRow { + flex: 0 0 $seriesIndexOverviewInfoRowHeight; + margin: 2px 0; +} + +.icon { + margin-right: 5px; + width: 25px !important; + text-align: center; +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js new file mode 100644 index 000000000..87c388869 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './SeriesIndexOverviewInfoRow.css'; + +function SeriesIndexOverviewInfoRow(props) { + const { + title, + iconName, + label + } = props; + + return ( +
    + + + {label} +
    + ); +} + +SeriesIndexOverviewInfoRow.propTypes = { + title: PropTypes.string, + iconName: PropTypes.object.isRequired, + label: PropTypes.string.isRequired +}; + +export default SeriesIndexOverviewInfoRow; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js new file mode 100644 index 000000000..3d0ab8c20 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js @@ -0,0 +1,289 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexOverview from './SeriesIndexOverview'; +import styles from './SeriesIndexOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +function calculatePosterWidth(posterSize, isSmallScreen) { + const maxiumPosterWidth = isSmallScreen ? 152 : 162; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { + const { + detailedProgressBar + } = overviewOptions; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class SeriesIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + overviewOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions); + + if ( + prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged || + overviewOptionsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + rowHeight + } = this.state; + + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + scrollToFirstCharacter(character) { + const items = this.props.items; + const { + rowHeight + } = this.state; + + const index = getIndexOfFirstCharacter(items, character); + + if (index != null) { + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + overviewOptions + } = this.props; + + const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); + + this.setState({ + width, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + overviewOptions, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen + } = this.props; + + const { + posterWidth, + posterHeight, + rowHeight + } = this.state; + + const series = items[rowIndex]; + + if (!series) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +SeriesIndexOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + overviewOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviews; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js new file mode 100644 index 000000000..cf7025984 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import SeriesIndexOverviews from './SeriesIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.overviewOptions, + createClientSideCollectionSelector('series', 'seriesIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (overviewOptions, series, uiSettings, dimensions) => { + return { + overviewOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...series + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexOverviews); diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js new file mode 100644 index 000000000..8b1d79dcb --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesIndexPosterOptionsModalContentConnector from './SeriesIndexPosterOptionsModalContentConnector'; + +function SeriesIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +SeriesIndexPosterOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexPosterOptionsModal; diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js new file mode 100644 index 000000000..b7f66401e --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class SeriesIndexPosterOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showTitle !== prevProps.showTitle) { + state.showTitle = showTitle; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangePosterOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangePosterOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Poster Options + + + +
    + + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Title + + + + + + Show Monitored + + + + + + Show Quality Profile + + + + + + Show Search + + + +
    +
    + + + + +
    + ); + } +} + +SeriesIndexPosterOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangePosterOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexPosterOptionsModalContent; diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js new file mode 100644 index 000000000..bb15208d7 --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexPosterOptionsModalContent from './SeriesIndexPosterOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + return seriesIndex.posterOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangePosterOption(payload) { + dispatch(setSeriesPosterOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexPosterOptionsModalContent); diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.css b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css new file mode 100644 index 000000000..227784df1 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css @@ -0,0 +1,102 @@ +$hoverScale: 1.05; + +.container { + padding: 10px; +} + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #4f566f; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from 'Components/Link/IconButton.css'; + + &:hover { + color: #ccc; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.js b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js new file mode 100644 index 000000000..4e4238cc7 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js @@ -0,0 +1,293 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexPosterInfo from './SeriesIndexPosterInfo'; +import styles from './SeriesIndexPoster.css'; + +class SeriesIndexPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + title, + monitored, + status, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + qualityProfile, + showSearchAction, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount + } = statistics; + + const { + hasPosterError, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + const link = `/series/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
    +
    +
    + + + { + status === 'ended' && +
    + } + + + + + { + hasPosterError && +
    + {title} +
    + } + +
    + + + + { + showTitle && +
    + {title} +
    + } + + { + showMonitored && +
    + {monitored ? 'Monitored' : 'Unmonitored'} +
    + } + + { + showQualityProfile && +
    + {qualityProfile.name} +
    + } + + { + nextAiring && +
    + { + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
    + } + + + + + + +
    +
    + ); + } +} + +SeriesIndexPoster.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesIndexPoster.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + } +}; + +export default SeriesIndexPoster; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js new file mode 100644 index 000000000..39ecdca52 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js @@ -0,0 +1,125 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './SeriesIndexPosterInfo.css'; + +function SeriesIndexPosterInfo(props) { + const { + network, + qualityProfile, + showQualityProfile, + previousAiring, + added, + seasonCount, + path, + sizeOnDisk, + sortKey, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (sortKey === 'network' && network) { + return ( +
    + {network} +
    + ); + } + + if (sortKey === 'qualityProfileId' && !showQualityProfile) { + return ( +
    + {qualityProfile.name} +
    + ); + } + + if (sortKey === 'previousAiring' && previousAiring) { + return ( +
    + { + getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
    + ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false + } + ); + + return ( +
    + {`Added ${addedDate}`} +
    + ); + } + + if (sortKey === 'seasonCount') { + let seasons = '1 season'; + + if (seasonCount === 0) { + seasons = 'No seasons'; + } else if (seasonCount > 1) { + seasons = `${seasonCount} seasons`; + } + + return ( +
    + {seasons} +
    + ); + } + + if (sortKey === 'path') { + return ( +
    + {path} +
    + ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
    + {formatBytes(sizeOnDisk)} +
    + ); + } + + return null; +} + +SeriesIndexPosterInfo.propTypes = { + network: PropTypes.string, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + seasonCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default SeriesIndexPosterInfo; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.css b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.js b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js new file mode 100644 index 000000000..986943a05 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js @@ -0,0 +1,327 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexPoster from './SeriesIndexPoster'; +import styles from './SeriesIndexPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + switch (sortKey) { + case 'network': + case 'seasons': + case 'previousAiring': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class SeriesIndexPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + posterOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + + if ( + prevProps.sortKey !== sortKey || + prevProps.posterOptions !== posterOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; + + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + posterOptions + } = this.props; + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + items, + sortKey, + posterOptions, + showRelativeDates, + shortDateFormat, + timeFormat + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const series = items[rowIndex * columnCount + columnIndex]; + + if (!series) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(items.length / columnCount); + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +SeriesIndexPosters.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + posterOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexPosters; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js new file mode 100644 index 000000000..0d74d3256 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import SeriesIndexPosters from './SeriesIndexPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.posterOptions, + createClientSideCollectionSelector('series', 'seriesIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (posterOptions, series, uiSettings, dimensions) => { + return { + posterOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...series + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexPosters); diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css new file mode 100644 index 000000000..dbf3499ab --- /dev/null +++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css @@ -0,0 +1,14 @@ +.progress { + composes: container from 'Components/ProgressBar.css'; + + border-radius: 0; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + +.progressBar { + composes: progressBar from 'Components/ProgressBar.css'; + + transition: width 200ms ease; +} diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js new file mode 100644 index 000000000..882f4850a --- /dev/null +++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; +import { sizes } from 'Helpers/Props'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './SeriesIndexProgressBar.css'; + +function SeriesIndexProgressBar(props) { + const { + monitored, + status, + episodeCount, + episodeFileCount, + totalEpisodeCount, + posterWidth, + detailedProgressBar + } = props; + + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + const text = `${episodeFileCount} / ${episodeCount}`; + + return ( + + ); +} + +SeriesIndexProgressBar.propTypes = { + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + episodeCount: PropTypes.number.isRequired, + episodeFileCount: PropTypes.number.isRequired, + totalEpisodeCount: PropTypes.number.isRequired, + posterWidth: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired +}; + +export default SeriesIndexProgressBar; diff --git a/frontend/src/Series/Index/SeriesIndex.css b/frontend/src/Series/Index/SeriesIndex.css new file mode 100644 index 000000000..443372a73 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndex.css @@ -0,0 +1,51 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.contentBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Series/Index/SeriesIndex.js b/frontend/src/Series/Index/SeriesIndex.js new file mode 100644 index 000000000..f1eae53c6 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndex.js @@ -0,0 +1,369 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import NoSeries from 'Series/NoSeries'; +import SeriesIndexTableConnector from './Table/SeriesIndexTableConnector'; +import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal'; +import SeriesIndexPostersConnector from './Posters/SeriesIndexPostersConnector'; +import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverviewOptionsModal'; +import SeriesIndexOverviewsConnector from './Overview/SeriesIndexOverviewsConnector'; +import SeriesIndexFooter from './SeriesIndexFooter'; +import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu'; +import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu'; +import SeriesIndexViewMenu from './Menus/SeriesIndexViewMenu'; +import styles from './SeriesIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return SeriesIndexPostersConnector; + } + + if (view === 'overview') { + return SeriesIndexOverviewsConnector; + } + + return SeriesIndexTableConnector; +} + +class SeriesIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + jumpBarItems: [], + jumpToCharacter: null, + isPosterOptionsModalOpen: false, + isOverviewOptionsModalOpen: false, + isRendered: false + }; + } + + componentDidMount() { + this.setJumpBarItems(); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + scrollTop + } = this.props; + + if ( + hasDifferentItems(prevProps.items, items) || + sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection + ) { + this.setJumpBarItems(); + } + + if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortTitle + if (sortKey !== 'sortTitle') { + this.setState({ jumpBarItems: [] }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (isNaN(firstCharacter)) { + acc.push(firstCharacter); + } else { + acc.push('#'); + } + + return acc; + }, []).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + characters.reverse(); + } + + this.setState({ jumpBarItems: _.sortedUniq(characters) }); + } + + // + // Listeners + + onPosterOptionsPress = () => { + this.setState({ isPosterOptionsModalOpen: true }); + } + + onPosterOptionsModalClose = () => { + this.setState({ isPosterOptionsModalOpen: false }); + } + + onOverviewOptionsPress = () => { + this.setState({ isOverviewOptionsModalOpen: true }); + } + + onOverviewOptionsModalClose = () => { + this.setState({ isOverviewOptionsModalOpen: false }); + } + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onRender = () => { + this.setState({ isRendered: true }, () => { + const { + scrollTop, + isSmallScreen + } = this.props; + + if (isSmallScreen) { + // Seems to result in the view being off by 125px (distance to the top of the page) + // document.documentElement.scrollTop = document.body.scrollTop = scrollTop; + + // This works, but then jumps another 1px after scrolling + document.documentElement.scrollTop = scrollTop; + } + }); + } + + onScroll = ({ scrollTop }) => { + this.props.onScroll({ scrollTop }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + isRefreshingSeries, + isRssSyncExecuting, + scrollTop, + onSortSelect, + onFilterSelect, + onViewSelect, + onRefreshSeriesPress, + onRssSyncPress, + ...otherProps + } = this.props; + + const { + contentBody, + jumpBarItems, + jumpToCharacter, + isPosterOptionsModalOpen, + isOverviewOptionsModalOpen, + isRendered + } = this.state; + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && contentBody); + const hasNoSeries = !totalItems; + + return ( + + + + + + + + + + + + { + view === 'posters' && + + } + + { + view === 'overview' && + + } + + { + (view === 'posters' || view === 'overview') && + + } + + + + + + + + + +
    + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
    Unable to load series
    + } + + { + isLoaded && +
    + + + +
    + } + + { + !error && isPopulated && !items.length && + + } +
    + + { + isLoaded && !!jumpBarItems.length && + + } +
    + + + + +
    + ); + } +} + +SeriesIndex.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + view: PropTypes.string.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isRssSyncExecuting: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onViewSelect: PropTypes.func.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onRssSyncPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndex; diff --git a/frontend/src/Series/Index/SeriesIndexConnector.js b/frontend/src/Series/Index/SeriesIndexConnector.js new file mode 100644 index 000000000..63a6288cc --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexConnector.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { fetchSeries } from 'Store/Actions/seriesActions'; +import scrollPositions from 'Store/scrollPositions'; +import { setSeriesSort, setSeriesFilter, setSeriesView } from 'Store/Actions/seriesIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import withScrollPosition from 'Components/withScrollPosition'; +import SeriesIndex from './SeriesIndex'; + +const POSTERS_PADDING = 15; +const POSTERS_PADDING_SMALL_SCREEN = 5; +const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding); +const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen); + +// If the scrollTop is greater than zero it needs to be offset +// by the padding so when it is set initially so it is correct +// after React Virtualized takes the padding into account. + +function getScrollTop(view, scrollTop, isSmallScreen) { + if (scrollTop === 0) { + return 0; + } + + let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING; + + if (view === 'posters') { + padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING; + } + + return scrollTop + padding; +} + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('series', 'seriesIndex'), + createCommandExecutingSelector(commandNames.REFRESH_SERIES), + createCommandExecutingSelector(commandNames.RSS_SYNC), + createDimensionsSelector(), + ( + series, + isRefreshingSeries, + isRssSyncExecuting, + dimensionsState + ) => { + return { + ...series, + isRefreshingSeries, + isRssSyncExecuting, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + fetchSeries, + setSeriesSort, + setSeriesFilter, + setSeriesView, + executeCommand +}; + +class SeriesIndexConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + view, + scrollTop, + isSmallScreen + } = props; + + this.state = { + scrollTop: getScrollTop(view, scrollTop, isSmallScreen) + }; + } + + componentDidMount() { + this.props.fetchSeries(); + } + + // + // Listeners + + onSortSelect = (sortKey) => { + this.props.setSeriesSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setSeriesFilter({ selectedFilterKey }); + } + + onViewSelect = (view) => { + // Reset the scroll position before changing the view + this.setState({ scrollTop: 0 }, () => { + this.props.setSeriesView({ view }); + }); + } + + onScroll = ({ scrollTop }) => { + this.setState({ + scrollTop + }, () => { + scrollPositions.seriesIndex = scrollTop; + }); + } + + onRefreshSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES + }); + } + + onRssSyncPress = () => { + this.props.executeCommand({ + name: commandNames.RSS_SYNC + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + scrollTop: PropTypes.number.isRequired, + fetchSeries: PropTypes.func.isRequired, + setSeriesSort: PropTypes.func.isRequired, + setSeriesFilter: PropTypes.func.isRequired, + setSeriesView: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexConnector), + 'seriesIndex' +); diff --git a/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js new file mode 100644 index 000000000..4833f42f2 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesFilter } from 'Store/Actions/seriesIndexActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seriesIndex.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seriesIndex' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeriesFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Series/Index/SeriesIndexFooter.css b/frontend/src/Series/Index/SeriesIndexFooter.css new file mode 100644 index 000000000..3aa369576 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFooter.css @@ -0,0 +1,66 @@ +.footer { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + font-size: $smallFontSize; +} + +.legendItem { + display: flex; + margin-bottom: 4px; + line-height: 16px; +} + +.legendItemColor { + margin-right: 8px; + width: 30px; + height: 16px; + border-radius: 4px; +} + +.continuing { + composes: legendItemColor; + + background-color: $primaryColor; +} + +.ended { + composes: legendItemColor; + + background-color: $successColor; +} + +.missingMonitored { + composes: legendItemColor; + + background-color: $dangerColor; +} + +.missingUnmonitored { + composes: legendItemColor; + + background-color: $warningColor; +} + +.statistics { + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +@media (max-width: $breakpointLarge) { + .statistics { + display: block; + } +} + +@media (max-width: $breakpointSmall) { + .footer { + display: block; + } + + .statistics { + display: flex; + margin-top: 20px; + } +} diff --git a/frontend/src/Series/Index/SeriesIndexFooter.js b/frontend/src/Series/Index/SeriesIndexFooter.js new file mode 100644 index 000000000..8afba25a7 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFooter.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SeriesIndexFooter.css'; + +function SeriesIndexFooter({ series }) { + const count = series.length; + let episodes = 0; + let episodeFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + series.forEach((s) => { + const { statistics = {} } = s; + + const { + episodeCount = 0, + episodeFileCount = 0, + sizeOnDisk = 0 + } = statistics; + + episodes += episodeCount; + episodeFiles += episodeFileCount; + + if (s.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (s.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( +
    +
    +
    +
    +
    Continuing (All episodes downloaded)
    +
    + +
    +
    +
    Ended (All episodes downloaded)
    +
    + +
    +
    +
    Missing Episodes (Series monitored)
    +
    + +
    +
    +
    Missing Episodes (Series not monitored)
    +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + ); +} + +SeriesIndexFooter.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SeriesIndexFooter; diff --git a/frontend/src/Series/Index/SeriesIndexItemConnector.js b/frontend/src/Series/Index/SeriesIndexItemConnector.js new file mode 100644 index 000000000..633fa722b --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexItemConnector.js @@ -0,0 +1,125 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; + +function selectShowSearchAction() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + const view = seriesIndex.view; + + switch (view) { + case 'posters': + return seriesIndex.posterOptions.showSearchAction; + case 'overview': + return seriesIndex.overviewOptions.showSearchAction; + default: + return seriesIndex.tableOptions.showSearchAction; + } + } + ); +} + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createQualityProfileSelector(), + createLanguageProfileSelector(), + selectShowSearchAction(), + createCommandsSelector(), + ( + series, + qualityProfile, + languageProfile, + showSearchAction, + commands + ) => { + const isRefreshingSeries = commands.some((command) => { + return ( + command.name === commandNames.REFRESH_SERIES && + command.body.seriesId === series.id && + isCommandExecuting(command) + ); + }); + + const isSearchingSeries = commands.some((command) => { + return ( + command.name === commandNames.SERIES_SEARCH && + command.body.seriesId === series.id && + isCommandExecuting(command) + ); + }); + + const latestSeason = _.maxBy(series.seasons, (season) => season.seasonNumber); + + return { + ...series, + qualityProfile, + languageProfile, + latestSeason, + showSearchAction, + isRefreshingSeries, + isSearchingSeries + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class SeriesIndexItemConnector extends Component { + + // + // Listeners + + onRefreshSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES, + seriesId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.SERIES_SEARCH, + seriesId: this.props.id + }); + } + + // + // Render + + render() { + const { + component: ItemComponent, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +SeriesIndexItemConnector.propTypes = { + id: PropTypes.number.isRequired, + component: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexItemConnector); diff --git a/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js new file mode 100644 index 000000000..d85263469 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; + +class SeriesIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingSeries, + onRefreshSeriesPress, + ...otherProps + } = this.props; + + const { + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +SeriesIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired +}; + +export default SeriesIndexActionsCell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.css b/frontend/src/Series/Index/Table/SeriesIndexHeader.css new file mode 100644 index 000000000..826d94110 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.css @@ -0,0 +1,89 @@ +.status { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + +.sortTitle { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.network { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 2 0 90px; +} + +.qualityProfileId, +.languageProfileId { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 125px; +} + +.nextAiring, +.previousAiring, +.added, +.genres { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 180px; +} + +.seasonCount, +.certification { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + +.episodeProgress, +.latestSeason { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 150px; +} + +.episodeCount { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 130px; +} + +.path { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 145px; +} + +.actions { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.js b/frontend/src/Series/Index/Table/SeriesIndexHeader.js new file mode 100644 index 000000000..3de1ec64c --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import SeriesIndexTableOptionsConnector from './SeriesIndexTableOptionsConnector'; +import styles from './SeriesIndexHeader.css'; + +class SeriesIndexHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + showSearchAction, + columns, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + ); + } + + return ( + + {label} + + ); + }) + } + + + + ); + } +} + +SeriesIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default SeriesIndexHeader; diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js new file mode 100644 index 000000000..fa9a895fd --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setSeriesTableOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexHeader from './SeriesIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setSeriesTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(SeriesIndexHeader); diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css new file mode 100644 index 000000000..2513e1913 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -0,0 +1,138 @@ +.cell { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.status { + composes: cell; + + flex: 0 0 60px; +} + +.sortTitle { + composes: cell; + + flex: 4 0 110px; +} + +.banner { + flex: 0 0 379px; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.bannerImage { + width: 379px; + height: 70px; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.network { + composes: cell; + + flex: 2 0 90px; +} + +.qualityProfileId, +.languageProfileId { + composes: cell; + + flex: 1 0 125px; +} + +.nextAiring, +.previousAiring, +.added, +.genres { + composes: cell; + + flex: 0 0 180px; +} + +.seasonCount, +.certification { + composes: cell; + + flex: 0 0 100px; +} + +.episodeProgress, +.latestSeason { + composes: cell; + + display: flex; + justify-content: center; + flex: 0 0 150px; + flex-direction: column; +} + +.episodeCount { + composes: cell; + + flex: 0 0 130px; +} + +.path { + composes: cell; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: cell; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: cell; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: cell; + + flex: 0 0 145px; +} + +.actions { + composes: cell; + + flex: 0 1 90px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.js b/frontend/src/Series/Index/Table/SeriesIndexRow.js new file mode 100644 index 000000000..b36926c46 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.js @@ -0,0 +1,515 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TagListConnector from 'Components/TagListConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesBanner from 'Series/SeriesBanner'; +import SeriesStatusCell from './SeriesStatusCell'; +import styles from './SeriesIndexRow.css'; + +class SeriesIndexRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasBannerError: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onUseSceneNumberingChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + onBannerLoad = () => { + if (this.state.hasBannerError) { + this.setState({ hasBannerError: false }); + } + } + + onBannerLoadError = () => { + if (!this.state.hasBannerError) { + this.setState({ hasBannerError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + monitored, + status, + title, + titleSlug, + network, + qualityProfile, + languageProfile, + nextAiring, + previousAiring, + added, + statistics, + latestSeason, + path, + genres, + ratings, + certification, + tags, + images, + useSceneNumbering, + showBanners, + showSearchAction, + columns, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount, + sizeOnDisk + } = statistics; + + const { + hasBannerError, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortTitle') { + return ( + + { + showBanners ? + + + + { + hasBannerError && +
    + {title} +
    + } + : + + + } +
    + ); + } + + if (name === 'network') { + return ( + + {network} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'languageProfileId') { + return ( + + {languageProfile.name} + + ); + } + + if (name === 'nextAiring') { + return ( + + ); + } + + if (name === 'previousAiring') { + return ( + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'seasonCount') { + return ( + + {seasonCount} + + ); + } + + if (name === 'episodeProgress') { + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + + return ( + + + + ); + } + + if (name === 'latestSeason') { + if (!latestSeason) { + return ( + + ); + } + + const seasonStatistics = latestSeason.statistics; + const progress = seasonStatistics.episodeCount ? seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 : 100; + + return ( + + + + ); + } + + if (name === 'episodeCount') { + return ( + + {totalEpisodeCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + + {joinedGenres} + + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'certification') { + return ( + + {certification} + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'useSceneNumbering') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + { + showSearchAction && + + } + + + + ); + } + + return null; + }) + } + + + + +
    + ); + } +} + +SeriesIndexRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + network: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + languageProfile: PropTypes.object.isRequired, + nextAiring: PropTypes.string, + previousAiring: PropTypes.string, + added: PropTypes.string, + statistics: PropTypes.object.isRequired, + latestSeason: PropTypes.object, + path: PropTypes.string.isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + ratings: PropTypes.object.isRequired, + certification: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + useSceneNumbering: PropTypes.bool.isRequired, + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesIndexRow.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + }, + genres: [], + tags: [] +}; + +export default SeriesIndexRow; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.css b/frontend/src/Series/Index/Table/SeriesIndexTable.css new file mode 100644 index 000000000..e46160a96 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from 'Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.js b/frontend/src/Series/Index/Table/SeriesIndexTable.js new file mode 100644 index 000000000..d5cae1d51 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import { sortDirections } from 'Helpers/Props'; +import VirtualTable from 'Components/Table/VirtualTable'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexHeaderConnector from './SeriesIndexHeaderConnector'; +import SeriesIndexRow from './SeriesIndexRow'; +import styles from './SeriesIndexTable.css'; + +class SeriesIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const jumpToCharacter = this.props.jumpToCharacter; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const items = this.props.items; + + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); + } + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + showBanners + } = this.props; + + const series = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + columns, + filters, + sortKey, + sortDirection, + showBanners, + isSmallScreen, + scrollTop, + contentBody, + onSortPress, + onRender, + onScroll + } = this.props; + + return ( + + } + columns={columns} + filters={filters} + sortKey={sortKey} + sortDirection={sortDirection} + onRender={onRender} + onScroll={onScroll} + /> + ); + } +} + +SeriesIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + showBanners: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexTable; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js new file mode 100644 index 000000000..39804c9c6 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setSeriesSort } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexTable from './SeriesIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + createClientSideCollectionSelector('series', 'seriesIndex'), + (dimensions, series) => { + return { + isSmallScreen: dimensions.isSmallScreen, + ...series, + showBanners: series.tableOptions.showBanners + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setSeriesSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexTable); diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js new file mode 100644 index 000000000..52eeda885 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class SeriesIndexTableOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + showBanners: props.showBanners, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + showBanners, + showSearchAction + } = this.props; + + if ( + showBanners !== prevProps.showBanners || + showSearchAction !== prevProps.showSearchAction + ) { + this.setState({ + showBanners, + showSearchAction + }); + } + } + + // + // Listeners + + onTableOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onTableOptionChange({ + tableOptions: { + ...this.state, + [name]: value + } + }); + }); + } + + // + // Render + + render() { + const { + showBanners, + showSearchAction + } = this.state; + + return ( + + + Show Banners + + + + + + Show Search + + + + + ); + } +} + +SeriesIndexTableOptions.propTypes = { + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default SeriesIndexTableOptions; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js new file mode 100644 index 000000000..ac608abe5 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import SeriesIndexTableOptions from './SeriesIndexTableOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.tableOptions, + (tableOptions) => { + return tableOptions; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexTableOptions); diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.css b/frontend/src/Series/Index/Table/SeriesStatusCell.css new file mode 100644 index 000000000..a7681e36c --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.css @@ -0,0 +1,9 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} + +.statusIcon { + width: 20px !important; +} diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.js b/frontend/src/Series/Index/Table/SeriesStatusCell.js new file mode 100644 index 000000000..44cd42f0e --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './SeriesStatusCell.css'; + +function SeriesStatusCell(props) { + const { + className, + monitored, + status, + component: Component, + ...otherProps + } = props; + + return ( + + + + + + ); +} + +SeriesStatusCell.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + component: PropTypes.func +}; + +SeriesStatusCell.defaultProps = { + className: styles.status, + component: VirtualTableRowCell +}; + +export default SeriesStatusCell; diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.css b/frontend/src/Series/MoveSeries/MoveSeriesModal.css new file mode 100644 index 000000000..11f33bef2 --- /dev/null +++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.css @@ -0,0 +1,5 @@ +.doNotMoveButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.js b/frontend/src/Series/MoveSeries/MoveSeriesModal.js new file mode 100644 index 000000000..6d767f22f --- /dev/null +++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './MoveSeriesModal.css'; + +function MoveSeriesModal(props) { + const { + originalPath, + destinationPath, + destinationRootFolder, + isOpen, + onSavePress, + onMoveSeriesPress + } = props; + + if ( + isOpen && + !originalPath && + !destinationPath && + !destinationRootFolder + ) { + console.error('orginalPath and destinationPath OR destinationRootFolder must be provided'); + } + + return ( + + + + Move Files + + + + { + destinationRootFolder ? + `Would you like to move the series folders to '${destinationRootFolder}'?` : + `Would you like to move the series files from '${originalPath}' to '${destinationPath}'?` + } + + + + + + + + + + ); +} + +MoveSeriesModal.propTypes = { + originalPath: PropTypes.string, + destinationPath: PropTypes.string, + destinationRootFolder: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onMoveSeriesPress: PropTypes.func.isRequired +}; + +export default MoveSeriesModal; diff --git a/frontend/src/Series/NoSeries.css b/frontend/src/Series/NoSeries.css new file mode 100644 index 000000000..38a01f391 --- /dev/null +++ b/frontend/src/Series/NoSeries.css @@ -0,0 +1,11 @@ +.message { + margin-top: 10px; + margin-bottom: 30px; + text-align: center; + font-size: 20px; +} + +.buttonContainer { + margin-top: 20px; + text-align: center; +} diff --git a/frontend/src/Series/NoSeries.js b/frontend/src/Series/NoSeries.js new file mode 100644 index 000000000..cfcbb53ab --- /dev/null +++ b/frontend/src/Series/NoSeries.js @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import styles from './NoSeries.css'; + +function NoSeries(props) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( +
    +
    + All series are hidden due to the applied filter. +
    +
    + ); + } + + return ( +
    +
    + No series found, to get started you'll want to add a new series or import some existing ones. +
    + +
    + +
    + +
    + +
    +
    + ); +} + +NoSeries.propTypes = { + totalItems: PropTypes.number.isRequired +}; + +export default NoSeries; diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModal.js b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js new file mode 100644 index 000000000..7973affba --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent'; + +function SeasonInteractiveSearchModal(props) { + const { + isOpen, + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + + ); +} + +SeasonInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModal; diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js new file mode 100644 index 000000000..e270ebdec --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModal); diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js new file mode 100644 index 000000000..536c48a40 --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import SeasonNumber from 'Season/SeasonNumber'; + +function SeasonInteractiveSearchModalContent(props) { + const { + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + Interactive Search {seasonNumber != null && } + + + + + + + + + + + ); +} + +SeasonInteractiveSearchModalContent.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModalContent; diff --git a/frontend/src/Series/SeriesBanner.js b/frontend/src/Series/SeriesBanner.js new file mode 100644 index 000000000..13aa8c117 --- /dev/null +++ b/frontend/src/Series/SeriesBanner.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SeriesImage from './SeriesImage'; + +const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXsAAABGCAIAAACiz6ObAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAAVeSURBVHhe7d3dduI4EEXheaMOfzPv/2ZzpCqLsmULQWjf1P4WkwnEtrhhr7IhnX9uAHAWigPgPBQHwHkoDoDzUBwA56E4AM5DcQCch+IAOA/FwQfuuonfA6ZRHLymuDwej3+r/zp6TI9rAxqElygODtXQ7CRmwNLj+wMdioMdas3uODOPkQe7KA5Wft+aiO5gg+LAfbc1DedZiCgOzF/JTaOD+zrIjeKguF6vmnE8D9+mlKloWsIXQ2IUByU3Rqc/HomviktQneQoTnaXy/Wi/xbfnXQ03eiAfuirL+QLIyWKk1oLQWhOic5XrunoIJvc+DK+ODKiOEmpBY9HuZpbaxByUOnxX0bHLhX74Zbpxuhx3r1Ki+IkZUGJXVAS+i5YPt5io83zsOuztrY00cmJ4mSkIlgdZBWdy/Xn51kHozTMjzuxNSbmRuKvTdTnglwoTkY2ZTS66z2ogdhEx+4oJZu9Gj2uKmmDuuHKj44VirMZmix2SIXipBMHnGZ9TWdbCrPct8M43dVD/cY6QJebnWDZTIQ8KE46R6OKBhBvQ51NdqMzQ3tp1z9/ygHsES26mW4axpxsKE4uuwNO086MajU+iY7vGHIjR7kxelL+5JAAxcnlaMAx+mnrhLVDo8pb0VFoSmxCbhS50ZK8aZUMxcnFX+XH4gVgi04fHD2iH+2WqH/8fn/xFjsnVqlQnETGp1Qmjjk91URTT7vZ2dNgBtKi46lKKE4qFCeR8fWUxt5+6pWTrHqe1d+OqqNF/aBDvGOVB8VJZLI49/CmVWPXdEz5pr91Hx2UmalKKE4eFCeRlyc45hE+EGjsZMpa03/T7vaTzmTjuHicB8VJZLI42syDsShRWXhrluK0R8rdneLMNY7ipEFxEpksjngwFq0pJTXt++4mvsNidqqiOGlQnETeKE78bcxLKU4dZupXad+Y8FPfZUFxsEFxEpkvjjb2ZtRP37QRZvvNMt34XYqDVyhOIm/MOPEN8kFxFu2u77KgONigOIlMvv61lQdj8fzg3xKXzc2Gnf4NcoqDDYqTyHRxdj52XCeZ8mXANw2UEj/o0P1OcbKgOIlMvv61WV+cS/0VTZ9o9iad3Y8d8wlAbFCcRCZf/9rSg7GmqFhcNsXR7POb33LQSEVx8qA4iUwVp7uIE6ksJTdt2Cn12W+N0aIvT+W0gT09ZEBxcnn5+leVvBb1ffH6q+FdU/SA3TqlQOvtX57KUZxUKE4u49e/Xvzts3/KhurRF2Ss7LI+ydKi48xxSpUKxUln8PqPA84HuTHltKte6/H7wzFHz8WfFnKgOOkcFcfObqwRPqoMr9EMLNHx3QeLKkb1SSELipPO7vXjmBspI8r7001ULyo/x5z7wZhjTwl5UJyMNqc5ys36gnHhd6K6r7ZclL+KJ/Vh1Wr1nnrZP/z9X/1Pe/p6CwachChORspEO80Z58Y2VhqOTouMfliPU/8yZ5iV4tFKdG6rde3JIBWKk5SNOfaytyJI35pxaHYpTrE7OuT6sOWYom3qE0EuFCevelLj042SELugMHzQmmj9Z4UL+17UGnKTFsVJzRKwzc31qjnFy/ELatZzifJhZV/ClkZOFCe7koPwLrjK88vpJtKk48et0bGFfGGkRHFwiwPOF3Nj7A0pO7gWshWRFsVBoRzo69dzY1p06lJIjeLA3ef+LYsP2AUdQCgOnhSdv3FWxTtTaCgOtr7VHR3DzqeAhuJgn2Lh5XifgkVrsIvi4JCGHYVD+ZifeLQp51AYoDiYoYyU+hwpPyY0mEBxAJyH4gA4D8UBcB6KA+A8FAfAeSgOgPNQHADnoTgAzkNxAJzldvsfnbIbPuBaveQAAAAASUVORK5CYII='; + +function SeriesBanner(props) { + return ( + + ); +} + +SeriesBanner.propTypes = { + size: PropTypes.number.isRequired +}; + +SeriesBanner.defaultProps = { + size: 70 +}; + +export default SeriesBanner; diff --git a/frontend/src/Series/SeriesImage.js b/frontend/src/Series/SeriesImage.js new file mode 100644 index 000000000..6c99be067 --- /dev/null +++ b/frontend/src/Series/SeriesImage.js @@ -0,0 +1,198 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +function findImage(images, coverType) { + return images.find((image) => image.coverType === coverType); +} + +function getUrl(image, coverType, size) { + if (image) { + // Remove protocol + let url = image.url.replace(/^https?:/, ''); + url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); + + return url; + } +} + +class SeriesImage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.floor(window.devicePixelRatio); + + const { + images, + coverType, + size + } = props; + + const image = findImage(images, coverType); + + this.state = { + pixelRatio, + image, + url: getUrl(image, coverType, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidMount() { + if (!this.state.url && this.props.onError) { + this.props.onError(); + } + } + + componentDidUpdate() { + const { + images, + coverType, + placeholder, + size, + onError + } = this.props; + + const { + image, + pixelRatio + } = this.state; + + const nextImage = findImage(images, coverType); + + if (nextImage && (!image || nextImage.url !== image.url)) { + this.setState({ + image: nextImage, + url: getUrl(nextImage, coverType, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } else if (!nextImage && image) { + this.setState({ + image: nextImage, + url: placeholder, + hasError: false + }); + + if (onError) { + onError(); + } + } + } + + // + // Listeners + + onError = () => { + this.setState({ + hasError: true + }); + + if (this.props.onError) { + this.props.onError(); + } + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + // + // Render + + render() { + const { + className, + style, + placeholder, + size, + lazy, + overflow + } = this.props; + + const { + url, + hasError, + isLoaded + } = this.state; + + if (hasError || !url) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +SeriesImage.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + coverType: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired, + onError: PropTypes.func, + onLoad: PropTypes.func +}; + +SeriesImage.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default SeriesImage; diff --git a/frontend/src/Series/SeriesPoster.js b/frontend/src/Series/SeriesPoster.js new file mode 100644 index 000000000..2b04f2883 --- /dev/null +++ b/frontend/src/Series/SeriesPoster.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SeriesImage from './SeriesImage'; + +const posterPlaceholder = 'data:image/png;base64,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'; + +function SeriesPoster(props) { + return ( + + ); +} + +SeriesPoster.propTypes = { + size: PropTypes.number.isRequired +}; + +SeriesPoster.defaultProps = { + size: 250 +}; + +export default SeriesPoster; diff --git a/frontend/src/Series/SeriesTitleLink.js b/frontend/src/Series/SeriesTitleLink.js new file mode 100644 index 000000000..b91934a28 --- /dev/null +++ b/frontend/src/Series/SeriesTitleLink.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function SeriesTitleLink({ titleSlug, title }) { + const link = `/series/${titleSlug}`; + + return ( + + {title} + + ); +} + +SeriesTitleLink.propTypes = { + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default SeriesTitleLink; diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css new file mode 100644 index 000000000..4b7460ea2 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.css @@ -0,0 +1,31 @@ +.button { + composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css'; + + position: relative; +} + +.labelContainer { + composes: labelContainer from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.indicatorContainer { + position: absolute; + top: 10px; + right: 12px; +} + +.indicatorBackground { + color: $themeDarkColor; +} + +.enabled { + color: $successColor; +} + +.disabled { + color: $dangerColor; +} diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js new file mode 100644 index 000000000..12d9902d5 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './AdvancedSettingsButton.css'; + +function AdvancedSettingsButton(props) { + const { + advancedSettings, + onAdvancedSettingsPress + } = props; + + return ( + + + + + + + + + +
    +
    + {advancedSettings ? 'Hide Advanced' : 'Show Advanced'} +
    +
    + + ); +} + +AdvancedSettingsButton.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + onAdvancedSettingsPress: PropTypes.func.isRequired +}; + +export default AdvancedSettingsButton; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js new file mode 100644 index 000000000..c82604a88 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; +import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; + +class DownloadClientSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllDownloadClients + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + + ); + } +} + +DownloadClientSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired +}; + +export default DownloadClientSettings; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js new file mode 100644 index 000000000..5e1a8a1ca --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; +import DownloadClientSettings from './DownloadClientSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllDownloadClients: testAllDownloadClients +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css new file mode 100644 index 000000000..e1032ddef --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css @@ -0,0 +1,44 @@ +.downloadClient { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js new file mode 100644 index 000000000..3a2265d28 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem'; +import styles from './AddDownloadClientItem.css'; + +class AddDownloadClientItem extends Component { + + // + // Listeners + + onDownloadClientSelect = () => { + const { + implementation + } = this.props; + + this.props.onDownloadClientSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onDownloadClientSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
    + + +
    +
    + {implementationName} +
    + +
    + { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
    +
    +
    + ); + } +} + +AddDownloadClientItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onDownloadClientSelect: PropTypes.func.isRequired +}; + +export default AddDownloadClientItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js new file mode 100644 index 000000000..0c21e7dbd --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector'; + +function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css new file mode 100644 index 000000000..b4d5c6787 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css @@ -0,0 +1,5 @@ +.downloadClients { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js new file mode 100644 index 000000000..65ccf7b41 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddDownloadClientItem from './AddDownloadClientItem'; +import styles from './AddDownloadClientModalContent.css'; + +class AddDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients, + onDownloadClientSelect, + onModalClose + } = this.props; + + return ( + + + Add DownloadClient + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
    Unable to add a new downloadClient, please try again.
    + } + + { + isSchemaPopulated && !schemaError && +
    + + +
    Sonarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
    +
    For more information on the individual downloadClients, clink on the info buttons.
    +
    + +
    +
    + { + usenetDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
    +
    + +
    +
    + { + torrentDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
    +
    +
    + } +
    + + + +
    + ); + } +} + +AddDownloadClientModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + onDownloadClientSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js new file mode 100644 index 000000000..99d5c4f19 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions'; +import AddDownloadClientModalContent from './AddDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = downloadClients; + + const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' }); + const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClientSchema, + selectDownloadClientSchema +}; + +class AddDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClientSchema(); + } + + // + // Listeners + + onDownloadClientSelect = ({ implementation }) => { + this.props.selectDownloadClientSchema({ implementation }); + this.props.onModalClose({ downloadClientSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddDownloadClientModalContentConnector.propTypes = { + fetchDownloadClientSchema: PropTypes.func.isRequired, + selectDownloadClientSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js new file mode 100644 index 000000000..f356f8140 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddDownloadClientPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddDownloadClientPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddDownloadClientPresetMenuItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css new file mode 100644 index 000000000..cfeacec77 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css @@ -0,0 +1,19 @@ +.downloadClient { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js new file mode 100644 index 000000000..6a86fef16 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClient.css'; + +class DownloadClient extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onEditDownloadClientPress = () => { + this.setState({ isEditDownloadClientModalOpen: true }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + onDeleteDownloadClientPress = () => { + this.setState({ + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: true + }); + } + + onDeleteDownloadClientModalClose= () => { + this.setState({ isDeleteDownloadClientModalOpen: false }); + } + + onConfirmDeleteDownloadClient = () => { + this.props.onConfirmDeleteDownloadClient(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enable + } = this.props; + + return ( + +
    + {name} +
    + +
    + { + enable ? + : + + } +
    + + + + +
    + ); + } +} + +DownloadClient.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClient; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css new file mode 100644 index 000000000..ad53e6311 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css @@ -0,0 +1,20 @@ +.downloadClients { + display: flex; + flex-wrap: wrap; +} + +.addDownloadClient { + composes: downloadClient from './DownloadClient.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js new file mode 100644 index 000000000..029845025 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DownloadClient from './DownloadClient'; +import AddDownloadClientModal from './AddDownloadClientModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClients.css'; + +class DownloadClients extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onAddDownloadClientPress = () => { + this.setState({ isAddDownloadClientModalOpen: true }); + } + + onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => { + this.setState({ + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: downloadClientSelected + }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteDownloadClient, + ...otherProps + } = this.props; + + const { + isAddDownloadClientModalOpen, + isEditDownloadClientModalOpen + } = this.state; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
    + +
    +
    +
    + + + + +
    +
    + ); + } +} + +DownloadClients.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClients; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js new file mode 100644 index 000000000..d318bc163 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions'; +import DownloadClients from './DownloadClients'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + return { + ...downloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClients, + deleteDownloadClient +}; + +class DownloadClientsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClients(); + } + + // + // Listeners + + onConfirmDeleteDownloadClient = (id) => { + this.props.deleteDownloadClient({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientsConnector.propTypes = { + fetchDownloadClients: PropTypes.func.isRequired, + deleteDownloadClient: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js new file mode 100644 index 000000000..f6b07599c --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector'; + +function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js new file mode 100644 index 000000000..b5e5520fb --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestDownloadClient, cancelSaveDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModal from './EditDownloadClientModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.downloadClients'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestDownloadClient() { + dispatch(cancelTestDownloadClient({ section })); + }, + + dispatchCancelSaveDownloadClient() { + dispatch(cancelSaveDownloadClient({ section })); + } + }; +} + +class EditDownloadClientModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestDownloadClient(); + this.props.dispatchCancelSaveDownloadClient(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestDownloadClient, + dispatchCancelSaveDownloadClient, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditDownloadClientModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestDownloadClient: PropTypes.func.isRequired, + dispatchCancelSaveDownloadClient: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css new file mode 100644 index 000000000..c73406b57 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from 'Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js new file mode 100644 index 000000000..60eff3eb8 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditDownloadClientModalContent.css'; + +class EditDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteDownloadClientPress, + ...otherProps + } = this.props; + + const { + id, + implementationName, + name, + enable, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new download client, please try again.
    + } + + { + !isFetching && !error && +
    + { + !!message && + + {message.value.message} + + } + + + Name + + + + + + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
    + + { + id && + + } + + + Test + + + + + + Save + + +
    + ); + } +} + +EditDownloadClientModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isTesting: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteDownloadClientPress: PropTypes.func +}; + +export default EditDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js new file mode 100644 index 000000000..75f6f0bc3 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModalContent from './EditDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('downloadClients'), + (advancedSettings, downloadClient) => { + return { + advancedSettings, + ...downloadClient + }; + } + ); +} + +const mapDispatchToProps = { + setDownloadClientValue, + setDownloadClientFieldValue, + saveDownloadClient, + testDownloadClient +}; + +class EditDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDownloadClientValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setDownloadClientFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveDownloadClient({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testDownloadClient({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDownloadClientModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDownloadClientValue: PropTypes.func.isRequired, + setDownloadClientFieldValue: PropTypes.func.isRequired, + saveDownloadClient: PropTypes.func.isRequired, + testDownloadClient: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js new file mode 100644 index 000000000..c345feb5b --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js @@ -0,0 +1,116 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function DownloadClientOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
    + { + isFetching && + + } + + { + !isFetching && error && +
    Unable to load download client options
    + } + + { + hasSettings && !isFetching && !error && +
    +
    +
    + + Enable + + + + + + Remove + + + +
    +
    + +
    +
    + + Redownload + + + + + + Remove + + + +
    +
    +
    + } +
    + ); +} + +DownloadClientOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default DownloadClientOptions; diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js new file mode 100644 index 000000000..ef97de47f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import DownloadClientOptions from './DownloadClientOptions'; + +const SECTION = 'downloadClientOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDownloadClientOptions: fetchDownloadClientOptions, + dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue, + dispatchSaveDownloadClientOptions: saveDownloadClientOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class DownloadClientOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchDownloadClientOptions, + dispatchSaveDownloadClientOptions, + onChildMounted + } = this.props; + + dispatchFetchDownloadClientOptions(); + onChildMounted(dispatchSaveDownloadClientOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetDownloadClientOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchDownloadClientOptions: PropTypes.func.isRequired, + dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired, + dispatchSaveDownloadClientOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js new file mode 100644 index 000000000..f66113619 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector'; + +function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditRemotePathMappingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditRemotePathMappingModal; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js new file mode 100644 index 000000000..94172429d --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditRemotePathMappingModal from './EditRemotePathMappingModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditRemotePathMappingModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.remotePathMappings' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css new file mode 100644 index 000000000..0071acc4e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css @@ -0,0 +1,12 @@ +.body { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + flex: 1 1 430px; +} + +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js new file mode 100644 index 000000000..7172bd31e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditRemotePathMappingModalContent.css'; + +function EditRemotePathMappingModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + downloadClientHosts, + onInputChange, + onSavePress, + onModalClose, + onDeleteRemotePathMappingPress, + ...otherProps + } = props; + + const { + host, + remotePath, + localPath + } = item; + + return ( + + + {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new remote path mapping, please try again.
    + } + + { + !isFetching && !error && +
    + + Host + + + + + + Remote Path + + + + + + Local Path + + + +
    + } +
    + + + { + id && + + } + + + + + Save + + +
    + ); +} + +const remotePathMappingShape = { + host: PropTypes.shape(stringSettingShape).isRequired, + remotePath: PropTypes.shape(stringSettingShape).isRequired, + localPath: PropTypes.shape(stringSettingShape).isRequired +}; + +EditRemotePathMappingModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(remotePathMappingShape).isRequired, + downloadClientHosts: PropTypes.arrayOf(PropTypes.string).isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteRemotePathMappingPress: PropTypes.func +}; + +export default EditRemotePathMappingModalContent; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js new file mode 100644 index 000000000..ae0e51bc0 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -0,0 +1,138 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions'; +import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent'; + +const newRemotePathMapping = { + host: '', + remotePath: '', + localPath: '' +}; + +const selectDownloadClientHosts = createSelector( + (state) => state.settings.downloadClients.items, + (downloadClients) => { + return downloadClients.reduce((acc, downloadClient) => { + const host = downloadClient.fields.find((field) => { + return field.name === 'host'; + }); + + if (host && !acc.includes(host.value)) { + acc.push(host.value); + } + + return acc; + }, []); + } +); + +function createRemotePathMappingSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.remotePathMappings, + selectDownloadClientHosts, + (id, remotePathMappings, downloadClientHosts) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = remotePathMappings; + + const mapping = id ? _.find(items, { id }) : newRemotePathMapping; + const settings = selectSettings(mapping, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings, + downloadClientHosts + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createRemotePathMappingSelector(), + (remotePathMapping) => { + return { + ...remotePathMapping + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetRemotePathMappingValue: setRemotePathMappingValue, + dispatchSaveRemotePathMapping: saveRemotePathMapping +}; + +class EditRemotePathMappingModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newRemotePathMapping).forEach((name) => { + this.props.dispatchSetRemotePathMappingValue({ + name, + value: newRemotePathMapping[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetRemotePathMappingValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveRemotePathMapping({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + dispatchSetRemotePathMappingValue: PropTypes.func.isRequired, + dispatchSaveRemotePathMapping: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css new file mode 100644 index 000000000..a79efda26 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css @@ -0,0 +1,23 @@ +.remotePathMapping { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.actions { + display: flex; + justify-content: flex-end; + flex: 1 0 auto; + padding-right: 10px; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js new file mode 100644 index 000000000..3f25dbd0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMapping.css'; + +class RemotePathMapping extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onEditRemotePathMappingPress = () => { + this.setState({ isEditRemotePathMappingModalOpen: true }); + } + + onEditRemotePathMappingModalClose = () => { + this.setState({ isEditRemotePathMappingModalOpen: false }); + } + + onDeleteRemotePathMappingPress = () => { + this.setState({ + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: true + }); + } + + onDeleteRemotePathMappingModalClose = () => { + this.setState({ isDeleteRemotePathMappingModalOpen: false }); + } + + onConfirmDeleteRemotePathMapping = () => { + this.props.onConfirmDeleteRemotePathMapping(this.props.id); + } + + // + // Render + + render() { + const { + id, + host, + remotePath, + localPath + } = this.props; + + return ( +
    +
    {host}
    +
    {remotePath}
    +
    {localPath}
    + +
    + + + +
    + + + + +
    + ); + } +} + +RemotePathMapping.propTypes = { + id: PropTypes.number.isRequired, + host: PropTypes.string.isRequired, + remotePath: PropTypes.string.isRequired, + localPath: PropTypes.string.isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +RemotePathMapping.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default RemotePathMapping; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css new file mode 100644 index 000000000..4ef9dcb0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css @@ -0,0 +1,23 @@ +.remotePathMappingsHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.addRemotePathMapping { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} + +.addButton { + text-align: center; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js new file mode 100644 index 000000000..f633a3279 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import RemotePathMapping from './RemotePathMapping'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMappings.css'; + +class RemotePathMappings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onAddRemotePathMappingPress = () => { + this.setState({ isAddRemotePathMappingModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddRemotePathMappingModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteRemotePathMapping, + ...otherProps + } = this.props; + + return ( +
    + +
    +
    Host
    +
    Remote Path
    +
    Local Path
    +
    + +
    + { + items.map((item, index) => { + return ( + + ); + }) + } +
    + +
    + + + +
    + + +
    +
    + ); + } +} + +RemotePathMappings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default RemotePathMappings; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js new file mode 100644 index 000000000..7a029818a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions'; +import RemotePathMappings from './RemotePathMappings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.remotePathMappings, + (remotePathMappings) => { + return { + ...remotePathMappings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRemotePathMappings: fetchRemotePathMappings, + dispatchDeleteRemotePathMapping: deleteRemotePathMapping +}; + +class RemotePathMappingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRemotePathMappings(); + } + + // + // Listeners + + onConfirmDeleteRemotePathMapping = (id) => { + this.props.dispatchDeleteRemotePathMapping({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RemotePathMappingsConnector.propTypes = { + dispatchFetchRemotePathMappings: PropTypes.func.isRequired, + dispatchDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector); diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js new file mode 100644 index 000000000..049265435 --- /dev/null +++ b/frontend/src/Settings/General/AnalyticSettings.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function AnalyticSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + analyticsEnabled + } = settings; + + return ( +
    + + Send Anonymous Usage Data + + + +
    + ); +} + +AnalyticSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default AnalyticSettings; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js new file mode 100644 index 000000000..c358ee511 --- /dev/null +++ b/frontend/src/Settings/General/BackupSettings.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function BackupSettings(props) { + const { + advancedSettings, + settings, + onInputChange + } = props; + + const { + backupFolder, + backupInterval, + backupRetention + } = settings; + + if (!advancedSettings) { + return null; + } + + return ( +
    + + Folder + + + + + + Interval + + + + + + Retention + + + +
    + ); +} + +BackupSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default BackupSettings; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js new file mode 100644 index 000000000..485d8a1be --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import AnalyticSettings from './AnalyticSettings'; +import BackupSettings from './BackupSettings'; +import HostSettings from './HostSettings'; +import LoggingSettings from './LoggingSettings'; +import ProxySettings from './ProxySettings'; +import SecuritySettings from './SecuritySettings'; +import UpdateSettings from './UpdateSettings'; + +class GeneralSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestartRequiredModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + const { + settings, + isSaving, + saveError + } = this.props; + + if (isSaving || saveError || !prevProps.isSaving) { + return; + } + + const prevSettings = prevProps.settings; + + const keys = [ + 'bindAddress', + 'port', + 'urlBase', + 'enableSsl', + 'sslPort', + 'sslCertHash', + 'authenticationMethod', + 'username', + 'password', + 'apiKey' + ]; + + const pendingRestart = _.some(keys, (key) => { + const setting = settings[key]; + const prevSetting = prevSettings[key]; + + if (!setting || !prevSetting) { + return false; + } + + const previousValue = prevSetting.previousValue; + const value = setting.value; + + return previousValue != null && previousValue !== value; + }); + + this.setState({ isRestartRequiredModalOpen: pendingRestart }); + } + + // + // Listeners + + onConfirmRestart = () => { + this.setState({ isRestartRequiredModalOpen: false }); + this.props.onConfirmRestart(); + } + + onCloseRestartRequiredModalOpen = () => { + this.setState({ isRestartRequiredModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + isPopulated, + error, + settings, + hasSettings, + isResettingApiKey, + isMono, + isWindows, + mode, + onInputChange, + onConfirmResetApiKey, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
    Unable to load General settings
    + } + + { + hasSettings && isPopulated && !error && +
    + + + + + + + + + + + + + + + } +
    + + +
    + ); + } + +} + +GeneralSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired, + onConfirmRestart: PropTypes.func.isRequired +}; + +export default GeneralSettings; diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js new file mode 100644 index 000000000..804fdfde7 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettingsConnector.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { restart } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import GeneralSettings from './GeneralSettings'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + createCommandExecutingSelector(commandNames.RESET_API_KEY), + createSystemStatusSelector(), + (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => { + return { + advancedSettings, + isResettingApiKey, + isMono: systemStatus.isMono, + isWindows: systemStatus.isWindows, + mode: systemStatus.mode, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setGeneralSettingsValue, + saveGeneralSettings, + fetchGeneralSettings, + executeCommand, + restart, + clearPendingChanges +}; + +class GeneralSettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchGeneralSettings(); + } + + componentDidUpdate(prevProps) { + if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) { + this.props.fetchGeneralSettings(); + } + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setGeneralSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveGeneralSettings(); + } + + onConfirmResetApiKey = () => { + this.props.executeCommand({ name: commandNames.RESET_API_KEY }); + } + + onConfirmRestart = () => { + this.props.restart(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +GeneralSettingsConnector.propTypes = { + isResettingApiKey: PropTypes.bool.isRequired, + setGeneralSettingsValue: PropTypes.func.isRequired, + saveGeneralSettings: PropTypes.func.isRequired, + fetchGeneralSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + restart: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector); diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js new file mode 100644 index 000000000..2d17dc09f --- /dev/null +++ b/frontend/src/Settings/General/HostSettings.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function HostSettings(props) { + const { + advancedSettings, + settings, + isWindows, + mode, + onInputChange + } = props; + + const { + bindAddress, + port, + urlBase, + enableSsl, + sslPort, + sslCertHash, + launchBrowser + } = settings; + + return ( +
    + + Bind Address + + + + + + Port Number + + + + + + URL Base + + + + + + Enable SSL + + + + + { + enableSsl.value && + + SSL Port + + + + } + + { + isWindows && enableSsl.value && + + SSL Cert Hash + + + + } + + { + mode !== 'service' && + + Open browser on start + + + + } + +
    + ); +} + +HostSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default HostSettings; diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js new file mode 100644 index 000000000..e7853328e --- /dev/null +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function LoggingSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + logLevel + } = settings; + + const logLevelOptions = [ + { key: 'info', value: 'Info' }, + { key: 'debug', value: 'Debug' }, + { key: 'trace', value: 'Trace' } + ]; + + return ( +
    + + Log Level + + + +
    + ); +} + +LoggingSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default LoggingSettings; diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js new file mode 100644 index 000000000..238cf3a30 --- /dev/null +++ b/frontend/src/Settings/General/ProxySettings.js @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function ProxySettings(props) { + const { + settings, + onInputChange + } = props; + + const { + proxyEnabled, + proxyType, + proxyHostname, + proxyPort, + proxyUsername, + proxyPassword, + proxyBypassFilter, + proxyBypassLocalAddresses + } = settings; + + const proxyTypeOptions = [ + { key: 'http', value: 'HTTP(S)' }, + { key: 'socks4', value: 'Socks4' }, + { key: 'socks5', value: 'Socks5 (Support TOR)' } + ]; + + return ( +
    + + Use Proxy + + + + + { + proxyEnabled.value && +
    + + Proxy Type + + + + + + Hostname + + + + + + Port + + + + + + Username + + + + + + Password + + + + + + Ignored Addresses + + + + + + Bypass Proxy for Local Addresses + + + +
    + } +
    + ); +} + +ProxySettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default ProxySettings; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js new file mode 100644 index 000000000..42c77a46f --- /dev/null +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; + +class SecuritySettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmApiKeyResetModalOpen: false + }; + } + + // + // Listeners + + onApikeyFocus = (event) => { + event.target.select(); + } + + onResetApiKeyPress = () => { + this.setState({ isConfirmApiKeyResetModalOpen: true }); + } + + onConfirmResetApiKey = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + this.props.onConfirmResetApiKey(); + } + + onCloseResetApiKeyModal = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + } + + // + // Render + + render() { + const { + settings, + isResettingApiKey, + onInputChange + } = this.props; + + const { + authenticationMethod, + username, + password, + apiKey + } = settings; + + const authenticationMethodOptions = [ + { key: 'none', value: 'None' }, + { key: 'basic', value: 'Basic (Browser Popup)' }, + { key: 'forms', value: 'Forms (Login Page)' } + ]; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + return ( +
    + + Authentication + + + + + { + authenticationEnabled && + + Username + + + + } + + { + authenticationEnabled && + + Password + + + + } + + + API Key + + , + + + + + ]} + onChange={onInputChange} + onFocus={this.onApikeyFocus} + {...apiKey} + /> + + + +
    + ); + } +} + +SecuritySettings.propTypes = { + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired +}; + +export default SecuritySettings; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js new file mode 100644 index 000000000..8a4a549f8 --- /dev/null +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function UpdateSettings(props) { + const { + advancedSettings, + settings, + isMono, + onInputChange + } = props; + + const { + branch, + updateAutomatically, + updateMechanism, + updateScriptPath + } = settings; + + if (!advancedSettings) { + return null; + } + + const updateOptions = [ + { key: 'builtIn', value: 'Built-In' }, + { key: 'script', value: 'Script' } + ]; + + return ( +
    + + Branch + + + + + { + isMono && +
    + + Automatic + + + + + + Mechanism + + + + + { + updateMechanism.value === 'script' && + + Script Path + + + + } +
    + } +
    + ); +} + +UpdateSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isMono: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UpdateSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js new file mode 100644 index 000000000..2a8993c92 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import IndexersConnector from './Indexers/IndexersConnector'; +import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; + +class IndexerSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // Render + // + + render() { + const { + isTestingAll, + dispatchTestAllIndexers + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + ); + } +} + +IndexerSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default IndexerSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js new file mode 100644 index 000000000..1eaf098d7 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllIndexers } from 'Store/Actions/settingsActions'; +import IndexerSettings from './IndexerSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllIndexers: testAllIndexers +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css new file mode 100644 index 000000000..d228b842b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css @@ -0,0 +1,44 @@ +.indexer { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js new file mode 100644 index 000000000..21db4ecf1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem'; +import styles from './AddIndexerItem.css'; + +class AddIndexerItem extends Component { + + // + // Listeners + + onIndexerSelect = () => { + const { + implementation + } = this.props; + + this.props.onIndexerSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onIndexerSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
    + + +
    +
    + {implementationName} +
    + +
    + { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
    +
    +
    + ); + } +} + +AddIndexerItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onIndexerSelect: PropTypes.func.isRequired +}; + +export default AddIndexerItem; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js new file mode 100644 index 000000000..d05e8eb9a --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddIndexerModalContentConnector from './AddIndexerModalContentConnector'; + +function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css new file mode 100644 index 000000000..946305dff --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css @@ -0,0 +1,5 @@ +.indexers { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js new file mode 100644 index 000000000..5364bd9dd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddIndexerItem from './AddIndexerItem'; +import styles from './AddIndexerModalContent.css'; + +class AddIndexerModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers, + onIndexerSelect, + onModalClose + } = this.props; + + return ( + + + Add Indexer + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
    Unable to add a new indexer, please try again.
    + } + + { + isSchemaPopulated && !schemaError && +
    + + +
    Sonarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
    +
    For more information on the individual indexers, clink on the info buttons.
    +
    + +
    +
    + { + usenetIndexers.map((indexer) => { + return ( + + ); + }) + } +
    +
    + +
    +
    + { + torrentIndexers.map((indexer) => { + return ( + + ); + }) + } +
    +
    +
    + } +
    + + + +
    + ); + } +} + +AddIndexerModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + onIndexerSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js new file mode 100644 index 000000000..d79f028da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = indexers; + + const usenetIndexers = _.filter(schema, { protocol: 'usenet' }); + const torrentIndexers = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexerSchema, + selectIndexerSchema +}; + +class AddIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexerSchema(); + } + + // + // Listeners + + onIndexerSelect = ({ implementation, name }) => { + this.props.selectIndexerSchema({ implementation, presetName: name }); + this.props.onModalClose({ indexerSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddIndexerModalContentConnector.propTypes = { + fetchIndexerSchema: PropTypes.func.isRequired, + selectIndexerSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js new file mode 100644 index 000000000..ddea8b043 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddIndexerPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddIndexerPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddIndexerPresetMenuItem; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js new file mode 100644 index 000000000..d7401b95f --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditIndexerModalContentConnector from './EditIndexerModalContentConnector'; + +function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js new file mode 100644 index 000000000..ec0b7586e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModal from './EditIndexerModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.indexers'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestIndexer() { + dispatch(cancelTestIndexer({ section })); + }, + + dispatchCancelSaveIndexer() { + dispatch(cancelSaveIndexer({ section })); + } + }; +} + +class EditIndexerModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestIndexer(); + this.props.dispatchCancelSaveIndexer(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestIndexer, + dispatchCancelSaveIndexer, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditIndexerModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestIndexer: PropTypes.func.isRequired, + dispatchCancelSaveIndexer: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js new file mode 100644 index 000000000..6a250df61 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditIndexerModalContent.css'; + +function EditIndexerModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteIndexerPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new indexer, please try again.
    + } + + { + !isFetching && !error && +
    + + Name + + + + + + Enable RSS + + + + + + Enable Automatic Search + + + + + + Enable Interactive Search + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
    + + { + id && + + } + + + Test + + + + + + Save + + +
    + ); +} + +EditIndexerModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteIndexerPress: PropTypes.func +}; + +export default EditIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js new file mode 100644 index 000000000..f993d2796 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModalContent from './EditIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('indexers'), + (advancedSettings, indexer) => { + return { + advancedSettings, + ...indexer + }; + } + ); +} + +const mapDispatchToProps = { + setIndexerValue, + setIndexerFieldValue, + saveIndexer, + testIndexer +}; + +class EditIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setIndexerValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setIndexerFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveIndexer({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testIndexer({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditIndexerModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setIndexerValue: PropTypes.func.isRequired, + setIndexerFieldValue: PropTypes.func.isRequired, + saveIndexer: PropTypes.func.isRequired, + testIndexer: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css new file mode 100644 index 000000000..d8e1a731e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css @@ -0,0 +1,19 @@ +.indexer { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js new file mode 100644 index 000000000..9269f8532 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -0,0 +1,140 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexer.css'; + +class Indexer extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: false + }; + } + + // + // Listeners + + onEditIndexerPress = () => { + this.setState({ isEditIndexerModalOpen: true }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + onDeleteIndexerPress = () => { + this.setState({ + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: true + }); + } + + onDeleteIndexerModalClose= () => { + this.setState({ isDeleteIndexerModalOpen: false }); + } + + onConfirmDeleteIndexer = () => { + this.props.onConfirmDeleteIndexer(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch + } = this.props; + + return ( + +
    + {name} +
    + +
    + + { + supportsRss && enableRss && + + } + + { + supportsSearch && enableAutomaticSearch && + + } + + { + supportsSearch && enableInteractiveSearch && + + } + + { + !enableRss && !enableAutomaticSearch && !enableInteractiveSearch && + + } +
    + + + + +
    + ); + } +} + +Indexer.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableRss: PropTypes.bool.isRequired, + enableAutomaticSearch: PropTypes.bool.isRequired, + enableInteractiveSearch: PropTypes.bool.isRequired, + supportsRss: PropTypes.bool.isRequired, + supportsSearch: PropTypes.bool.isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexer; diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css new file mode 100644 index 000000000..ec8cb2891 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css @@ -0,0 +1,20 @@ +.indexers { + display: flex; + flex-wrap: wrap; +} + +.addIndexer { + composes: indexer from './Indexer.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js new file mode 100644 index 000000000..f5fea9aac --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Indexer from './Indexer'; +import AddIndexerModal from './AddIndexerModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexers.css'; + +class Indexers extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false + }; + } + + // + // Listeners + + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); + } + + onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { + this.setState({ + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: indexerSelected + }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteIndexer, + ...otherProps + } = this.props; + + const { + isAddIndexerModalOpen, + isEditIndexerModalOpen + } = this.state; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
    + +
    +
    +
    + + + + +
    +
    + ); + } +} + +Indexers.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexers; diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js new file mode 100644 index 000000000..415dae32b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions'; +import Indexers from './Indexers'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + return { + ...indexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexers, + deleteIndexer +}; + +class IndexersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexers(); + } + + // + // Listeners + + onConfirmDeleteIndexer = (id) => { + this.props.deleteIndexer({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexersConnector.propTypes = { + fetchIndexers: PropTypes.func.isRequired, + deleteIndexer: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector); diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js new file mode 100644 index 000000000..30e2c39bc --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js @@ -0,0 +1,112 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function IndexerOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
    + { + isFetching && + + } + + { + !isFetching && error && +
    Unable to load indexer options
    + } + + { + hasSettings && !isFetching && !error && +
    + + Minimum Age + + + + + + Retention + + + + + + Maximum Size + + + + + + RSS Sync Interval + + + +
    + } +
    + ); +} + +IndexerOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default IndexerOptions; diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js new file mode 100644 index 000000000..28d07c77e --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import IndexerOptions from './IndexerOptions'; + +const SECTION = 'indexerOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexerOptions: fetchIndexerOptions, + dispatchSetIndexerOptionsValue: setIndexerOptionsValue, + dispatchSaveIndexerOptions: saveIndexerOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class IndexerOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchIndexerOptions, + dispatchSaveIndexerOptions, + onChildMounted + } = this.props; + + dispatchFetchIndexerOptions(); + onChildMounted(dispatchSaveIndexerOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetIndexerOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexerOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchIndexerOptions: PropTypes.func.isRequired, + dispatchSetIndexerOptionsValue: PropTypes.func.isRequired, + dispatchSaveIndexerOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector); diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js new file mode 100644 index 000000000..9e01323c7 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -0,0 +1,384 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingConnector from './Naming/NamingConnector'; + +const rescanAfterRefreshOptions = [ + { key: 'always', value: 'Always' }, + { key: 'afterManual', value: 'After Manual Refresh' }, + { key: 'never', value: 'Never' } +]; + +const fileDateOptions = [ + { key: 'none', value: 'None' }, + { key: 'localAirDate', value: 'Local Air Date' }, + { key: 'utcAirDate', value: 'UTC Air Date' } +]; + +class MediaManagement extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + isMono, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
    Unable to load Media Management settings
    + } + + { + hasSettings && !isFetching && !error && +
    + + + { + advancedSettings && +
    + + Create empty series folders + + + + + + Delete empty folders + + + +
    + } + + { + advancedSettings && +
    + { + isMono && + + Skip Free Space Check + + + + } + + + Use Hardlinks instead of Copy + + + + + + Import Extra Files + + + + + { + settings.importExtraFiles.value && + + Import Extra Files + + + + } +
    + } + +
    + + Ignore Deleted Episodes + + + + + + Download Propers + + + + + + Analyse video files + + + + + + Rescan Series Folder after Refresh + + + + + + Change File Date + + + + + + Recycling Bin + + + +
    + + { + advancedSettings && isMono && +
    + + Set Permissions + + + + + + File chmod mode + + + + + + Folder chmod mode + + + + + + chown User + + + + + + chown Group + + + +
    + } + + } +
    +
    + ); + } + +} + +MediaManagement.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default MediaManagement; diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js new file mode 100644 index 000000000..d6ad03ad2 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js @@ -0,0 +1,86 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MediaManagement from './MediaManagement'; + +const SECTION = 'mediaManagement'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.naming, + createSettingsSectionSelector(SECTION), + createSystemStatusSelector(), + (advancedSettings, namingSettings, sectionSettings, systemStatus) => { + return { + advancedSettings, + ...sectionSettings, + hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges, + isMono: systemStatus.isMono + }; + } + ); +} + +const mapDispatchToProps = { + fetchMediaManagementSettings, + setMediaManagementSettingsValue, + saveMediaManagementSettings, + saveNamingSettings, + clearPendingChanges +}; + +class MediaManagementConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMediaManagementSettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMediaManagementSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMediaManagementSettings(); + this.props.saveNamingSettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MediaManagementConnector.propTypes = { + fetchMediaManagementSettings: PropTypes.func.isRequired, + setMediaManagementSettingsValue: PropTypes.func.isRequired, + saveMediaManagementSettings: PropTypes.func.isRequired, + saveNamingSettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css new file mode 100644 index 000000000..da27e292e --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css @@ -0,0 +1,5 @@ +.namingInput { + composes: input from 'Components/Form/TextInput.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js new file mode 100644 index 000000000..d38d86e0a --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -0,0 +1,342 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingModal from './NamingModal'; +import styles from './Naming.css'; + +class Naming extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNamingModalOpen: false, + namingModalOptions: null + }; + } + + // + // Listeners + + onStandardNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'standardEpisodeFormat', + season: true, + episode: true, + additional: true + } + }); + } + + onDailyNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'dailyEpisodeFormat', + season: true, + episode: true, + daily: true, + additional: true + } + }); + } + + onAnimeNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'animeEpisodeFormat', + season: true, + episode: true, + anime: true, + additional: true + } + }); + } + + onSeriesFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'seriesFolderFormat' + } + }); + } + + onSeasonFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'seasonFolderFormat', + season: true + } + }); + } + + onNamingModalClose = () => { + this.setState({ isNamingModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + examples, + examplesPopulated, + onInputChange + } = this.props; + + const { + isNamingModalOpen, + namingModalOptions + } = this.state; + + const renameEpisodes = hasSettings && settings.renameEpisodes.value; + + const multiEpisodeStyleOptions = [ + { key: 0, value: 'Extend' }, + { key: 1, value: 'Duplicate' }, + { key: 2, value: 'Repeat' }, + { key: 3, value: 'Scene' }, + { key: 4, value: 'Range' }, + { key: 5, value: 'Prefixed Range' } + ]; + + const standardEpisodeFormatHelpTexts = []; + const standardEpisodeFormatErrors = []; + const dailyEpisodeFormatHelpTexts = []; + const dailyEpisodeFormatErrors = []; + const animeEpisodeFormatHelpTexts = []; + const animeEpisodeFormatErrors = []; + const seriesFolderFormatHelpTexts = []; + const seriesFolderFormatErrors = []; + const seasonFolderFormatHelpTexts = []; + const seasonFolderFormatErrors = []; + + if (examplesPopulated) { + if (examples.singleEpisodeExample) { + standardEpisodeFormatHelpTexts.push(`Single Episode: ${examples.singleEpisodeExample}`); + } else { + standardEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' }); + } + + if (examples.multiEpisodeExample) { + standardEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`); + } else { + standardEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' }); + } + + if (examples.dailyEpisodeExample) { + dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`); + } else { + dailyEpisodeFormatErrors.push({ message: 'Invalid Format' }); + } + + if (examples.animeEpisodeExample) { + animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`); + } else { + animeEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' }); + } + + if (examples.animeMultiEpisodeExample) { + animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`); + } else { + animeEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' }); + } + + if (examples.seriesFolderExample) { + seriesFolderFormatHelpTexts.push(`Example: ${examples.seriesFolderExample}`); + } else { + seriesFolderFormatErrors.push({ message: 'Invalid Format' }); + } + + if (examples.seasonFolderExample) { + seasonFolderFormatHelpTexts.push(`Example: ${examples.seasonFolderExample}`); + } else { + seasonFolderFormatErrors.push({ message: 'Invalid Format' }); + } + } + + return ( +
    + { + isFetching && + + } + + { + !isFetching && error && +
    Unable to load Naming settings
    + } + + { + hasSettings && !isFetching && !error && +
    + + Rename Episodes + + + + + + Replace Illegal Characters + + + + + { + renameEpisodes && +
    + + Standard Episode Format + + ?} + onChange={onInputChange} + {...settings.standardEpisodeFormat} + helpTexts={standardEpisodeFormatHelpTexts} + errors={[...standardEpisodeFormatErrors, ...settings.standardEpisodeFormat.errors]} + /> + + + + Daily Episode Format + + ?} + onChange={onInputChange} + {...settings.dailyEpisodeFormat} + helpTexts={dailyEpisodeFormatHelpTexts} + errors={[...dailyEpisodeFormatErrors, ...settings.dailyEpisodeFormat.errors]} + /> + + + + Anime Episode Format + + ?} + onChange={onInputChange} + {...settings.animeEpisodeFormat} + helpTexts={animeEpisodeFormatHelpTexts} + errors={[...animeEpisodeFormatErrors, ...settings.animeEpisodeFormat.errors]} + /> + +
    + } + + + Series Folder Format + + ?} + onChange={onInputChange} + {...settings.seriesFolderFormat} + helpTexts={['Used when adding a new series or moving series via the series editor', ...seriesFolderFormatHelpTexts]} + errors={[...seriesFolderFormatErrors, ...settings.seriesFolderFormat.errors]} + /> + + + + Season Folder Format + + ?} + onChange={onInputChange} + {...settings.seasonFolderFormat} + helpTexts={seasonFolderFormatHelpTexts} + errors={[...seasonFolderFormatErrors, ...settings.seasonFolderFormat.errors]} + /> + + + + Multi-Episode Style + + + + + { + namingModalOptions && + + } + + } +
    + ); + } + +} + +Naming.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + examples: PropTypes.object.isRequired, + examplesPopulated: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default Naming; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js new file mode 100644 index 000000000..d8210317e --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import Naming from './Naming'; + +const SECTION = 'naming'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.namingExamples, + createSettingsSectionSelector(SECTION), + (advancedSettings, examples, sectionSettings) => { + return { + advancedSettings, + examples: examples.item, + examplesPopulated: !_.isEmpty(examples.item), + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + fetchNamingSettings, + setNamingSettingsValue, + fetchNamingExamples, + clearPendingChanges +}; + +class NamingConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._namingExampleTimeout = null; + } + + componentDidMount() { + this.props.fetchNamingSettings(); + this.props.fetchNamingExamples(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Control + + _fetchNamingExamples = () => { + this.props.fetchNamingExamples(); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNamingSettingsValue({ name, value }); + + if (this._namingExampleTimeout) { + clearTimeout(this._namingExampleTimeout); + } + + this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NamingConnector.propTypes = { + fetchNamingSettings: PropTypes.func.isRequired, + setNamingSettingsValue: PropTypes.func.isRequired, + fetchNamingExamples: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css new file mode 100644 index 000000000..de6a54e7f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css @@ -0,0 +1,18 @@ +.groups { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.namingSelectContainer { + display: flex; + justify-content: flex-end; +} + +.namingSelect { + composes: select from 'Components/Form/SelectInput.css'; + + margin-left: 10px; + width: 200px; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js new file mode 100644 index 000000000..b7579c675 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -0,0 +1,551 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import NamingOption from './NamingOption'; +import styles from './NamingModal.css'; + +const separatorOptions = [ + { key: ' ', value: 'Space ( )' }, + { key: '.', value: 'Period (.)' }, + { key: '_', value: 'Underscore (_)' }, + { key: '-', value: 'Dash (-)' } +]; + +const caseOptions = [ + { key: 'title', value: 'Default Case' }, + { key: 'lower', value: 'Lower Case' }, + { key: 'upper', value: 'Upper Case' } +]; + +const fileNameTokens = [ + { + token: '{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - S01E01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series Title} - {season:0}x{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - 1x01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series.Title}.S{season:00}E{episode:00}.{EpisodeClean.Title}.{Quality.Full}', + example: 'Series.Title.(2010).S01E01.Episode.Title.HDTV-720p' + } +]; + +const seriesTokens = [ + { token: '{Series Title}', example: 'Series Title!' }, + { token: '{Series CleanTitle}', example: 'Series Title' }, + { token: '{Series CleanTitleYear}', example: 'Series Title 2010' }, + { token: '{Series TitleThe}', example: 'Series Title, The' }, + { token: '{Series TitleTheYear}', example: 'Series Title, The (2010)' }, + { token: '{Series TitleYear}', example: 'Series Title (2010)' }, + { token: '{Series TitleFirstCharacter}', example: 'S' } +]; + +const seriesIdTokens = [ + { token: '{ImdbId}', example: 'tt12345' }, + { token: '{TvdbId}', example: '12345' }, + { token: '{TvMazeId}', example: '54321' } +]; + +const seasonTokens = [ + { token: '{season:0}', example: '1' }, + { token: '{season:00}', example: '01' } +]; + +const episodeTokens = [ + { token: '{episode:0}', example: '1' }, + { token: '{episode:00}', example: '01' } +]; + +const airDateTokens = [ + { token: '{Air-Date}', example: '2016-03-20' }, + { token: '{Air Date}', example: '2016 03 20' } +]; + +const absoluteTokens = [ + { token: '{absolute:0}', example: '1' }, + { token: '{absolute:00}', example: '01' }, + { token: '{absolute:000}', example: '001' } +]; + +const episodeTitleTokens = [ + { token: '{Episode Title}', example: 'Episode Title' }, + { token: '{Episode CleanTitle}', example: 'Episode Title' } +]; + +const qualityTokens = [ + { token: '{Quality Full}', example: 'HDTV 720p Proper' }, + { token: '{Quality Title}', example: 'HDTV 720p' } +]; + +const mediaInfoTokens = [ + { token: '{MediaInfo Simple}', example: 'x264 DTS' }, + { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' }, + { token: '{MediaInfo VideoCodec}', example: 'x264' }, + { token: '{MediaInfo AudioFormat}', example: 'DTS' }, + { token: '{MediaInfo AudioChannels}', example: '5.1' } +]; + +const otherTokens = [ + { token: '{Release Group}', example: 'Rls Grp' }, + { token: '{Preferred Words}', example: 'iNTERNAL' } +]; + +const originalTokens = [ + { token: '{Original Title}', example: 'Series.Title.S01E01.HDTV.x264-EVOLVE' }, + { token: '{Original Filename}', example: 'series.title.s01e01.hdtv.x264-EVOLVE' } +]; + +class NamingModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._selectionStart = null; + this._selectionEnd = null; + + this.state = { + separator: ' ', + case: 'title' + }; + } + + // + // Listeners + + onTokenSeparatorChange = (event) => { + this.setState({ separator: event.value }); + } + + onTokenCaseChange = (event) => { + this.setState({ case: event.value }); + } + + onInputSelectionChange = (selectionStart, selectionEnd) => { + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + } + + onOptionPress = ({ isFullFilename, tokenValue }) => { + const { + name, + value, + onInputChange + } = this.props; + + const selectionStart = this._selectionStart; + const selectionEnd = this._selectionEnd; + + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null) { + onInputChange({ + name, + value: `${value}${tokenValue}` + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + this._selectionStart = newValue.length - 1; + this._selectionEnd = newValue.length - 1; + } + } + + // + // Render + + render() { + const { + name, + value, + isOpen, + advancedSettings, + season, + episode, + daily, + anime, + additional, + onInputChange, + onModalClose + } = this.props; + + const { + separator: tokenSeparator, + case: tokenCase + } = this.state; + + return ( + + + + File Name Tokens + + + +
    + + + +
    + + { + !advancedSettings && +
    +
    + { + fileNameTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + } + +
    +
    + { + seriesTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + +
    +
    + { + seriesIdTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + + { + season && +
    +
    + { + seasonTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + } + + { + episode && +
    +
    +
    + { + episodeTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + + { + daily && +
    +
    + { + airDateTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + } + + { + anime && +
    +
    + { + absoluteTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + } +
    + } + + { + additional && +
    +
    +
    + { + episodeTitleTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + +
    +
    + { + qualityTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + +
    +
    + { + mediaInfoTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + +
    +
    + { + otherTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    + +
    +
    + { + originalTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
    +
    +
    + } +
    + + + + + +
    +
    + ); + } +} + +NamingModal.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + advancedSettings: PropTypes.bool.isRequired, + season: PropTypes.bool.isRequired, + episode: PropTypes.bool.isRequired, + daily: PropTypes.bool.isRequired, + anime: PropTypes.bool.isRequired, + additional: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +NamingModal.defaultProps = { + season: false, + episode: false, + daily: false, + anime: false, + additional: false +}; + +export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css new file mode 100644 index 000000000..d9f865936 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -0,0 +1,69 @@ +.option { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 3px; + border: 1px solid $borderColor; + + &:hover { + .token { + background-color: #ddd; + } + + .example { + background-color: #ccc; + } + } +} + +.small { + width: 460px; +} + +.large { + width: 100%; +} + +.token { + flex: 0 0 50%; + padding: 6px 16px; + background-color: #eee; + font-family: $monoSpaceFontFamily; +} + +.example { + display: flex; + align-items: center; + align-self: stretch; + flex: 0 0 50%; + padding: 6px 16px; + background-color: #ddd; +} + +.lower { + text-transform: lowercase; +} + +.upper { + text-transform: uppercase; +} + +.isFullFilename { + .token, + .example { + flex: 1 0 auto; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .option.small { + width: 100%; + } +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .token, + .example { + flex: 1 0 auto; + } +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js new file mode 100644 index 000000000..269266a5f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { sizes } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import styles from './NamingOption.css'; + +class NamingOption extends Component { + + // + // Listeners + + onPress = () => { + const { + token, + tokenSeparator, + tokenCase, + isFullFilename, + onPress + } = this.props; + + let tokenValue = token; + + tokenValue = tokenValue.replace(/ /g, tokenSeparator); + + if (tokenCase === 'lower') { + tokenValue = token.toLowerCase(); + } else if (tokenCase === 'upper') { + tokenValue = token.toUpperCase(); + } + + onPress({ isFullFilename, tokenValue }); + } + + // + // Render + render() { + const { + token, + tokenSeparator, + example, + tokenCase, + isFullFilename, + size + } = this.props; + + return ( + +
    + {token.replace(/ /g, tokenSeparator)} +
    + +
    + {example.replace(/ /g, tokenSeparator)} +
    + + ); + } +} + +NamingOption.propTypes = { + token: PropTypes.string.isRequired, + example: PropTypes.string.isRequired, + tokenSeparator: PropTypes.string.isRequired, + tokenCase: PropTypes.string.isRequired, + isFullFilename: PropTypes.bool.isRequired, + size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]), + onPress: PropTypes.func.isRequired +}; + +NamingOption.defaultProps = { + size: sizes.SMALL, + isFullFilename: false +}; + +export default NamingOption; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js new file mode 100644 index 000000000..24c0237cd --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataModalContentConnector from './EditMetadataModalContentConnector'; + +function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js new file mode 100644 index 000000000..1a38beb4a --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataModal from './EditMetadataModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.metadata'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + } + }; +} + +class EditMetadataModalConnector extends Component { + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges({ section: 'metadata' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js new file mode 100644 index 000000000..1e7006068 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; + +function EditMetadataModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + ...otherProps + } = props; + + const { + name, + enable, + fields + } = item; + + return ( + + + Edit {name.value} Metadata + + + +
    + + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + +
    + + + + + + Save + + +
    + ); +} + +EditMetadataModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteMetadataPress: PropTypes.func +}; + +export default EditMetadataModalContent; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js new file mode 100644 index 000000000..2cd7636a0 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions'; +import EditMetadataModalContent from './EditMetadataModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.metadata, + (id, metadata) => { + const { + isSaving, + saveError, + pendingChanges, + items + } = metadata; + + const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError); + + return { + id, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setMetadataValue, + setMetadataFieldValue, + saveMetadata +}; + +class EditMetadataModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMetadataValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setMetadataFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMetadata({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setMetadataValue: PropTypes.func.isRequired, + setMetadataFieldValue: PropTypes.func.isRequired, + saveMetadata: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css new file mode 100644 index 000000000..31507ee23 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css @@ -0,0 +1,15 @@ +.metadata { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.section { + margin-top: 10px; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js new file mode 100644 index 000000000..eba01463c --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import EditMetadataModalConnector from './EditMetadataModalConnector'; +import styles from './Metadata.css'; + +class Metadata extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMetadataModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataPress = () => { + this.setState({ isEditMetadataModalOpen: true }); + } + + onEditMetadataModalClose = () => { + this.setState({ isEditMetadataModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + name, + enable, + fields + } = this.props; + + const metadataFields = []; + const imageFields = []; + + fields.forEach((field) => { + if (field.section === 'metadata') { + metadataFields.push(field); + } else { + imageFields.push(field); + } + }); + + return ( + +
    + {name} +
    + +
    + { + enable ? + : + + } +
    + + { + enable && !!metadataFields.length && +
    +
    + Metadata +
    + + { + metadataFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
    + } + + { + enable && !!imageFields.length && +
    +
    + Images +
    + + { + imageFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
    + } + + +
    + ); + } +} + +Metadata.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadata; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css new file mode 100644 index 000000000..fb1bd6080 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css @@ -0,0 +1,4 @@ +.metadatas { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js new file mode 100644 index 000000000..8659c5799 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Metadata from './Metadata'; +import styles from './Metadatas.css'; + +function Metadatas(props) { + const { + items, + ...otherProps + } = props; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } +
    +
    +
    + ); +} + +Metadatas.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadatas; diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js new file mode 100644 index 000000000..fb7153950 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMetadata } from 'Store/Actions/settingsActions'; +import Metadatas from './Metadatas'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.metadata, + (metadata) => { + return { + ...metadata + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadata +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMetadata(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + fetchMetadata: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js new file mode 100644 index 000000000..001936ab7 --- /dev/null +++ b/frontend/src/Settings/Metadata/MetadataSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import MetadatasConnector from './Metadata/MetadatasConnector'; + +function MetadataSettings() { + return ( + + + + + + + + ); +} + +export default MetadataSettings; diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js new file mode 100644 index 000000000..c9bed6501 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import NotificationsConnector from './Notifications/NotificationsConnector'; + +function NotificationSettings() { + return ( + + + + + + + + ); +} + +export default NotificationSettings; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css new file mode 100644 index 000000000..e2181150c --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css @@ -0,0 +1,44 @@ +.notification { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js new file mode 100644 index 000000000..6d90961b0 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem'; +import styles from './AddNotificationItem.css'; + +class AddNotificationItem extends Component { + + // + // Listeners + + onNotificationSelect = () => { + const { + implementation + } = this.props; + + this.props.onNotificationSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onNotificationSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
    + + +
    +
    + {implementationName} +
    + +
    + { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
    +
    +
    + ); + } +} + +AddNotificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onNotificationSelect: PropTypes.func.isRequired +}; + +export default AddNotificationItem; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js new file mode 100644 index 000000000..45f5e14b6 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNotificationModalContentConnector from './AddNotificationModalContentConnector'; + +function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddNotificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNotificationModal; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css new file mode 100644 index 000000000..8744e516c --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css @@ -0,0 +1,5 @@ +.notifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js new file mode 100644 index 000000000..e09342b98 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddNotificationItem from './AddNotificationItem'; +import styles from './AddNotificationModalContent.css'; + +class AddNotificationModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema, + onNotificationSelect, + onModalClose + } = this.props; + + return ( + + + Add Notification + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
    Unable to add a new notification, please try again.
    + } + + { + isSchemaPopulated && !schemaError && +
    +
    + { + schema.map((notification) => { + return ( + + ); + }) + } +
    +
    + } +
    + + + +
    + ); + } +} + +AddNotificationModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + schema: PropTypes.arrayOf(PropTypes.object).isRequired, + onNotificationSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js new file mode 100644 index 000000000..abeb5e2ac --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions'; +import AddNotificationModalContent from './AddNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = notifications; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotificationSchema, + selectNotificationSchema +}; + +class AddNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotificationSchema(); + } + + // + // Listeners + + onNotificationSelect = ({ implementation, name }) => { + this.props.selectNotificationSchema({ implementation, presetName: name }); + this.props.onModalClose({ notificationSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNotificationModalContentConnector.propTypes = { + fetchNotificationSchema: PropTypes.func.isRequired, + selectNotificationSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js new file mode 100644 index 000000000..e4df85b8a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddNotificationPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddNotificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddNotificationPresetMenuItem; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js new file mode 100644 index 000000000..27e41d062 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditNotificationModalContentConnector from './EditNotificationModalContentConnector'; + +function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditNotificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditNotificationModal; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js new file mode 100644 index 000000000..e1452d142 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestNotification, cancelSaveNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModal from './EditNotificationModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.notifications'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestNotification() { + dispatch(cancelTestNotification({ section })); + }, + + dispatchCancelSaveNotification() { + dispatch(cancelSaveNotification({ section })); + } + }; +} + +class EditNotificationModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestNotification(); + this.props.dispatchCancelSaveNotification(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestNotification, + dispatchCancelSaveNotification, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditNotificationModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestNotification: PropTypes.func.isRequired, + dispatchCancelSaveNotification: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css new file mode 100644 index 000000000..c73406b57 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from 'Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js new file mode 100644 index 000000000..9c1d199c5 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -0,0 +1,237 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditNotificationModalContent.css'; + +function EditNotificationModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteNotificationPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + onGrab, + onDownload, + onUpgrade, + onRename, + supportsOnGrab, + supportsOnDownload, + supportsOnUpgrade, + supportsOnRename, + tags, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new notification, please try again.
    + } + + { + !isFetching && !error && +
    + { + !!message && + + {message.value.message} + + } + + + Name + + + + + + On Grab + + + + + + On Import + + + + + { + onDownload.value && + + On Upgrade + + + + } + + + On Rename + + + + + + Tags + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
    + + { + id && + + } + + + Test + + + + + + Save + + +
    + ); +} + +EditNotificationModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteNotificationPress: PropTypes.func +}; + +export default EditNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js new file mode 100644 index 000000000..104f1897a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModalContent from './EditNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('notifications'), + (advancedSettings, notification) => { + return { + advancedSettings, + ...notification + }; + } + ); +} + +const mapDispatchToProps = { + setNotificationValue, + setNotificationFieldValue, + saveNotification, + testNotification +}; + +class EditNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNotificationValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setNotificationFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveNotification({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testNotification({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditNotificationModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setNotificationValue: PropTypes.func.isRequired, + setNotificationFieldValue: PropTypes.func.isRequired, + saveNotification: PropTypes.func.isRequired, + testNotification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css new file mode 100644 index 000000000..5a17a4c20 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.css @@ -0,0 +1,19 @@ +.notification { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js new file mode 100644 index 000000000..143e5491a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notification.css'; + +class Notification extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: false + }; + } + + // + // Listeners + + onEditNotificationPress = () => { + this.setState({ isEditNotificationModalOpen: true }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + onDeleteNotificationPress = () => { + this.setState({ + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: true + }); + } + + onDeleteNotificationModalClose= () => { + this.setState({ isDeleteNotificationModalOpen: false }); + } + + onConfirmDeleteNotification = () => { + this.props.onConfirmDeleteNotification(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + onGrab, + onDownload, + onUpgrade, + onRename, + supportsOnGrab, + supportsOnDownload, + supportsOnUpgrade, + supportsOnRename + } = this.props; + + return ( + +
    + {name} +
    + + { + supportsOnGrab && onGrab && + + } + + { + supportsOnDownload && onDownload && + + } + + { + supportsOnUpgrade && onDownload && onUpgrade && + + } + + { + supportsOnRename && onRename && + + } + + { + !onGrab && !onDownload && !onRename && + + } + + + + +
    + ); + } +} + +Notification.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + onGrab: PropTypes.bool.isRequired, + onDownload: PropTypes.bool.isRequired, + onUpgrade: PropTypes.bool.isRequired, + onRename: PropTypes.bool.isRequired, + supportsOnGrab: PropTypes.bool.isRequired, + supportsOnDownload: PropTypes.bool.isRequired, + supportsOnUpgrade: PropTypes.bool.isRequired, + supportsOnRename: PropTypes.bool.isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notification; diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css new file mode 100644 index 000000000..26b890e88 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css @@ -0,0 +1,20 @@ +.notifications { + display: flex; + flex-wrap: wrap; +} + +.addNotification { + composes: notification from './Notification.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js new file mode 100644 index 000000000..0296c2ed4 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Notification from './Notification'; +import AddNotificationModal from './AddNotificationModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notifications.css'; + +class Notifications extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: false + }; + } + + // + // Listeners + + onAddNotificationPress = () => { + this.setState({ isAddNotificationModalOpen: true }); + } + + onAddNotificationModalClose = ({ notificationSelected = false } = {}) => { + this.setState({ + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: notificationSelected + }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteNotification, + ...otherProps + } = this.props; + + const { + isAddNotificationModalOpen, + isEditNotificationModalOpen + } = this.state; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
    + +
    +
    +
    + + + + +
    +
    + ); + } +} + +Notifications.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notifications; diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js new file mode 100644 index 000000000..b2b5e5166 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions'; +import Notifications from './Notifications'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + return { + ...notifications + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotifications, + deleteNotification +}; + +class NotificationsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotifications(); + } + + // + // Listeners + + onConfirmDeleteNotification = (id) => { + this.props.deleteNotification({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NotificationsConnector.propTypes = { + fetchNotifications: PropTypes.func.isRequired, + deleteNotification: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector); diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js new file mode 100644 index 000000000..e3b14e228 --- /dev/null +++ b/frontend/src/Settings/PendingChangesModal.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function PendingChangesModal(props) { + const { + isOpen, + onConfirm, + onCancel + } = props; + + return ( + + + Unsaved Changes + + + You have unsaved changes, are you sure you want to leave this page? + + + + + + + + + + ); +} + +PendingChangesModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +PendingChangesModal.defaultProps = { + kind: kinds.PRIMARY +}; + +export default PendingChangesModal; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css new file mode 100644 index 000000000..238742efd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css @@ -0,0 +1,40 @@ +.delayProfile { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.column { + flex: 0 0 200px; +} + +.actions { + display: flex; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.editButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js new file mode 100644 index 000000000..d72a06467 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfile.css'; + +function getDelay(enabled, delay) { + if (!enabled) { + return '-'; + } + + if (!delay) { + return 'No Delay'; + } + + if (delay === 1) { + return '1 Minute'; + } + + // TODO: use better units of time than just minutes + return `${delay} Minutes`; +} + +class DelayProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: false + }; + } + + // + // Listeners + + onEditDelayProfilePress = () => { + this.setState({ isEditDelayProfileModalOpen: true }); + } + + onEditDelayProfileModalClose = () => { + this.setState({ isEditDelayProfileModalOpen: false }); + } + + onDeleteDelayProfilePress = () => { + this.setState({ + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: true + }); + } + + onDeleteDelayProfileModalClose = () => { + this.setState({ isDeleteDelayProfileModalOpen: false }); + } + + onConfirmDeleteDelayProfile = () => { + this.props.onConfirmDeleteDelayProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + enableUsenet, + enableTorrent, + preferredProtocol, + usenetDelay, + torrentDelay, + tags, + tagList, + isDragging, + connectDragSource + } = this.props; + + let preferred = titleCase(preferredProtocol); + + if (!enableUsenet) { + preferred = 'Only Torrent'; + } else if (!enableTorrent) { + preferred = 'Only Usenet'; + } + + return ( +
    +
    {preferred}
    +
    {getDelay(enableUsenet, usenetDelay)}
    +
    {getDelay(enableTorrent, torrentDelay)}
    + + + +
    + + + + + { + id !== 1 && + connectDragSource( +
    + +
    + ) + } +
    + + + + +
    + ); + } +} + +DelayProfile.propTypes = { + id: PropTypes.number.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + preferredProtocol: PropTypes.string.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +DelayProfile.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default DelayProfile; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css new file mode 100644 index 000000000..cc5a92830 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css @@ -0,0 +1,3 @@ +.dragPreview { + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js new file mode 100644 index 000000000..402ddcc13 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragPreview.css'; + +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class DelayProfileDragPreview extends Component { + + // + // Render + + render() { + const { + width, + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== DELAY_PROFILE) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = width - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + width, + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
    + +
    +
    + ); + } +} + +DelayProfileDragPreview.propTypes = { + width: PropTypes.number.isRequired, + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(DelayProfileDragPreview); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css new file mode 100644 index 000000000..835250678 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css @@ -0,0 +1,17 @@ +.delayProfileDragSource { + padding: 4px 0; +} + +.delayProfilePlaceholder { + width: 100%; + height: 30px; + border-bottom: 1px dotted #aaa; +} + +.delayProfilePlaceholderBefore { + margin-bottom: 8px; +} + +.delayProfilePlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js new file mode 100644 index 000000000..5c1c565e0 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragSource.css'; + +const delayProfileDragSource = { + beginDrag(item) { + return item; + }, + + endDrag(props, monitor, component) { + props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const delayProfileDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().order; + const hoverIndex = props.order; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex + 1); + } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex); + } + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class DelayProfileDragSource extends Component { + + // + // Render + + render() { + const { + id, + order, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + ...otherProps + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
    + { + isBefore && +
    + } + + + + { + isAfter && +
    + } +
    + ); + } +} + +DelayProfileDragSource.propTypes = { + id: PropTypes.number.isRequired, + order: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onDelayProfileDragMove: PropTypes.func.isRequired, + onDelayProfileDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + DELAY_PROFILE, + delayProfileDropTarget, + collectDropTarget +)(DragSource( + DELAY_PROFILE, + delayProfileDragSource, + collectDragSource +)(DelayProfileDragSource)); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css new file mode 100644 index 000000000..3cf3e9020 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css @@ -0,0 +1,27 @@ +.delayProfiles { + user-select: none; +} + +.delayProfilesHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.column { + flex: 0 0 200px; +} + +.tags { + flex: 1 0 auto; +} + +.addDelayProfile { + display: flex; + justify-content: flex-end; +} + +.addButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js new file mode 100644 index 000000000..a745da9d4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DelayProfileDragSource from './DelayProfileDragSource'; +import DelayProfileDragPreview from './DelayProfileDragPreview'; +import DelayProfile from './DelayProfile'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfiles.css'; + +class DelayProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDelayProfileModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onAddDelayProfilePress = () => { + this.setState({ isAddDelayProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddDelayProfileModalOpen: false }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + // + // Render + + render() { + const { + defaultProfile, + items, + tagList, + dragIndex, + dropIndex, + onConfirmDeleteDelayProfile, + ...otherProps + } = this.props; + + const { + isAddDelayProfileModalOpen, + width + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + +
    + +
    +
    Protocol
    +
    Usenet Delay
    +
    Torrent Delay
    +
    Tags
    +
    + +
    + { + items.map((item, index) => { + return ( + + ); + }) + } + + +
    + + { + defaultProfile && +
    + +
    + } + +
    + + + +
    + + +
    +
    +
    + ); + } +} + +DelayProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +export default DelayProfiles; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js new file mode 100644 index 000000000..16fe5718c --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js @@ -0,0 +1,105 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import DelayProfiles from './DelayProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.delayProfiles, + createTagsSelector(), + (delayProfiles, tagList) => { + const defaultProfile = _.find(delayProfiles.items, { id: 1 }); + const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']); + + return { + defaultProfile, + ...delayProfiles, + items, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchDelayProfiles, + deleteDelayProfile, + reorderDelayProfile +}; + +class DelayProfilesConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + this.props.fetchDelayProfiles(); + } + + // + // Listeners + + onConfirmDeleteDelayProfile = (id) => { + this.props.deleteDelayProfile({ id }); + } + + onDelayProfileDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onDelayProfileDragEnd = ({ id }, didDrop) => { + const { + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DelayProfilesConnector.propTypes = { + fetchDelayProfiles: PropTypes.func.isRequired, + deleteDelayProfile: PropTypes.func.isRequired, + reorderDelayProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js new file mode 100644 index 000000000..9444fd65e --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector'; + +function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDelayProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDelayProfileModal; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js new file mode 100644 index 000000000..a1e8d2dcd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditDelayProfileModal from './EditDelayProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditDelayProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.delayProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js new file mode 100644 index 000000000..faa32ee32 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Alert from 'Components/Alert'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditDelayProfileModalContent.css'; + +function EditDelayProfileModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + protocol, + protocolOptions, + onInputChange, + onProtocolChange, + onSavePress, + onModalClose, + onDeleteDelayProfilePress, + ...otherProps + } = props; + + const { + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay, + tags + } = item; + + return ( + + + {id ? 'Edit Delay Profile' : 'Add Delay Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new quality profile, please try again.
    + } + + { + !isFetching && !error && +
    + + Protocol + + + + + { + enableUsenet.value && + + Usenet Delay + + + + } + + { + enableTorrent.value && + + Torrent Delay + + + + } + + { + id === 1 ? + + This is the default profile. It applies to all series that don't have an explicit profile. + : + + + Tags + + + + } +
    + } +
    + + { + id && id > 1 && + + } + + + + + Save + + +
    + ); +} + +const delayProfileShape = { + enableUsenet: PropTypes.shape(boolSettingShape).isRequired, + enableTorrent: PropTypes.shape(boolSettingShape).isRequired, + usenetDelay: PropTypes.shape(numberSettingShape).isRequired, + torrentDelay: PropTypes.shape(numberSettingShape).isRequired, + order: PropTypes.shape(numberSettingShape), + tags: PropTypes.shape(tagSettingShape).isRequired +}; + +EditDelayProfileModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(delayProfileShape).isRequired, + protocol: PropTypes.string.isRequired, + protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + onInputChange: PropTypes.func.isRequired, + onProtocolChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteDelayProfilePress: PropTypes.func +}; + +export default EditDelayProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js new file mode 100644 index 000000000..5b7e036f5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js @@ -0,0 +1,178 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions'; +import EditDelayProfileModalContent from './EditDelayProfileModalContent'; + +const newDelayProfile = { + enableUsenet: true, + enableTorrent: true, + preferredProtocol: 'usenet', + usenetDelay: 0, + torrentDelay: 0, + tags: [] +}; + +const protocolOptions = [ + { key: 'preferUsenet', value: 'Prefer Usenet' }, + { key: 'preferTorrent', value: 'Prefer Torrent' }, + { key: 'onlyUsenet', value: 'Only Usenet' }, + { key: 'onlyTorrent', value: 'Only Torrent' } +]; + +function createDelayProfileSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.delayProfiles, + (id, delayProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = delayProfiles; + + const profile = id ? _.find(items, { id }) : newDelayProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createDelayProfileSelector(), + (delayProfile) => { + const enableUsenet = delayProfile.item.enableUsenet.value; + const enableTorrent = delayProfile.item.enableTorrent.value; + const preferredProtocol = delayProfile.item.preferredProtocol.value; + let protocol = 'preferUsenet'; + + if (preferredProtocol === 'usenet') { + protocol = 'preferUsenet'; + } else { + protocol = 'preferTorrent'; + } + + if (!enableUsenet) { + protocol = 'onlyTorrent'; + } + + if (!enableTorrent) { + protocol = 'onlyUsenet'; + } + + return { + protocol, + protocolOptions, + ...delayProfile + }; + } + ); +} + +const mapDispatchToProps = { + setDelayProfileValue, + saveDelayProfile +}; + +class EditDelayProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newDelayProfile).forEach((name) => { + this.props.setDelayProfileValue({ + name, + value: newDelayProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDelayProfileValue({ name, value }); + } + + onProtocolChange = ({ value }) => { + switch (value) { + case 'preferUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'preferTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + case 'onlyUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: false }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'onlyTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: false }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + default: + throw Error(`Unknown protocol option: ${value}`); + } + } + + onSavePress = () => { + this.props.saveDelayProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDelayProfileValue: PropTypes.func.isRequired, + saveDelayProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js new file mode 100644 index 000000000..6a17fd1fc --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditLanguageProfileModalContentConnector from './EditLanguageProfileModalContentConnector'; + +function EditLanguageProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditLanguageProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditLanguageProfileModal; diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js new file mode 100644 index 000000000..8e112c9e1 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditLanguageProfileModal from './EditLanguageProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditLanguageProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.languageProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditLanguageProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditLanguageProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css new file mode 100644 index 000000000..74dd1c8b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css @@ -0,0 +1,3 @@ +.deleteButtonContainer { + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js new file mode 100644 index 000000000..ec4247c74 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js @@ -0,0 +1,167 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import LanguageProfileItems from './LanguageProfileItems'; +import styles from './EditLanguageProfileModalContent.css'; + +function EditLanguageProfileModalContent(props) { + const { + isFetching, + error, + isSaving, + saveError, + languages, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteLanguageProfilePress, + ...otherProps + } = props; + + const { + id, + name, + upgradeAllowed, + cutoff, + languages: itemLanguages + } = item; + + return ( + + + {id ? 'Edit Language Profile' : 'Add Language Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new language profile, please try again.
    + } + + { + !isFetching && !error && +
    + + Name + + + + + + + Upgrades Allowed + + + + + + { + upgradeAllowed.value && + + Upgrade Until + + + + } + + + + + } +
    + + { + id && +
    + +
    + } + + + + + Save + +
    +
    + ); +} + +EditLanguageProfileModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onCutoffChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteLanguageProfilePress: PropTypes.func +}; + +export default EditLanguageProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js new file mode 100644 index 000000000..3cad2bb3c --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js @@ -0,0 +1,189 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchLanguageProfileSchema, setLanguageProfileValue, saveLanguageProfile } from 'Store/Actions/settingsActions'; +import EditLanguageProfileModalContent from './EditLanguageProfileModalContent'; + +function createLanguagesSelector() { + return createSelector( + createProviderSettingsSelector('languageProfiles'), + (languageProfile) => { + const languages = languageProfile.item.languages; + if (!languages || !languages.value) { + return []; + } + + return _.reduceRight(languages.value, (result, { allowed, language }) => { + if (allowed) { + result.push({ + key: language.id, + value: language.name + }); + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('languageProfiles'), + createLanguagesSelector(), + createProfileInUseSelector('languageProfileId'), + (languageProfile, languages, isInUse) => { + return { + languages, + ...languageProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchLanguageProfileSchema, + setLanguageProfileValue, + saveLanguageProfile +}; + +class EditLanguageProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchLanguageProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setLanguageProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.languages.value, (i) => i.language.id === id); + + this.props.setLanguageProfileValue({ name, value: item.language }); + } + + onSavePress = () => { + this.props.saveLanguageProfile({ id: this.props.id }); + } + + onLanguageProfileItemAllowedChange = (id, allowed) => { + const languageProfile = _.cloneDeep(this.props.item); + + const item = _.find(languageProfile.languages.value, (i) => i.language.id === id); + item.allowed = allowed; + + this.props.setLanguageProfileValue({ + name: 'languages', + value: languageProfile.languages.value + }); + + const cutoff = languageProfile.cutoff.value; + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !_.find(languageProfile.languages.value, (i) => i.language.id === cutoff.id).allowed) { + const firstAllowed = _.find(languageProfile.languages.value, { allowed: true }); + + this.props.setLanguageProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.language : null }); + } + } + + onLanguageProfileItemDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onLanguageProfileItemDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const languageProfile = _.cloneDeep(this.props.item); + + const languages = languageProfile.languages.value.splice(dragIndex, 1); + languageProfile.languages.value.splice(dropIndex, 0, languages[0]); + + this.props.setLanguageProfileValue({ + name: 'languages', + value: languageProfile.languages.value + }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.languages) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditLanguageProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setLanguageProfileValue: PropTypes.func.isRequired, + fetchLanguageProfileSchema: PropTypes.func.isRequired, + saveLanguageProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditLanguageProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.css b/frontend/src/Settings/Profiles/Language/LanguageProfile.css new file mode 100644 index 000000000..2ad9adfe9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.css @@ -0,0 +1,31 @@ +.languageProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from 'Components/Link/IconButton.css'; + + height: 36px; +} + +.languages { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.js b/frontend/src/Settings/Profiles/Language/LanguageProfile.js new file mode 100644 index 000000000..c9d042e45 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector'; +import styles from './LanguageProfile.css'; + +class LanguageProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditLanguageProfileModalOpen: false, + isDeleteLanguageProfileModalOpen: false + }; + } + + // + // Listeners + + onEditLanguageProfilePress = () => { + this.setState({ isEditLanguageProfileModalOpen: true }); + } + + onEditLanguageProfileModalClose = () => { + this.setState({ isEditLanguageProfileModalOpen: false }); + } + + onDeleteLanguageProfilePress = () => { + this.setState({ + isEditLanguageProfileModalOpen: false, + isDeleteLanguageProfileModalOpen: true + }); + } + + onDeleteLanguageProfileModalClose = () => { + this.setState({ isDeleteLanguageProfileModalOpen: false }); + } +onCloneLanguageProfilePress + onConfirmDeleteLanguageProfile = () => { + this.props.onConfirmDeleteLanguageProfile(this.props.id); + } + + onCloneLanguageProfilePress = () => { + const { + id, + onCloneLanguageProfilePress + } = this.props; + + onCloneLanguageProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + upgradeAllowed, + cutoff, + languages, + isDeleting + } = this.props; + + return ( + +
    +
    + {name} +
    + + +
    + +
    + { + languages.map((item) => { + if (!item.allowed) { + return null; + } + + const isCutoff = upgradeAllowed && item.language.id === cutoff.id; + + return ( + + ); + }) + } +
    + + + + +
    + ); + } +} + +LanguageProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + upgradeAllowed: PropTypes.bool.isRequired, + cutoff: PropTypes.object.isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteLanguageProfile: PropTypes.func.isRequired, + onCloneLanguageProfilePress: PropTypes.func.isRequired +}; + +export default LanguageProfile; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css new file mode 100644 index 000000000..a10233929 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css @@ -0,0 +1,44 @@ +.languageProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.languageName { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js new file mode 100644 index 000000000..2a3671268 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './LanguageProfileItem.css'; + +class LanguageProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + languageId, + onLanguageProfileItemAllowedChange + } = this.props; + + onLanguageProfileItemAllowedChange(languageId, value); + } + + // + // Render + + render() { + const { + name, + allowed, + isDragging, + connectDragSource + } = this.props; + + return ( +
    + + + { + connectDragSource( +
    + +
    + ) + } +
    + ); + } +} + +LanguageProfileItem.propTypes = { + languageId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onLanguageProfileItemAllowedChange: PropTypes.func +}; + +LanguageProfileItem.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default LanguageProfileItem; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js new file mode 100644 index 000000000..ded14b39d --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import LanguageProfileItem from './LanguageProfileItem'; +import styles from './LanguageProfileItemDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class LanguageProfileItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + languageId, + name, + allowed, + sortIndex + } = item; + + return ( + +
    + +
    +
    + ); + } +} + +LanguageProfileItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(LanguageProfileItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css new file mode 100644 index 000000000..f59379129 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css @@ -0,0 +1,18 @@ +.languageProfileItemDragSource { + padding: 4px 0; +} + +.languageProfileItemPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.languageProfileItemPlaceholderBefore { + margin-bottom: 8px; +} + +.languageProfileItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js new file mode 100644 index 000000000..304363726 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import LanguageProfileItem from './LanguageProfileItem'; +import styles from './LanguageProfileItemDragSource.css'; + +const languageProfileItemDragSource = { + beginDrag({ languageId, name, allowed, sortIndex }) { + return { + languageId, + name, + allowed, + sortIndex + }; + }, + + endDrag(props, monitor, component) { + props.onLanguageProfileItemDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const languageProfileItemDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().sortIndex; + const hoverIndex = props.sortIndex; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // Moving up, only trigger if drag position is above 50% + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + // Moving down, only trigger if drag position is below 50% + if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + props.onLanguageProfileItemDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class LanguageProfileItemDragSource extends Component { + + // + // Render + + render() { + const { + languageId, + name, + allowed, + sortIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onLanguageProfileItemAllowedChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
    + { + isBefore && +
    + } + + + + { + isAfter && +
    + } +
    + ); + } +} + +LanguageProfileItemDragSource.propTypes = { + languageId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onLanguageProfileItemAllowedChange: PropTypes.func.isRequired, + onLanguageProfileItemDragMove: PropTypes.func.isRequired, + onLanguageProfileItemDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + QUALITY_PROFILE_ITEM, + languageProfileItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_ITEM, + languageProfileItemDragSource, + collectDragSource +)(LanguageProfileItemDragSource)); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css new file mode 100644 index 000000000..48b30f326 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css @@ -0,0 +1,6 @@ +.languages { + margin-top: 10px; + /* TODO: This should consider the number of languages in the list */ + min-height: 550px; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js new file mode 100644 index 000000000..831743cbe --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import LanguageProfileItemDragSource from './LanguageProfileItemDragSource'; +import LanguageProfileItemDragPreview from './LanguageProfileItemDragPreview'; +import styles from './LanguageProfileItems.css'; + +class LanguageProfileItems extends Component { + + // + // Render + + render() { + const { + dragIndex, + dropIndex, + languageProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex > dragIndex; + const isDraggingDown = isDragging && dropIndex < dragIndex; + + return ( + + Languages +
    + + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
    + { + languageProfileItems.map(({ allowed, language }, index) => { + return ( + + ); + }).reverse() + } + + +
    +
    +
    + ); + } +} + +LanguageProfileItems.propTypes = { + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + languageProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object) +}; + +LanguageProfileItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default LanguageProfileItems; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js new file mode 100644 index 000000000..61a7153b5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createLanguageProfileSelector(), + (languageProfile) => { + return { + name: languageProfile.name + }; + } + ); +} + +function LanguageProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +LanguageProfileNameConnector.propTypes = { + languageProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(LanguageProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.css b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css new file mode 100644 index 000000000..5a2fb73bd --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css @@ -0,0 +1,21 @@ +.languageProfiles { + display: flex; + flex-wrap: wrap; +} + +.addLanguageProfile { + composes: languageProfile from './LanguageProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.js b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js new file mode 100644 index 000000000..eb8286dfc --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import LanguageProfile from './LanguageProfile'; +import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector'; +import styles from './LanguageProfiles.css'; + +class LanguageProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLanguageProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneLanguageProfilePress = (id) => { + this.props.onCloneLanguageProfilePress(id); + this.setState({ isLanguageProfileModalOpen: true }); + } + + onEditLanguageProfilePress = () => { + this.setState({ isLanguageProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isLanguageProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteLanguageProfile, + onCloneLanguageProfilePress, + ...otherProps + } = this.props; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
    + +
    +
    +
    + + +
    +
    + ); + } +} + +LanguageProfiles.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteLanguageProfile: PropTypes.func.isRequired, + onCloneLanguageProfilePress: PropTypes.func.isRequired +}; + +export default LanguageProfiles; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js new file mode 100644 index 000000000..1b122ab0f --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchLanguageProfiles, deleteLanguageProfile, cloneLanguageProfile } from 'Store/Actions/settingsActions'; +import LanguageProfiles from './LanguageProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.languageProfiles, + (advancedSettings, languageProfiles) => { + return { + advancedSettings, + ...languageProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchLanguageProfiles: fetchLanguageProfiles, + dispatchDeleteLanguageProfile: deleteLanguageProfile, + dispatchCloneLanguageProfile: cloneLanguageProfile +}; + +class LanguageProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchLanguageProfiles(); + } + + // + // Listeners + + onConfirmDeleteLanguageProfile = (id) => { + this.props.dispatchDeleteLanguageProfile({ id }); + } + + onCloneLanguageProfilePress = (id) => { + this.props.dispatchCloneLanguageProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LanguageProfilesConnector.propTypes = { + dispatchFetchLanguageProfiles: PropTypes.func.isRequired, + dispatchDeleteLanguageProfile: PropTypes.func.isRequired, + dispatchCloneLanguageProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LanguageProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js new file mode 100644 index 000000000..9f0130f27 --- /dev/null +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityProfilesConnector from './Quality/QualityProfilesConnector'; +import LanguageProfilesConnector from './Language/LanguageProfilesConnector'; +import DelayProfilesConnector from './Delay/DelayProfilesConnector'; +import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector'; + +class Profiles extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + + + ); + } +} + +// Only a single DragDropContext can exist so it's done here to allow editing +// quality profiles and reordering delay profiles to work. + +export default DragDropContext(HTML5Backend)(Profiles); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js new file mode 100644 index 000000000..9ecbd1ca8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector'; + +class EditQualityProfileModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditQualityProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js new file mode 100644 index 000000000..942949cac --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditQualityProfileModal from './EditQualityProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditQualityProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.qualityProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditQualityProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css new file mode 100644 index 000000000..2f6589933 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css @@ -0,0 +1,18 @@ +.formGroupsContainer { + display: flex; + flex-wrap: wrap; +} + +.formGroupWrapper { + flex: 0 0 calc($formGroupSmallWidth - 100px); +} + +.deleteButtonContainer { + margin-right: auto; +} + +@media only screen and (max-width: $breakpointLarge) { + .formGroupsContainer { + display: block; + } +} diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js new file mode 100644 index 000000000..7991f1a7e --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -0,0 +1,270 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Measure from 'Components/Measure'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import QualityProfileItems from './QualityProfileItems'; +import styles from './EditQualityProfileModalContent.css'; + +const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); + +class EditQualityProfileModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + headerHeight: 0, + bodyHeight: 0, + footerHeight: 0 + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + headerHeight, + bodyHeight, + footerHeight + } = this.state; + + if ( + headerHeight > 0 && + bodyHeight > 0 && + footerHeight > 0 && + ( + headerHeight !== prevState.headerHeight || + bodyHeight !== prevState.bodyHeight || + footerHeight !== prevState.footerHeight + ) + ) { + const padding = MODAL_BODY_PADDING * 2; + + this.props.onContentHeightChange( + headerHeight + bodyHeight + footerHeight + padding + ); + } + } + + // + // Listeners + + onHeaderMeasure = ({ height }) => { + if (height > this.state.headerHeight) { + this.setState({ headerHeight: height }); + } + } + + onBodyMeasure = ({ height }) => { + + if (height > this.state.bodyHeight) { + this.setState({ bodyHeight: height }); + } + } + + onFooterMeasure = ({ height }) => { + if (height > this.state.footerHeight) { + this.setState({ footerHeight: height }); + } + } + + // + // Render + + render() { + const { + editGroups, + isFetching, + error, + isSaving, + saveError, + qualities, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteQualityProfilePress, + ...otherProps + } = this.props; + + const { + id, + name, + upgradeAllowed, + cutoff, + items + } = item; + + return ( + + + + {id ? 'Edit Quality Profile' : 'Add Quality Profile'} + + + + + +
    + { + isFetching && + + } + + { + !isFetching && !!error && +
    Unable to add a new quality profile, please try again.
    + } + + { + !isFetching && !error && +
    +
    +
    + + + Name + + + + + + + + Upgrades Allowed + + + + + + { + upgradeAllowed.value && + + + Upgrade Until + + + + + } +
    + +
    + +
    +
    +
    + + } +
    +
    +
    + + + + { + id && +
    + +
    + } + + + + + Save + +
    +
    +
    + ); + } +} + +EditQualityProfileModalContent.propTypes = { + editGroups: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onCutoffChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onContentHeightChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteQualityProfilePress: PropTypes.func +}; + +export default EditQualityProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js new file mode 100644 index 000000000..2decf2198 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -0,0 +1,442 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions'; +import EditQualityProfileModalContent from './EditQualityProfileModalContent'; + +function getQualityItemGroupId(qualityProfile) { + // Get items with an `id` and filter out null/undefined values + const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null); + + return Math.max(1000, ...ids) + 1; +} + +function parseIndex(index) { + const split = index.split('.'); + + if (split.length === 1) { + return [ + null, + parseInt(split[0]) - 1 + ]; + } + + return [ + parseInt(split[0]) - 1, + parseInt(split[1]) - 1 + ]; +} + +function createQualitiesSelector() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + (qualityProfile) => { + const items = qualityProfile.item.items; + if (!items || !items.value) { + return []; + } + + return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => { + if (allowed) { + if (id) { + result.push({ + key: id, + value: name + }); + } else { + result.push({ + key: quality.id, + value: quality.name + }); + } + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + createQualitiesSelector(), + createProfileInUseSelector('qualityProfileId'), + (qualityProfile, qualities, isInUse) => { + return { + qualities, + ...qualityProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + setQualityProfileValue, + saveQualityProfile +}; + +class EditQualityProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null, + editGroups: false + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Control + + ensureCutoff = (qualityProfile) => { + const cutoff = qualityProfile.cutoff.value; + + const cutoffItem = _.find(qualityProfile.items.value, (i) => { + if (!cutoff) { + return false; + } + + return i.id === cutoff || (i.quality && i.quality.id === cutoff); + }); + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !cutoffItem || !cutoffItem.allowed) { + const firstAllowed = _.find(qualityProfile.items.value, { allowed: true }); + let cutoffId = null; + + if (firstAllowed) { + cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id; + } + + this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setQualityProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.items.value, (i) => { + if (i.quality) { + return i.quality.id === id; + } + + return i.id === id; + }); + + const cutoffId = item.quality ? item.quality.id : item.id; + + this.props.setQualityProfileValue({ name, value: cutoffId }); + } + + onSavePress = () => { + this.props.saveQualityProfile({ id: this.props.id }); + } + + onQualityProfileItemAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id); + + item.allowed = allowed; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.id === id); + + item.allowed = allowed; + + // Update each item in the group (for consistency only) + item.items.forEach((i) => { + i.allowed = allowed; + }); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupNameChange = (id, name) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + + group.name = name; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + } + + onCreateGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(items, (i) => i.quality && i.quality.id === id); + const index = items.indexOf(item); + const groupId = getQualityItemGroupId(qualityProfile); + + const group = { + id: groupId, + name: item.quality.name, + allowed: item.allowed, + items: [ + item + ] + }; + + // Add the group in the same location the quality item was in. + items.splice(index, 1, group); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onDeleteGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + const index = items.indexOf(group); + + // Add the items in the same location the group was in + items.splice(index, 1, ...group.items); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onQualityProfileItemDragMove = (options) => { + const { + dragQualityIndex, + dropQualityIndex, + dropPosition + } = options; + + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + if ( + (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) || + (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex) + ) { + if ( + this.state.dragQualityIndex != null && + this.state.dropQualityIndex != null && + this.state.dropPosition != null + ) { + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + return; + } + + let adjustedDropQualityIndex = dropQualityIndex; + + // Correct dragging out of a group to the position above + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex != null + ) { + // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`; + } + + // Correct inserting above outside a group + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex == null + ) { + // Add 2 to the item index so it's entered in the correct place + adjustedDropQualityIndex = `${dropItemIndex + 2}`; + } + + // Correct inserting below a quality within the same group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex != null && + dragItemIndex < dropItemIndex + ) { + // Add 1 to the group index leave the item index + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`; + } + + // Correct inserting below a quality outside a group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex == null && + dragItemIndex < dropItemIndex + ) { + // Leave the item index so it's inserted below the item + adjustedDropQualityIndex = `${dropItemIndex}`; + } + + if ( + dragQualityIndex !== this.state.dragQualityIndex || + adjustedDropQualityIndex !== this.state.dropQualityIndex || + dropPosition !== this.state.dropPosition + ) { + this.setState({ + dragQualityIndex, + dropQualityIndex: adjustedDropQualityIndex, + dropPosition + }); + } + } + + onQualityProfileItemDragEnd = (didDrop) => { + const { + dragQualityIndex, + dropQualityIndex + } = this.state; + + if (didDrop && dropQualityIndex != null) { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + let item = null; + let dropGroup = null; + + // Get the group before moving anything so we know the correct place to drop it. + if (dropGroupIndex != null) { + dropGroup = items[dropGroupIndex]; + } + + if (dragGroupIndex == null) { + item = items.splice(dragItemIndex, 1)[0]; + } else { + const group = items[dragGroupIndex]; + item = group.items.splice(dragItemIndex, 1)[0]; + + // If the group is now empty, destroy it. + if (!group.items.length) { + items.splice(dragGroupIndex, 1); + } + } + + if (dropGroupIndex == null) { + items.splice(dropItemIndex, 0, item); + } else { + dropGroup.items.splice(dropItemIndex, 0, item); + } + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + onToggleEditGroupsMode = () => { + this.setState({ editGroups: !this.state.editGroups }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.items) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditQualityProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setQualityProfileValue: PropTypes.func.isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + saveQualityProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css new file mode 100644 index 000000000..341d75aa2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css @@ -0,0 +1,38 @@ +.qualityProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from 'Components/Link/IconButton.css'; + + height: 36px; +} + +.qualities { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from 'Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js new file mode 100644 index 000000000..f4a4ca414 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfile.css'; + +class QualityProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onEditQualityProfilePress = () => { + this.setState({ isEditQualityProfileModalOpen: true }); + } + + onEditQualityProfileModalClose = () => { + this.setState({ isEditQualityProfileModalOpen: false }); + } + + onDeleteQualityProfilePress = () => { + this.setState({ + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: true + }); + } + + onDeleteQualityProfileModalClose = () => { + this.setState({ isDeleteQualityProfileModalOpen: false }); + } + + onConfirmDeleteQualityProfile = () => { + this.props.onConfirmDeleteQualityProfile(this.props.id); + } + + onCloneQualityProfilePress = () => { + const { + id, + onCloneQualityProfilePress + } = this.props; + + onCloneQualityProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + upgradeAllowed, + cutoff, + items, + isDeleting + } = this.props; + + return ( + +
    +
    + {name} +
    + + +
    + +
    + { + items.map((item) => { + if (!item.allowed) { + return null; + } + + if (item.quality) { + const isCutoff = upgradeAllowed && item.quality.id === cutoff; + + return ( + + ); + } + + const isCutoff = upgradeAllowed && item.id === cutoff; + + return ( + + {item.name} + + } + tooltip={ +
    + { + item.items.map((groupItem) => { + return ( + + ); + }) + } +
    + } + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + ); + }) + } +
    + + + + +
    + ); + } +} + +QualityProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + upgradeAllowed: PropTypes.bool.isRequired, + cutoff: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfile; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css new file mode 100644 index 000000000..7e6370ff8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css @@ -0,0 +1,85 @@ +.qualityProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.isInGroup { + border-style: dashed; + } +} + +.checkInputContainer { + position: relative; + margin-right: 4px; + margin-bottom: 5px; + margin-left: 8px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 5px; +} + +.qualityNameContainer { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: $qualityProfileItemHeight; + cursor: pointer; +} + +.qualityName { + &.isInGroup { + margin-left: 14px; + } + + &.notAllowed { + color: #c6c6c6; + } +} + +.createGroupButton { + composes: buton from 'Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.isPreview { + .qualityName { + margin-left: 14px; + + &.isInGroup { + margin-left: 28px; + } + } +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js new file mode 100644 index 000000000..8161e7061 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './QualityProfileItem.css'; + +class QualityProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + qualityId, + onQualityProfileItemAllowedChange + } = this.props; + + onQualityProfileItemAllowedChange(qualityId, value); + } + + onCreateGroupPress = () => { + const { + qualityId, + onCreateGroupPress + } = this.props; + + onCreateGroupPress(qualityId); + } + + // + // Render + + render() { + const { + editGroups, + isPreview, + groupId, + name, + allowed, + isDragging, + isOverCurrent, + connectDragSource + } = this.props; + + return ( +
    + + + { + connectDragSource( +
    + +
    + ) + } +
    + ); + } +} + +QualityProfileItem.propTypes = { + editGroups: PropTypes.bool, + isPreview: PropTypes.bool, + groupId: PropTypes.number, + qualityId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + isDragging: PropTypes.bool.isRequired, + isOverCurrent: PropTypes.bool.isRequired, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func +}; + +QualityProfileItem.defaultProps = { + isPreview: false, + isOverCurrent: false, + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js new file mode 100644 index 000000000..e0c6e8e8c --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import QualityProfileItem from './QualityProfileItem'; +import styles from './QualityProfileItemDragPreview.css'; + +const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth); +const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class QualityProfileItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + editGroups, + groupId, + qualityId, + name, + allowed + } = item; + + // TODO: Show a different preview for groups + + return ( + +
    + +
    +
    + ); + } +} + +QualityProfileItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css new file mode 100644 index 000000000..d5061cc95 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css @@ -0,0 +1,18 @@ +.qualityProfileItemDragSource { + padding: $qualityProfileItemDragSourcePadding 0; +} + +.qualityProfileItemPlaceholder { + width: 100%; + height: $qualityProfileItemHeight; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.qualityProfileItemPlaceholderBefore { + margin-bottom: 8px; +} + +.qualityProfileItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js new file mode 100644 index 000000000..0e1838eb3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js @@ -0,0 +1,241 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import QualityProfileItem from './QualityProfileItem'; +import QualityProfileItemGroup from './QualityProfileItemGroup'; +import styles from './QualityProfileItemDragSource.css'; + +const qualityProfileItemDragSource = { + beginDrag(props) { + const { + editGroups, + qualityIndex, + groupId, + qualityId, + name, + allowed + } = props; + + return { + editGroups, + qualityIndex, + groupId, + qualityId, + isGroup: !qualityId, + name, + allowed + }; + }, + + endDrag(props, monitor, component) { + props.onQualityProfileItemDragEnd(monitor.didDrop()); + } +}; + +const qualityProfileItemDropTarget = { + hover(props, monitor, component) { + const { + qualityIndex: dragQualityIndex, + isGroup: isDragGroup + } = monitor.getItem(); + + const dropQualityIndex = props.qualityIndex; + const isDropGroupItem = !!(props.qualityId && props.groupId); + + // Use childNodeIndex to select the correct node to get the middle of so + // we don't bounce between above and below causing rapid setState calls. + const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0; + const componentDOMNode = findDOMNode(component).children[childNodeIndex]; + const hoverBoundingRect = componentDOMNode.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // If we're hovering over a child don't trigger on the parent + if (!monitor.isOver({ shallow: true })) { + return; + } + + // Don't show targets for dropping on self + if (dragQualityIndex === dropQualityIndex) { + return; + } + + // Don't allow a group to be dropped inside a group + if (isDragGroup && isDropGroupItem) { + return; + } + + let dropPosition = null; + + // Determine drop position based on position over target + if (hoverClientY > hoverMiddleY) { + dropPosition = 'below'; + } else if (hoverClientY < hoverMiddleY) { + dropPosition = 'above'; + } else { + return; + } + + props.onQualityProfileItemDragMove({ + dragQualityIndex, + dropQualityIndex, + dropPosition + }); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + isOverCurrent: monitor.isOver({ shallow: true }) + }; +} + +class QualityProfileItemDragSource extends Component { + + // + // Render + + render() { + const { + editGroups, + groupId, + qualityId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOverCurrent, + connectDragSource, + connectDropTarget, + onCreateGroupPress, + onDeleteGroupPress, + onQualityProfileItemAllowedChange, + onItemGroupAllowedChange, + onItemGroupNameChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOverCurrent; + const isAfter = !isDragging && isDraggingDown && isOverCurrent; + + return connectDropTarget( +
    + { + isBefore && +
    + } + + { + !!groupId && qualityId == null && + + } + + { + qualityId != null && + + } + + { + isAfter && +
    + } +
    + ); + } +} + +QualityProfileItemDragSource.propTypes = { + editGroups: PropTypes.bool.isRequired, + groupId: PropTypes.number, + qualityId: PropTypes.number, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOverCurrent: PropTypes.bool, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onDeleteGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupAllowedChange: PropTypes.func, + onItemGroupNameChange: PropTypes.func, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + QUALITY_PROFILE_ITEM, + qualityProfileItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_ITEM, + qualityProfileItemDragSource, + collectDragSource +)(QualityProfileItemDragSource)); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css new file mode 100644 index 000000000..d0720cb6a --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css @@ -0,0 +1,105 @@ +.qualityProfileItemGroup { + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.editGroups { + background: #fcfcfc; + } +} + +.qualityProfileItemGroupInfo { + display: flex; + align-items: stretch; + width: 100%; +} + +.checkInputContainer { + composes: checkInputContainer from './QualityProfileItem.css'; + + display: flex; + align-items: center; +} + +.checkInput { + composes: checkInput from './QualityProfileItem.css'; +} + +.nameInput { + composes: input from 'Components/Form/TextInput.css'; + + margin-top: 4px; + margin-right: 10px; +} + +.nameContainer { + display: flex; + align-items: center; + flex-grow: 1; +} + +.name { + flex-shrink: 0; + + &.notAllowed { + color: #c6c6c6; + } +} + +.groupQualities { + display: flex; + justify-content: flex-end; + flex-grow: 1; + flex-wrap: wrap; + margin: 2px 0 2px 10px; +} + +.qualityNameContainer { + display: flex; + align-items: stretch; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; +} + +.qualityNameLabel { + composes: qualityNameContainer; + + cursor: pointer; +} + +.deleteGroupButton { + composes: buton from 'Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.items { + margin: 0 50px 0 35px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js new file mode 100644 index 000000000..34008b1ec --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import TextInput from 'Components/Form/TextInput'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import styles from './QualityProfileItemGroup.css'; + +class QualityProfileItemGroup extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + groupId, + onItemGroupAllowedChange + } = this.props; + + onItemGroupAllowedChange(groupId, value); + } + + onNameChange = ({ value }) => { + const { + groupId, + onItemGroupNameChange + } = this.props; + + onItemGroupNameChange(groupId, value); + } + + onDeleteGroupPress = ({ value }) => { + const { + groupId, + onDeleteGroupPress + } = this.props; + + onDeleteGroupPress(groupId, value); + } + + // + // Render + + render() { + const { + editGroups, + groupId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + connectDragSource, + onQualityProfileItemAllowedChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + return ( +
    +
    + { + editGroups && +
    + + + +
    + } + + { + !editGroups && + + } + + { + connectDragSource( +
    + +
    + ) + } +
    + + { + editGroups && +
    + { + items.map(({ quality }, index) => { + return ( + + ); + }).reverse() + } +
    + } +
    + ); + } +} + +QualityProfileItemGroup.propTypes = { + editGroups: PropTypes.bool, + groupId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool.isRequired, + isDraggingUp: PropTypes.bool.isRequired, + isDraggingDown: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onItemGroupAllowedChange: PropTypes.func.isRequired, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupNameChange: PropTypes.func.isRequired, + onDeleteGroupPress: PropTypes.func.isRequired, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +QualityProfileItemGroup.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItemGroup; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css new file mode 100644 index 000000000..6b4268de9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css @@ -0,0 +1,15 @@ +.editGroupsButton { + composes: button from 'Components/Link/Button.css'; + + margin-top: 10px; +} + +.editGroupsButtonIcon { + margin-right: 8px; +} + +.qualities { + margin-top: 10px; + transition: min-height 200ms; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js new file mode 100644 index 000000000..c41d4b77d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js @@ -0,0 +1,181 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import Measure from 'Components/Measure'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import QualityProfileItemDragPreview from './QualityProfileItemDragPreview'; +import styles from './QualityProfileItems.css'; + +class QualityProfileItems extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + qualitiesHeight: 0, + qualitiesHeightEditGroups: 0 + }; + } + + // + // Listeners + + onMeasure = ({ height }) => { + if (this.props.editGroups) { + this.setState({ + qualitiesHeightEditGroups: height + }); + } else { + this.setState({ qualitiesHeight: height }); + } + } + + onToggleEditGroupsMode = () => { + this.props.onToggleEditGroupsMode(); + } + + // + // Render + + render() { + const { + editGroups, + dropQualityIndex, + dropPosition, + qualityProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + const { + qualitiesHeight, + qualitiesHeightEditGroups + } = this.state; + + const isDragging = dropQualityIndex !== null; + const isDraggingUp = isDragging && dropPosition === 'above'; + const isDraggingDown = isDragging && dropPosition === 'below'; + const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight; + + return ( + + + Qualities + + +
    + + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + + + + +
    + { + qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => { + const identifier = quality ? quality.id : id; + + return ( + + ); + }).reverse() + } + + +
    +
    +
    +
    + ); + } +} + +QualityProfileItems.propTypes = { + editGroups: PropTypes.bool.isRequired, + dragQualityIndex: PropTypes.string, + dropQualityIndex: PropTypes.string, + dropPosition: PropTypes.string, + qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + onToggleEditGroupsMode: PropTypes.func.isRequired +}; + +QualityProfileItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default QualityProfileItems; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js new file mode 100644 index 000000000..bf13815ff --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createQualityProfileSelector(), + (qualityProfile) => { + return { + name: qualityProfile.name + }; + } + ); +} + +function QualityProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +QualityProfileNameConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(QualityProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css new file mode 100644 index 000000000..9644a7c2d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css @@ -0,0 +1,21 @@ +.qualityProfiles { + display: flex; + flex-wrap: wrap; +} + +.addQualityProfile { + composes: qualityProfile from './QualityProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js new file mode 100644 index 000000000..cf1a21422 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityProfile from './QualityProfile'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfiles.css'; + +class QualityProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneQualityProfilePress = (id) => { + this.props.onCloneQualityProfilePress(id); + this.setState({ isQualityProfileModalOpen: true }); + } + + onEditQualityProfilePress = () => { + this.setState({ isQualityProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isQualityProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteQualityProfile, + onCloneQualityProfilePress, + ...otherProps + } = this.props; + + return ( +
    + +
    + { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
    + +
    +
    +
    + + +
    +
    + ); + } +} + +QualityProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfiles; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js new file mode 100644 index 000000000..c7596ad63 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions'; +import QualityProfiles from './QualityProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + return { + ...qualityProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfiles: fetchQualityProfiles, + dispatchDeleteQualityProfile: deleteQualityProfile, + dispatchCloneQualityProfile: cloneQualityProfile +}; + +class QualityProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityProfiles(); + } + + // + // Listeners + + onConfirmDeleteQualityProfile = (id) => { + this.props.dispatchDeleteQualityProfile({ id }); + } + + onCloneQualityProfilePress = (id) => { + this.props.dispatchCloneQualityProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityProfilesConnector.propTypes = { + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchDeleteQualityProfile: PropTypes.func.isRequired, + dispatchCloneQualityProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js new file mode 100644 index 000000000..5d0e74287 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector'; + +function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditReleaseProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditReleaseProfileModal; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js new file mode 100644 index 000000000..89b605652 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditReleaseProfileModal from './EditReleaseProfileModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditReleaseProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.releaseProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js new file mode 100644 index 000000000..81a93dd5a --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditReleaseProfileModalContent.css'; + +function EditReleaseProfileModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onModalClose, + onSavePress, + onDeleteReleaseProfilePress, + ...otherProps + } = props; + + const { + id, + required, + ignored, + preferred, + includePreferredWhenRenaming, + tags + } = item; + + return ( + + + {id ? 'Edit Release Profile' : 'Add Release Profile'} + + + +
    + + Must Contain + + + + + + Must Not Contain + + + + + + Preferred + + + + + + Include Preferred when Renaming + + + + + + Tags + + + +
    +
    + + { + id && + + } + + + + + Save + + +
    + ); +} + +EditReleaseProfileModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteReleaseProfilePress: PropTypes.func +}; + +export default EditReleaseProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js new file mode 100644 index 000000000..a5fbaa680 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js @@ -0,0 +1,113 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions'; +import EditReleaseProfileModalContent from './EditReleaseProfileModalContent'; + +const newReleaseProfile = { + required: '', + ignored: '', + preferred: '', + includePreferredWhenRenaming: false, + tags: [] +}; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.releaseProfiles, + (id, releaseProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = releaseProfiles; + + const profile = id ? _.find(items, { id }) : newReleaseProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setReleaseProfileValue, + saveReleaseProfile +}; + +class EditReleaseProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newReleaseProfile).forEach((name) => { + this.props.setReleaseProfileValue({ + name, + value: newReleaseProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setReleaseProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveReleaseProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setReleaseProfileValue: PropTypes.func.isRequired, + saveReleaseProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css new file mode 100644 index 000000000..8c703d283 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css @@ -0,0 +1,11 @@ +.releaseProfile { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js new file mode 100644 index 000000000..0455594c2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfile.css'; + +class ReleaseProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onEditReleaseProfilePress = () => { + this.setState({ isEditReleaseProfileModalOpen: true }); + } + + onEditReleaseProfileModalClose = () => { + this.setState({ isEditReleaseProfileModalOpen: false }); + } + + onDeleteReleaseProfilePress = () => { + this.setState({ + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: true + }); + } + + onDeleteReleaseProfileModalClose= () => { + this.setState({ isDeleteReleaseProfileModalOpen: false }); + } + + onConfirmDeleteReleaseProfile = () => { + this.props.onConfirmDeleteReleaseProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + required, + ignored, + preferred, + tags, + tagList + } = this.props; + + return ( + +
    + { + split(required).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
    + +
    + { + split(ignored).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
    + +
    + { + preferred.map((item) => { + const isPreferred = item.value >= 0; + + return ( + + ); + }) + } +
    + + + + + + +
    + ); + } +} + +ReleaseProfile.propTypes = { + id: PropTypes.number.isRequired, + required: PropTypes.string.isRequired, + ignored: PropTypes.string.isRequired, + preferred: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +ReleaseProfile.defaultProps = { + required: '', + ignored: '', + preferred: [] +}; + +export default ReleaseProfile; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css new file mode 100644 index 000000000..e3573452e --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css @@ -0,0 +1,20 @@ +.releaseProfiles { + display: flex; + flex-wrap: wrap; +} + +.addReleaseProfile { + composes: releaseProfile from './ReleaseProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js new file mode 100644 index 000000000..73c648a04 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import ReleaseProfile from './ReleaseProfile'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfiles.css'; + +class ReleaseProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onAddReleaseProfilePress = () => { + this.setState({ isAddReleaseProfileModalOpen: true }); + } + + onAddReleaseProfileModalClose = () => { + this.setState({ isAddReleaseProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + tagList, + onConfirmDeleteReleaseProfile, + ...otherProps + } = this.props; + + return ( +
    + +
    + +
    + +
    +
    + + { + items.map((item) => { + return ( + + ); + }) + } +
    + + +
    +
    + ); + } +} + +ReleaseProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +export default ReleaseProfiles; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js new file mode 100644 index 000000000..dd4b41171 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ReleaseProfiles from './ReleaseProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.releaseProfiles, + createTagsSelector(), + (releaseProfiles, tagList) => { + return { + ...releaseProfiles, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchReleaseProfiles, + deleteReleaseProfile +}; + +class ReleaseProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchReleaseProfiles(); + } + + // + // Listeners + + onConfirmDeleteReleaseProfile = (id) => { + this.props.deleteReleaseProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ReleaseProfilesConnector.propTypes = { + fetchReleaseProfiles: PropTypes.func.isRequired, + deleteReleaseProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css new file mode 100644 index 000000000..ccfd00c7a --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -0,0 +1,93 @@ +.qualityDefinition { + display: flex; + align-content: stretch; + margin: 5px 0; + padding-top: 5px; + height: 45px; + border-top: 1px solid $borderColor; +} + +.quality, +.title { + flex: 0 1 250px; + padding-right: 20px; + line-height: 40px; +} + +.sizeLimit { + flex: 0 1 500px; + padding-right: 30px; +} + +.slider { + width: 100%; + height: 20px; +} + +.bar { + top: 9px; + margin: 0 5px; + height: 3px; + background-color: $sliderAccentColor; + box-shadow: 0 0 0 #000; + + &:nth-child(odd) { + background-color: #ddd; + } +} + +.handle { + top: 1px; + z-index: 0 !important; + width: 18px; + height: 18px; + border: 3px solid $sliderAccentColor; + border-radius: 50%; + background-color: $white; + text-align: center; + cursor: pointer; +} + +.sizes { + display: flex; + justify-content: space-between; +} + +.megabytesPerMinute { + display: flex; + justify-content: space-between; + flex: 0 0 250px; +} + +.sizeInput { + composes: input from 'Components/Form/TextInput.css'; + + display: inline-block; + margin-left: 5px; + padding: 6px; + width: 75px; +} + +@media only screen and (max-width: $breakpointSmall) { + .qualityDefinition { + flex-wrap: wrap; + height: auto; + + &:first-child { + border-top: none; + } + } + + .qualityDefinition:first-child { + border-top: none; + } + + .quality { + font-weight: bold; + line-height: inherit; + } + + .sizeLimit { + margin-top: 10px; + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js new file mode 100644 index 000000000..2ff558037 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactSlider from 'react-slider'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import NumberInput from 'Components/Form/NumberInput'; +import TextInput from 'Components/Form/TextInput'; +import styles from './QualityDefinition.css'; + +const slider = { + min: 0, + max: 200, + step: 0.1 +}; + +function getValue(value) { + if (value < slider.min) { + return slider.min; + } + + if (value > slider.max) { + return slider.max; + } + + return value; +} + +class QualityDefinition extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._forceUpdateTimeout = null; + } + + componentDidMount() { + // A hack to deal with a bug in the slider component until a fix for it + // lands and an updated version is available. + // See: https://github.com/mpowaga/react-slider/issues/115 + + this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1); + } + + componentWillUnmount() { + if (this._forceUpdateTimeout) { + clearTimeout(this._forceUpdateTimeout); + } + } + + // + // Listeners + + onSizeChange = ([minSize, maxSize]) => { + maxSize = maxSize === slider.max ? null : maxSize; + + this.props.onSizeChange({ minSize, maxSize }); + } + + onMinSizeChange = ({ value }) => { + const minSize = getValue(value); + + this.props.onSizeChange({ + minSize, + maxSize: this.props.maxSize + }); + } + + onMaxSizeChange = ({ value }) => { + const maxSize = value === slider.max ? null : getValue(value); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize + }); + } + + // + // Render + + render() { + const { + id, + quality, + title, + minSize, + maxSize, + advancedSettings, + onTitleChange + } = this.props; + + const minBytes = minSize * 1024 * 1024; + const minThirty = formatBytes(minBytes * 30, 2); + const minSixty = formatBytes(minBytes * 60, 2); + + const maxBytes = maxSize && maxSize * 1024 * 1024; + const maxThirty = maxBytes ? formatBytes(maxBytes * 30, 2) : 'Unlimited'; + const maxSixty = maxBytes ? formatBytes(maxBytes * 60, 2) : 'Unlimited'; + + return ( +
    +
    + {quality.name} +
    + +
    + +
    + +
    + + +
    +
    + + +
    + +
    + + +
    +
    +
    + + { + advancedSettings && +
    +
    + Min + + +
    + +
    + Max + + +
    +
    + } +
    + ); + } +} + +QualityDefinition.propTypes = { + id: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + advancedSettings: PropTypes.bool.isRequired, + onTitleChange: PropTypes.func.isRequired, + onSizeChange: PropTypes.func.isRequired +}; + +export default QualityDefinition; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js new file mode 100644 index 000000000..9404dfd9f --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { setQualityDefinitionValue } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import QualityDefinition from './QualityDefinition'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + setQualityDefinitionValue, + clearPendingChanges +}; + +class QualityDefinitionConnector extends Component { + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' }); + } + + // + // Listeners + + onTitleChange = ({ value }) => { + this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value }); + } + + onSizeChange = ({ minSize, maxSize }) => { + const { + id, + minSize: currentMinSize, + maxSize: currentMaxSize + } = this.props; + + if (minSize !== currentMinSize) { + this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize }); + } + + if (minSize !== currentMaxSize) { + this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionConnector.propTypes = { + id: PropTypes.number.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + setQualityDefinitionValue: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(QualityDefinitionConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css new file mode 100644 index 000000000..689017684 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css @@ -0,0 +1,41 @@ +.header { + display: flex; + font-weight: bold; +} + +.quality, +.title { + flex: 0 1 250px; +} + +.sizeLimit { + flex: 0 1 500px; +} + +.megabytesPerMinute { + flex: 0 0 250px; +} + +.sizeLimitHelpTextContainer { + display: flex; + justify-content: flex-end; + margin-top: 20px; + max-width: 1000px; +} + +.sizeLimitHelpText { + max-width: 500px; + color: $helpTextColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .header { + display: none; + } + + .definitions { + &:first-child { + border-top: none; + } + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js new file mode 100644 index 000000000..18db844f8 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityDefinitionConnector from './QualityDefinitionConnector'; +import styles from './QualityDefinitions.css'; + +class QualityDefinitions extends Component { + + // + // Render + + render() { + const { + items, + ...otherProps + } = this.props; + + return ( +
    + +
    +
    Quality
    +
    Title
    +
    Size Limit
    +
    Megabytes Per Minute
    +
    + +
    + { + items.map((item) => { + return ( + + ); + }) + } +
    + +
    +
    + Limits are automatically adjusted for the series runtime and number of episodes in the file. +
    +
    +
    +
    + ); + } +} + +QualityDefinitions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default QualityDefinitions; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js new file mode 100644 index 000000000..9a3e0a90c --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js @@ -0,0 +1,90 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions'; +import QualityDefinitions from './QualityDefinitions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityDefinitions, + (qualityDefinitions) => { + const items = qualityDefinitions.items.map((item) => { + const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {}; + + return Object.assign({}, item, pendingChanges); + }); + + return { + ...qualityDefinitions, + items, + hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityDefinitions: fetchQualityDefinitions, + dispatchSaveQualityDefinitions: saveQualityDefinitions +}; + +class QualityDefinitionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityDefinitions(); + + const { + dispatchFetchQualityDefinitions, + dispatchSaveQualityDefinitions, + onChildMounted + } = this.props; + + dispatchFetchQualityDefinitions(); + onChildMounted(dispatchSaveQualityDefinitions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchQualityDefinitions: PropTypes.func.isRequired, + dispatchSaveQualityDefinitions: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector); diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js new file mode 100644 index 000000000..dfd6a24d7 --- /dev/null +++ b/frontend/src/Settings/Quality/Quality.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector'; + +class Quality extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + + ); + } +} + +export default Quality; diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css new file mode 100644 index 000000000..497ef47c0 --- /dev/null +++ b/frontend/src/Settings/Settings.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + + &:hover { + color: #616573; + text-decoration: none; + } +} + +.summary { + margin-top: 10px; + margin-bottom: 30px; + color: $dimColor; +} diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js new file mode 100644 index 000000000..5222ec415 --- /dev/null +++ b/frontend/src/Settings/Settings.js @@ -0,0 +1,133 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from './SettingsToolbarConnector'; +import styles from './Settings.css'; + +function Settings() { + return ( + + + + + + Media Management + + +
    + Naming and file management settings +
    + + + Profiles + + +
    + Quality, Language, Delay and Release profiles +
    + + + Quality + + +
    + Quality sizes and naming +
    + + + Indexers + + +
    + Indexers and indexer options +
    + + + Download Clients + + +
    + Download clients, download handling and remote path mappings +
    + + + Connect + + +
    + Notifications, connections to media servers/players and custom scripts +
    + + + Metadata + + +
    + Create metadata files when episodes are imported or series are refreshed +
    + + + Tags + + +
    + See all tags and how they are used. Unused tags can be removed +
    + + + General + + +
    + Port, SSL, username/password, proxy, analytics and updates +
    + + + UI + + +
    + Calendar, date and color impaired options +
    +
    +
    + ); +} + +Settings.propTypes = { +}; + +export default Settings; diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js new file mode 100644 index 000000000..8b70857d9 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbar.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PendingChangesModal from './PendingChangesModal'; +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +class SettingsToolbar extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true }); + } + + // + // Control + + saveSettings = (event) => { + event.preventDefault(); + + const { + hasPendingChanges, + onSavePress + } = this.props; + + if (hasPendingChanges) { + onSavePress(); + } + } + + // + // Render + + render() { + const { + advancedSettings, + showSave, + isSaving, + hasPendingChanges, + hasPendingLocation, + additionalButtons, + onSavePress, + onConfirmNavigation, + onCancelNavigation, + onAdvancedSettingsPress + } = this.props; + + return ( + + + + + { + showSave && + + } + + { + additionalButtons + } + + + + + ); + } +} + +SettingsToolbar.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + showSave: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + hasPendingLocation: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool, + additionalButtons: PropTypes.node, + onSavePress: PropTypes.func, + onAdvancedSettingsPress: PropTypes.func.isRequired, + onConfirmNavigation: PropTypes.func.isRequired, + onCancelNavigation: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +SettingsToolbar.defaultProps = { + showSave: true +}; + +export default keyboardShortcuts(SettingsToolbar); diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js new file mode 100644 index 000000000..8bfb3dad5 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { toggleAdvancedSettings } from 'Store/Actions/settingsActions'; +import SettingsToolbar from './SettingsToolbar'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + toggleAdvancedSettings +}; + +class SettingsToolbarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + nextLocation: null, + nextLocationAction: null, + confirmed: false + }; + + this._unblock = null; + } + + componentDidMount() { + this._unblock = this.props.history.block(this.routerWillLeave); + } + + componentWillUnmount() { + if (this._unblock) { + this._unblock(); + } + } + + // + // Control + + routerWillLeave = (nextLocation, nextLocationAction) => { + if (this.state.confirmed) { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + + return true; + } + + if (this.props.hasPendingChanges ) { + this.setState({ + nextLocation, + nextLocationAction + }); + + return false; + } + + return true; + } + + // + // Listeners + + onAdvancedSettingsPress = () => { + this.props.toggleAdvancedSettings(); + } + + onConfirmNavigation = () => { + const { + nextLocation, + nextLocationAction + } = this.state; + + const history = this.props.history; + + const path = `${nextLocation.pathname}${nextLocation.search}`; + + this.setState({ + confirmed: true + }, () => { + if (nextLocationAction === 'PUSH') { + history.push(path); + } else { + // Unfortunately back and forward both use POP, + // which means we don't actually know which direction + // the user wanted to go, assuming back. + + history.goBack(); + } + }); + } + + onCancelNavigation = () => { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + } + + // + // Render + + render() { + const hasPendingLocation = this.state.nextLocation !== null; + + return ( + + ); + } +} + +const historyShape = { + block: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.propTypes = { + hasPendingChanges: PropTypes.bool.isRequired, + history: PropTypes.shape(historyShape).isRequired, + onSavePress: PropTypes.func, + toggleAdvancedSettings: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.defaultProps = { + hasPendingChanges: false +}; + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector)); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js new file mode 100644 index 000000000..ab670359b --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import titleCase from 'Utilities/String/titleCase'; + +function TagDetailsDelayProfile(props) { + const { + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = props; + + return ( +
    +
    + Protocol: {titleCase(preferredProtocol)} +
    + +
    + { + enableUsenet ? + `Usenet Delay: ${usenetDelay}` : + 'Usenet disabled' + } +
    + +
    + { + enableTorrent ? + `Torrent Delay: ${torrentDelay}` : + 'Torrents disabled' + } +
    +
    + ); +} + +TagDetailsDelayProfile.propTypes = { + preferredProtocol: PropTypes.string.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired +}; + +export default TagDetailsDelayProfile; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js new file mode 100644 index 000000000..0fe1ec5d3 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import TagDetailsModalContentConnector from './TagDetailsModalContentConnector'; + +function TagDetailsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagDetailsModal; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css new file mode 100644 index 000000000..3488b0509 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css @@ -0,0 +1,26 @@ +.items { + display: flex; + flex-wrap: wrap; +} + +.item { + flex: 0 0 100%; +} + +.restriction { + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px solid $borderColor; + + &:last-child { + margin: 0; + padding: 0; + border-bottom: none; + } +} + +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js new file mode 100644 index 000000000..eadc9f468 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Label from 'Components/Label'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TagDetailsDelayProfile from './TagDetailsDelayProfile'; +import styles from './TagDetailsModalContent.css'; + +function TagDetailsModalContent(props) { + const { + label, + isTagUsed, + series, + delayProfiles, + notifications, + releaseProfiles, + onModalClose, + onDeleteTagPress + } = props; + + return ( + + + Tag Details - {label} + + + + { + !isTagUsed && +
    Tag is not used and can be deleted
    + } + + { + !!series.length && +
    + { + series.map((item) => { + return ( +
    + {item.title} +
    + ); + }) + } +
    + } + + { + !!delayProfiles.length && +
    + { + delayProfiles.map((item) => { + const { + id, + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = item; + + return ( + + ); + }) + } +
    + } + + { + !!notifications.length && +
    + { + notifications.map((item) => { + return ( +
    + {item.name} +
    + ); + }) + } +
    + } + + { + !!releaseProfiles.length && +
    + { + releaseProfiles.map((item) => { + return ( +
    +
    + { + split(item.required).map((r) => { + return ( + + ); + }) + } +
    + +
    + { + split(item.ignored).map((i) => { + return ( + + ); + }) + } +
    +
    + ); + }) + } +
    + } +
    + + + { + + } + + + +
    + ); +} + +TagDetailsModalContent.propTypes = { + label: PropTypes.string.isRequired, + isTagUsed: PropTypes.bool.isRequired, + series: PropTypes.arrayOf(PropTypes.object).isRequired, + delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteTagPress: PropTypes.func.isRequired +}; + +export default TagDetailsModalContent; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js new file mode 100644 index 000000000..5a6c9e229 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -0,0 +1,61 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import TagDetailsModalContent from './TagDetailsModalContent'; + +function findMatchingItems(ids, items) { + return items.filter((s) => { + return ids.includes(s.id); + }); +} + +function createMatchingSeriesSelector() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + findMatchingItems + ); +} + +function createMatchingDelayProfilesSelector() { + return createSelector( + (state, { delayProfileIds }) => delayProfileIds, + (state) => state.settings.delayProfiles.items, + findMatchingItems + ); +} + +function createMatchingNotificationsSelector() { + return createSelector( + (state, { notificationIds }) => notificationIds, + (state) => state.settings.notifications.items, + findMatchingItems + ); +} + +function createMatchingReleaseProfilesSelector() { + return createSelector( + (state, { restrictionIds }) => restrictionIds, + (state) => state.settings.releaseProfiles.items, + findMatchingItems + ); +} + +function createMapStateToProps() { + return createSelector( + createMatchingSeriesSelector(), + createMatchingDelayProfilesSelector(), + createMatchingNotificationsSelector(), + createMatchingReleaseProfilesSelector(), + (series, delayProfiles, notifications, releaseProfiles) => { + return { + series, + delayProfiles, + notifications, + releaseProfiles + }; + } + ); +} + +export default connect(createMapStateToProps)(TagDetailsModalContent); diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css new file mode 100644 index 000000000..ee425e309 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.css @@ -0,0 +1,11 @@ +.tag { + composes: card from 'Components/Card.css'; + + width: 150px; +} + +.label { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js new file mode 100644 index 000000000..0cb9ee208 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagDetailsModal from './Details/TagDetailsModal'; +import styles from './Tag.css'; + +class Tag extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isDeleteTagModalOpen: false + }; + } + + // + // Listeners + + onShowDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteTagPress = () => { + this.setState({ + isDetailsModalOpen: false, + isDeleteTagModalOpen: true + }); + } + + onDeleteTagModalClose= () => { + this.setState({ isDeleteTagModalOpen: false }); + } + + onConfirmDeleteTag = () => { + this.props.onConfirmDeleteTag({ id: this.props.id }); + } + + // + // Render + + render() { + const { + label, + delayProfileIds, + notificationIds, + restrictionIds, + seriesIds + } = this.props; + + const { + isDetailsModalOpen, + isDeleteTagModalOpen + } = this.state; + + const isTagUsed = !!( + delayProfileIds.length || + notificationIds.length || + restrictionIds.length || + seriesIds.length + ); + + return ( + +
    + {label} +
    + + { + isTagUsed && +
    + { + !!seriesIds.length && +
    + {seriesIds.length} series +
    + } + + { + !!delayProfileIds.length && +
    + {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} +
    + } + + { + !!notificationIds.length && +
    + {notificationIds.length} connection{notificationIds.length > 1 && 's'} +
    + } + + { + !!restrictionIds.length && +
    + {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} +
    + } +
    + } + + { + !isTagUsed && +
    + No links +
    + } + + + + +
    + ); + } +} + +Tag.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired, + notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, + restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onConfirmDeleteTag: PropTypes.func.isRequired +}; + +Tag.defaultProps = { + delayProfileIds: [], + notificationIds: [], + restrictionIds: [], + seriesIds: [] +}; + +export default Tag; diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js new file mode 100644 index 000000000..50f610153 --- /dev/null +++ b/frontend/src/Settings/Tags/TagConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector'; +import { deleteTag } from 'Store/Actions/tagActions'; +import Tag from './Tag'; + +function createMapStateToProps() { + return createSelector( + createTagDetailsSelector(), + (tagDetails) => { + return { + ...tagDetails + }; + } + ); +} + +const mapStateToProps = { + onConfirmDeleteTag: deleteTag +}; + +export default connect(createMapStateToProps, mapStateToProps)(Tag); diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js new file mode 100644 index 000000000..56ef92b49 --- /dev/null +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import TagsConnector from './TagsConnector'; + +function TagSettings() { + return ( + + + + + + + + ); +} + +export default TagSettings; diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css new file mode 100644 index 000000000..5a44f8331 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.css @@ -0,0 +1,4 @@ +.tags { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js new file mode 100644 index 000000000..e1375ba76 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import TagConnector from './TagConnector'; +import styles from './Tags.css'; + +function Tags(props) { + const { + items, + ...otherProps + } = props; + + if (!items.length) { + return ( +
    No tags have been added yet
    + ); + } + + return ( +
    + +
    + { + items.map((item) => { + return ( + + ); + }) + } +
    +
    +
    + ); +} + +Tags.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Tags; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js new file mode 100644 index 000000000..3439f6d0d --- /dev/null +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; +import Tags from './Tags'; + +function createMapStateToProps() { + return createSelector( + (state) => state.tags, + (tags) => { + const isFetching = tags.isFetching || tags.details.isFetching; + const error = tags.error || tags.details.error; + const isPopulated = tags.isPopulated && tags.details.isPopulated; + + return { + ...tags, + isFetching, + error, + isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTagDetails: fetchTagDetails, + dispatchFetchDelayProfiles: fetchDelayProfiles, + dispatchFetchNotifications: fetchNotifications, + dispatchFetchReleaseProfiles: fetchReleaseProfiles +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchTagDetails, + dispatchFetchDelayProfiles, + dispatchFetchNotifications, + dispatchFetchReleaseProfiles + } = this.props; + + dispatchFetchTagDetails(); + dispatchFetchDelayProfiles(); + dispatchFetchNotifications(); + dispatchFetchReleaseProfiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + dispatchFetchTagDetails: PropTypes.func.isRequired, + dispatchFetchDelayProfiles: PropTypes.func.isRequired, + dispatchFetchNotifications: PropTypes.func.isRequired, + dispatchFetchReleaseProfiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js new file mode 100644 index 000000000..71356a5f0 --- /dev/null +++ b/frontend/src/Settings/UI/UISettings.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +export const firstDayOfWeekOptions = [ + { key: 0, value: 'Sunday' }, + { key: 1, value: 'Monday' } +]; + +export const weekColumnOptions = [ + { key: 'ddd M/D', value: 'Tue 3/25' }, + { key: 'ddd MM/DD', value: 'Tue 03/25' }, + { key: 'ddd D/M', value: 'Tue 25/03' }, + { key: 'ddd DD/MM', value: 'Tue 25/03' } +]; + +const shortDateFormatOptions = [ + { key: 'MMM D YYYY', value: 'Mar 25 2014' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014' }, + { key: 'MM/D/YYYY', value: '03/25/2014' }, + { key: 'MM/DD/YYYY', value: '03/25/2014' }, + { key: 'DD/MM/YYYY', value: '25/03/2014' }, + { key: 'YYYY-MM-DD', value: '2014-03-25' } +]; + +const longDateFormatOptions = [ + { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' }, + { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' } +]; + +export const timeFormatOptions = [ + { key: 'h(:mm)a', value: '5pm/5:30pm' }, + { key: 'HH:mm', value: '17:00/17:30' } +]; + +class UISettings extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + settings, + hasSettings, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
    Unable to load UI settings
    + } + + { + hasSettings && !isFetching && !error && +
    +
    + + First Day of Week + + + + + + Week Column Header + + + +
    + +
    + + Short Date Format + + + + + + Long Date Format + + + + + + Time Format + + + + + + Show Relative Dates + + +
    + +
    + + Enable Color-Impaired Mode + + +
    +
    + } +
    +
    + ); + } + +} + +UISettings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UISettings; diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js new file mode 100644 index 000000000..24b55b6f0 --- /dev/null +++ b/frontend/src/Settings/UI/UISettingsConnector.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import UISettings from './UISettings'; + +const SECTION = 'ui'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setUISettingsValue, + saveUISettings, + fetchUISettings, + clearPendingChanges +}; + +class UISettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUISettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setUISettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveUISettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UISettingsConnector.propTypes = { + setUISettingsValue: PropTypes.func.isRequired, + saveUISettings: PropTypes.func.isRequired, + fetchUISettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector); diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js new file mode 100644 index 000000000..14c98c8ba --- /dev/null +++ b/frontend/src/Shared/piwikCheck.js @@ -0,0 +1,11 @@ +if (window.Sonarr.analytics) { + const d = document; + const g = d.createElement('script'); + const s = d.getElementsByTagName('script')[0]; + + g.type = 'text/javascript'; + g.async = true; + g.defer = true; + g.src = '//piwik.sonarr.tv/piwik.js'; + s.parentNode.insertBefore(g, s); +} diff --git a/frontend/src/Shims/jquery.js b/frontend/src/Shims/jquery.js new file mode 100644 index 000000000..d0234889c --- /dev/null +++ b/frontend/src/Shims/jquery.js @@ -0,0 +1,10 @@ +import $ from 'jquery'; +import ajax from 'jQuery/jquery.ajax'; + +ajax($); + +const jquery = $; +window.$ = $; +window.jQuery = $; + +export default jquery; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js new file mode 100644 index 000000000..2952973a9 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js @@ -0,0 +1,12 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createClearReducer(section, defaultState) { + return (state) => { + const newState = Object.assign(getSectionState(state, section), defaultState); + + return updateSectionState(state, section, newState); + }; +} + +export default createClearReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js new file mode 100644 index 000000000..d58bb1cd4 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js @@ -0,0 +1,14 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + newState.selectedFilterKey = payload.selectedFilterKey; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionFilterReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js new file mode 100644 index 000000000..1bc048a80 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js @@ -0,0 +1,29 @@ +import { sortDirections } from 'Helpers/Props'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionSortReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + const sortKey = payload.sortKey || newState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === newState.sortKey) { + sortDirection = newState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = newState.sortDirection; + } + } + + newState.sortKey = sortKey; + newState.sortDirection = sortDirection; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionSortReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js new file mode 100644 index 000000000..3af58dd3b --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js @@ -0,0 +1,23 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetProviderFieldValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + const fields = Object.assign({}, newState.pendingChanges.fields || {}); + + fields[name] = value; + + newState.pendingChanges.fields = fields; + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetProviderFieldValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js new file mode 100644 index 000000000..474eb7bb2 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetSettingValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + + const currentValue = newState.item ? newState.item[name] : null; + const pendingState = newState.pendingChanges; + + let parsedValue = null; + + if (_.isNumber(currentValue) && value != null) { + parsedValue = parseInt(value); + } else { + parsedValue = value; + } + + if (currentValue === parsedValue) { + delete pendingState[name]; + } else { + pendingState[name] = parsedValue; + } + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetSettingValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js new file mode 100644 index 000000000..70b57446d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +const whitelistedProperties = [ + 'pageSize', + 'columns', + 'tableOptions' +]; + +function createSetTableOptionReducer(section) { + return (state, { payload }) => { + const newState = Object.assign( + getSectionState(state, section), + _.pick(payload, whitelistedProperties)); + + return updateSectionState(state, section, newState); + }; +} + +export default createSetTableOptionReducer; diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js new file mode 100644 index 000000000..87be89828 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import updateEpisodes from 'Utilities/Episode/updateEpisodes'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const { + episodeIds, + monitored + } = payload; + + const state = getSectionState(getState(), section, true); + + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: true + })); + + const promise = $.ajax({ + url: '/episode/monitor', + method: 'PUT', + data: JSON.stringify({ episodeIds, monitored }), + dataType: 'json' + }); + + promise.done(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false, + monitored + })); + + dispatch(fetchHandler()); + }); + + promise.fail(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false + })); + }); + }; +} + +export default createBatchToggleEpisodeMonitoredHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js new file mode 100644 index 000000000..c9cd058bd --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js @@ -0,0 +1,44 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update, updateItem } from '../baseActions'; + +export default function createFetchHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + id, + ...otherPayload + } = payload; + + const { request, abortRequest } = createAjaxRequest({ + url: id == null ? url : `${url}/${id}`, + data: otherPayload, + traditional: true + }); + + request.done((data) => { + dispatch(batchActions([ + id == null ? update({ section, data }) : updateItem({ section, ...data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + + return abortRequest; + }; +} diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js new file mode 100644 index 000000000..2c02c8c20 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import { set } from '../baseActions'; + +function createFetchSchemaHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSchemaFetching: true })); + + const promise = $.ajax({ + url + }); + + promise.done((data) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: null, + schema: data + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: xhr + })); + }); + }; +} + +export default createFetchSchemaHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js new file mode 100644 index 000000000..e7f1d4b04 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, updateServerSideCollection } from '../baseActions'; + +function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const sectionState = getSectionState(getState(), section, true); + const page = payload.page || sectionState.page || 1; + + const data = Object.assign({ page }, + _.pick(sectionState, [ + 'pageSize', + 'sortDirection', + 'sortKey' + ])); + + if (fetchDataAugmenter) { + fetchDataAugmenter(getState, payload, data); + } + + const { + selectedFilterKey, + filters, + customFilters + } = sectionState; + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + selectedFilters.forEach((filter) => { + data[filter.key] = filter.value; + }); + + const promise = $.ajax({ + url, + data + }); + + promise.done((response) => { + dispatch(batchActions([ + updateServerSideCollection({ section, data: response }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }; +} + +export default createFetchServerSideCollectionHandler; diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js new file mode 100644 index 000000000..c3315ce94 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createHandleActions.js @@ -0,0 +1,143 @@ +import _ from 'lodash'; +import { handleActions } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { + SET, + UPDATE, + UPDATE_ITEM, + UPDATE_SERVER_SIDE_COLLECTION, + CLEAR_PENDING_CHANGES, + REMOVE_ITEM +} from 'Store/Actions/baseActions'; + +const blacklistedProperties = [ + 'section', + 'id' +]; + +export default function createHandleActions(handlers, defaultState, section) { + return handleActions({ + + [SET]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = Object.assign(getSectionState(state, payloadSection), + _.omit(payload, blacklistedProperties)); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + if (_.isArray(payload.data)) { + newState.items = payload.data; + } else { + newState.item = payload.data; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_ITEM]: function(state, { payload }) { + const { + section: payloadSection, + updateOnly = false, + ...otherProps + } = payload; + + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + // TODO: Move adding to it's own reducer + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...otherProps }); + } else if (!updateOnly) { + newState.items.push({ ...otherProps }); + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [CLEAR_PENDING_CHANGES]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + newState.pendingChanges = {}; + + if (newState.hasOwnProperty('saveError')) { + newState.saveError = null; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [REMOVE_ITEM]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const data = payload.data; + const newState = getSectionState(state, payloadSection); + + const serverState = _.omit(data, ['records']); + const calculatedState = { + totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1), + items: data.records + }; + + return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState)); + } + + return state; + }, + + ...handlers + + }, defaultState); +} diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js new file mode 100644 index 000000000..6f353ed17 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -0,0 +1,45 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { set, removeItem } from '../baseActions'; + +function createRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + id, + ...queryParams + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}/${id}?${$.param(queryParams, true)}`, + method: 'DELETE' + }; + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + removeItem({ section, id }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js new file mode 100644 index 000000000..e63c9f993 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js @@ -0,0 +1,43 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, update } from '../baseActions'; + +function createSaveHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const state = getSectionState(getState(), section, true); + const saveData = Object.assign({}, state.item, state.pendingChanges, payload); + + const promise = $.ajax({ + url, + method: 'PUT', + dataType: 'json', + data: JSON.stringify(saveData) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }; +} + +export default createSaveHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js new file mode 100644 index 000000000..6d555bf23 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set, updateItem } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelSaveProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createSaveProviderHandler(section, url, options = {}) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const { + id, + queryParams = {}, + ...otherPayload + } = payload; + + const saveData = getProviderState({ id, ...otherPayload }, getState, section); + + const ajaxOptions = { + url: `${url}?${$.param(queryParams, true)}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(saveData) + }; + + if (id) { + ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; + ajaxOptions.method = 'PUT'; + } + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + updateItem({ section, ...data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createSaveProviderHandler; diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js new file mode 100644 index 000000000..f81723769 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js @@ -0,0 +1,52 @@ +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import pages from 'Utilities/pages'; +import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler'; +import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler'; +import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler'; +import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler'; + +function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) { + const actionHandlers = {}; + const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH]; + const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter); + actionHandlers[fetchHandlerType] = fetchHandler; + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) { + const handlerType = handlers[serverSideCollectionHandlers.SORT]; + actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) { + const handlerType = handlers[serverSideCollectionHandlers.FILTER]; + actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk); + } + + return actionHandlers; +} + +export default createServerSideCollectionHandlers; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js new file mode 100644 index 000000000..d7e476444 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js @@ -0,0 +1,10 @@ +import { set } from '../baseActions'; + +function createSetServerSideCollectionFilterHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + dispatch(set({ section, ...payload })); + dispatch(fetchHandler({ page: 1 })); + }; +} + +export default createSetServerSideCollectionFilterHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js new file mode 100644 index 000000000..12b21bb0d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js @@ -0,0 +1,35 @@ +import pages from 'Utilities/pages'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createSetServerSideCollectionPageHandler(section, page, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const currentPage = sectionState.page || 1; + let nextPage = 0; + + switch (page) { + case pages.FIRST: + nextPage = 1; + break; + case pages.PREVIOUS: + nextPage = currentPage - 1; + break; + case pages.NEXT: + nextPage = currentPage + 1; + break; + case pages.LAST: + nextPage = sectionState.totalPages; + break; + default: + nextPage = payload.page; + } + + // If we prefer to update the page immediately we should + // set the page and not pass a page to the fetch handler. + + // dispatch(set({ section, page: nextPage })); + dispatch(fetchHandler({ page: nextPage })); + }; +} + +export default createSetServerSideCollectionPageHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js new file mode 100644 index 000000000..fbd66e83e --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js @@ -0,0 +1,26 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import { sortDirections } from 'Helpers/Props'; +import { set } from '../baseActions'; + +function createSetServerSideCollectionSortHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const sortKey = payload.sortKey || sectionState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === sectionState.sortKey) { + sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = sectionState.sortDirection; + } + } + + dispatch(set({ section, sortKey, sortDirection })); + dispatch(fetchHandler()); + }; +} + +export default createSetServerSideCollectionSortHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js new file mode 100644 index 000000000..77deaec64 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js @@ -0,0 +1,34 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set } from '../baseActions'; + +function createTestAllProvidersHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTestingAll: true })); + + const ajaxOptions = { + url: `${url}/testall`, + method: 'POST', + contentType: 'application/json', + dataType: 'json' + }; + + const { request } = createAjaxRequest(ajaxOptions); + + request.done((data) => { + dispatch(set({ + section, + isTestingAll: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTestingAll: false + })); + }); + }; +} + +export default createTestAllProvidersHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js new file mode 100644 index 000000000..ca26883fb --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -0,0 +1,52 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelTestProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createTestProviderHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTesting: true })); + + const testData = getProviderState(payload, getState, section); + + const ajaxOptions = { + url: `${url}/test`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(testData) + }; + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(set({ + section, + isTesting: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTesting: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createTestProviderHandler; diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js new file mode 100644 index 000000000..68232e510 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/delayProfiles.js @@ -0,0 +1,103 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import { update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.delayProfiles'; + +// +// Actions Types + +export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles'; +export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema'; +export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile'; +export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile'; +export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile'; +export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue'; + +// +// Action Creators + +export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES); +export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA); +export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE); +export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE); +export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE); + +export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'), + [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'), + + [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'), + [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'), + + [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => { + const { id, moveIndex } = payload; + const moveOrder = moveIndex + 1; + const delayProfiles = getState().settings.delayProfiles.items; + const moving = _.find(delayProfiles, { id }); + + // Don't move if the order hasn't changed + if (moving.order === moveOrder) { + return; + } + + const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null; + const afterQueryParam = after ? `after=${after.id}` : ''; + + const promise = $.ajax({ + method: 'PUT', + url: `/delayprofile/reorder/${id}?${afterQueryParam}` + }); + + promise.done((data) => { + dispatch(update({ section, data })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js new file mode 100644 index 000000000..6d4a3954d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.downloadClientOptions'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS'; +export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE'; +export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS'; + +// +// Action Creators + +export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS); +export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS); +export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'), + [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js new file mode 100644 index 000000000..a268053f7 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -0,0 +1,117 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.downloadClients'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients'; +export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema'; +export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema'; +export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue'; +export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue'; +export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient'; +export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient'; +export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient'; +export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; +export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; +export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; + +// +// Action Creators + +export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS); +export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA); +export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA); + +export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT); +export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT); +export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); +export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); +export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); +export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); + +export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), + [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), + + [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), + [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), + [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), + [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), + [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section), + [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enable = true; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js new file mode 100644 index 000000000..f5e8c277e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/general.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.general'; + +// +// Actions Types + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Action Creators + +export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS); +export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS); +export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'), + [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host') + }, + + // + // Reducers + + reducers: { + [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js new file mode 100644 index 000000000..53fb21651 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.indexerOptions'; + +// +// Actions Types + +export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions'; +export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions'; +export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue'; + +// +// Action Creators + +export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS); +export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS); +export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'), + [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js new file mode 100644 index 000000000..ddab7c154 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.indexers'; + +// +// Actions Types + +export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; +export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; +export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; +export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; +export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; +export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; +export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; +export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; +export const TEST_INDEXER = 'settings/indexers/testIndexer'; +export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; +export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; + +// +// Action Creators + +export const fetchIndexers = createThunk(FETCH_INDEXERS); +export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); +export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); + +export const saveIndexer = createThunk(SAVE_INDEXER); +export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); +export const deleteIndexer = createThunk(DELETE_INDEXER); +export const testIndexer = createThunk(TEST_INDEXER); +export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); +export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); + +export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), + [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), + + [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), + [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), + [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), + [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), + [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), + [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableRss = selectedSchema.supportsRss; + selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; + selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/languageProfiles.js b/frontend/src/Store/Actions/Settings/languageProfiles.js new file mode 100644 index 000000000..49fe9825b --- /dev/null +++ b/frontend/src/Store/Actions/Settings/languageProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.languageProfiles'; + +// +// Actions Types + +export const FETCH_LANGUAGE_PROFILES = 'settings/languageProfiles/fetchLanguageProfiles'; +export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'settings/languageProfiles/fetchLanguageProfileSchema'; +export const SAVE_LANGUAGE_PROFILE = 'settings/languageProfiles/saveLanguageProfile'; +export const DELETE_LANGUAGE_PROFILE = 'settings/languageProfiles/deleteLanguageProfile'; +export const SET_LANGUAGE_PROFILE_VALUE = 'settings/languageProfiles/setLanguageProfileValue'; +export const CLONE_LANGUAGE_PROFILE = 'settings/languageProfiles/cloneLanguageProfile'; + +// +// Action Creators + +export const fetchLanguageProfiles = createThunk(FETCH_LANGUAGE_PROFILES); +export const fetchLanguageProfileSchema = createThunk(FETCH_LANGUAGE_PROFILE_SCHEMA); +export const saveLanguageProfile = createThunk(SAVE_LANGUAGE_PROFILE); +export const deleteLanguageProfile = createThunk(DELETE_LANGUAGE_PROFILE); + +export const setLanguageProfileValue = createAction(SET_LANGUAGE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneLanguageProfile = createAction(CLONE_LANGUAGE_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_LANGUAGE_PROFILES]: createFetchHandler(section, '/languageprofile'), + [FETCH_LANGUAGE_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/languageprofile/schema'), + [SAVE_LANGUAGE_PROFILE]: createSaveProviderHandler(section, '/languageprofile'), + [DELETE_LANGUAGE_PROFILE]: createRemoveItemHandler(section, '/languageprofile') + }, + + // + // Reducers + + reducers: { + [SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_LANGUAGE_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js new file mode 100644 index 000000000..4ae9eba0c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/mediaManagement.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.mediaManagement'; + +// +// Actions Types + +export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings'; +export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings'; +export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue'; + +// +// Action Creators + +export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS); +export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS); +export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'), + [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement') + }, + + // + // Reducers + + reducers: { + [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js new file mode 100644 index 000000000..ed5e0aa86 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadata.js @@ -0,0 +1,75 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; + +// +// Variables + +const section = 'settings.metadata'; + +// +// Actions Types + +export const FETCH_METADATA = 'settings/metadata/fetchMetadata'; +export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue'; +export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue'; +export const SAVE_METADATA = 'settings/metadata/saveMetadata'; + +// +// Action Creators + +export const fetchMetadata = createThunk(FETCH_METADATA); +export const saveMetadata = createThunk(SAVE_METADATA); + +export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA]: createFetchHandler(section, '/metadata'), + [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_VALUE]: createSetSettingValueReducer(section), + [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js new file mode 100644 index 000000000..27add8309 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/naming.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.naming'; + +// +// Actions Types + +export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings'; +export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings'; +export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue'; + +// +// Action Creators + +export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS); +export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS); +export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'), + [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming') + }, + + // + // Reducers + + reducers: { + [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js new file mode 100644 index 000000000..e3f2ae01c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/namingExamples.js @@ -0,0 +1,79 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk } from 'Store/thunks'; +import { set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.namingExamples'; + +// +// Actions Types + +export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples'; + +// +// Action Creators + +export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const naming = getState().settings.naming; + + const promise = $.ajax({ + url: '/config/naming/examples', + data: Object.assign({}, naming.item, naming.pendingChanges) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: {} + +}; diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js new file mode 100644 index 000000000..b2c28dac9 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -0,0 +1,115 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.notifications'; + +// +// Actions Types + +export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications'; +export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema'; +export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; +export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; +export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; +export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; +export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; +export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; +export const TEST_NOTIFICATION = 'settings/notifications/testNotification'; +export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification'; + +// +// Action Creators + +export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS); +export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA); +export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA); + +export const saveNotification = createThunk(SAVE_NOTIFICATION); +export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION); +export const deleteNotification = createThunk(DELETE_NOTIFICATION); +export const testNotification = createThunk(TEST_NOTIFICATION); +export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION); + +export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'), + [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'), + + [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'), + [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section), + [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'), + [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'), + [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section) + }, + + // + // Reducers + + reducers: { + [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.onGrab = selectedSchema.supportsOnGrab; + selectedSchema.onDownload = selectedSchema.supportsOnDownload; + selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; + selectedSchema.onRename = selectedSchema.supportsOnRename; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js new file mode 100644 index 000000000..fb6572f45 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk } from 'Store/thunks'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; +import { clearPendingChanges, set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.qualityDefinitions'; + +// +// Actions Types + +export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions'; +export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions'; +export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue'; + +// +// Action Creators + +export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS); +export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS); + +export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'), + [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'), + + [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) { + const qualityDefinitions = getState().settings.qualityDefinitions; + + const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => { + const id = parseInt(key); + const pendingChanges = qualityDefinitions.pendingChanges[id] || {}; + const item = _.find(qualityDefinitions.items, { id }); + + return Object.assign({}, item, pendingChanges); + }); + + // If there is nothing to save don't bother isSaving + if (!upatedDefinitions || !upatedDefinitions.length) { + return; + } + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + method: 'PUT', + url: '/qualityDefinition/update', + data: JSON.stringify(upatedDefinitions) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + update({ section, data }), + clearPendingChanges({ section }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) { + const { id, name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = _.cloneDeep(newState.pendingChanges); + + const pendingState = newState.pendingChanges[id] || {}; + const currentValue = _.find(newState.items, { id })[name]; + + if (currentValue === value) { + delete pendingState[name]; + } else { + pendingState[name] = value; + } + + if (_.isEmpty(pendingState)) { + delete newState.pendingChanges[id]; + } else { + newState.pendingChanges[id] = pendingState; + } + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js new file mode 100644 index 000000000..6fdc204a0 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.qualityProfiles'; + +// +// Actions Types + +export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles'; +export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema'; +export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile'; +export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile'; +export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue'; +export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile'; + +// +// Action Creators + +export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES); +export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA); +export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE); +export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE); + +export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'), + [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'), + [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'), + [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile') + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_QUALITY_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js new file mode 100644 index 000000000..339e732f6 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.releaseProfiles'; + +// +// Actions Types + +export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles'; +export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile'; +export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile'; +export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue'; + +// +// Action Creators + +export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES); +export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE); +export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE); + +export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'), + + [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'), + + [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile') + }, + + // + // Reducers + + reducers: { + [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js new file mode 100644 index 000000000..3cfcc7f1f --- /dev/null +++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.remotePathMappings'; + +// +// Actions Types + +export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings'; +export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping'; +export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping'; +export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue'; + +// +// Action Creators + +export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS); +export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING); +export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING); + +export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'), + [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'), + [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping') + }, + + // + // Reducers + + reducers: { + [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js new file mode 100644 index 000000000..97d7223fd --- /dev/null +++ b/frontend/src/Store/Actions/Settings/ui.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.ui'; + +// +// Actions Types + +export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings'; +export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE'; +export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS'; + +// +// Action Creators + +export const fetchUISettings = createThunk(FETCH_UI_SETTINGS); +export const saveUISettings = createThunk(SAVE_UI_SETTINGS); +export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'), + [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui') + }, + + // + // Reducers + + reducers: { + [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js new file mode 100644 index 000000000..a0c784a7d --- /dev/null +++ b/frontend/src/Store/Actions/actionTypes.js @@ -0,0 +1,21 @@ +// +// App + +export const SHOW_MESSAGE = 'SHOW_MESSAGE'; +export const HIDE_MESSAGE = 'HIDE_MESSAGE'; +export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS'; +export const SET_VERSION = 'SET_VERSION'; +export const SET_APP_VALUE = 'SET_APP_VALUE'; +export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE'; + +// +// Settings + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Languages + +export const FETCH_LANGUAGES = 'FETCH_LANGUAGES'; diff --git a/frontend/src/Store/Actions/addSeriesActions.js b/frontend/src/Store/Actions/addSeriesActions.js new file mode 100644 index 000000000..469061820 --- /dev/null +++ b/frontend/src/Store/Actions/addSeriesActions.js @@ -0,0 +1,181 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import monitorOptions from 'Utilities/Series/monitorOptions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getNewSeries from 'Utilities/Series/getNewSeries'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'addSeries'; +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isAdding: false, + isAdded: false, + addError: null, + items: [], + + defaults: { + rootFolderPath: '', + monitor: monitorOptions[0].key, + qualityProfileId: 0, + languageProfileId: 0, + seriesType: 'standard', + seasonFolder: true, + tags: [] + } +}; + +export const persistState = [ + 'addSeries.defaults' +]; + +// +// Actions Types + +export const LOOKUP_SERIES = 'addSeries/lookupSeries'; +export const ADD_SERIES = 'addSeries/addSeries'; +export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue'; +export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries'; +export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault'; + +// +// Action Creators + +export const lookupSeries = createThunk(LOOKUP_SERIES); +export const addSeries = createThunk(ADD_SERIES); +export const clearAddSeries = createAction(CLEAR_ADD_SERIES); +export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT); + +export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [LOOKUP_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/series/lookup', + data: { + term: payload.term + } + }); + + abortCurrentRequest = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + }, + + [ADD_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isAdding: true })); + + const tvdbId = payload.tvdbId; + const items = getState().addSeries.items; + const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload); + + const promise = $.ajax({ + url: '/series', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(newSeries) + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ section: 'series', ...data }), + + set({ + section, + isAdding: false, + isAdded: true, + addError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isAdding: false, + isAdded: false, + addError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section), + + [SET_ADD_SERIES_DEFAULT]: function(state, { payload }) { + const newState = getSectionState(state, section); + + newState.defaults = { + ...newState.defaults, + ...payload + }; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_ADD_SERIES]: function(state) { + const { + defaults, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js new file mode 100644 index 000000000..81c61ca80 --- /dev/null +++ b/frontend/src/Store/Actions/appActions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createHandleActions from './Creators/createHandleActions'; + +function getDimensions(width, height) { + const dimensions = { + width, + height, + isExtraSmallScreen: width <= 480, + isSmallScreen: width <= 768, + isMediumScreen: width <= 992, + isLargeScreen: width <= 1200 + }; + + return dimensions; +} + +// +// Variables + +export const section = 'app'; +const messagesSection = 'app.messages'; + +// +// State + +export const defaultState = { + dimensions: getDimensions(window.innerWidth, window.innerHeight), + messages: { + items: [] + }, + version: window.Sonarr.version, + isUpdated: false, + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen +}; + +// +// Action Types + +export const SHOW_MESSAGE = 'app/showMessage'; +export const HIDE_MESSAGE = 'app/hideMessage'; +export const SAVE_DIMENSIONS = 'app/saveDimensions'; +export const SET_VERSION = 'app/setVersion'; +export const SET_APP_VALUE = 'app/setAppValue'; +export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; + +// +// Action Creators + +export const saveDimensions = createAction(SAVE_DIMENSIONS); +export const setVersion = createAction(SET_VERSION); +export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE); +export const setAppValue = createAction(SET_APP_VALUE); +export const showMessage = createAction(SHOW_MESSAGE); +export const hideMessage = createAction(HIDE_MESSAGE); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SAVE_DIMENSIONS]: function(state, { payload }) { + const { + width, + height + } = payload; + + const dimensions = getDimensions(width, height); + + return Object.assign({}, state, { dimensions }); + }, + + [SHOW_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, messagesSection, newState); + }, + + [HIDE_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, messagesSection, newState); + }, + + [SET_APP_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [SET_VERSION]: function(state, { payload }) { + const version = payload.version; + + const newState = { + version + }; + + if (state.version !== version) { + newState.isUpdated = true; + } + + return Object.assign({}, state, newState); + }, + + [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) { + const newState = { + isSidebarVisible: payload.isSidebarVisible + }; + + return Object.assign({}, state, newState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js new file mode 100644 index 000000000..37be3e0d2 --- /dev/null +++ b/frontend/src/Store/Actions/baseActions.js @@ -0,0 +1,29 @@ +import { createAction } from 'redux-actions'; + +// +// Action Types + +export const SET = 'base/set'; + +export const UPDATE = 'base/update'; +export const UPDATE_ITEM = 'base/updateItem'; +export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection'; + +export const SET_SETTING_VALUE = 'base/setSettingValue'; +export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges'; + +export const REMOVE_ITEM = 'base/removeItem'; + +// +// Action Creators + +export const set = createAction(SET); + +export const update = createAction(UPDATE); +export const updateItem = createAction(UPDATE_ITEM); +export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION); + +export const setSettingValue = createAction(SET_SETTING_VALUE); +export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES); + +export const removeItem = createAction(REMOVE_ITEM); diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js new file mode 100644 index 000000000..d6fbfb99b --- /dev/null +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -0,0 +1,144 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; + +// +// Variables + +export const section = 'blacklist'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'blacklist.pageSize', + 'blacklist.sortKey', + 'blacklist.sortDirection', + 'blacklist.columns' +]; + +// +// Action Types + +export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist'; +export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage'; +export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage'; +export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage'; +export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage'; +export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; +export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; +export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; +export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; + +// +// Action Creators + +export const fetchBlacklist = createThunk(FETCH_BLACKLIST); +export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE); +export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE); +export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE); +export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE); +export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); +export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); +export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); +export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const clearBlacklist = createAction(CLEAR_BLACKLIST); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/blacklist', + fetchBlacklist, + { + [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE, + [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT + }), + + [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_BLACKLIST]: createClearReducer('history', { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js new file mode 100644 index 000000000..83c28036a --- /dev/null +++ b/frontend/src/Store/Actions/calendarActions.js @@ -0,0 +1,391 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import moment from 'moment'; +import { filterTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import * as calendarViews from 'Calendar/calendarViews'; +import * as commandNames from 'Commands/commandNames'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; +import { executeCommandHelper } from './commandActions'; + +// +// Variables + +export const section = 'calendar'; + +const viewRanges = { + [calendarViews.DAY]: 'day', + [calendarViews.WEEK]: 'week', + [calendarViews.MONTH]: 'month', + [calendarViews.FORECAST]: 'day' +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + start: null, + end: null, + dates: [], + dayCount: 7, + view: window.innerWidth > 768 ? 'week' : 'day', + error: null, + items: [], + searchMissingCommandId: null, + + options: { + collapseMultipleEpisodes: false, + showEpisodeInformation: true, + showFinaleIcon: false, + showSpecialIcon: false, + showCutoffUnmetIcon: false + }, + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'all', + label: 'All', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + } + + ] +}; + +export const persistState = [ + 'calendar.view', + 'calendar.selectedFilterKey', + 'calendar.options' +]; + +// +// Actions Types + +export const FETCH_CALENDAR = 'calendar/fetchCalendar'; +export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; +export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; +export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; +export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; +export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; +export const CLEAR_CALENDAR = 'calendar/clearCalendar'; +export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; +export const SEARCH_MISSING = 'calendar/searchMissing'; +export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; + +// +// Helpers + +function getDays(start, end) { + const startTime = moment(start); + const endTime = moment(end); + const difference = endTime.diff(startTime, 'days'); + + // Difference is one less than the number of days we need to account for. + return _.times(difference + 1, (i) => { + return startTime.clone().add(i, 'days').toISOString(); + }); +} + +function getDates(time, view, firstDayOfWeek, dayCount) { + const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; + + let start = time.clone().startOf('day'); + let end = time.clone().endOf('day'); + + if (view === calendarViews.WEEK) { + start = time.clone().startOf(weekName); + end = time.clone().endOf(weekName); + } + + if (view === calendarViews.FORECAST) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(dayCount - 2, 'days').endOf('day'); + } + + if (view === calendarViews.MONTH) { + start = time.clone().startOf('month').startOf(weekName); + end = time.clone().endOf('month').endOf(weekName); + } + + if (view === calendarViews.AGENDA) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(1, 'month').endOf('day'); + } + + return { + start: start.toISOString(), + end: end.toISOString(), + time: time.toISOString(), + dates: getDays(start, end) + }; +} + +function getPopulatableRange(startDate, endDate, view) { + switch (view) { + case calendarViews.DAY: + return { + start: moment(startDate).subtract(1, 'day').toISOString(), + end: moment(endDate).add(1, 'day').toISOString() + }; + case calendarViews.WEEK: + case calendarViews.FORECAST: + return { + start: moment(startDate).subtract(1, 'week').toISOString(), + end: moment(endDate).add(1, 'week').toISOString() + }; + default: + return { + start: startDate, + end: endDate + }; + } +} + +function isRangePopulated(start, end, state) { + const { + start: currentStart, + end: currentEnd, + view: currentView + } = state; + + if (!currentStart || !currentEnd) { + return false; + } + + const { + start: currentPopulatedStart, + end: currentPopulatedEnd + } = getPopulatableRange(currentStart, currentEnd, currentView); + + if ( + moment(start).isAfter(currentPopulatedStart) && + moment(start).isBefore(currentPopulatedEnd) + ) { + return true; + } + + return false; +} + +// +// Action Creators + +export const fetchCalendar = createThunk(FETCH_CALENDAR); +export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); +export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); +export const setCalendarView = createThunk(SET_CALENDAR_VIEW); +export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); +export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); +export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); +export const clearCalendar = createAction(CLEAR_CALENDAR); +export const setCalendarOption = createAction(SET_CALENDAR_OPTION); +export const searchMissing = createThunk(SEARCH_MISSING); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CALENDAR]: function(getState, payload, dispatch) { + const state = getState(); + const calendar = state.calendar; + const unmonitored = calendar.selectedFilterKey === 'all'; + + const { + time = calendar.time, + view = calendar.view + } = payload; + + const dayCount = state.calendar.dayCount; + const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount); + const { start, end } = getPopulatableRange(dates.start, dates.end, view); + const isPrePopulated = isRangePopulated(start, end, state.calendar); + + const basesAttrs = { + section, + isFetching: true + }; + + const attrs = isPrePopulated ? + { + view, + ...basesAttrs, + ...dates + } : + basesAttrs; + + dispatch(set(attrs)); + + const promise = $.ajax({ + url: '/calendar', + data: { + unmonitored, + start, + end + } + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + view, + ...dates, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) { + if (payload.dayCount === getState().calendar.dayCount) { + return; + } + + dispatch(set({ + section, + dayCount: payload.dayCount + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { + dispatch(set({ + section, + selectedFilterKey: payload.selectedFilterKey + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { + const state = getState(); + const view = payload.view; + const time = view === calendarViews.FORECAST || calendarViews.AGENDA ? + moment() : + state.calendar.time; + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) { + const state = getState(); + const view = state.calendar.view; + const time = moment(); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).subtract(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).add(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [SEARCH_MISSING]: function(getState, payload, dispatch) { + const { episodeIds } = payload; + + const commandPayload = { + name: commandNames.EPISODE_SEARCH, + episodeIds + }; + + executeCommandHelper(commandPayload, dispatch).then((data) => { + dispatch(set({ + section, + searchMissingCommandId: data.id + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_CALENDAR]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }), + + [SET_CALENDAR_OPTION]: function(state, { payload }) { + const options = state.options; + + return { + ...state, + options: { + ...options, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js new file mode 100644 index 000000000..d506566f7 --- /dev/null +++ b/frontend/src/Store/Actions/captchaActions.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'captcha'; + +// +// State + +export const defaultState = { + refreshing: false, + token: null, + siteKey: null, + secretToken: null, + ray: null, + stoken: null, + responseUrl: null +}; + +// +// Actions Types + +export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha'; +export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie'; +export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue'; +export const RESET_CAPTCHA = 'captcha/resetCaptcha'; + +// +// Action Creators + +export const refreshCaptcha = createThunk(REFRESH_CAPTCHA); +export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE); +export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE); +export const resetCaptcha = createAction(RESET_CAPTCHA); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [REFRESH_CAPTCHA]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'checkCaptcha', + ...payload + }; + + dispatch(setCaptchaValue({ + refreshing: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + if (!data.captchaRequest) { + dispatch(setCaptchaValue({ + refreshing: false + })); + } + + dispatch(setCaptchaValue({ + refreshing: false, + ...data.captchaRequest + })); + }); + + promise.fail(() => { + dispatch(setCaptchaValue({ + refreshing: false + })); + }); + }, + + [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) { + const state = getState().captcha; + + const queryParams = { + responseUrl: state.responseUrl, + ray: state.ray, + captchaResponse: payload.captchaResponse + }; + + const actionPayload = { + action: 'getCaptchaCookie', + queryParams, + ...payload + }; + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(setCaptchaValue({ + token: data.captchaToken + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_CAPTCHA_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_CAPTCHA]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js new file mode 100644 index 000000000..5a7baf58a --- /dev/null +++ b/frontend/src/Store/Actions/commandActions.js @@ -0,0 +1,215 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { isSameCommand } from 'Utilities/Command'; +import { messageTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { showMessage, hideMessage } from './appActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'commands'; + +let lastCommand = null; +let lastCommandTimeout = null; +const removeCommandTimeoutIds = {}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + handlers: {} +}; + +// +// Actions Types + +export const FETCH_COMMANDS = 'commands/fetchCommands'; +export const EXECUTE_COMMAND = 'commands/executeCommand'; +export const CANCEL_COMMAND = 'commands/cancelCommand'; +export const ADD_COMMAND = 'commands/updateCommand'; +export const UPDATE_COMMAND = 'commands/finishCommand'; +export const FINISH_COMMAND = 'commands/addCommand'; +export const REMOVE_COMMAND = 'commands/removeCommand'; + +// +// Action Creators + +export const fetchCommands = createThunk(FETCH_COMMANDS); +export const executeCommand = createThunk(EXECUTE_COMMAND); +export const cancelCommand = createThunk(CANCEL_COMMAND); +export const updateCommand = createThunk(UPDATE_COMMAND); +export const finishCommand = createThunk(FINISH_COMMAND); +export const addCommand = createAction(ADD_COMMAND); +export const removeCommand = createAction(REMOVE_COMMAND); + +// +// Helpers + +function showCommandMessage(payload, dispatch) { + const { + id, + name, + trigger, + message, + body = {}, + status + } = payload; + + const { + sendUpdatesToClient, + suppressMessages + } = body; + + if (!message || !body || !sendUpdatesToClient || suppressMessages) { + return; + } + + let type = messageTypes.INFO; + let hideAfter = 0; + + if (status === 'completed') { + type = messageTypes.SUCCESS; + hideAfter = 4; + } else if (status === 'failed') { + type = messageTypes.ERROR; + hideAfter = trigger === 'manual' ? 10 : 4; + } + + dispatch(showMessage({ + id, + name, + message, + type, + hideAfter + })); +} + +function scheduleRemoveCommand(command, dispatch) { + const { + id, + status + } = command; + + if (status === 'queued') { + return; + } + + const timeoutId = removeCommandTimeoutIds[id]; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + removeCommandTimeoutIds[id] = setTimeout(() => { + dispatch(batchActions([ + removeCommand({ section: 'commands', id }), + hideMessage({ id }) + ])); + + delete removeCommandTimeoutIds[id]; + }, 60000 * 5); +} + +export function executeCommandHelper( payload, dispatch) { + // TODO: show a message for the user + if (lastCommand && isSameCommand(lastCommand, payload)) { + console.warn('Please wait at least 5 seconds before running this command again'); + } + + lastCommand = payload; + + // clear last command after 5 seconds. + if (lastCommandTimeout) { + clearTimeout(lastCommandTimeout); + } + + lastCommandTimeout = setTimeout(() => { + lastCommand = null; + }, 5000); + + const promise = $.ajax({ + url: '/command', + method: 'POST', + data: JSON.stringify(payload) + }); + + return promise.then((data) => { + dispatch(addCommand(data)); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + + [EXECUTE_COMMAND]: function(getState, payload, dispatch) { + executeCommandHelper(payload, dispatch); + }, + + [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), + + [UPDATE_COMMAND]: function(getState, payload, dispatch) { + dispatch(updateItem({ section: 'commands', ...payload })); + + showCommandMessage(payload, dispatch); + scheduleRemoveCommand(payload, dispatch); + }, + + [FINISH_COMMAND]: function(getState, payload, dispatch) { + const state = getState(); + const handlers = state.commands.handlers; + + Object.keys(handlers).forEach((key) => { + const handler = handlers[key]; + + if (handler.name === payload.name) { + dispatch(handler.handler(payload)); + } + }); + + dispatch(updateItem({ section: 'commands', ...payload })); + scheduleRemoveCommand(payload, dispatch); + showCommandMessage(payload, dispatch); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [ADD_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items, payload]; + + return newState; + }, + + [REMOVE_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items]; + + const index = _.findIndex(newState.items, { id: payload.id }); + + if (index > -1) { + newState.items.splice(index, 1); + } + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js new file mode 100644 index 000000000..750c3ef6f --- /dev/null +++ b/frontend/src/Store/Actions/customFilterActions.js @@ -0,0 +1,55 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'customFilters'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters'; +export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter'; +export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter'; + +// +// Action Creators + +export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS); +export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER); +export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'), + + [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'), + + [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js new file mode 100644 index 000000000..089d49bf3 --- /dev/null +++ b/frontend/src/Store/Actions/deviceActions.js @@ -0,0 +1,83 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'devices'; + +// +// State + +export const defaultState = { + items: [], + isFetching: false, + isPopulated: false, + error: false +}; + +// +// Actions Types + +export const FETCH_DEVICES = 'devices/fetchDevices'; +export const CLEAR_DEVICES = 'devices/clearDevices'; + +// +// Action Creators + +export const fetchDevices = createThunk(FETCH_DEVICES); +export const clearDevices = createAction(CLEAR_DEVICES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_DEVICES]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'getDevices', + ...payload + }; + + dispatch(set({ + section, + isFetching: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null, + items: data.devices || [] + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_DEVICES]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js new file mode 100644 index 000000000..2c367d343 --- /dev/null +++ b/frontend/src/Store/Actions/episodeActions.js @@ -0,0 +1,235 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import episodeEntities from 'Episode/episodeEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'episodes'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'monitored', + columnLabel: 'Monitored', + isVisible: true, + isModifiable: false + }, + { + name: 'episodeNumber', + label: '#', + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'path', + label: 'Path', + isVisible: false + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: false + }, + { + name: 'airDateUtc', + label: 'Air Date', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'audioInfo', + label: 'Audio Info', + isVisible: false + }, + { + name: 'videoCodec', + label: 'Video Codec', + isVisible: false + }, + { + name: 'size', + label: 'Size', + isVisible: false + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'episodes.columns' +]; + +// +// Actions Types + +export const FETCH_EPISODES = 'episodes/fetchEpisodes'; +export const SET_EPISODES_SORT = 'episodes/setEpisodesSort'; +export const SET_EPISODES_TABLE_OPTION = 'episodes/setEpisodesTableOption'; +export const CLEAR_EPISODES = 'episodes/clearEpisodes'; +export const TOGGLE_EPISODE_MONITORED = 'episodes/toggleEpisodeMonitored'; +export const TOGGLE_EPISODES_MONITORED = 'episodes/toggleEpisodesMonitored'; + +// +// Action Creators + +export const fetchEpisodes = createThunk(FETCH_EPISODES); +export const setEpisodesSort = createAction(SET_EPISODES_SORT); +export const setEpisodesTableOption = createAction(SET_EPISODES_TABLE_OPTION); +export const clearEpisodes = createAction(CLEAR_EPISODES); +export const toggleEpisodeMonitored = createThunk(TOGGLE_EPISODE_MONITORED); +export const toggleEpisodesMonitored = createThunk(TOGGLE_EPISODES_MONITORED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EPISODES]: createFetchHandler(section, '/episode'), + + [TOGGLE_EPISODE_MONITORED]: function(getState, payload, dispatch) { + const { + episodeId: id, + episodeEntity = episodeEntities.EPISODES, + monitored + } = payload; + + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: true + })); + + const promise = $.ajax({ + url: `/episode/${id}`, + method: 'PUT', + data: JSON.stringify({ monitored }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: false + })); + }); + }, + + [TOGGLE_EPISODES_MONITORED]: function(getState, payload, dispatch) { + const { + episodeIds, + episodeEntity = episodeEntities.EPISODES, + monitored + } = payload; + + const episodeSection = _.last(episodeEntity.split('.')); + + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: true + }); + }) + )); + + const promise = $.ajax({ + url: '/episode/monitor', + method: 'PUT', + data: JSON.stringify({ episodeIds, monitored }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: false, + monitored + }); + }) + )); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: false + }); + }) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_EPISODES]: (state) => { + return Object.assign({}, state, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }); + }, + + [SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js new file mode 100644 index 000000000..cb5329355 --- /dev/null +++ b/frontend/src/Store/Actions/episodeFileActions.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import episodeEntities from 'Episode/episodeEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, removeItem, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'episodeFiles'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_EPISODE_FILES = 'episodeFiles/fetchEpisodeFiles'; +export const DELETE_EPISODE_FILE = 'episodeFiles/deleteEpisodeFile'; +export const DELETE_EPISODE_FILES = 'episodeFiles/deleteEpisodeFiles'; +export const UPDATE_EPISODE_FILES = 'episodeFiles/updateEpisodeFiles'; +export const CLEAR_EPISODE_FILES = 'episodeFiles/clearEpisodeFiles'; + +// +// Action Creators + +export const fetchEpisodeFiles = createThunk(FETCH_EPISODE_FILES); +export const deleteEpisodeFile = createThunk(DELETE_EPISODE_FILE); +export const deleteEpisodeFiles = createThunk(DELETE_EPISODE_FILES); +export const updateEpisodeFiles = createThunk(UPDATE_EPISODE_FILES); +export const clearEpisodeFiles = createAction(CLEAR_EPISODE_FILES); + +// +// Helpers + +const deleteEpisodeFileHelper = createRemoveItemHandler(section, '/episodeFile'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EPISODE_FILES]: createFetchHandler(section, '/episodeFile'), + + [DELETE_EPISODE_FILE]: function(getState, payload, dispatch) { + const { + id: episodeFileId, + episodeEntity = episodeEntities.EPISODES + } = payload; + + const episodeSection = _.last(episodeEntity.split('.')); + const deletePromise = deleteEpisodeFileHelper(getState, payload, dispatch); + + deletePromise.done(() => { + const episodes = getState().episodes.items; + const episodesWithRemovedFiles = _.filter(episodes, { episodeFileId }); + + dispatch(batchActions([ + ...episodesWithRemovedFiles.map((episode) => { + return updateItem({ + section: episodeSection, + ...episode, + episodeFileId: 0, + hasFile: false + }); + }) + ])); + }); + }, + + [DELETE_EPISODE_FILES]: function(getState, payload, dispatch) { + const { + episodeFileIds + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const promise = $.ajax({ + url: '/episodeFile/bulk', + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ episodeFileIds }) + }); + + promise.done(() => { + const episodes = getState().episodes.items; + const episodesWithRemovedFiles = episodeFileIds.reduce((acc, episodeFileId) => { + acc.push(..._.filter(episodes, { episodeFileId })); + + return acc; + }, []); + + dispatch(batchActions([ + ...episodeFileIds.map((id) => { + return removeItem({ section, id }); + }), + + ...episodesWithRemovedFiles.map((episode) => { + return updateItem({ + section: 'episodes', + ...episode, + episodeFileId: 0, + hasFile: false + }); + }), + + set({ + section, + isDeleting: false, + deleteError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + }, + + [UPDATE_EPISODE_FILES]: function(getState, payload, dispatch) { + const { + episodeFileIds, + language, + quality + } = payload; + + dispatch(set({ section, isSaving: true })); + + const data = { + episodeFileIds + }; + + if (language) { + data.language = language; + } + + if (quality) { + data.quality = quality; + } + + const promise = $.ajax({ + url: '/episodeFile/editor', + method: 'PUT', + dataType: 'json', + data: JSON.stringify(data) + }); + + promise.done(() => { + dispatch(batchActions([ + ...episodeFileIds.map((id) => { + const props = {}; + + if (language) { + props.language = language; + } + + if (quality) { + props.quality = quality; + } + + return updateItem({ section, id, ...props }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_EPISODE_FILES]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeHistoryActions.js b/frontend/src/Store/Actions/episodeHistoryActions.js new file mode 100644 index 000000000..35f6ea8b7 --- /dev/null +++ b/frontend/src/Store/Actions/episodeHistoryActions.js @@ -0,0 +1,112 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'episodeHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_EPISODE_HISTORY = 'episodeHistory/fetchEpisodeHistory'; +export const CLEAR_EPISODE_HISTORY = 'episodeHistory/clearEpisodeHistory'; +export const EPISODE_HISTORY_MARK_AS_FAILED = 'episodeHistory/episodeHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchEpisodeHistory = createThunk(FETCH_EPISODE_HISTORY); +export const clearEpisodeHistory = createAction(CLEAR_EPISODE_HISTORY); +export const episodeHistoryMarkAsFailed = createThunk(EPISODE_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_EPISODE_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const queryParams = { + pageSize: 1000, + page: 1, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + episodeId: payload.episodeId + }; + + const promise = $.ajax({ + url: '/history', + data: queryParams + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data: data.records }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [EPISODE_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + episodeId + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchEpisodeHistory({ episodeId })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_EPISODE_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js new file mode 100644 index 000000000..9eafd6704 --- /dev/null +++ b/frontend/src/Store/Actions/historyActions.js @@ -0,0 +1,267 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'history'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'eventType', + columnLabel: 'Event Type', + isVisible: true, + isModifiable: false + }, + { + name: 'series.sortTitle', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'downloadClient', + label: 'Download Client', + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isVisible: false + }, + { + name: 'releaseGroup', + label: 'Release Group', + isVisible: false + }, + { + name: 'details', + columnLabel: 'Details', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'grabbed', + label: 'Grabbed', + filters: [ + { + key: 'eventType', + value: '1', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'imported', + label: 'Imported', + filters: [ + { + key: 'eventType', + value: '3', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'failed', + label: 'Failed', + filters: [ + { + key: 'eventType', + value: '4', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'deleted', + label: 'Deleted', + filters: [ + { + key: 'eventType', + value: '5', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'renamed', + label: 'Renamed', + filters: [ + { + key: 'eventType', + value: '6', + type: filterTypes.EQUAL + } + ] + } + ] + +}; + +export const persistState = [ + 'history.pageSize', + 'history.sortKey', + 'history.sortDirection', + 'history.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_HISTORY = 'history/fetchHistory'; +export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage'; +export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage'; +export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage'; +export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage'; +export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage'; +export const SET_HISTORY_SORT = 'history/setHistorySort'; +export const SET_HISTORY_FILTER = 'history/setHistoryFilter'; +export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption'; +export const CLEAR_HISTORY = 'history/clearHistory'; +export const MARK_AS_FAILED = 'history/markAsFailed'; + +// +// Action Creators + +export const fetchHistory = createThunk(FETCH_HISTORY); +export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE); +export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE); +export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE); +export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE); +export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE); +export const setHistorySort = createThunk(SET_HISTORY_SORT); +export const setHistoryFilter = createThunk(SET_HISTORY_FILTER); +export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION); +export const clearHistory = createAction(CLEAR_HISTORY); +export const markAsFailed = createThunk(MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/history', + fetchHistory, + { + [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE, + [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT, + [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER + }), + + [MARK_AS_FAILED]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: true + })); + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id + } + }); + + promise.done(() => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: null + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_HISTORY]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js new file mode 100644 index 000000000..74d68f87e --- /dev/null +++ b/frontend/src/Store/Actions/importSeriesActions.js @@ -0,0 +1,328 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import getNewSeries from 'Utilities/Series/getNewSeries'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, removeItem, updateItem } from './baseActions'; +import { fetchRootFolders } from './rootFolderActions'; + +// +// Variables + +export const section = 'importSeries'; +let concurrentLookups = 0; +let abortCurrentLookup = null; +const queue = []; + +// +// State + +export const defaultState = { + isLookingUpSeries: false, + isImporting: false, + isImported: false, + importError: null, + items: [] +}; + +// +// Actions Types + +export const QUEUE_LOOKUP_SERIES = 'importSeries/queueLookupSeries'; +export const START_LOOKUP_SERIES = 'importSeries/startLookupSeries'; +export const CANCEL_LOOKUP_SERIES = 'importSeries/cancelLookupSeries'; +export const LOOKUP_UNSEARCHED_SERIES = 'importSeries/lookupUnsearchedSeries'; +export const CLEAR_IMPORT_SERIES = 'importSeries/clearImportSeries'; +export const SET_IMPORT_SERIES_VALUE = 'importSeries/setImportSeriesValue'; +export const IMPORT_SERIES = 'importSeries/importSeries'; + +// +// Action Creators + +export const queueLookupSeries = createThunk(QUEUE_LOOKUP_SERIES); +export const startLookupSeries = createThunk(START_LOOKUP_SERIES); +export const importSeries = createThunk(IMPORT_SERIES); +export const lookupUnsearchedSeries = createThunk(LOOKUP_UNSEARCHED_SERIES); +export const clearImportSeries = createAction(CLEAR_IMPORT_SERIES); +export const cancelLookupSeries = createAction(CANCEL_LOOKUP_SERIES); + +export const setImportSeriesValue = createAction(SET_IMPORT_SERIES_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [QUEUE_LOOKUP_SERIES]: function(getState, payload, dispatch) { + const { + name, + path, + term, + topOfQueue = false + } = payload; + + const state = getState().importSeries; + const item = _.find(state.items, { id: name }) || { + id: name, + term, + path, + isFetching: false, + isPopulated: false, + error: null + }; + + dispatch(updateItem({ + section, + ...item, + term, + isQueued: true, + items: [] + })); + + const itemIndex = queue.indexOf(item.id); + + if (itemIndex >= 0) { + queue.splice(itemIndex, 1); + } + + if (topOfQueue) { + queue.unshift(item.id); + } else { + queue.push(item.id); + } + + if (term && term.length > 2) { + dispatch(startLookupSeries({ start: true })); + } + }, + + [START_LOOKUP_SERIES]: function(getState, payload, dispatch) { + if (concurrentLookups >= 1) { + return; + } + + const state = getState().importSeries; + + const { + isLookingUpSeries, + items + } = state; + + const queueId = queue[0]; + + if (payload.start && !isLookingUpSeries) { + dispatch(set({ section, isLookingUpSeries: true })); + } else if (!isLookingUpSeries) { + return; + } else if (!queueId) { + dispatch(set({ section, isLookingUpSeries: false })); + return; + } + + concurrentLookups++; + queue.splice(0, 1); + + const queued = items.find((i) => i.id === queueId); + + dispatch(updateItem({ + section, + id: queued.id, + isFetching: true + })); + + const { request, abortRequest } = createAjaxRequest({ + url: '/series/lookup', + data: { + term: queued.term + } + }); + + abortCurrentLookup = abortRequest; + + request.done((data) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: true, + error: null, + items: data, + isQueued: false, + selectedSeries: queued.selectedSeries || data[0], + updateOnly: true + })); + }); + + request.fail((xhr) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: false, + error: xhr, + isQueued: false, + updateOnly: true + })); + }); + + request.always(() => { + concurrentLookups--; + + dispatch(startLookupSeries()); + }); + }, + + [LOOKUP_UNSEARCHED_SERIES]: function(getState, payload, dispatch) { + const state = getState().importSeries; + + if (state.isLookingUpSeries) { + return; + } + + state.items.forEach((item) => { + const id = item.id; + + if ( + !item.isPopulated && + !queue.includes(id) + ) { + queue.push(item.id); + } + }); + + if (queue.length) { + dispatch(startLookupSeries({ start: true })); + } + }, + + [IMPORT_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isImporting: true })); + + const ids = payload.ids; + const items = getState().importSeries.items; + const addedIds = []; + + const allNewSeries = ids.reduce((acc, id) => { + const item = _.find(items, { id }); + const selectedSeries = item.selectedSeries; + + // Make sure we have a selected series and + // the same series hasn't been added yet. + if (selectedSeries && !_.some(acc, { tvdbId: selectedSeries.tvdbId })) { + const newSeries = getNewSeries(_.cloneDeep(selectedSeries), item); + newSeries.path = item.path; + + addedIds.push(id); + acc.push(newSeries); + } + + return acc; + }, []); + + const promise = $.ajax({ + url: '/series/import', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(allNewSeries) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isImporting: false, + isImported: true + }), + + ...data.map((series) => updateItem({ section: 'series', ...series })), + + ...addedIds.map((id) => removeItem({ section, id })) + ])); + + dispatch(fetchRootFolders()); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + set({ + section, + isImporting: false, + isImported: true + }), + + addedIds.map((id) => updateItem({ + section, + id, + importError: xhr + })) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CANCEL_LOOKUP_SERIES]: function(state) { + queue.splice(0, queue.length); + + const items = state.items.map((item) => { + if (item.isQueued) { + return { + ...item, + isQueued: false + }; + } + + return item; + }); + + return Object.assign({}, state, { + isLookingUpSeries: false, + items + }); + }, + + [CLEAR_IMPORT_SERIES]: function(state) { + if (abortCurrentLookup) { + abortCurrentLookup(); + + abortCurrentLookup = null; + } + + queue.splice(0, queue.length); + + return Object.assign({}, state, defaultState); + }, + + [SET_IMPORT_SERIES_VALUE]: function(state, { payload }) { + const newState = getSectionState(state, section); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, section, newState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js new file mode 100644 index 000000000..015be478f --- /dev/null +++ b/frontend/src/Store/Actions/index.js @@ -0,0 +1,61 @@ +import * as addSeries from './addSeriesActions'; +import * as app from './appActions'; +import * as blacklist from './blacklistActions'; +import * as calendar from './calendarActions'; +import * as captcha from './captchaActions'; +import * as customFilters from './customFilterActions'; +import * as devices from './deviceActions'; +import * as commands from './commandActions'; +import * as episodes from './episodeActions'; +import * as episodeFiles from './episodeFileActions'; +import * as episodeHistory from './episodeHistoryActions'; +import * as history from './historyActions'; +import * as importSeries from './importSeriesActions'; +import * as interactiveImportActions from './interactiveImportActions'; +import * as oAuth from './oAuthActions'; +import * as organizePreview from './organizePreviewActions'; +import * as paths from './pathActions'; +import * as queue from './queueActions'; +import * as releases from './releaseActions'; +import * as rootFolders from './rootFolderActions'; +import * as seasonPass from './seasonPassActions'; +import * as series from './seriesActions'; +import * as seriesEditor from './seriesEditorActions'; +import * as seriesHistory from './seriesHistoryActions'; +import * as seriesIndex from './seriesIndexActions'; +import * as settings from './settingsActions'; +import * as system from './systemActions'; +import * as tags from './tagActions'; +import * as wanted from './wantedActions'; + +export default [ + addSeries, + app, + blacklist, + calendar, + captcha, + commands, + customFilters, + devices, + episodes, + episodeFiles, + episodeHistory, + history, + importSeries, + interactiveImportActions, + oAuth, + organizePreview, + paths, + queue, + releases, + rootFolders, + seasonPass, + series, + seriesEditor, + seriesHistory, + seriesIndex, + settings, + system, + tags, + wanted +]; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js new file mode 100644 index 000000000..538790756 --- /dev/null +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -0,0 +1,204 @@ +import $ from 'jquery'; +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'interactiveImport'; + +const episodesSection = `${section}.episodes`; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'quality', + sortDirection: sortDirections.DESCENDING, + recentFolders: [], + importMode: 'move', + sortPredicates: { + relativePath: function(item, direction) { + const relativePath = item.relativePath; + + return relativePath.toLowerCase(); + }, + + series: function(item, direction) { + const series = item.series; + + return series ? series.sortTitle : ''; + }, + + quality: function(item, direction) { + return item.quality ? item.quality.qualityWeight : 0; + } + }, + + episodes: { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.DESCENDING, + items: [] + } +}; + +export const persistState = [ + 'interactiveImport.recentFolders', + 'interactiveImport.importMode' +]; + +// +// Actions Types + +export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems'; +export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort'; +export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem'; +export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport'; +export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; +export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; +export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; + +export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes'; +export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort'; +export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes'; + +// +// Action Creators + +export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS); +export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT); +export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM); +export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT); +export const addRecentFolder = createAction(ADD_RECENT_FOLDER); +export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); +export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); + +export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES); +export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT); +export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES); + +// +// Action Handlers +export const actionHandlers = handleThunks({ + [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { + if (!payload.downloadId && !payload.folder) { + dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); + return; + } + + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/manualimport', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => { + const id = payload.id; + const newState = Object.assign({}, state); + const items = newState.items; + const index = items.findIndex((item) => item.id === id); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [ADD_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolder = { folder, lastUsed: moment().toISOString() }; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1, recentFolder); + } else { + recentFolders.push(recentFolder); + } + + return Object.assign({}, state, { recentFolders }); + }, + + [REMOVE_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + recentFolders.splice(index, 1); + + return Object.assign({}, state, { recentFolders }); + }, + + [CLEAR_INTERACTIVE_IMPORT]: function(state) { + const newState = { + ...defaultState, + recentFolders: state.recentFolders, + importMode: state.importMode + }; + + return newState; + }, + + [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { + return Object.assign({}, state, { importMode: payload.importMode }); + }, + + [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection), + + [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => { + return updateSectionState(state, episodesSection, { + ...defaultState.episodes + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js new file mode 100644 index 000000000..e1d5cd124 --- /dev/null +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -0,0 +1,205 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { set } from 'Store/Actions/baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'oAuth'; +const callbackUrl = `${window.location.origin}${window.Sonarr.urlBase}/oauth.html`; + +// +// State + +export const defaultState = { + authorizing: false, + result: null, + error: null +}; + +// +// Actions Types + +export const START_OAUTH = 'oAuth/startOAuth'; +export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue'; +export const RESET_OAUTH = 'oAuth/resetOAuth'; + +// +// Action Creators + +export const startOAuth = createThunk(START_OAUTH); +export const setOAuthValue = createAction(SET_OAUTH_VALUE); +export const resetOAuth = createAction(RESET_OAUTH); + +// +// Helpers + +function showOAuthWindow(url, payload) { + const deferred = $.Deferred(); + const selfWindow = window; + + const newWindow = window.open(url); + + if ( + !newWindow || + newWindow.closed || + typeof newWindow.closed == 'undefined' + ) { + + // A fake validation error to mimic a 400 response from the API. + const error = { + status: 400, + responseJSON: [ + { + propertyName: payload.name, + errorMessage: 'Pop-ups are being blocked by your browser' + } + ] + }; + + return deferred.reject(error).promise(); + } + + selfWindow.onCompleteOauth = function(query, onComplete) { + delete selfWindow.onCompleteOauth; + + const queryParams = {}; + const splitQuery = query.substring(1).split('&'); + + splitQuery.forEach((param) => { + if (param) { + const paramSplit = param.split('='); + + queryParams[paramSplit[0]] = paramSplit[1]; + } + }); + + onComplete(); + deferred.resolve(queryParams); + }; + + return deferred.promise(); +} + +function executeIntermediateRequest(payload, ajaxOptions) { + return $.ajax(ajaxOptions).then((data) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl + }, + ...payload + }); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [START_OAUTH]: function(getState, payload, dispatch) { + const { + name, + section: actionSection, + ...otherPayload + } = payload; + + const actionPayload = { + action: 'startOAuth', + queryParams: { callbackUrl }, + ...otherPayload + }; + + dispatch(setOAuthValue({ + authorizing: true + })); + + let startResponse = {}; + + const promise = requestAction(actionPayload) + .then((response) => { + startResponse = response; + + if (response.oauthUrl) { + return showOAuthWindow(response.oauthUrl, payload); + } + + return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => { + startResponse = intermediateResponse; + + return showOAuthWindow(intermediateResponse.oauthUrl, payload); + }); + }) + .then((queryParams) => { + return requestAction({ + action: 'getOAuthToken', + queryParams: { + ...startResponse, + ...queryParams + }, + ...otherPayload + }); + }) + .then((response) => { + dispatch(setOAuthValue({ + authorizing: false, + result: response, + error: null + })); + }); + + promise.done(() => { + // Clear any previously set save error. + dispatch(set({ + section: actionSection, + saveError: null + })); + }); + + promise.fail((xhr) => { + const actions = [ + setOAuthValue({ + authorizing: false, + result: null, + error: xhr + }) + ]; + + if (xhr.status === 400) { + // Set a save error so the UI can display validation errors to the user. + actions.splice(0, 0, set({ + section: actionSection, + saveError: xhr + })); + } + + dispatch(batchActions(actions)); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_OAUTH_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_OAUTH]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js new file mode 100644 index 000000000..78f943f32 --- /dev/null +++ b/frontend/src/Store/Actions/organizePreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'organizePreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview'; +export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview'; + +// +// Action Creators + +export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW); +export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ORGANIZE_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js new file mode 100644 index 000000000..129a9cb4d --- /dev/null +++ b/frontend/src/Store/Actions/pathActions.js @@ -0,0 +1,110 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'paths'; + +// +// State + +export const defaultState = { + currentPath: '', + isPopulated: false, + isFetching: false, + error: null, + directories: [], + files: [], + parent: null +}; + +// +// Actions Types + +export const FETCH_PATHS = 'paths/fetchPaths'; +export const UPDATE_PATHS = 'paths/updatePaths'; +export const CLEAR_PATHS = 'paths/clearPaths'; + +// +// Action Creators + +export const fetchPaths = createThunk(FETCH_PATHS); +export const updatePaths = createAction(UPDATE_PATHS); +export const clearPaths = createAction(CLEAR_PATHS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_PATHS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + path, + allowFoldersWithoutTrailingSlashes = false + } = payload; + + const promise = $.ajax({ + url: '/filesystem', + data: { + path, + allowFoldersWithoutTrailingSlashes + } + }); + + promise.done((data) => { + dispatch(updatePaths({ path, ...data })); + + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.currentPath = payload.path; + newState.directories = payload.directories; + newState.files = payload.files; + newState.parent = payload.parent; + + return newState; + }, + + [CLEAR_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.path = ''; + newState.directories = []; + newState.files = []; + newState.parent = ''; + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js new file mode 100644 index 000000000..bb6b3c1f2 --- /dev/null +++ b/frontend/src/Store/Actions/queueActions.js @@ -0,0 +1,438 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'queue'; +const status = `${section}.status`; +const details = `${section}.details`; +const paged = `${section}.paged`; + +// +// State + +export const defaultState = { + options: { + includeUnknownSeriesItems: false + }, + + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + params: {} + }, + + paged: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'timeleft', + sortDirection: sortDirections.ASCENDING, + error: null, + items: [], + isGrabbing: false, + isRemoving: false, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isVisible: true, + isModifiable: false + }, + { + name: 'series.sortTitle', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isSortable: true, + isVisible: true + }, + { + name: 'episode.title', + label: 'Episode Title', + isVisible: true + }, + { + name: 'episode.airDateUtc', + label: 'Episode Air Date', + isSortable: true, + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'protocol', + label: 'Protocol', + isSortable: true, + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'downloadClient', + label: 'Download Client', + isSortable: true, + isVisible: false + }, + { + name: 'estimatedCompletionTime', + label: 'Timeleft', + isSortable: true, + isVisible: true + }, + { + name: 'progress', + label: 'Progress', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] + } +}; + +export const persistState = [ + 'queue.options', + 'queue.paged.pageSize', + 'queue.paged.sortKey', + 'queue.paged.sortDirection', + 'queue.paged.columns' +]; + +// +// Helpers + +function fetchDataAugmenter(getState, payload, data) { + data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems; +} + +// +// Actions Types + +export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus'; + +export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails'; +export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails'; + +export const FETCH_QUEUE = 'queue/fetchQueue'; +export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage'; +export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage'; +export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage'; +export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; +export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; +export const SET_QUEUE_SORT = 'queue/setQueueSort'; +export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; +export const SET_QUEUE_OPTION = 'queue/setQueueOption'; +export const CLEAR_QUEUE = 'queue/clearQueue'; + +export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem'; +export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems'; +export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem'; +export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems'; + +// +// Action Creators + +export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS); + +export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS); +export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS); + +export const fetchQueue = createThunk(FETCH_QUEUE); +export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE); +export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE); +export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE); +export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); +export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); +export const setQueueSort = createThunk(SET_QUEUE_SORT); +export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); +export const setQueueOption = createAction(SET_QUEUE_OPTION); +export const clearQueue = createAction(CLEAR_QUEUE); + +export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM); +export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS); +export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM); +export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS); + +// +// Helpers + +const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'), + + [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) { + let params = payload; + + // If the payload params are empty try to get params from state. + + if (params && !_.isEmpty(params)) { + dispatch(set({ section: details, params })); + } else { + params = getState().queue.details.params; + } + + // Ensure there are params before trying to fetch the queue + // so we don't make a bad request to the server. + + if (params && !_.isEmpty(params)) { + fetchQueueDetailsHelper(getState, params, dispatch); + } + }, + + ...createServerSideCollectionHandlers( + paged, + '/queue', + fetchQueue, + { + [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, + [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT + }, + fetchDataAugmenter + ), + + [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ section: paged, id, isGrabbing: true })); + + const promise = $.ajax({ + url: `/queue/grab/${id}`, + method: 'POST' + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: xhr + })); + }); + }, + + [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const ids = payload.ids; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: true + }); + }), + + set({ + section: paged, + isGrabbing: true + }) + ])); + + const promise = $.ajax({ + url: '/queue/grab/bulk', + method: 'POST', + dataType: 'json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ section: paged, isGrabbing: false }) + ])); + }); + }, + + [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) { + const { + id, + blacklist + } = payload; + + dispatch(updateItem({ section: paged, id, isRemoving: true })); + + const promise = $.ajax({ + url: `/queue/${id}?blacklist=${blacklist}`, + method: 'DELETE' + }); + + promise.done((data) => { + dispatch(fetchQueue()); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ section: paged, id, isRemoving: false })); + }); + }, + + [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const { + ids, + blacklist + } = payload; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: true + }); + }), + + set({ section: paged, isRemoving: true }) + ])); + + const promise = $.ajax({ + url: `/queue/bulk?blacklist=${blacklist}`, + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ ids }) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ section: paged, isRemoving: false }), + fetchQueue() + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: false + }); + }), + + set({ section: paged, isRemoving: false }) + ])); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details), + + [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged), + + [SET_QUEUE_OPTION]: function(state, { payload }) { + const queueOptions = state.options; + + return { + ...state, + options: { + ...queueOptions, + ...payload + } + }; + }, + + [CLEAR_QUEUE]: createClearReducer(paged, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/reducers.js b/frontend/src/Store/Actions/reducers.js new file mode 100644 index 000000000..0254cd226 --- /dev/null +++ b/frontend/src/Store/Actions/reducers.js @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux'; +import { enableBatching } from 'redux-batched-actions'; +import { routerReducer } from 'react-router-redux'; +import actions from 'Store/Actions'; + +const defaultState = {}; + +const reducers = { + routing: routerReducer +}; + +actions.forEach((action) => { + const section = action.section; + + defaultState[section] = action.defaultState; + reducers[section] = action.reducers; +}); + +export { defaultState }; +export default enableBatching(combineReducers(reducers)); diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js new file mode 100644 index 000000000..1ea4fa6b3 --- /dev/null +++ b/frontend/src/Store/Actions/releaseActions.js @@ -0,0 +1,280 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'releases'; +export const episodeSection = 'releases.episode'; +export const seasonSection = 'releases.season'; + +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'releaseWeight', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + peers: function(item, direction) { + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + + return seeders * 1000000 + leechers; + }, + + rejections: function(item, direction) { + const rejections = item.rejections; + const releaseWeight = item.releaseWeight; + + if (rejections.length !== 0) { + return releaseWeight + 1000000; + } + + return releaseWeight; + } + }, + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'season-pack', + label: 'Season Pack', + filters: [ + { + key: 'fullSeason', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'not-season-pack', + label: 'Not Season Pack', + filters: [ + { + key: 'fullSeason', + value: false, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + quality: function(item, value, type) { + const qualityId = item.quality.quality.id; + + if (type === filterTypes.EQUAL) { + return qualityId === value; + } + + if (type === filterTypes.NOT_EQUAL) { + return qualityId !== value; + } + + // Default to false + return false; + } + }, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'age', + label: 'Age', + type: filterBuilderTypes.NUMBER + }, + { + name: 'protocol', + label: 'Protocol', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'indexerId', + label: 'Indexer', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'size', + label: 'Size', + type: filterBuilderTypes.NUMBER + }, + { + name: 'seeders', + label: 'Seeders', + type: filterBuilderTypes.NUMBER + }, + { + name: 'peers', + label: 'Peers', + type: filterBuilderTypes.NUMBER + }, + { + name: 'quality', + label: 'Quality', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'rejections', + label: 'Rejections', + type: filterBuilderTypes.NUMBER + } + ], + + episode: { + selectedFilterKey: 'all' + }, + + season: { + selectedFilterKey: 'season-pack' + } +}; + +export const persistState = [ + 'releases.selectedFilterKey', + 'releases.episode.customFilters', + 'releases.season.customFilters' +]; + +// +// Actions Types + +export const FETCH_RELEASES = 'releases/fetchReleases'; +export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases'; +export const SET_RELEASES_SORT = 'releases/setReleasesSort'; +export const CLEAR_RELEASES = 'releases/clearReleases'; +export const GRAB_RELEASE = 'releases/grabRelease'; +export const UPDATE_RELEASE = 'releases/updateRelease'; +export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter'; +export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter'; + +// +// Action Creators + +export const fetchReleases = createThunk(FETCH_RELEASES); +export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES); +export const setReleasesSort = createAction(SET_RELEASES_SORT); +export const clearReleases = createAction(CLEAR_RELEASES); +export const grabRelease = createThunk(GRAB_RELEASE); +export const updateRelease = createAction(UPDATE_RELEASE); +export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER); +export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER); + +// +// Helpers + +const fetchReleasesHelper = createFetchHandler(section, '/release'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RELEASES]: function(getState, payload, dispatch) { + const abortRequest = fetchReleasesHelper(getState, payload, dispatch); + + abortCurrentRequest = abortRequest; + }, + + [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) { + if (abortCurrentRequest) { + abortCurrentRequest = abortCurrentRequest(); + } + }, + + [GRAB_RELEASE]: function(getState, payload, dispatch) { + const guid = payload.guid; + + dispatch(updateRelease({ guid, isGrabbing: true })); + + const promise = $.ajax({ + url: '/release', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: true, + grabError: null + })); + }); + + promise.fail((xhr) => { + const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue'; + + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: false, + grabError + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RELEASES]: (state) => { + const { + episode, + season, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + }, + + [UPDATE_RELEASE]: (state, { payload }) => { + const guid = payload.guid; + const newState = Object.assign({}, state); + const items = newState.items; + + // Return early if there aren't any items (the user closed the modal) + if (!items.length) { + return; + } + + const index = items.findIndex((item) => item.guid === guid); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection), + [SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js new file mode 100644 index 000000000..8180cdc7d --- /dev/null +++ b/frontend/src/Store/Actions/rootFolderActions.js @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'rootFolders'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; +export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; +export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; + +// +// Action Creators + +export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); +export const addRootFolder = createThunk(ADD_ROOT_FOLDER); +export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), + + [DELETE_ROOT_FOLDER]: createRemoveItemHandler( + 'rootFolders', + '/rootFolder', + (state) => state.rootFolders + ), + + [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { + const path = payload.path; + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/rootFolder', + method: 'POST', + data: JSON.stringify({ path }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ + section, + ...data + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/seasonPassActions.js b/frontend/src/Store/Actions/seasonPassActions.js new file mode 100644 index 000000000..d2040136d --- /dev/null +++ b/frontend/src/Store/Actions/seasonPassActions.js @@ -0,0 +1,164 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; +import { fetchSeries, filters, filterPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seasonPass'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'seriesType', + label: 'Series Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'seasonPass.sortKey', + 'seasonPass.sortDirection', + 'seasonPass.selectedFilterKey', + 'seasonPass.customFilters' +]; + +// +// Actions Types + +export const SET_SEASON_PASS_SORT = 'seasonPass/setSeasonPassSort'; +export const SET_SEASON_PASS_FILTER = 'seasonPass/setSeasonPassFilter'; +export const SAVE_SEASON_PASS = 'seasonPass/saveSeasonPass'; + +// +// Action Creators + +export const setSeasonPassSort = createAction(SET_SEASON_PASS_SORT); +export const setSeasonPassFilter = createAction(SET_SEASON_PASS_FILTER); +export const saveSeasonPass = createThunk(SAVE_SEASON_PASS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [SAVE_SEASON_PASS]: function(getState, payload, dispatch) { + const { + seriesIds, + monitored, + monitor + } = payload; + + const series = []; + + seriesIds.forEach((id) => { + const seriesToUpdate = { id }; + + if (payload.hasOwnProperty('monitored')) { + seriesToUpdate.monitored = monitored; + } + + series.push(seriesToUpdate); + }); + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/seasonPass', + method: 'POST', + data: JSON.stringify({ + series, + monitoringOptions: { monitor } + }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(fetchSeries()); + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SEASON_PASS_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SEASON_PASS_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js new file mode 100644 index 000000000..b3e28cf44 --- /dev/null +++ b/frontend/src/Store/Actions/seriesActions.js @@ -0,0 +1,379 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Local + +const MONITOR_TIMEOUT = 1000; +const seasonsToUpdate = {}; +let seasonMonitorToggleTimeout = null; + +// +// Variables + +export const section = 'series'; + +export const filters = [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'continuing', + label: 'Continuing Only', + filters: [ + { + key: 'status', + value: 'continuing', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'ended', + label: 'Ended Only', + filters: [ + { + key: 'status', + value: 'ended', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'missing', + label: 'Missing Episodes', + filters: [ + { + key: 'missing', + value: true, + type: filterTypes.EQUAL + } + ] + } +]; + +export const filterPredicates = { + missing: function(item) { + const { statistics = {} } = item; + + return statistics.episodeCount - statistics.episodeFileCount > 0; + }, + + nextAiring: function(item, filterValue, type) { + return dateFilterPredicate(item.nextAiring, filterValue, type); + }, + + previousAiring: function(item, filterValue, type) { + return dateFilterPredicate(item.previousAiring, filterValue, type); + }, + + added: function(item, filterValue, type) { + return dateFilterPredicate(item.added, filterValue, type); + }, + + ratings: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.ratings.value * 10, filterValue); + }, + + seasonCount: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const seasonCount = item.statistics ? item.statistics.seasonCount : 0; + + return predicate(seasonCount, filterValue); + }, + + sizeOnDisk: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0; + + return predicate(sizeOnDisk, filterValue); + } +}; + +export const sortPredicates = { + status: function(item) { + let result = 0; + + if (item.monitored) { + result += 2; + } + + if (item.status === 'continuing') { + result++; + } + + return result; + } +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_SERIES = 'series/fetchSeries'; +export const SET_SERIES_VALUE = 'series/setSeriesValue'; +export const SAVE_SERIES = 'series/saveSeries'; +export const DELETE_SERIES = 'series/deleteSeries'; + +export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored'; +export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored'; + +// +// Action Creators + +export const fetchSeries = createThunk(FETCH_SERIES); +export const saveSeries = createThunk(SAVE_SERIES, (payload) => { + const newPayload = { + ...payload + }; + + if (payload.moveFiles) { + newPayload.queryParams = { + moveFiles: true + }; + } + + delete newPayload.moveFiles; + + return newPayload; +}); + +export const deleteSeries = createThunk(DELETE_SERIES, (payload) => { + return { + ...payload, + queryParams: { + deleteFiles: payload.deleteFiles + } + }; +}); + +export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED); +export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED); + +export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => { + return { + section: 'series', + ...payload + }; +}); + +// +// Helpers + +function getSaveAjaxOptions({ ajaxOptions, payload }) { + if (payload.moveFolder) { + ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`; + } + + return ajaxOptions; +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_SERIES]: createFetchHandler(section, '/series'), + [SAVE_SERIES]: createSaveProviderHandler(section, '/series', { getAjaxOptions: getSaveAjaxOptions }), + [DELETE_SERIES]: createRemoveItemHandler(section, '/series'), + + [TOGGLE_SERIES_MONITORED]: (getState, payload, dispatch) => { + const { + seriesId: id, + monitored + } = payload; + + const series = _.find(getState().series.items, { id }); + + dispatch(updateItem({ + id, + section, + isSaving: true + })); + + const promise = $.ajax({ + url: `/series/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...series, + monitored + }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(updateItem({ + id, + section, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + isSaving: false + })); + }); + }, + + [TOGGLE_SEASON_MONITORED]: function(getState, payload, dispatch) { + if (seasonMonitorToggleTimeout) { + seasonMonitorToggleTimeout = clearTimeout(seasonMonitorToggleTimeout); + } + + const { + seriesId: id, + seasonNumber, + monitored + } = payload; + + const series = getState().series.items.find((s) => s.id === id); + const seasons = _.cloneDeep(series.seasons); + const season = seasons.find((s) => s.seasonNumber === seasonNumber); + + season.isSaving = true; + + dispatch(updateItem({ + id, + section, + seasons + })); + + seasonsToUpdate[seasonNumber] = monitored; + season.monitored = monitored; + + seasonMonitorToggleTimeout = setTimeout(() => { + $.ajax({ + url: `/series/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...series, + seasons + }), + dataType: 'json' + }).then( + (data) => { + const changedSeasons = []; + + data.seasons.forEach((s) => { + if (seasonsToUpdate.hasOwnProperty(s.seasonNumber)) { + if (s.monitored === seasonsToUpdate[s.seasonNumber]) { + changedSeasons.push(s); + } else { + s.isSaving = true; + } + } + }); + + const episodesToUpdate = getState().episodes.items.reduce((acc, episode) => { + if (episode.seriesId !== data.id) { + return acc; + } + + const changedSeason = changedSeasons.find((s) => s.seasonNumber === episode.seasonNumber); + + if (!changedSeason) { + return acc; + } + + acc.push(updateItem({ + id: episode.id, + section: 'episodes', + monitored: changedSeason.monitored + })); + + return acc; + }, []); + + dispatch(batchActions([ + updateItem({ + id, + section, + ...data + }), + + ...episodesToUpdate + ])); + + changedSeasons.forEach((s) => { + delete seasonsToUpdate[s.seasonNumber]; + }); + }, + (xhr) => { + dispatch(updateItem({ + id, + section, + seasons: series.seasons + })); + + Object.keys(seasonsToUpdate).forEach((s) => { + delete seasonsToUpdate[s]; + }); + }); + }, MONITOR_TIMEOUT); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_VALUE]: createSetSettingValueReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/seriesEditorActions.js b/frontend/src/Store/Actions/seriesEditorActions.js new file mode 100644 index 000000000..cbfd78094 --- /dev/null +++ b/frontend/src/Store/Actions/seriesEditorActions.js @@ -0,0 +1,190 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, updateItem } from './baseActions'; +import { filters, filterPredicates, sortPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seriesEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'seriesType', + label: 'Series Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ], + + sortPredicates +}; + +export const persistState = [ + 'seriesEditor.sortKey', + 'seriesEditor.sortDirection', + 'seriesEditor.selectedFilterKey', + 'seriesEditor.customFilters' +]; + +// +// Actions Types + +export const SET_SERIES_EDITOR_SORT = 'seriesEditor/setSeriesEditorSort'; +export const SET_SERIES_EDITOR_FILTER = 'seriesEditor/setSeriesEditorFilter'; +export const SAVE_SERIES_EDITOR = 'seriesEditor/saveSeriesEditor'; +export const BULK_DELETE_SERIES = 'seriesEditor/bulkDeleteSeries'; +// +// Action Creators + +export const setSeriesEditorSort = createAction(SET_SERIES_EDITOR_SORT); +export const setSeriesEditorFilter = createAction(SET_SERIES_EDITOR_FILTER); +export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR); +export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES); +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((series) => { + return updateItem({ + id: series.id, + section: 'series', + ...series + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done(() => { + // SignaR will take care of removing the series from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/seriesHistoryActions.js b/frontend/src/Store/Actions/seriesHistoryActions.js new file mode 100644 index 000000000..348853441 --- /dev/null +++ b/frontend/src/Store/Actions/seriesHistoryActions.js @@ -0,0 +1,104 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'seriesHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_SERIES_HISTORY = 'seriesHistory/fetchSeriesHistory'; +export const CLEAR_SERIES_HISTORY = 'seriesHistory/clearSeriesHistory'; +export const SERIES_HISTORY_MARK_AS_FAILED = 'seriesHistory/seriesHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchSeriesHistory = createThunk(FETCH_SERIES_HISTORY); +export const clearSeriesHistory = createAction(CLEAR_SERIES_HISTORY); +export const seriesHistoryMarkAsFailed = createThunk(SERIES_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_SERIES_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/history/series', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SERIES_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + seriesId, + seasonNumber + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchSeriesHistory({ seriesId, seasonNumber })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_SERIES_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/seriesIndexActions.js b/frontend/src/Store/Actions/seriesIndexActions.js new file mode 100644 index 000000000..4ba268e73 --- /dev/null +++ b/frontend/src/Store/Actions/seriesIndexActions.js @@ -0,0 +1,427 @@ +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { filters, filterPredicates, sortPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seriesIndex'; + +// +// State + +export const defaultState = { + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + view: 'posters', + + posterOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: false, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showMonitored: true, + showNetwork: true, + showQualityProfile: true, + showPreviousAiring: false, + showAdded: false, + showSeasonCount: true, + showPath: false, + showSizeOnDisk: false, + showSearchAction: false + }, + + tableOptions: { + showBanners: false, + showSearchAction: false + }, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'network', + label: 'Network', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'languageProfileId', + label: 'Language Profile', + isSortable: true, + isVisible: false + }, + { + name: 'nextAiring', + label: 'Next Airing', + isSortable: true, + isVisible: true + }, + { + name: 'previousAiring', + label: 'Previous Airing', + isSortable: true, + isVisible: false + }, + { + name: 'added', + label: 'Added', + isSortable: true, + isVisible: false + }, + { + name: 'seasonCount', + label: 'Seasons', + isSortable: true, + isVisible: true + }, + { + name: 'episodeProgress', + label: 'Episodes', + isSortable: true, + isVisible: true + }, + { + name: 'episodeCount', + label: 'Episode Count', + isSortable: true, + isVisible: false + }, + { + name: 'latestSeason', + label: 'Latest Season', + isSortable: true, + isVisible: false + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: false + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + isSortable: true, + isVisible: false + }, + { + name: 'genres', + label: 'Genres', + isSortable: false, + isVisible: false + }, + { + name: 'ratings', + label: 'Rating', + isSortable: true, + isVisible: false + }, + { + name: 'certification', + label: 'Certification', + isSortable: false, + isVisible: false + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: false + }, + { + name: 'useSceneNumbering', + label: 'Scene Numbering', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + network: function(item) { + const network = item.network; + + return network ? network.toLowerCase() : ''; + }, + + nextAiring: function(item, direction) { + const nextAiring = item.nextAiring; + + if (nextAiring) { + return moment(nextAiring).unix(); + } + + if (direction === sortDirections.DESCENDING) { + return 0; + } + + return Number.MAX_VALUE; + }, + + episodeProgress: function(item) { + const { statistics = {} } = item; + + const { + episodeCount = 0, + episodeFileCount + } = statistics; + + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + + return progress + episodeCount / 1000000; + }, + + episodeCount: function(item) { + const { statistics = {} } = item; + + return statistics.episodeCount; + }, + + seasonCount: function(item) { + const { statistics = {} } = item; + + return statistics.seasonCount; + }, + + sizeOnDisk: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'network', + label: 'Network', + type: filterBuilderTypes.STRING + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'nextAiring', + label: 'Next Airing', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'previousAiring', + label: 'Previous Airing', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'added', + label: 'Added', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'seasonCount', + label: 'Season Count', + type: filterBuilderTypes.NUMBER + }, + { + name: 'episodeProgress', + label: 'Episode Progress', + type: filterBuilderTypes.NUMBER + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES + }, + { + name: 'genres', + label: 'Genres', + type: filterBuilderTypes.ARRAY, + optionsSelector: function(items) { + const tagList = items.reduce((acc, series) => { + series.genres.forEach((genre) => { + acc.push({ + id: genre, + name: genre + }); + }); + + return acc; + }, []); + + return tagList.sort(sortByName); + } + }, + { + name: 'ratings', + label: 'Rating', + type: filterBuilderTypes.NUMBER + }, + { + name: 'certification', + label: 'Certification', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + }, + { + name: 'useSceneNumbering', + label: 'Scene Numbering', + type: filterBuilderTypes.EXACT + } + ] +}; + +export const persistState = [ + 'seriesIndex.sortKey', + 'seriesIndex.sortDirection', + 'seriesIndex.selectedFilterKey', + 'seriesIndex.customFilters', + 'seriesIndex.view', + 'seriesIndex.columns', + 'seriesIndex.posterOptions', + 'seriesIndex.overviewOptions', + 'seriesIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_SERIES_SORT = 'seriesIndex/setSeriesSort'; +export const SET_SERIES_FILTER = 'seriesIndex/setSeriesFilter'; +export const SET_SERIES_VIEW = 'seriesIndex/setSeriesView'; +export const SET_SERIES_TABLE_OPTION = 'seriesIndex/setSeriesTableOption'; +export const SET_SERIES_POSTER_OPTION = 'seriesIndex/setSeriesPosterOption'; +export const SET_SERIES_OVERVIEW_OPTION = 'seriesIndex/setSeriesOverviewOption'; + +// +// Action Creators + +export const setSeriesSort = createAction(SET_SERIES_SORT); +export const setSeriesFilter = createAction(SET_SERIES_FILTER); +export const setSeriesView = createAction(SET_SERIES_VIEW); +export const setSeriesTableOption = createAction(SET_SERIES_TABLE_OPTION); +export const setSeriesPosterOption = createAction(SET_SERIES_POSTER_OPTION); +export const setSeriesOverviewOption = createAction(SET_SERIES_OVERVIEW_OPTION); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SERIES_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_SERIES_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_SERIES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_SERIES_POSTER_OPTION]: function(state, { payload }) { + const posterOptions = state.posterOptions; + + return { + ...state, + posterOptions: { + ...posterOptions, + ...payload + } + }; + }, + + [SET_SERIES_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js new file mode 100644 index 000000000..b8640dead --- /dev/null +++ b/frontend/src/Store/Actions/settingsActions.js @@ -0,0 +1,134 @@ +import { createAction } from 'redux-actions'; +import { handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import delayProfiles from './Settings/delayProfiles'; +import downloadClients from './Settings/downloadClients'; +import downloadClientOptions from './Settings/downloadClientOptions'; +import general from './Settings/general'; +import indexerOptions from './Settings/indexerOptions'; +import indexers from './Settings/indexers'; +import languageProfiles from './Settings/languageProfiles'; +import mediaManagement from './Settings/mediaManagement'; +import metadata from './Settings/metadata'; +import naming from './Settings/naming'; +import namingExamples from './Settings/namingExamples'; +import notifications from './Settings/notifications'; +import qualityDefinitions from './Settings/qualityDefinitions'; +import qualityProfiles from './Settings/qualityProfiles'; +import releaseProfiles from './Settings/releaseProfiles'; +import remotePathMappings from './Settings/remotePathMappings'; +import ui from './Settings/ui'; + +export * from './Settings/delayProfiles'; +export * from './Settings/downloadClients'; +export * from './Settings/downloadClientOptions'; +export * from './Settings/general'; +export * from './Settings/indexerOptions'; +export * from './Settings/indexers'; +export * from './Settings/languageProfiles'; +export * from './Settings/mediaManagement'; +export * from './Settings/metadata'; +export * from './Settings/naming'; +export * from './Settings/namingExamples'; +export * from './Settings/notifications'; +export * from './Settings/qualityDefinitions'; +export * from './Settings/qualityProfiles'; +export * from './Settings/releaseProfiles'; +export * from './Settings/remotePathMappings'; +export * from './Settings/ui'; + +// +// Variables + +export const section = 'settings'; + +// +// State + +export const defaultState = { + advancedSettings: false, + + delayProfiles: delayProfiles.defaultState, + downloadClients: downloadClients.defaultState, + downloadClientOptions: downloadClientOptions.defaultState, + general: general.defaultState, + indexerOptions: indexerOptions.defaultState, + indexers: indexers.defaultState, + languageProfiles: languageProfiles.defaultState, + mediaManagement: mediaManagement.defaultState, + metadata: metadata.defaultState, + naming: naming.defaultState, + namingExamples: namingExamples.defaultState, + notifications: notifications.defaultState, + qualityDefinitions: qualityDefinitions.defaultState, + qualityProfiles: qualityProfiles.defaultState, + releaseProfiles: releaseProfiles.defaultState, + remotePathMappings: remotePathMappings.defaultState, + ui: ui.defaultState +}; + +export const persistState = [ + 'settings.advancedSettings' +]; + +// +// Actions Types + +export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings'; + +// +// Action Creators + +export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...delayProfiles.actionHandlers, + ...downloadClients.actionHandlers, + ...downloadClientOptions.actionHandlers, + ...general.actionHandlers, + ...indexerOptions.actionHandlers, + ...indexers.actionHandlers, + ...languageProfiles.actionHandlers, + ...mediaManagement.actionHandlers, + ...metadata.actionHandlers, + ...naming.actionHandlers, + ...namingExamples.actionHandlers, + ...notifications.actionHandlers, + ...qualityDefinitions.actionHandlers, + ...qualityProfiles.actionHandlers, + ...releaseProfiles.actionHandlers, + ...remotePathMappings.actionHandlers, + ...ui.actionHandlers +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => { + return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); + }, + + ...delayProfiles.reducers, + ...downloadClients.reducers, + ...downloadClientOptions.reducers, + ...general.reducers, + ...indexerOptions.reducers, + ...indexers.reducers, + ...languageProfiles.reducers, + ...mediaManagement.reducers, + ...metadata.reducers, + ...naming.reducers, + ...namingExamples.reducers, + ...notifications.reducers, + ...qualityDefinitions.reducers, + ...qualityProfiles.reducers, + ...releaseProfiles.reducers, + ...remotePathMappings.reducers, + ...ui.reducers + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js new file mode 100644 index 000000000..de156cde2 --- /dev/null +++ b/frontend/src/Store/Actions/systemActions.js @@ -0,0 +1,375 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { setAppValue } from 'Store/Actions/appActions'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'system'; +const backupsSection = 'system.backups'; + +// +// State + +export const defaultState = { + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + health: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + diskSpace: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + tasks: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + backups: { + isFetching: false, + isPopulated: false, + error: null, + isRestoring: false, + restoreError: null, + isDeleting: false, + deleteError: null, + items: [] + }, + + updates: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + logs: { + isFetching: false, + isPopulated: false, + pageSize: 50, + sortKey: 'time', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'level', + isSortable: true, + isVisible: true + }, + { + name: 'logger', + label: 'Component', + isSortable: true, + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isSortable: true, + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'info', + label: 'Info', + filters: [ + { + key: 'level', + value: 'info', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'warn', + label: 'Warn', + filters: [ + { + key: 'level', + value: 'warn', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'error', + label: 'Error', + filters: [ + { + key: 'level', + value: 'error', + type: filterTypes.EQUAL + } + ] + } + ] + }, + + logFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + updateLogFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +export const persistState = [ + 'system.logs.pageSize', + 'system.logs.sortKey', + 'system.logs.sortDirection', + 'system.logs.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_STATUS = 'system/status/fetchStatus'; +export const FETCH_HEALTH = 'system/health/fetchHealth'; +export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace'; + +export const FETCH_TASK = 'system/tasks/fetchTask'; +export const FETCH_TASKS = 'system/tasks/fetchTasks'; + +export const FETCH_BACKUPS = 'system/backups/fetchBackups'; +export const RESTORE_BACKUP = 'system/backups/restoreBackup'; +export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; +export const DELETE_BACKUP = 'system/backups/deleteBackup'; + +export const FETCH_UPDATES = 'system/updates/fetchUpdates'; + +export const FETCH_LOGS = 'system/logs/fetchLogs'; +export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage'; +export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage'; +export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage'; +export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage'; +export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage'; +export const SET_LOGS_SORT = 'system/logs/setLogsSort'; +export const SET_LOGS_FILTER = 'system/logs/setLogsFilter'; +export const SET_LOGS_TABLE_OPTION = 'system/logs/ssetLogsTableOption'; + +export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles'; +export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles'; + +export const RESTART = 'system/restart'; +export const SHUTDOWN = 'system/shutdown'; + +// +// Action Creators + +export const fetchStatus = createThunk(FETCH_STATUS); +export const fetchHealth = createThunk(FETCH_HEALTH); +export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE); + +export const fetchTask = createThunk(FETCH_TASK); +export const fetchTasks = createThunk(FETCH_TASKS); + +export const fetchBackups = createThunk(FETCH_BACKUPS); +export const restoreBackup = createThunk(RESTORE_BACKUP); +export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); +export const deleteBackup = createThunk(DELETE_BACKUP); + +export const fetchUpdates = createThunk(FETCH_UPDATES); + +export const fetchLogs = createThunk(FETCH_LOGS); +export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE); +export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE); +export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE); +export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE); +export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE); +export const setLogsSort = createThunk(SET_LOGS_SORT); +export const setLogsFilter = createThunk(SET_LOGS_FILTER); +export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION); + +export const fetchLogFiles = createThunk(FETCH_LOG_FILES); +export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES); + +export const restart = createThunk(RESTART); +export const shutdown = createThunk(SHUTDOWN); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'), + [FETCH_HEALTH]: createFetchHandler('system.health', '/health'), + [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'), + [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'), + [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'), + + [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'), + + [RESTORE_BACKUP]: function(getState, payload, dispatch) { + const { + id, + file + } = payload; + + dispatch(set({ + section: backupsSection, + isRestoring: true + })); + + let ajaxOptions = null; + + if (id) { + ajaxOptions = { + url: `/system/backup/restore/${id}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + id + }) + }; + } else if (file) { + const formData = new FormData(); + formData.append('restore', file); + + ajaxOptions = { + url: '/system/backup/restore/upload', + method: 'POST', + processData: false, + contentType: false, + data: formData + }; + } else { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: 'Error restoring backup' + })); + } + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: xhr + })); + }); + }, + + [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), + + [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'), + [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'), + [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'), + + ...createServerSideCollectionHandlers( + 'system.logs', + '/log', + fetchLogs, + { + [serverSideCollectionHandlers.FETCH]: FETCH_LOGS, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE, + [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT, + [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER + } + ), + + [RESTART]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/system/restart', + method: 'POST' + }); + + promise.done(() => { + dispatch(setAppValue({ isRestarting: true })); + }); + }, + + [SHUTDOWN]: function() { + $.ajax({ + url: '/system/shutdown', + method: 'POST' + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RESTORE_BACKUP]: function(state, { payload }) { + return { + ...state, + backups: { + ...state.backups, + isRestoring: false, + restoreError: null + } + }; + }, + + [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs') + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js new file mode 100644 index 000000000..b9d217bd3 --- /dev/null +++ b/frontend/src/Store/Actions/tagActions.js @@ -0,0 +1,75 @@ +import $ from 'jquery'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { update } from './baseActions'; + +// +// Variables + +export const section = 'tags'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +// +// Actions Types + +export const FETCH_TAGS = 'tags/fetchTags'; +export const ADD_TAG = 'tags/addTag'; +export const DELETE_TAG = 'tags/deleteTag'; +export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails'; + +// +// Action Creators + +export const fetchTags = createThunk(FETCH_TAGS); +export const addTag = createThunk(ADD_TAG); +export const deleteTag = createThunk(DELETE_TAG); +export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TAGS]: createFetchHandler(section, '/tag'), + + [ADD_TAG]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/tag', + method: 'POST', + data: JSON.stringify(payload.tag) + }); + + promise.done((data) => { + const tags = getState().tags.items.slice(); + tags.push(data); + + dispatch(update({ section, data: tags })); + payload.onTagCreated(data); + }); + }, + + [DELETE_TAG]: createRemoveItemHandler(section, '/tag'), + [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js new file mode 100644 index 000000000..3d1d19cc5 --- /dev/null +++ b/frontend/src/Store/Actions/wantedActions.js @@ -0,0 +1,317 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createBatchToggleEpisodeMonitoredHandler from './Creators/createBatchToggleEpisodeMonitoredHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'wanted'; + +// +// State + +export const defaultState = { + missing: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'airDateUtc', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isSortable: true, + isVisible: true + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + }, + + cutoffUnmet: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'airDateUtc', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + } +}; + +export const persistState = [ + 'wanted.missing.pageSize', + 'wanted.missing.sortKey', + 'wanted.missing.sortDirection', + 'wanted.missing.selectedFilterKey', + 'wanted.missing.columns', + 'wanted.cutoffUnmet.pageSize', + 'wanted.cutoffUnmet.sortKey', + 'wanted.cutoffUnmet.sortDirection', + 'wanted.cutoffUnmet.selectedFilterKey', + 'wanted.cutoffUnmet.columns' +]; + +// +// Actions Types + +export const FETCH_MISSING = 'wanted/missing/fetchMissing'; +export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage'; +export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage'; +export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage'; +export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage'; +export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage'; +export const SET_MISSING_SORT = 'wanted/missing/setMissingSort'; +export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter'; +export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption'; +export const CLEAR_MISSING = 'wanted/missing/clearMissing'; + +export const BATCH_TOGGLE_MISSING_EPISODES = 'wanted/missing/batchToggleMissingEpisodes'; + +export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet'; +export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage'; +export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage'; +export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage'; +export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage'; +export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage'; +export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort'; +export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter'; +export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption'; +export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet'; + +export const BATCH_TOGGLE_CUTOFF_UNMET_EPISODES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetEpisodes'; + +// +// Action Creators + +export const fetchMissing = createThunk(FETCH_MISSING); +export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE); +export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE); +export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE); +export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE); +export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE); +export const setMissingSort = createThunk(SET_MISSING_SORT); +export const setMissingFilter = createThunk(SET_MISSING_FILTER); +export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION); +export const clearMissing = createAction(CLEAR_MISSING); + +export const batchToggleMissingEpisodes = createThunk(BATCH_TOGGLE_MISSING_EPISODES); + +export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET); +export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE); +export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT); +export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER); +export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION); +export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET); + +export const batchToggleCutoffUnmetEpisodes = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_EPISODES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + ...createServerSideCollectionHandlers( + 'wanted.missing', + '/wanted/missing', + fetchMissing, + { + [serverSideCollectionHandlers.FETCH]: FETCH_MISSING, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE, + [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT, + [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER + } + ), + + [BATCH_TOGGLE_MISSING_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.missing', fetchMissing), + + ...createServerSideCollectionHandlers( + 'wanted.cutoffUnmet', + '/wanted/cutoff', + fetchCutoffUnmet, + { + [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT, + [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER + } + ), + + [BATCH_TOGGLE_CUTOFF_UNMET_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet) + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'), + [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'), + + [CLEAR_MISSING]: createClearReducer( + 'wanted.missing', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ), + + [CLEAR_CUTOFF_UNMET]: createClearReducer( + 'wanted.cutoffUnmet', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ) + +}, defaultState, section); diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js new file mode 100644 index 000000000..ffb2aa0d0 --- /dev/null +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import persistState from 'redux-localstorage'; +import actions from 'Store/Actions'; +import migrate from 'Store/Migrators/migrate'; + +const columnPaths = []; + +const paths = _.reduce([...actions], (acc, action) => { + if (action.persistState) { + action.persistState.forEach((path) => { + if (path.match(/\.columns$/)) { + columnPaths.push(path); + } + + acc.push(path); + }); + } + + return acc; +}, []); + +function mergeColumns(path, initialState, persistedState, computedState) { + const initialColumns = _.get(initialState, path); + const persistedColumns = _.get(persistedState, path); + + if (!persistedColumns || !persistedColumns.length) { + return; + } + + const columns = []; + + initialColumns.forEach((initialColumn) => { + const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name }); + const column = Object.assign({}, initialColumn); + const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined; + + if (persistedColumn) { + column.isVisible = persistedColumn.isVisible; + } + + // If there is a persisted column, it's index doesn't exceed the column list + // and it's modifiable, insert it in the proper position. + + if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) { + columns.splice(persistedColumnIndex, 0, column); + } else { + columns.push(column); + } + + // Set the columns in the persisted state + _.set(computedState, path, columns); + }); +} + +function slicer(paths_) { + return (state) => { + const subset = {}; + + paths_.forEach((path) => { + _.set(subset, path, _.get(state, path)); + }); + + return subset; + }; +} + +function serialize(obj) { + return JSON.stringify(obj, null, 2); +} + +function merge(initialState, persistedState) { + if (!persistedState) { + return initialState; + } + + const computedState = {}; + + _.merge(computedState, initialState, persistedState); + + columnPaths.forEach((columnPath) => { + mergeColumns(columnPath, initialState, persistedState, computedState); + }); + + return computedState; +} + +const config = { + slicer, + serialize, + merge, + key: 'sonarr' +}; + +export default function createPersistState() { + // Migrate existing local storage before proceeding + const persistedState = JSON.parse(localStorage.getItem(config.key)); + migrate(persistedState); + localStorage.setItem(config.key, serialize(persistedState)); + + return persistState(paths, config); +} diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js new file mode 100644 index 000000000..ef861379c --- /dev/null +++ b/frontend/src/Store/Middleware/createSentryMiddleware.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import * as sentry from '@sentry/browser'; +import parseUrl from 'Utilities/String/parseUrl'; + +function cleanseUrl(url) { + const properties = parseUrl(url); + + return `${properties.pathname}${properties.search}`; +} + +function cleanseData(data) { + const result = _.cloneDeep(data); + + result.transaction = cleanseUrl(result.transaction); + + if (result.exception) { + result.exception.values.forEach((exception) => { + const stacktrace = exception.stacktrace; + + if (stacktrace) { + stacktrace.frames.forEach((frame) => { + frame.filename = cleanseUrl(frame.filename); + }); + } + }); + } + + result.request.url = cleanseUrl(result.request.url); + + return result; +} + +function identity(stuff) { + return stuff; +} + +function createMiddleware() { + return (store) => (next) => (action) => { + try { + // Adds a breadcrumb for reporting later (if necessary). + sentry.addBreadcrumb({ + category: 'redux', + message: action.type + }); + + return next(action); + } catch (err) { + console.error(`[sentry] Reporting error to Sentry: ${err}`); + + // Send the report including breadcrumbs. + sentry.captureException(err, { + extra: { + action: identity(action), + state: identity(store.getState()) + } + }); + } + }; +} + +export default function createSentryMiddleware() { + const { + analytics, + branch, + version, + release, + isProduction + } = window.Sonarr; + + if (!analytics) { + return; + } + + const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' : + 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12'; + + sentry.init({ + dsn, + environment: isProduction ? 'production' : 'development', + release, + sendDefaultPii: true, + beforeSend: cleanseData + }); + + sentry.configureScope((scope) => { + scope.setTag('branch', branch); + scope.setTag('version', version); + }); + + return createMiddleware(); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js new file mode 100644 index 000000000..59937bc45 --- /dev/null +++ b/frontend/src/Store/Middleware/middlewares.js @@ -0,0 +1,25 @@ +import { applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { routerMiddleware } from 'react-router-redux'; +import createSentryMiddleware from './createSentryMiddleware'; +import createPersistState from './createPersistState'; + +export default function(history) { + const middlewares = []; + const sentryMiddleware = createSentryMiddleware(); + + if (sentryMiddleware) { + middlewares.push(sentryMiddleware); + } + + middlewares.push(routerMiddleware(history)); + middlewares.push(thunk); + + // eslint-disable-next-line no-underscore-dangle + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + return composeEnhancers( + applyMiddleware(...middlewares), + createPersistState() + ); +} diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js new file mode 100644 index 000000000..e8d637ff0 --- /dev/null +++ b/frontend/src/Store/Migrators/migrate.js @@ -0,0 +1,5 @@ +import migrateAddSeriesDefaults from './migrateAddSeriesDefaults'; + +export default function migrate(persistedState) { + migrateAddSeriesDefaults(persistedState); +} diff --git a/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js new file mode 100644 index 000000000..5aaf0a850 --- /dev/null +++ b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; +import monitorOptions from 'Utilities/Series/monitorOptions'; + +export default function migrateAddSeriesDefaults(persistedState) { + const monitor = get(persistedState, 'addSeries.defaults.monitor'); + + if (!monitor) { + return; + } + + if (!monitorOptions.find((option) => option.key === monitor)) { + persistedState.addSeries.defaults.monitor = monitorOptions[0].key; + } +} diff --git a/frontend/src/Store/Selectors/createAllSeriesSelector.js b/frontend/src/Store/Selectors/createAllSeriesSelector.js new file mode 100644 index 000000000..6a1abdac4 --- /dev/null +++ b/frontend/src/Store/Selectors/createAllSeriesSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createAllSeriesSelector() { + return createSelector( + (state) => state.series, + (series) => { + return series.items; + } + ); +} + +export default createAllSeriesSelector; diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js new file mode 100644 index 000000000..929b0afe0 --- /dev/null +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -0,0 +1,130 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; + +function getSortClause(sortKey, sortDirection, sortPredicates) { + if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) { + return function(item) { + return sortPredicates[sortKey](item, sortDirection); + }; + } + + return function(item) { + return item[sortKey]; + }; +} + +function filter(items, state) { + const { + selectedFilterKey, + filters, + customFilters, + filterPredicates + } = state; + + if (!selectedFilterKey) { + return items; + } + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + return _.filter(items, (item) => { + let i = 0; + let accepted = true; + + while (accepted && i < selectedFilters.length) { + const { + key, + value, + type = filterTypes.EQUAL + } = selectedFilters[i]; + + if (filterPredicates && filterPredicates.hasOwnProperty(key)) { + const predicate = filterPredicates[key]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item, v, type)); + } else { + accepted = predicate(item, value, type); + } + } else if (item.hasOwnProperty(key)) { + const predicate = filterTypePredicates[type]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item[key], v)); + } else { + accepted = predicate(item[key], value); + } + } else { + // Default to false if the filter can't be tested + accepted = false; + } + + i++; + } + + return accepted; + }); +} + +function sort(items, state) { + const { + sortKey, + sortDirection, + sortPredicates, + secondarySortKey, + secondarySortDirection + } = state; + + const clauses = []; + const orders = []; + + clauses.push(getSortClause(sortKey, sortDirection, sortPredicates)); + orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + + if (secondarySortKey && + secondarySortDirection && + (sortKey !== secondarySortKey || + sortDirection !== secondarySortDirection)) { + clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates)); + orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + } + + return _.orderBy(items, clauses, orders); +} + +function createCustomFiltersSelector(type, alternateType) { + return createSelector( + (state) => state.customFilters.items, + (customFilters) => { + return customFilters.filter((customFilter) => { + return customFilter.type === type || customFilter.type === alternateType; + }); + } + ); +} + +function createClientSideCollectionSelector(section, uiSection) { + return createSelector( + (state) => _.get(state, section), + (state) => _.get(state, uiSection), + createCustomFiltersSelector(section, uiSection), + (sectionState, uiSectionState = {}, customFilters) => { + const state = Object.assign({}, sectionState, uiSectionState, { customFilters }); + + const filtered = filter(state.items, state); + const sorted = sort(filtered, state); + + return { + ...sectionState, + ...uiSectionState, + customFilters, + items: sorted, + totalItems: state.items.length + }; + } + ); +} + +export default createClientSideCollectionSelector; diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js new file mode 100644 index 000000000..6037d5820 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createCommandSelector from './createCommandSelector'; + +function createCommandExecutingSelector(name, contraints = {}) { + return createSelector( + createCommandSelector(name, contraints), + (command) => { + return isCommandExecuting(command); + } + ); +} + +export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js new file mode 100644 index 000000000..709dfebaf --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name, contraints = {}) { + return createSelector( + createCommandsSelector(), + (commands) => { + return findCommand(commands, { name, ...contraints }); + } + ); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js new file mode 100644 index 000000000..7b9edffd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createCommandsSelector() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands.items; + } + ); +} + +export default createCommandsSelector; diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js new file mode 100644 index 000000000..ce26b2e2c --- /dev/null +++ b/frontend/src/Store/Selectors/createDimensionsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createDimensionsSelector() { + return createSelector( + (state) => state.app.dimensions, + (dimensions) => { + return dimensions; + } + ); +} + +export default createDimensionsSelector; diff --git a/frontend/src/Store/Selectors/createEpisodeFileSelector.js b/frontend/src/Store/Selectors/createEpisodeFileSelector.js new file mode 100644 index 000000000..f58f000ff --- /dev/null +++ b/frontend/src/Store/Selectors/createEpisodeFileSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; + +function createEpisodeFileSelector() { + return createSelector( + (state, { episodeFileId }) => episodeFileId, + (state) => state.episodeFiles, + (episodeFileId, episodeFiles) => { + if (!episodeFileId) { + return; + } + + return episodeFiles.items.find((episodeFile) => episodeFile.id === episodeFileId); + } + ); +} + +export default createEpisodeFileSelector; diff --git a/frontend/src/Store/Selectors/createEpisodeSelector.js b/frontend/src/Store/Selectors/createEpisodeSelector.js new file mode 100644 index 000000000..6725cadd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createEpisodeSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import episodeEntities from 'Episode/episodeEntities'; + +function createEpisodeSelector() { + return createSelector( + (state, { episodeId }) => episodeId, + (state, { episodeEntity = episodeEntities.EPISODES }) => _.get(state, episodeEntity, { items: [] }), + (episodeId, episodes) => { + return _.find(episodes.items, { id: episodeId }); + } + ); +} + +export default createEpisodeSelector; diff --git a/frontend/src/Store/Selectors/createExistingSeriesSelector.js b/frontend/src/Store/Selectors/createExistingSeriesSelector.js new file mode 100644 index 000000000..77d18acee --- /dev/null +++ b/frontend/src/Store/Selectors/createExistingSeriesSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createExistingSeriesSelector() { + return createSelector( + (state, { tvdbId }) => tvdbId, + createAllSeriesSelector(), + (tvdbId, series) => { + return _.some(series, { tvdbId }); + } + ); +} + +export default createExistingSeriesSelector; diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.js b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js new file mode 100644 index 000000000..dc6c28a05 --- /dev/null +++ b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js @@ -0,0 +1,28 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createImportSeriesItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.addSeries, + (state) => state.importSeries, + createAllSeriesSelector(), + (id, addSeries, importSeries, series) => { + const item = _.find(importSeries.items, { id }) || {}; + const selectedSeries = item && item.selectedSeries; + const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); + + return { + defaultMonitor: addSeries.defaults.monitor, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultSeriesType: addSeries.defaults.seriesType, + defaultSeasonFolder: addSeries.defaults.seasonFolder, + ...item, + isExistingSeries + }; + } + ); +} + +export default createImportSeriesItemSelector; diff --git a/frontend/src/Store/Selectors/createLanguageProfileSelector.js b/frontend/src/Store/Selectors/createLanguageProfileSelector.js new file mode 100644 index 000000000..2ad04d506 --- /dev/null +++ b/frontend/src/Store/Selectors/createLanguageProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createLanguageProfileSelector() { + return createSelector( + (state, { languageProfileId }) => languageProfileId, + (state) => state.settings.languageProfiles.items, + (languageProfileId, languageProfiles) => { + return _.find(languageProfiles, { id: languageProfileId }); + } + ); +} + +export default createLanguageProfileSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js new file mode 100644 index 000000000..540b61d26 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createProfileInUseSelector(profileProp) { + return createSelector( + (state, { id }) => id, + createAllSeriesSelector(), + (id, series) => { + if (!id) { + return false; + } + + return _.some(series, { [profileProp]: id }); + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js new file mode 100644 index 000000000..46659609f --- /dev/null +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createProviderSettingsSelector(sectionName) { + return createSelector( + (state, { id }) => id, + (state) => state.settings[sectionName], + (id, section) => { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; + } + ); +} + +export default createProviderSettingsSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js new file mode 100644 index 000000000..9308d63ac --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createQualityProfileSelector() { + return createSelector( + (state, { qualityProfileId }) => qualityProfileId, + (state) => state.settings.qualityProfiles.items, + (qualityProfileId, qualityProfiles) => { + return _.find(qualityProfiles, { id: qualityProfileId }); + } + ); +} + +export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js new file mode 100644 index 000000000..1172feb1e --- /dev/null +++ b/frontend/src/Store/Selectors/createQueueItemSelector.js @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; + +function createQueueItemSelector() { + return createSelector( + (state, { episodeId }) => episodeId, + (state) => state.queue.details.items, + (episodeId, details) => { + if (!episodeId) { + return null; + } + + return details.find((item) => { + return item.episode.id === episodeId; + }); + } + ); +} + +export default createQueueItemSelector; diff --git a/frontend/src/Store/Selectors/createSeriesCountSelector.js b/frontend/src/Store/Selectors/createSeriesCountSelector.js new file mode 100644 index 000000000..f59d8ce5e --- /dev/null +++ b/frontend/src/Store/Selectors/createSeriesCountSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createSeriesCountSelector() { + return createSelector( + createAllSeriesSelector(), + (series) => { + return series.length; + } + ); +} + +export default createSeriesCountSelector; diff --git a/frontend/src/Store/Selectors/createSeriesSelector.js b/frontend/src/Store/Selectors/createSeriesSelector.js new file mode 100644 index 000000000..1c1ab5bb8 --- /dev/null +++ b/frontend/src/Store/Selectors/createSeriesSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createSeriesSelector() { + return createSelector( + (state, { seriesId }) => seriesId, + createAllSeriesSelector(), + (seriesId, series) => { + return _.find(series, { id: seriesId }); + } + ); +} + +export default createSeriesSelector; diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js new file mode 100644 index 000000000..a9f6cbff6 --- /dev/null +++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createSettingsSectionSelector(section) { + return createSelector( + (state) => state.settings[section], + (sectionSettings) => { + const { + isFetching, + isPopulated, + error, + item, + pendingChanges, + isSaving, + saveError + } = sectionSettings; + + const settings = selectSettings(item, pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + ...settings + }; + } + ); +} + +export default createSettingsSectionSelector; diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js new file mode 100644 index 000000000..df586bbb9 --- /dev/null +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createSystemStatusSelector() { + return createSelector( + (state) => state.system.status, + (status) => { + return status.item; + } + ); +} + +export default createSystemStatusSelector; diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js new file mode 100644 index 000000000..dd178944c --- /dev/null +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; + +function createTagDetailsSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.tags.details.items, + (id, tagDetails) => { + return tagDetails.find((t) => t.id === id); + } + ); +} + +export default createTagDetailsSelector; diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js new file mode 100644 index 000000000..fbfd91cdb --- /dev/null +++ b/frontend/src/Store/Selectors/createTagsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createTagsSelector() { + return createSelector( + (state) => state.tags.items, + (tags) => { + return tags; + } + ); +} + +export default createTagsSelector; diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js new file mode 100644 index 000000000..b256d0e98 --- /dev/null +++ b/frontend/src/Store/Selectors/createUISettingsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createUISettingsSelector() { + return createSelector( + (state) => state.settings.ui, + (ui) => { + return ui.item; + } + ); +} + +export default createUISettingsSelector; diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js new file mode 100644 index 000000000..3e30478b7 --- /dev/null +++ b/frontend/src/Store/Selectors/selectSettings.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; + +function getValidationFailures(saveError) { + if (!saveError || saveError.status !== 400) { + return []; + } + + return _.cloneDeep(saveError.responseJSON); +} + +function mapFailure(failure) { + return { + message: failure.errorMessage, + link: failure.infoLink, + detailedMessage: failure.detailedDescription + }; +} + +function selectSettings(item, pendingChanges, saveError) { + const validationFailures = getValidationFailures(saveError); + + // Merge all settings from the item along with pending + // changes to ensure any settings that were not included + // with the item are included. + const allSettings = Object.assign({}, item, pendingChanges); + + const settings = _.reduce(allSettings, (result, value, key) => { + if (key === 'fields') { + return result; + } + + // Return a flattened value + if (key === 'implementationName') { + result.implementationName = item[key]; + + return result; + } + + const setting = { + value: item[key], + errors: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning; + }), mapFailure), + + warnings: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning; + }), mapFailure) + }; + + if (pendingChanges.hasOwnProperty(key)) { + setting.previousValue = setting.value; + setting.value = pendingChanges[key]; + setting.pending = true; + } + + result[key] = setting; + return result; + }, {}); + + const fields = _.reduce(item.fields, (result, f) => { + const field = Object.assign({ pending: false }, f); + const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name); + + if (hasPendingFieldChange) { + field.previousValue = field.value; + field.value = pendingChanges.fields[field.name]; + field.pending = true; + } + + field.errors = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning; + }), mapFailure); + + field.warnings = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning; + }), mapFailure); + + result.push(field); + return result; + }, []); + + if (fields.length) { + settings.fields = fields; + } + + const validationErrors = _.filter(validationFailures, (failure) => { + return !failure.isWarning; + }); + + const validationWarnings = _.filter(validationFailures, (failure) => { + return failure.isWarning; + }); + + return { + settings, + validationErrors, + validationWarnings, + hasPendingChanges: !_.isEmpty(pendingChanges), + hasSettings: !_.isEmpty(settings), + pendingChanges + }; +} + +export default selectSettings; diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js new file mode 100644 index 000000000..e05c80323 --- /dev/null +++ b/frontend/src/Store/createAppStore.js @@ -0,0 +1,15 @@ +import { createStore } from 'redux'; +import reducers, { defaultState } from 'Store/Actions/reducers'; +import middlewares from 'Store/Middleware/middlewares'; + +function createAppStore(history) { + const appStore = createStore( + reducers, + defaultState, + middlewares(history) + ); + + return appStore; +} + +export default createAppStore; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js new file mode 100644 index 000000000..39648c008 --- /dev/null +++ b/frontend/src/Store/scrollPositions.js @@ -0,0 +1,5 @@ +const scrollPositions = { + seriesIndex: 0 +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js new file mode 100644 index 000000000..ebcf10917 --- /dev/null +++ b/frontend/src/Store/thunks.js @@ -0,0 +1,28 @@ +const thunks = {}; + +function identity(payload) { + return payload; +} + +export function createThunk(type, identityFunction = identity) { + return function(payload = {}) { + return function(dispatch, getState) { + const thunk = thunks[type]; + + if (thunk) { + return thunk(getState, identityFunction(payload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} + diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css new file mode 100644 index 000000000..e44c99be6 --- /dev/null +++ b/frontend/src/Styles/Mixins/cover.css @@ -0,0 +1,8 @@ +@define-mixin cover { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css new file mode 100644 index 000000000..74c3fd753 --- /dev/null +++ b/frontend/src/Styles/Mixins/linkOverlay.css @@ -0,0 +1,11 @@ +@define-mixin linkOverlay { + @add-mixin cover; + + pointer-events: none; + user-select: none; + + a, + button { + pointer-events: all; + } +} diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css new file mode 100644 index 000000000..62a619103 --- /dev/null +++ b/frontend/src/Styles/Mixins/scroller.css @@ -0,0 +1,26 @@ +@define-mixin scrollbar { + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } +} + +@define-mixin scrollbarTrack { + &&::-webkit-scrollbar-track { + background-color: transparent; + } +} + +@define-mixin scrollbarThumb { + &::-webkit-scrollbar-thumb { + min-height: 50px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } + } +} diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css new file mode 100644 index 000000000..1941afc9b --- /dev/null +++ b/frontend/src/Styles/Mixins/truncate.css @@ -0,0 +1,18 @@ +/** + * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css + * + * Text truncation + * + * Prevent text from wrapping onto multiple lines, and truncate with an + * ellipsis. + * + * 1. Ensure that the node has a maximum width after which truncation can + * occur. + */ + +@define-mixin truncate { + overflow: hidden !important; + max-width: 100%; /* 1 */ + text-overflow: ellipsis !important; + white-space: nowrap !important; +} diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js new file mode 100644 index 000000000..52d12827a --- /dev/null +++ b/frontend/src/Styles/Variables/animations.js @@ -0,0 +1,8 @@ +// Use CommonJS since this is consumed by PostCSS via webpack (node.js). + +module.exports = { + // Durations + defaultSpeed: '0.2s', + slowSpeed: '0.6s', + fastSpeed: '0.1s' +}; diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js new file mode 100644 index 000000000..4ded49029 --- /dev/null +++ b/frontend/src/Styles/Variables/colors.js @@ -0,0 +1,181 @@ +const sonarrBlue = '#35c5f4'; + +module.exports = { + defaultColor: '#333', + disabledColor: '#999', + dimColor: '#555', + black: '#000', + white: '#fff', + offWhite: '#f5f7fa', + primaryColor: '#5d9cec', + selectedColor: '#f9be03', + successColor: '#27c24c', + dangerColor: '#f05050', + warningColor: '#ffa500', + infoColor: sonarrBlue, + purple: '#7a43b6', + pink: '#ff69b4', + sonarrBlue, + helpTextColor: '#909293', + darkGray: '#888', + gray: '#adadad', + lightGray: '#ddd', + disabledInputColor: '#808080', + + // Theme Colors + + themeBlue: sonarrBlue, + themeAlternateBlue: '#2193b5', + themeRed: '#c4273c', + themeDarkColor: '#3a3f51', + themeLightColor: '#4f566f', + + torrentColor: '#00853d', + usenetColor: '#17b1d9', + + // Links + defaultLinkHoverColor: '#fff', + linkColor: '#5d9cec', + linkHoverColor: '#1b72e2', + + // Sidebar + + sidebarColor: '#e1e2e3', + sidebarBackgroundColor: '#3a3f51', + sidebarActiveBackgroundColor: '#252833', + + // Toolbar + toolbarColor: '#e1e2e3', + toolbarBackgroundColor: '#4f566f', + toolbarMenuItemBackgroundColor: '#454b60', + toolbarMenuItemHoverBackgroundColor: '#3a3f51', + toolbarLabelColor: '#8895aa', + + // Accents + borderColor: '#e5e5e5', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + inputErrorBorderColor: '#f05050', + inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)', + inputWarningBorderColor: '#ffa500', + inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)', + colorImpairedGradient: '#fcfcfc', + + // + // Buttons + + defaultBackgroundColor: '#fff', + defaultBorderColor: '#eaeaea', + defaultHoverBackgroundColor: '#f5f5f5', + defaultHoverBorderColor: '#d6d6d6;', + + primaryBackgroundColor: '#5d9cec', + primaryBorderColor: '#5899eb', + primaryHoverBackgroundColor: '#4b91ea', + primaryHoverBorderColor: '#3483e7;', + + successBackgroundColor: '#27c24c', + successBorderColor: '#26be4a', + successHoverBackgroundColor: '#24b145', + successHoverBorderColor: '#1f9c3d;', + + warningBackgroundColor: '#ff902b', + warningBorderColor: '#ff8d26', + warningHoverBackgroundColor: '#ff8517', + warningHoverBorderColor: '#fc7800;', + + dangerBackgroundColor: '#f05050', + dangerBorderColor: '#f04b4b', + dangerHoverBackgroundColor: '#ee3d3d', + dangerHoverBorderColor: '#ec2626;', + + iconButtonDisabledColor: '#7a7a7a', + iconButtonHoverColor: '#666', + iconButtonHoverLightColor: '#ccc', + + // + // Modal + + modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)', + modalBackgroundColor: '#fff', + modalCloseButtonHoverColor: '#888', + + // + // Menu + menuItemColor: '#e1e2e3', + menuItemHoverColor: '#fbfcfc', + menuItemHoverBackgroundColor: '#f5f7fa', + + // + // Toolbar + + toobarButtonHoverColor: '#35c5f4', + toobarButtonSelectedColor: '#35c5f4', + + // + // Scroller + + scrollbarBackgroundColor: '#9ea4b9', + scrollbarHoverBackgroundColor: '#656d8c', + + // + // Card + + cardShadowColor: '#e1e1e1', + cardAlternateBackgroundColor: '#f5f5f5', + + // + // Alert + + alertDangerBorderColor: '#ebccd1', + alertDangerBackgroundColor: '#f2dede', + alertDangerColor: '#a94442', + + alertInfoBorderColor: '#bce8f1', + alertInfoBackgroundColor: '#d9edf7', + alertInfoColor: '#31708f', + + alertSuccessBorderColor: '#d6e9c6', + alertSuccessBackgroundColor: '#dff0d8', + alertSuccessColor: '#3c763d', + + alertWarningBorderColor: '#faebcc', + alertWarningBackgroundColor: '#fcf8e3', + alertWarningColor: '#8a6d3b', + + // + // Slider + + sliderAccentColor: '#5d9cec', + + // + // Form + + advancedFormLabelColor: '#ff902b', + disabledCheckInputColor: '#ddd', + + // + // Popover + + popoverTitleBackgroundColor: '#f7f7f7', + popoverTitleBorderColor: '#ebebeb', + popoverShadowColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)', + + popoverTitleBackgroundInverseColor: '#3a3f51', + popoverTitleBorderInverseColor: '#4f566f', + popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)', + + // + // Calendar + + calendarTodayBackgroundColor: '#ddd', + + // + // Table + + tableRowHoverBackgroundColor: '#fafbfc' +}; diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js new file mode 100644 index 000000000..83f9e9d4e --- /dev/null +++ b/frontend/src/Styles/Variables/dimensions.js @@ -0,0 +1,53 @@ +module.exports = { + // Page + pageContentBodyPadding: '20px', + pageContentBodyPaddingSmallScreen: '10px', + + // Header + headerHeight: '60px', + + // Sidebar + sidebarWidth: '210px', + + // Toolbar + toolbarHeight: '60px', + toolbarButtonWidth: '60px', + toolbarSeparatorMargin: '20px', + + // Break Points + breakpointExtraSmall: '480px', + breakpointSmall: '768px', + breakpointMedium: '992px', + breakpointLarge: '1200px', + breakpointExtraLarge: '1450px', + + // Form + formGroupExtraSmallWidth: '550px', + formGroupSmallWidth: '650px', + formGroupMediumWidth: '800px', + formGroupLargeWidth: '1200px', + formLabelSmallWidth: '150px', + formLabelLargeWidth: '250px', + formLabelRightMarginWidth: '20px', + + // Drag + dragHandleWidth: '40px', + qualityProfileItemHeight: '30px', + qualityProfileItemDragSourcePadding: '4px', + + // Progress Bar + progressBarSmallHeight: '5px', + progressBarMediumHeight: '15px', + progressBarLargeHeight: '20px', + + // Jump Bar + jumpBarItemHeight: '25px', + + // Modal + modalBodyPadding: '30px', + + // Series + seriesIndexColumnPadding: '20px', + seriesIndexColumnPaddingSmallScreen: '10px', + seriesIndexOverviewInfoRowHeight: '21px' +}; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js new file mode 100644 index 000000000..3b0077c5a --- /dev/null +++ b/frontend/src/Styles/Variables/fonts.js @@ -0,0 +1,15 @@ +module.exports = { + // Families + defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', + monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', + passwordFamily: 'text-security-disc', + + // Sizes + extraSmallFontSize: '11px', + smallFontSize: '12px', + defaultFontSize: '14px', + intermediateFontSize: '15px', + largeFontSize: '16px', + + lineHeight: '1.528571429' +}; diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css new file mode 100644 index 000000000..70967f0c4 --- /dev/null +++ b/frontend/src/Styles/globals.css @@ -0,0 +1,7 @@ +/* stylelint-disable */ + +@import '~normalize.css/normalize.css'; +@import 'scaffolding.css'; +@import '../Content/Fonts/fonts.css'; + +/* stylelint-enable */ diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css new file mode 100644 index 000000000..8d95f8d12 --- /dev/null +++ b/frontend/src/Styles/scaffolding.css @@ -0,0 +1,45 @@ +/* stylelint-disable */ +* { + box-sizing: border-box; +} + +*::before, +*::after { + box-sizing: border-box; +} + +*:focus { + outline: none; +} +/* stylelint-enable */ + +html, +body { + color: #515253; + font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; +} + +body { + font-size: 14px; + line-height: 1.528571429; /* 20/14 */ +} + +/* Override normalize */ + +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-size: inherit; + font-family: inherit; + line-height: 1.528571429; /* 20/14 */ +} + +/* Better defaults for unordererd lists */ + +ul { + margin: 0; + padding-left: 20px; +} diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css new file mode 100644 index 000000000..d83a22e25 --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.css @@ -0,0 +1,12 @@ +.type { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; + text-align: center; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js new file mode 100644 index 000000000..df5ff974c --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.js @@ -0,0 +1,153 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; +import styles from './BackupRow.css'; + +class BackupRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onConfirmDeletePress = () => { + const { + id, + onDeleteBackupPress + } = this.props; + + this.setState({ isConfirmDeleteModalOpen: false }, () => { + onDeleteBackupPress(id); + }); + } + + // + // Render + + render() { + const { + id, + type, + name, + path, + time + } = this.props; + + const { + isRestoreModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + let iconClassName = icons.SCHEDULED; + let iconTooltip = 'Scheduled'; + + if (type === 'manual') { + iconClassName = icons.INTERACTIVE; + iconTooltip = 'Manual'; + } else if (type === 'update') { + iconClassName = icons.UPDATE; + iconTooltip = 'Before update'; + } + + return ( + + + { + + } + + + + + {name} + + + + + + + + + + + + + + + + ); + } +} + +BackupRow.propTypes = { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default BackupRow; diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js new file mode 100644 index 000000000..97167e6f2 --- /dev/null +++ b/frontend/src/System/Backup/Backups.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BackupRow from './BackupRow'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; + +const columns = [ + { + name: 'type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class Backups extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + backupExecuting, + onBackupPress, + onDeleteBackupPress + } = this.props; + + const hasBackups = isPopulated && !!items.length; + const noBackups = isPopulated && !items.length; + + return ( + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
    Unable to load backups
    + } + + { + noBackups && +
    No backups are available
    + } + + { + hasBackups && + + + { + items.map((item) => { + const { + id, + type, + name, + path, + time + } = item; + + return ( + + ); + }) + } + +
    + } +
    + + +
    + ); + } + +} + +Backups.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + backupExecuting: PropTypes.bool.isRequired, + onBackupPress: PropTypes.func.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default Backups; diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js new file mode 100644 index 000000000..434354f5b --- /dev/null +++ b/frontend/src/System/Backup/BackupsConnector.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Backups from './Backups'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + createCommandExecutingSelector(commandNames.BACKUP), + (backups, backupExecuting) => { + const { + isFetching, + isPopulated, + error, + items + } = backups; + + return { + isFetching, + isPopulated, + error, + items, + backupExecuting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchBackups() { + dispatch(fetchBackups()); + }, + + onDeleteBackupPress(id) { + dispatch(deleteBackup({ id })); + }, + + onBackupPress() { + dispatch(executeCommand({ + name: commandNames.BACKUP + })); + } + }; +} + +class BackupsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchBackups(); + } + + componentDidUpdate(prevProps) { + if (prevProps.backupExecuting && !this.props.backupExecuting) { + this.props.dispatchFetchBackups(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +BackupsConnector.propTypes = { + backupExecuting: PropTypes.bool.isRequired, + dispatchFetchBackups: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector); diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js new file mode 100644 index 000000000..48dad4d2a --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector'; + +function RestoreBackupModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RestoreBackupModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js new file mode 100644 index 000000000..98cbcd11b --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { clearRestoreBackup } from 'Store/Actions/systemActions'; +import RestoreBackupModal from './RestoreBackupModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(clearRestoreBackup()); + + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RestoreBackupModal); diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css new file mode 100644 index 000000000..783443e33 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.css @@ -0,0 +1,24 @@ +.additionalInfo { + flex-grow: 1; + color: #777; +} + +.steps { + margin-top: 20px; +} + +.step { + display: flex; + font-size: $largeFontSize; + line-height: 20px; +} + +.stepState { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + flex-wrap: wrap; +} diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js new file mode 100644 index 000000000..2c42d7ab5 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RestoreBackupModalContent.css'; + +function getErrorMessage(error) { + if (!error || !error.responseJSON || !error.responseJSON.message) { + return 'Error restoring backup'; + } + + return error.responseJSON.message; +} + +function getStepIconProps(isExecuting, hasExecuted, error) { + if (isExecuting) { + return { + name: icons.SPINNER, + isSpinning: true + }; + } + + if (hasExecuted) { + return { + name: icons.CHECK, + kind: kinds.SUCCESS + }; + } + + if (error) { + return { + name: icons.FATAL, + kinds: kinds.DANGER, + title: getErrorMessage(error) + }; + } + + return { + name: icons.PENDING + }; +} + +class RestoreBackupModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + file: null, + path: '', + isRestored: false, + isRestarted: false, + isReloading: false + }; + } + + componentDidUpdate(prevProps) { + const { + isRestoring, + restoreError, + isRestarting, + dispatchRestart + } = this.props; + + if (prevProps.isRestoring && !isRestoring && !restoreError) { + this.setState({ isRestored: true }, () => { + dispatchRestart(); + }); + } + + if (prevProps.isRestarting && !isRestarting) { + this.setState({ + isRestarted: true, + isReloading: true + }, () => { + location.reload(); + }); + } + } + + // + // Listeners + + onPathChange = ({ value, files }) => { + this.setState({ + file: files[0], + path: value + }); + } + + onRestorePress = () => { + const { + id, + onRestorePress + } = this.props; + + onRestorePress({ + id, + file: this.state.file + }); + } + + // + // Render + + render() { + const { + id, + name, + isRestoring, + restoreError, + isRestarting, + onModalClose + } = this.props; + + const { + path, + isRestored, + isRestarted, + isReloading + } = this.state; + + const isRestoreDisabled = ( + (!id && !path) || + isRestoring || + isRestarting || + isReloading + ); + + return ( + + + Restore Backup + + + + { + !!id && `Would you like to restore the backup '${name}'?` + } + + { + !id && + + } + +
    +
    +
    + +
    + +
    Restore
    +
    + +
    +
    + +
    + +
    Restart
    +
    + +
    +
    + +
    + +
    Reload
    +
    +
    +
    + + +
    + Note: Sonarr will automatically restart and reload the UI during the restore process. +
    + + + + + Restore + +
    +
    + ); + } +} + +RestoreBackupModalContent.propTypes = { + id: PropTypes.number, + name: PropTypes.string, + path: PropTypes.string, + isRestoring: PropTypes.bool.isRequired, + restoreError: PropTypes.object, + isRestarting: PropTypes.bool.isRequired, + dispatchRestart: PropTypes.func.isRequired, + onRestorePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js new file mode 100644 index 000000000..7f2b7a6e8 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restoreBackup, restart } from 'Store/Actions/systemActions'; +import RestoreBackupModalContent from './RestoreBackupModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + (state) => state.app.isRestarting, + (backups, isRestarting) => { + const { + isRestoring, + restoreError + } = backups; + + return { + isRestoring, + restoreError, + isRestarting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRestorePress(payload) { + dispatch(restoreBackup(payload)); + }, + + dispatchRestart() { + dispatch(restart()); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent); diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js new file mode 100644 index 000000000..1858d483a --- /dev/null +++ b/frontend/src/System/Events/LogsTable.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import LogsTableRow from './LogsTableRow'; + +function LogsTable(props) { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + clearLogExecuting, + onRefreshPress, + onClearLogsPress, + onFilterSelect, + ...otherProps + } = props; + + return ( + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
    + No logs found +
    + } + + { + isPopulated && !error && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + + +
    + } +
    +
    + ); +} + +LogsTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + clearLogExecuting: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onClearLogsPress: PropTypes.func.isRequired +}; + +export default LogsTable; diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js new file mode 100644 index 000000000..c02d67650 --- /dev/null +++ b/frontend/src/System/Events/LogsTableConnector.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as systemActions from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogsTable from './LogsTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logs, + createCommandExecutingSelector(commandNames.CLEAR_LOGS), + (logs, clearLogExecuting) => { + return { + clearLogExecuting, + ...logs + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + ...systemActions +}; + +class LogsTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogs(); + } + + componentDidUpdate(prevProps) { + if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) { + this.props.gotoLogsFirstPage(); + } + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoLogsFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoLogsPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoLogsNextPage(); + } + + onLastPagePress = () => { + this.props.gotoLogsLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoLogsPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setLogsSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setLogsFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setLogsTableOption(payload); + + if (payload.pageSize) { + this.props.gotoLogsFirstPage(); + } + } + + onRefreshPress = () => { + this.props.gotoLogsFirstPage(); + } + + onClearLogsPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_LOGS }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogsTableConnector.propTypes = { + clearLogExecuting: PropTypes.bool.isRequired, + fetchLogs: PropTypes.func.isRequired, + gotoLogsFirstPage: PropTypes.func.isRequired, + gotoLogsPreviousPage: PropTypes.func.isRequired, + gotoLogsNextPage: PropTypes.func.isRequired, + gotoLogsLastPage: PropTypes.func.isRequired, + gotoLogsPage: PropTypes.func.isRequired, + setLogsSort: PropTypes.func.isRequired, + setLogsFilter: PropTypes.func.isRequired, + setLogsTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector); diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css new file mode 100644 index 000000000..127c1139f --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.css @@ -0,0 +1,17 @@ +.detailsText { + composes: scroller from 'Components/Scroller/Scroller.css'; + + display: block; + margin: 0 0 10.5px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + color: #3a3f51; + white-space: pre; + word-wrap: break-word; + word-break: break-all; + font-size: 13px; + font-family: $monoSpaceFontFamily; + line-height: 1.52857143; +} diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js new file mode 100644 index 000000000..de6a881df --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './LogsTableDetailsModal.css'; + +function LogsTableDetailsModal(props) { + const { + isOpen, + message, + exception, + onModalClose + } = props; + + return ( + + + + Details + + + +
    Message
    + + + {message} + + + { + !!exception && +
    +
    Exception
    + + {exception} + +
    + } +
    + + + + +
    +
    + ); +} + +LogsTableDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, + exception: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default LogsTableDetailsModal; diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css new file mode 100644 index 000000000..557d690f0 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.css @@ -0,0 +1,35 @@ +.level { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} + +.info { + color: #1e90ff; +} + +.debug { + color: #808080; +} + +.trace { + color: #d3d3d3; +} + +.warn { + color: $warningColor; +} + +.error { + color: $dangerColor; +} + +.fatal { + color: $purple; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js new file mode 100644 index 000000000..390769727 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import LogsTableDetailsModal from './LogsTableDetailsModal'; +import styles from './LogsTableRow.css'; + +function getIconName(level) { + switch (level) { + case 'trace': + case 'debug': + case 'info': + return icons.INFO; + case 'warn': + return icons.DANGER; + case 'error': + return icons.BUG; + case 'fatal': + return icons.FATAL; + default: + return icons.UNKNOWN; + } +} + +class LogsTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + // Don't re-open the modal if it's already open + if (!this.state.isDetailsModalOpen) { + this.setState({ isDetailsModalOpen: true }); + } + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + level, + logger, + message, + time, + exception, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'level') { + return ( + + + + ); + } + + if (name === 'logger') { + return ( + + {logger} + + ); + } + + if (name === 'message') { + return ( + + {message} + + ); + } + + if (name === 'time') { + return ( + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + + + ); + } + +} + +LogsTableRow.propTypes = { + level: PropTypes.string.isRequired, + logger: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + exception: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default LogsTableRow; diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js new file mode 100644 index 000000000..47482b3fe --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import TableBody from 'Components/Table/TableBody'; +import LogsNavMenu from '../LogsNavMenu'; +import LogFilesTableRow from './LogFilesTableRow'; + +const columns = [ + { + name: 'filename', + label: 'Filename', + isVisible: true + }, + { + name: 'lastWriteTime', + label: 'Last Write Time', + isVisible: true + }, + { + name: 'download', + isVisible: true + } +]; + +class LogFiles extends Component { + + // + // Render + + render() { + const { + isFetching, + items, + deleteFilesExecuting, + currentLogView, + location, + onRefreshPress, + onDeleteFilesPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + +
    + Log files are located in: {location} +
    + + { + currentLogView === 'Log Files' && +
    + The log level defaults to 'Info' and can be changed in General Settings +
    + } +
    + + { + isFetching && + + } + + { + !isFetching && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    +
    + } + + { + !isFetching && !items.length && +
    No log files
    + } +
    +
    + ); + } + +} + +LogFiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + deleteFilesExecuting: PropTypes.bool.isRequired, + currentLogView: PropTypes.string.isRequired, + location: PropTypes.string.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onDeleteFilesPress: PropTypes.func.isRequired +}; + +export default LogFiles; diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js new file mode 100644 index 000000000..628bb571c --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from './LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_LOG_FILES), + (logFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = logFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Log Files', + location: combinePath(isWindows, appData, ['logs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchLogFiles, + executeCommand +}; + +class LogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector); diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css new file mode 100644 index 000000000..779794b7d --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css @@ -0,0 +1,5 @@ +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js new file mode 100644 index 000000000..7ae61a531 --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './LogFilesTableRow.css'; + +class LogFilesTableRow extends Component { + + // + // Render + + render() { + const { + filename, + lastWriteTime, + downloadUrl + } = this.props; + + return ( + + {filename} + + + + + + Download + + + + ); + } + +} + +LogFilesTableRow.propTypes = { + filename: PropTypes.string.isRequired, + lastWriteTime: PropTypes.string.isRequired, + downloadUrl: PropTypes.string.isRequired +}; + +export default LogFilesTableRow; diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js new file mode 100644 index 000000000..fa0be453e --- /dev/null +++ b/frontend/src/System/Logs/Logs.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import LogFilesConnector from './Files/LogFilesConnector'; +import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector'; + +class Logs extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default Logs; diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js new file mode 100644 index 000000000..b69630248 --- /dev/null +++ b/frontend/src/System/Logs/LogsNavMenu.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; + +class LogsNavMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false + }; + } + + // + // Listeners + + onMenuButtonPress = () => { + this.setState({ isMenuOpen: !this.state.isMenuOpen }); + } + + onMenuItemPress = () => { + this.setState({ isMenuOpen: false }); + } + + // + // Render + + render() { + const { + current + } = this.props; + + return ( + + + {current} + + + + Log Files + + + + Updater Log Files + + + + ); + } +} + +LogsNavMenu.propTypes = { + current: PropTypes.string.isRequired +}; + +export default LogsNavMenu; diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js new file mode 100644 index 000000000..3030c12ce --- /dev/null +++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchUpdateLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from '../Files/LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updateLogFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES), + (updateLogFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = updateLogFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Updater Log Files', + location: combinePath(isWindows, appData, ['UpdateLogs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdateLogFiles, + executeCommand +}; + +class UpdateLogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdateLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchUpdateLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchUpdateLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdateLogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchUpdateLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector); diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css new file mode 100644 index 000000000..fc20848b0 --- /dev/null +++ b/frontend/src/System/Status/About/About.css @@ -0,0 +1,5 @@ +.descriptionList { + composes: descriptionList from 'Components/DescriptionList/DescriptionList.css'; + + margin-bottom: 10px; +} diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js new file mode 100644 index 000000000..0965baff4 --- /dev/null +++ b/frontend/src/System/Status/About/About.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import StartTime from './StartTime'; +import styles from './About.css'; + +class About extends Component { + + // + // Render + + render() { + const { + version, + isMonoRuntime, + runtimeVersion, + appData, + startupPath, + mode, + startTime, + timeFormat, + longDateFormat + } = this.props; + + return ( +
    + + + + { + isMonoRuntime && + + } + + + + + + + + + } + /> + +
    + ); + } + +} + +About.propTypes = { + version: PropTypes.string.isRequired, + isMonoRuntime: PropTypes.bool.isRequired, + runtimeVersion: PropTypes.string.isRequired, + appData: PropTypes.string.isRequired, + startupPath: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default About; diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js new file mode 100644 index 000000000..475d9778b --- /dev/null +++ b/frontend/src/System/Status/About/AboutConnector.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import About from './About'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + createUISettingsSelector(), + (status, uiSettings) => { + return { + ...status.item, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchStatus +}; + +class AboutConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchStatus(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AboutConnector.propTypes = { + fetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector); diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js new file mode 100644 index 000000000..94b4322d5 --- /dev/null +++ b/frontend/src/System/Status/About/StartTime.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; + +function getUptime(startTime) { + return formatTimeSpan(moment().diff(startTime)); +} + +class StartTime extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + startTime, + timeFormat, + longDateFormat + } = props; + + this._timeoutId = null; + + this.state = { + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }; + } + + componentDidMount() { + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + componentDidUpdate(prevProps) { + const { + startTime, + timeFormat, + longDateFormat + } = this.props; + + if ( + startTime !== prevProps.startTime || + timeFormat !== prevProps.timeFormat || + longDateFormat !== prevProps.longDateFormat + ) { + this.setState({ + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }); + } + } + + componentWillUnmount() { + if (this._timeoutId) { + this._timeoutId = clearTimeout(this._timeoutId); + } + } + + // + // Listeners + + onTimeout = () => { + this.setState({ uptime: getUptime(this.props.startTime) }); + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + // + // Render + + render() { + const { + uptime, + startTime + } = this.state; + + return ( + + {uptime} + + ); + } +} + +StartTime.propTypes = { + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default StartTime; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css new file mode 100644 index 000000000..70ef6f884 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css @@ -0,0 +1,5 @@ +.space { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js new file mode 100644 index 000000000..ad1de4d64 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './DiskSpace.css'; + +const columns = [ + { + name: 'path', + label: 'Location', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'totalSpace', + label: 'Total Space', + isVisible: true + }, + { + name: 'progress', + isVisible: true + } +]; + +class DiskSpace extends Component { + + // + // Render + + render() { + const { + isFetching, + items + } = this.props; + + return ( +
    + { + isFetching && + + } + + { + !isFetching && + + + { + items.map((item) => { + const { + freeSpace, + totalSpace + } = item; + + const diskUsage = (100 - freeSpace / totalSpace * 100); + let diskUsageKind = kinds.PRIMARY; + + if (diskUsage > 90) { + diskUsageKind = kinds.DANGER; + } else if (diskUsage > 80) { + diskUsageKind = kinds.WARNING; + } + + return ( + + + {item.path} + + { + item.label && + ` (${item.label})` + } + + + + {formatBytes(freeSpace)} + + + + {formatBytes(totalSpace)} + + + + + + + ); + }) + } + +
    + } +
    + ); + } + +} + +DiskSpace.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default DiskSpace; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js new file mode 100644 index 000000000..3049b2ead --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDiskSpace } from 'Store/Actions/systemActions'; +import DiskSpace from './DiskSpace'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.diskSpace, + (diskSpace) => { + const { + isFetching, + items + } = diskSpace; + + return { + isFetching, + items + }; + } + ); +} + +const mapDispatchToProps = { + fetchDiskSpace +}; + +class DiskSpaceConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDiskSpace(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DiskSpaceConnector.propTypes = { + fetchDiskSpace: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector); diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css new file mode 100644 index 000000000..1aad8ee77 --- /dev/null +++ b/frontend/src/System/Status/Health/Health.css @@ -0,0 +1,21 @@ +.legend { + display: flex; + justify-content: space-between; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 2px; + margin-left: 10px; + text-align: left; +} + +.status { + width: 20px; +} + +.healthOk { + margin-bottom: 25px; +} + diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js new file mode 100644 index 000000000..92c95022c --- /dev/null +++ b/frontend/src/System/Status/Health/Health.js @@ -0,0 +1,206 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './Health.css'; + +function getInternalLink(source) { + switch (source) { + case 'IndexerRssCheck': + case 'IndexerSearchCheck': + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + case 'ImportMechanismCheck': + return ( + + ); + case 'RootFolderCheck': + return ( + + ); + case 'UpdateCheck': + return ( + + ); + default: + return; + } +} + +function getTestLink(source, props) { + switch (source) { + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + return ( + + ); + + default: + break; + } +} + +const columns = [ + { + className: styles.status, + name: 'type', + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class Health extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + items + } = this.props; + + const healthIssues = !!items.length; + + return ( +
    + Health + + { + isFetching && isPopulated && + + } +
    + } + > + { + isFetching && !isPopulated && + + } + + { + !healthIssues && +
    + No issues with your configuration +
    + } + + { + healthIssues && + + + { + items.map((item) => { + const internalLink = getInternalLink(item.source); + const testLink = getTestLink(item.source, this.props); + + return ( + + + + + + {item.message} + + + + + { + internalLink + } + + { + !!testLink && + testLink + } + + + ); + }) + } + +
    + } + + ); + } + +} + +Health.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + isTestingAllDownloadClients: PropTypes.bool.isRequired, + isTestingAllIndexers: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default Health; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js new file mode 100644 index 000000000..d2adc41cc --- /dev/null +++ b/frontend/src/System/Status/Health/HealthConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions'; +import Health from './Health'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.health, + (state) => state.settings.downloadClients.isTestingAll, + (state) => state.settings.indexers.isTestingAll, + (health, isTestingAllDownloadClients, isTestingAllIndexers) => { + const { + isFetching, + isPopulated, + items + } = health; + + return { + isFetching, + isPopulated, + items, + isTestingAllDownloadClients, + isTestingAllIndexers + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchHealth: fetchHealth, + dispatchTestAllDownloadClients: testAllDownloadClients, + dispatchTestAllIndexers: testAllIndexers +}; + +class HealthConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchHealth(); + } + + // + // Render + + render() { + const { + dispatchFetchHealth, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +HealthConnector.propTypes = { + dispatchFetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector); diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js new file mode 100644 index 000000000..181eae916 --- /dev/null +++ b/frontend/src/System/Status/Health/HealthStatusConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.system.health, + (app, health) => { + const count = health.items.length; + let errors = false; + let warnings = false; + + health.items.forEach((item) => { + if (item.type === 'error') { + errors = true; + } + + if (item.type === 'warning') { + warnings = true; + } + }); + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: health.isPopulated, + count, + errors, + warnings + }; + } + ); +} + +const mapDispatchToProps = { + fetchHealth +}; + +class HealthStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchHealth(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchHealth(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HealthStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector); diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js new file mode 100644 index 000000000..4ac04c4e7 --- /dev/null +++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; + +class MoreInfo extends Component { + + // + // Render + + render() { + return ( +
    + + Home page + + sonarr.tv + + + Wiki + + wiki.sonarr.tv + + + Forums + + forums.sonarr.tv + + + Twitter + + @sonarrtv + + + IRC + + #sonarr on Freenode + + + Freenode webchat + + + Donations + + sonarr.tv/donate + + + Source + + github.com/Sonarr/Sonarr + + + Feature Requests + + forums.sonarr.tv + + + github.com/Sonarr/Sonarr/issues + + + +
    + ); + } +} + +MoreInfo.propTypes = { + +}; + +export default MoreInfo; diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js new file mode 100644 index 000000000..f0b157515 --- /dev/null +++ b/frontend/src/System/Status/Status.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import HealthConnector from './Health/HealthConnector'; +import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector'; +import AboutConnector from './About/AboutConnector'; +import MoreInfo from './MoreInfo/MoreInfo'; + +class Status extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + ); + } + +} + +export default Status; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css new file mode 100644 index 000000000..30f86efff --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -0,0 +1,31 @@ +.trigger { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.triggerContent { + display: flex; + justify-content: space-between; + width: 100%; +} + +.queued, +.started, +.ended { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.duration { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js new file mode 100644 index 000000000..4aa6d76d6 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -0,0 +1,265 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status, message) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}` + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.ERROR, + title: `${title}: ${message}` + }; + + default: + return { + name: icons.UNKNOWN, + title + }; + } +} + +function getFormattedDates(props) { + const { + queued, + started, + ended, + showRelativeDates, + shortDateFormat + } = props; + + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-' + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-' + }; +} + +class QueuedTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + ...getFormattedDates(props), + isCancelConfirmModalOpen: false + }; + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + queued, + started, + ended + } = this.props; + + if ( + queued !== prevProps.queued || + started !== prevProps.started || + ended !== prevProps.ended + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Control + + setUpdateTimer() { + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, 30000); + } + + // + // Listeners + + onCancelPress = () => { + this.setState({ + isCancelConfirmModalOpen: true + }); + } + + onAbortCancel = () => { + this.setState({ + isCancelConfirmModalOpen: false + }); + } + + // + // Render + + render() { + const { + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + longDateFormat, + timeFormat, + onCancelPress + } = this.props; + + const { + queuedAt, + startedAt, + endedAt, + isCancelConfirmModalOpen + } = this.state; + + let triggerIcon = icons.UNKNOWN; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + {commandName} + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + { + status === 'queued' && + + } + + + + + ); + } +} + +QueuedTaskRow.propTypes = { + trigger: PropTypes.string.isRequired, + commandName: PropTypes.string.isRequired, + queued: PropTypes.string.isRequired, + started: PropTypes.string, + ended: PropTypes.string, + status: PropTypes.string.isRequired, + duration: PropTypes.string, + message: PropTypes.string, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onCancelPress: PropTypes.func.isRequired +}; + +export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js new file mode 100644 index 000000000..f55ab985a --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueuedTaskRow from './QueuedTaskRow'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onCancelPress() { + dispatch(cancelCommand({ + id: props.id + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js new file mode 100644 index 000000000..a2fd526fa --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import QueuedTaskRowConnector from './QueuedTaskRowConnector'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true + }, + { + name: 'commandName', + label: 'Name', + isVisible: true + }, + { + name: 'queued', + label: 'Queued', + isVisible: true + }, + { + name: 'started', + label: 'Started', + isVisible: true + }, + { + name: 'ended', + label: 'Ended', + isVisible: true + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function QueuedTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
    + { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } +
    + ); +} + +QueuedTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js new file mode 100644 index 000000000..5fa4d9ead --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import QueuedTasks from './QueuedTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands +}; + +class QueuedTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchCommands(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueuedTasksConnector.propTypes = { + dispatchFetchCommands: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css new file mode 100644 index 000000000..dc83cfd69 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css @@ -0,0 +1,18 @@ +.interval { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.lastExecution, +.nextExecution { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js new file mode 100644 index 000000000..82cedc720 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js @@ -0,0 +1,182 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ScheduledTaskRow.css'; + +function getFormattedDates(props) { + const { + lastExecution, + nextExecution, + interval, + showRelativeDates, + shortDateFormat + } = props; + + const isDisabled = interval === 0; + + if (showRelativeDates) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) + }; +} + +class ScheduledTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = getFormattedDates(props); + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + lastExecution, + nextExecution + } = this.props; + + if ( + lastExecution !== prevProps.lastExecution || + nextExecution !== prevProps.nextExecution + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Listeners + + setUpdateTimer() { + const { interval } = this.props; + const timeout = interval < 60 ? 10000 : 60000; + + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, timeout); + } + + // + // Render + + render() { + const { + name, + interval, + lastExecution, + nextExecution, + isQueued, + isExecuting, + longDateFormat, + timeFormat, + onExecutePress + } = this.props; + + const { + lastExecutionTime, + nextExecutionTime + } = this.state; + + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + { + isDisabled && + - + } + + { + executeNow && isQueued && + queued + } + + { + executeNow && !isQueued && + now + } + + { + hasNextExecutionTime && + + {nextExecutionTime} + + } + + + + + + ); + } +} + +ScheduledTaskRow.propTypes = { + name: PropTypes.string.isRequired, + interval: PropTypes.number.isRequired, + lastExecution: PropTypes.string.isRequired, + nextExecution: PropTypes.string.isRequired, + isQueued: PropTypes.bool.isRequired, + isExecuting: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onExecutePress: PropTypes.func.isRequired +}; + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js new file mode 100644 index 000000000..79a0c6c87 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchTask } from 'Store/Actions/systemActions'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import ScheduledTaskRow from './ScheduledTaskRow'; + +function createMapStateToProps() { + return createSelector( + (state, { taskName }) => taskName, + createCommandsSelector(), + createUISettingsSelector(), + (taskName, commands, uiSettings) => { + const command = findCommand(commands, { name: taskName }); + + return { + isQueued: !!(command && command.state === 'queued'), + isExecuting: isCommandExecuting(command), + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + const taskName = props.taskName; + + return { + dispatchFetchTask() { + dispatch(fetchTask({ + id: props.id + })); + }, + + onExecutePress() { + dispatch(executeCommand({ + name: taskName + })); + } + }; +} + +class ScheduledTaskRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + isExecuting, + dispatchFetchTask + } = this.props; + + if (!isExecuting && prevProps.isExecuting) { + // Give the host a moment to update after the command completes + setTimeout(() => { + dispatchFetchTask(); + }, 1000); + } + } + + // + // Render + + render() { + const { + dispatchFetchTask, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +ScheduledTaskRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isExecuting: PropTypes.bool.isRequired, + dispatchFetchTask: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js new file mode 100644 index 000000000..7c6fe8a32 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; + +const columns = [ + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'interval', + label: 'Interval', + isVisible: true + }, + { + name: 'lastExecution', + label: 'Last Execution', + isVisible: true + }, + { + name: 'nextExecution', + label: 'Next Execution', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function ScheduledTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
    + { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + } +
    + ); +} + +ScheduledTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js new file mode 100644 index 000000000..8f418d3bb --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTasks } from 'Store/Actions/systemActions'; +import ScheduledTasks from './ScheduledTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.tasks, + (tasks) => { + return tasks; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTasks: fetchTasks +}; + +class ScheduledTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchTasks(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ScheduledTasksConnector.propTypes = { + dispatchFetchTasks: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js new file mode 100644 index 000000000..dbbb4d1bf --- /dev/null +++ b/frontend/src/System/Tasks/Tasks.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasksConnector from './Queued/QueuedTasksConnector'; + +function Tasks() { + return ( + + + + + + + ); +} + +export default Tasks; diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css new file mode 100644 index 000000000..d21897373 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.css @@ -0,0 +1,4 @@ +.title { + margin-top: 10px; + font-size: 16px; +} diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js new file mode 100644 index 000000000..63c7e0d85 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './UpdateChanges.css'; + +class UpdateChanges extends Component { + + // + // Render + + render() { + const { + title, + changes + } = this.props; + + if (changes.length === 0) { + return null; + } + + return ( +
    +
    {title}
    +
      + { + changes.map((change, index) => { + return ( +
    • + {change} +
    • + ); + }) + } +
    +
    + ); + } + +} + +UpdateChanges.propTypes = { + title: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.string) +}; + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css new file mode 100644 index 000000000..3502f6d1f --- /dev/null +++ b/frontend/src/System/Updates/Updates.css @@ -0,0 +1,57 @@ +.updateAvailable { + display: flex; +} + +.upToDate { + display: flex; + margin-bottom: 20px; +} + +.upToDateIcon { + color: #37bc9b; + font-size: 30px; +} + +.upToDateMessage { + padding-left: 5px; + font-size: 18px; + line-height: 30px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-left: auto; +} + +.update { + margin-top: 20px; +} + +.info { + display: flex; + align-items: center; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; +} + +.version { + font-size: 21px; +} + +.space { + padding: 0 5px; +} + +.date { + font-size: 16px; +} + +.branch { + composes: label from 'Components/Label.css'; + + margin-left: 10px; + font-size: 14px; +} diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js new file mode 100644 index 000000000..1ee7af090 --- /dev/null +++ b/frontend/src/System/Updates/Updates.js @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatDate from 'Utilities/Date/formatDate'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +class Updates extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat, + onInstallLatestPress + } = this.props; + + const hasUpdates = isPopulated && !error && items.length > 0; + const noUpdates = isPopulated && !error && !items.length; + const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + return ( + + + { + !isPopulated && !error && + + } + + { + noUpdates && +
    No updates are available
    + } + + { + hasUpdateToInstall && +
    + + Install Latest + + + { + isFetching && + + } +
    + } + + { + noUpdateToInstall && +
    + +
    + The latest version of Sonarr is already installed +
    + + { + isFetching && + + } +
    + } + + { + hasUpdates && +
    + { + items.map((update) => { + const hasChanges = !!update.changes; + + return ( +
    +
    +
    {update.version}
    +
    +
    {formatDate(update.releaseDate, shortDateFormat)}
    + + { + update.branch !== 'master' && + + } +
    + + { + !hasChanges && +
    Maintenance release
    + } + + { + hasChanges && +
    + + + +
    + } +
    + ); + }) + } +
    + } + + { + !!error && +
    + Failed to fetch updates +
    + } +
    +
    + ); + } + +} + +Updates.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + isInstallingUpdate: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + onInstallLatestPress: PropTypes.func.isRequired +}; + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js new file mode 100644 index 000000000..0d0aa491f --- /dev/null +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import * as commandNames from 'Commands/commandNames'; +import Updates from './Updates'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updates, + createUISettingsSelector(), + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), + (updates, uiSettings, isInstallingUpdate) => { + const { + isFetching, + isPopulated, + error, + items + } = updates; + + return { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat: uiSettings.shortDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdates, + executeCommand +}; + +class UpdatesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdates(); + } + + // + // Listeners + + onInstallLatestPress = () => { + this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdatesConnector.propTypes = { + fetchUpdates: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js new file mode 100644 index 000000000..165bb5cc1 --- /dev/null +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; + +export default function getIndexOfFirstCharacter(items, character) { + return _.findIndex(items, (item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (character === '#') { + return !isNaN(firstCharacter); + } + + return firstCharacter === character; + }); +} diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js new file mode 100644 index 000000000..1956d3bac --- /dev/null +++ b/frontend/src/Utilities/Array/sortByName.js @@ -0,0 +1,5 @@ +function sortByName(a, b) { + return a.name.localeCompare(b.name); +} + +export default sortByName; diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js new file mode 100644 index 000000000..cf7d5444a --- /dev/null +++ b/frontend/src/Utilities/Command/findCommand.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +import isSameCommand from './isSameCommand'; + +function findCommand(commands, options) { + return _.findLast(commands, (command) => { + return isSameCommand(command.body, options); + }); +} + +export default findCommand; diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js new file mode 100644 index 000000000..66043bf03 --- /dev/null +++ b/frontend/src/Utilities/Command/index.js @@ -0,0 +1,5 @@ +export { default as findCommand } from './findCommand'; +export { default as isCommandComplete } from './isCommandComplete'; +export { default as isCommandExecuting } from './isCommandExecuting'; +export { default as isCommandFailed } from './isCommandFailed'; +export { default as isSameCommand } from './isSameCommand'; diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js new file mode 100644 index 000000000..558ab801b --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandComplete.js @@ -0,0 +1,9 @@ +function isCommandComplete(command) { + if (!command) { + return false; + } + + return command.status === 'complete'; +} + +export default isCommandComplete; diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js new file mode 100644 index 000000000..8e637704e --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandExecuting.js @@ -0,0 +1,9 @@ +function isCommandExecuting(command) { + if (!command) { + return false; + } + + return command.status === 'queued' || command.status === 'started'; +} + +export default isCommandExecuting; diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js new file mode 100644 index 000000000..00e5ccdf2 --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandFailed.js @@ -0,0 +1,12 @@ +function isCommandFailed(command) { + if (!command) { + return false; + } + + return command.status === 'failed' || + command.status === 'aborted' || + command.status === 'cancelled' || + command.status === 'orphaned'; +} + +export default isCommandFailed; diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js new file mode 100644 index 000000000..d0acb24b5 --- /dev/null +++ b/frontend/src/Utilities/Command/isSameCommand.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; + +function isSameCommand(commandA, commandB) { + if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) { + return false; + } + + for (const key in commandB) { + if (key !== 'name') { + const value = commandB[key]; + if (Array.isArray(value)) { + if (_.difference(value, commandA[key]).length > 0) { + return false; + } + } else if (value !== commandA[key]) { + return false; + } + } + } + + return true; +} + +export default isSameCommand; diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js new file mode 100644 index 000000000..9285b10fe --- /dev/null +++ b/frontend/src/Utilities/Constants/keyCodes.js @@ -0,0 +1,7 @@ +export const TAB = 9; +export const ENTER = 13; +export const SHIFT = 16; +export const CONTROL = 17; +export const ESCAPE = 27; +export const UP_ARROW = 38; +export const DOWN_ARROW = 40; diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js new file mode 100644 index 000000000..2c74f435a --- /dev/null +++ b/frontend/src/Utilities/Date/dateFilterPredicate.js @@ -0,0 +1,33 @@ +import moment from 'moment'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import * as filterTypes from 'Helpers/Props/filterTypes'; + +export default function(itemValue, filterValue, type) { + if (!itemValue) { + return false; + } + + switch (type) { + case filterTypes.LESS_THAN: + return moment(itemValue).isBefore(filterValue); + + case filterTypes.GREATER_THAN: + return moment(itemValue).isAfter(filterValue); + + case filterTypes.IN_LAST: + return ( + isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) && + isBefore(itemValue) + ); + + case filterTypes.IN_NEXT: + return ( + isAfter(itemValue) && + isBefore(itemValue, { [filterValue.time]: filterValue.value }) + ); + + default: + return false; + } +} diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js new file mode 100644 index 000000000..92eb57840 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDate.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function formatDate(date, dateFormat) { + if (!date) { + return ''; + } + + return moment(date).format(dateFormat); +} + +export default formatDate; diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js new file mode 100644 index 000000000..f36f4f3e0 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDateTime.js @@ -0,0 +1,39 @@ +import moment from 'moment'; +import formatTime from './formatTime'; +import isToday from './isToday'; +import isTomorrow from './isTomorrow'; +import isYesterday from './isYesterday'; + +function getRelativeDay(date, includeRelativeDate) { + if (!includeRelativeDate) { + return ''; + } + + if (isYesterday(date)) { + return 'Yesterday, '; + } + + if (isToday(date)) { + return 'Today, '; + } + + if (isTomorrow(date)) { + return 'Tomorrow, '; + } + + return ''; +} + +function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) { + if (!date) { + return ''; + } + + const relativeDay = getRelativeDay(date, includeRelativeDay); + const formattedDate = moment(date).format(dateFormat); + const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + + return `${relativeDay}${formattedDate} ${formattedTime}`; +} + +export default formatDateTime; diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js new file mode 100644 index 000000000..89c908d1f --- /dev/null +++ b/frontend/src/Utilities/Date/formatTime.js @@ -0,0 +1,19 @@ +import moment from 'moment'; + +function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) { + if (!date) { + return ''; + } + + if (includeSeconds) { + timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); + } else if (includeMinuteZero) { + timeFormat = timeFormat.replace('(:mm)', ':mm'); + } else { + timeFormat = timeFormat.replace('(:mm)', ''); + } + + return moment(date).format(timeFormat); +} + +export default formatTime; diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js new file mode 100644 index 000000000..ef1a278e5 --- /dev/null +++ b/frontend/src/Utilities/Date/formatTimeSpan.js @@ -0,0 +1,24 @@ +import moment from 'moment'; +import padNumber from 'Utilities/Number/padNumber'; + +function formatTimeSpan(timeSpan) { + if (!timeSpan) { + return ''; + } + + const duration = moment.duration(timeSpan); + const days = duration.get('days'); + const hours = padNumber(duration.get('hours'), 2); + const minutes = padNumber(duration.get('minutes'), 2); + const seconds = padNumber(duration.get('seconds'), 2); + + const time = `${hours}:${minutes}:${seconds}`; + + if (days > 0) { + return `${days}d ${time}`; + } + + return time; +} + +export default formatTimeSpan; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js new file mode 100644 index 000000000..0a60135ce --- /dev/null +++ b/frontend/src/Utilities/Date/getRelativeDate.js @@ -0,0 +1,42 @@ +import moment from 'moment'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import isYesterday from 'Utilities/Date/isYesterday'; + +function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) { + if (!date) { + return null; + } + + const isTodayDate = isToday(date); + + if (isTodayDate && timeForToday && timeFormat) { + return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + } + + if (!showRelativeDates) { + return moment(date).format(shortDateFormat); + } + + if (isYesterday(date)) { + return 'Yesterday'; + } + + if (isTodayDate) { + return 'Today'; + } + + if (isTomorrow(date)) { + return 'Tomorrow'; + } + + if (isInNextWeek(date)) { + return moment(date).format('dddd'); + } + + return moment(date).format(shortDateFormat); +} + +export default getRelativeDate; diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js new file mode 100644 index 000000000..4bbd8660b --- /dev/null +++ b/frontend/src/Utilities/Date/isAfter.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isAfter(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isAfter(offsetTime); +} + +export default isAfter; diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js new file mode 100644 index 000000000..3e1e81f67 --- /dev/null +++ b/frontend/src/Utilities/Date/isBefore.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isBefore(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isBefore(offsetTime); +} + +export default isBefore; diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js new file mode 100644 index 000000000..7b5fd7cc7 --- /dev/null +++ b/frontend/src/Utilities/Date/isInNextWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isInNextWeek(date) { + if (!date) { + return false; + } + const now = moment(); + return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day')); +} + +export default isInNextWeek; diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js new file mode 100644 index 000000000..14b76ffb7 --- /dev/null +++ b/frontend/src/Utilities/Date/isSameWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isSameWeek(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'week'); +} + +export default isSameWeek; diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js new file mode 100644 index 000000000..31502951f --- /dev/null +++ b/frontend/src/Utilities/Date/isToday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isToday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'day'); +} + +export default isToday; diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js new file mode 100644 index 000000000..d22386dbd --- /dev/null +++ b/frontend/src/Utilities/Date/isTomorrow.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isTomorrow(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().add(1, 'day'), 'day'); +} + +export default isTomorrow; diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js new file mode 100644 index 000000000..9de21d82a --- /dev/null +++ b/frontend/src/Utilities/Date/isYesterday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isYesterday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().subtract(1, 'day'), 'day'); +} + +export default isYesterday; diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Episode/updateEpisodes.js new file mode 100644 index 000000000..80890b53f --- /dev/null +++ b/frontend/src/Utilities/Episode/updateEpisodes.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { update } from 'Store/Actions/baseActions'; + +function updateEpisodes(section, episodes, episodeIds, options) { + const data = _.reduce(episodes, (result, item) => { + if (episodeIds.indexOf(item.id) > -1) { + result.push({ + ...item, + ...options + }); + } else { + result.push(item); + } + + return result; + }, []); + + return update({ section, data }); +} + +export default updateEpisodes; diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js new file mode 100644 index 000000000..1c104073c --- /dev/null +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -0,0 +1,19 @@ +export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) { + if (!selectedFilterKey) { + return []; + } + + let selectedFilter = filters.find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + selectedFilter = customFilters.find((f) => f.id === selectedFilterKey); + } + + if (!selectedFilter) { + // TODO: throw in dev + console.error('Matching filter not found'); + return []; + } + + return selectedFilter.filters; +} diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js new file mode 100644 index 000000000..70b0b51f1 --- /dev/null +++ b/frontend/src/Utilities/Filter/getFilterValue.js @@ -0,0 +1,11 @@ +export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) { + const filter = filters.find((f) => f.key === filterKey); + + if (!filter) { + return defaultValue; + } + + const filterValue = filter.filters.find((f) => f.key === filterValueKey); + + return filterValue ? filterValue.value : defaultValue; +} diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js new file mode 100644 index 000000000..6c63fb117 --- /dev/null +++ b/frontend/src/Utilities/Number/convertToBytes.js @@ -0,0 +1,16 @@ + +function convertToBytes(input, power, binaryPrefix) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + const prefix = binaryPrefix ? 1024 : 1000; + const multiplier = Math.pow(prefix, power); + const result = size * multiplier; + + return Math.round(result); +} + +export default convertToBytes; diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js new file mode 100644 index 000000000..b8a4aacc5 --- /dev/null +++ b/frontend/src/Utilities/Number/formatAge.js @@ -0,0 +1,17 @@ +function formatAge(age, ageHours, ageMinutes) { + age = Math.round(age); + ageHours = parseFloat(ageHours); + ageMinutes = ageMinutes && parseFloat(ageMinutes); + + if (age < 2 && ageHours) { + if (ageHours < 2 && !!ageMinutes) { + return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`; + } + + return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`; + } + + return `${age} ${age === 1 ? 'day' : 'days'}`; +} + +export default formatAge; diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js new file mode 100644 index 000000000..1ff1b5a97 --- /dev/null +++ b/frontend/src/Utilities/Number/formatBytes.js @@ -0,0 +1,16 @@ +import filesize from 'filesize'; + +function formatBytes(input) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + return filesize(size, { + base: 2, + round: 1 + }); +} + +export default formatBytes; diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js new file mode 100644 index 000000000..53ae69cac --- /dev/null +++ b/frontend/src/Utilities/Number/padNumber.js @@ -0,0 +1,10 @@ +function padNumber(input, width, paddingCharacter = 0) { + if (input == null) { + return ''; + } + + input = `${input}`; + return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input; +} + +export default padNumber; diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js new file mode 100644 index 000000000..1ba874660 --- /dev/null +++ b/frontend/src/Utilities/Object/getErrorMessage.js @@ -0,0 +1,11 @@ +function getErrorMessage(xhr, fallbackErrorMessage) { + if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) { + return fallbackErrorMessage; + } + + const message = xhr.responseJSON.message; + + return message || fallbackErrorMessage; +} + +export default getErrorMessage; diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js new file mode 100644 index 000000000..f89c99a10 --- /dev/null +++ b/frontend/src/Utilities/Object/hasDifferentItems.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; + +function hasDifferentItems(prevItems, currentItems, idProp = 'id') { + const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]); + const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]); + + return diff1.length > 0 || diff2.length > 0; +} + +export default hasDifferentItems; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js new file mode 100644 index 000000000..c2c0c17e3 --- /dev/null +++ b/frontend/src/Utilities/Object/selectUniqueIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function selectUniqueIds(items, idProp) { + const ids = _.reduce(items, (result, item) => { + if (item[idProp]) { + result.push(item[idProp]); + } + + return result; + }, []); + + return _.uniq(ids); +} + +export default selectUniqueIds; diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js new file mode 100644 index 000000000..da09851ea --- /dev/null +++ b/frontend/src/Utilities/Quality/getQualities.js @@ -0,0 +1,16 @@ +export default function getQualities(qualities) { + if (!qualities) { + return []; + } + + return qualities.reduce((acc, item) => { + if (item.quality) { + acc.push(item.quality); + } else { + const groupQualities = item.items.map((i) => i.quality); + acc.push(...groupQualities); + } + + return acc; + }, []); +} diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js new file mode 100644 index 000000000..358448ca9 --- /dev/null +++ b/frontend/src/Utilities/ResolutionUtility.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +module.exports = { + resolutions: { + desktopLarge: 1200, + desktop: 992, + tablet: 768, + mobile: 480 + }, + + isDesktopLarge() { + return $(window).width() < this.resolutions.desktopLarge; + }, + + isDesktop() { + return $(window).width() < this.resolutions.desktop; + }, + + isTablet() { + return $(window).width() < this.resolutions.tablet; + }, + + isMobile() { + return $(window).width() < this.resolutions.mobile; + } +}; diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js new file mode 100644 index 000000000..f33b5c869 --- /dev/null +++ b/frontend/src/Utilities/Series/getNewSeries.js @@ -0,0 +1,31 @@ + +function getNewSeries(series, payload) { + const { + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags, + searchForMissingEpisodes = false + } = payload; + + const addOptions = { + monitor, + searchForMissingEpisodes + }; + + series.addOptions = addOptions; + series.monitored = true; + series.qualityProfileId = qualityProfileId; + series.languageProfileId = languageProfileId; + series.rootFolderPath = rootFolderPath; + series.seriesType = seriesType; + series.seasonFolder = seasonFolder; + series.tags = tags; + + return series; +} + +export default getNewSeries; diff --git a/frontend/src/Utilities/Series/getProgressBarKind.js b/frontend/src/Utilities/Series/getProgressBarKind.js new file mode 100644 index 000000000..eb3b2dd6e --- /dev/null +++ b/frontend/src/Utilities/Series/getProgressBarKind.js @@ -0,0 +1,15 @@ +import { kinds } from 'Helpers/Props'; + +function getProgressBarKind(status, monitored, progress) { + if (progress === 100) { + return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY; + } + + if (monitored) { + return kinds.DANGER; + } + + return kinds.WARNING; +} + +export default getProgressBarKind; diff --git a/frontend/src/Utilities/Series/monitorOptions.js b/frontend/src/Utilities/Series/monitorOptions.js new file mode 100644 index 000000000..57d46413f --- /dev/null +++ b/frontend/src/Utilities/Series/monitorOptions.js @@ -0,0 +1,11 @@ +const monitorOptions = [ + { key: 'all', value: 'All Episodes' }, + { key: 'future', value: 'Future Episodes' }, + { key: 'missing', value: 'Missing Episodes' }, + { key: 'existing', value: 'Existing Episodes' }, + { key: 'firstSeason', value: 'Only First Season' }, + { key: 'latestSeason', value: 'Only Latest Season' }, + { key: 'none', value: 'None' } +]; + +export default monitorOptions; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js new file mode 100644 index 000000000..987680cb5 --- /dev/null +++ b/frontend/src/Utilities/State/getProviderState.js @@ -0,0 +1,35 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; + +function getProviderState(payload, getState, section) { + const { + id, + ...otherPayload + } = payload; + + const state = getSectionState(getState(), section, true); + const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); + const pendingFields = state.pendingChanges.fields || {}; + delete pendingChanges.fields; + + const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {}; + + if (item.fields) { + pendingChanges.fields = _.reduce(item.fields, (result, field) => { + const value = pendingFields.hasOwnProperty(field.name) ? + pendingFields[field.name] : + field.value; + + result.push({ + ...field, + value + }); + + return result; + }, []); + } + + return Object.assign({}, item, pendingChanges); +} + +export default getProviderState; diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js new file mode 100644 index 000000000..00871bed2 --- /dev/null +++ b/frontend/src/Utilities/State/getSectionState.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; + +function getSectionState(state, section, isFullStateTree = false) { + if (isFullStateTree) { + return _.get(state, section); + } + + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state[subSection]); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state[section]); + } + + return Object.assign({}, state); +} + +export default getSectionState; diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js new file mode 100644 index 000000000..c8a31760c --- /dev/null +++ b/frontend/src/Utilities/State/selectProviderSchema.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function applySchemaDefaults(selectedSchema, schemaDefaults) { + if (!schemaDefaults) { + return selectedSchema; + } else if (_.isFunction(schemaDefaults)) { + return schemaDefaults(selectedSchema); + } + + return Object.assign(selectedSchema, schemaDefaults); +} + +function selectProviderSchema(state, section, payload, schemaDefaults) { + const newState = getSectionState(state, section); + + const { + implementation, + presetName + } = payload; + + const selectedImplementation = _.find(newState.schema, { implementation }); + + const selectedSchema = presetName ? + _.find(selectedImplementation.presets, { name: presetName }) : + selectedImplementation; + + newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults); + + return updateSectionState(state, section, newState); +} + +export default selectProviderSchema; diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js new file mode 100644 index 000000000..81b33ecaf --- /dev/null +++ b/frontend/src/Utilities/State/updateSectionState.js @@ -0,0 +1,16 @@ +function updateSectionState(state, section, newState) { + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state, { [subSection]: newState }); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state, { [section]: newState }); + } + + return Object.assign({}, state, newState); +} + +export default updateSectionState; diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js new file mode 100644 index 000000000..9e4e9abe8 --- /dev/null +++ b/frontend/src/Utilities/String/combinePath.js @@ -0,0 +1,5 @@ +export default function combinePath(isWindows, basePath, paths = []) { + const slash = isWindows ? '\\' : '/'; + + return `${basePath}${slash}${paths.join(slash)}`; +} diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js new file mode 100644 index 000000000..51b15ec60 --- /dev/null +++ b/frontend/src/Utilities/String/generateUUIDv4.js @@ -0,0 +1,6 @@ +export default function generateUUIDv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => + // eslint-disable-next-line no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js new file mode 100644 index 000000000..1e7c3dff8 --- /dev/null +++ b/frontend/src/Utilities/String/isString.js @@ -0,0 +1,3 @@ +export default function isString(possibleString) { + return typeof possibleString === 'string' || possibleString instanceof String; +} diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js new file mode 100644 index 000000000..93341f85f --- /dev/null +++ b/frontend/src/Utilities/String/parseUrl.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import qs from 'qs'; + +// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils +const anchor = document.createElement('a'); + +export default function parseUrl(url) { + anchor.href = url; + + // The `origin`, `password`, and `username` properties are unavailable in + // Opera Presto. We synthesize `origin` if it's not present. While `password` + // and `username` are ignored intentionally. + const properties = _.pick( + anchor, + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search' + ); + + properties.isAbsolute = (/^[\w:]*\/\//).test(url); + + if (properties.search) { + // Remove leading ? from querystring before parsing. + properties.params = qs.parse(properties.search.substring(1)); + } else { + properties.params = {}; + } + + return properties; +} diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js new file mode 100644 index 000000000..0e57e7545 --- /dev/null +++ b/frontend/src/Utilities/String/split.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function split(input, separator = ',') { + if (!input) { + return []; + } + + return _.reduce(input.split(separator), (result, s) => { + if (s) { + result.push(s); + } + + return result; + }, []); +} + +export default split; diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js new file mode 100644 index 000000000..5b76c10dd --- /dev/null +++ b/frontend/src/Utilities/String/titleCase.js @@ -0,0 +1,11 @@ +function titleCase(input) { + if (!input) { + return ''; + } + + return input.replace(/\b\w+/g, (match) => { + return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase(); + }); +} + +export default titleCase; diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js new file mode 100644 index 000000000..26102f89b --- /dev/null +++ b/frontend/src/Utilities/Table/areAllSelected.js @@ -0,0 +1,17 @@ +export default function areAllSelected(selectedState) { + let allSelected = true; + let allUnselected = true; + + Object.keys(selectedState).forEach((key) => { + if (selectedState[key]) { + allUnselected = false; + } else { + allSelected = false; + } + }); + + return { + allSelected, + allUnselected + }; +} diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js new file mode 100644 index 000000000..705f13a5d --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function getSelectedIds(selectedState, { parseIds = true } = {}) { + return _.reduce(selectedState, (result, value, id) => { + if (value) { + const parsedId = parseIds ? parseInt(id) : id; + + result.push(parsedId); + } + + return result; + }, []); +} + +export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js new file mode 100644 index 000000000..c0cc44fe5 --- /dev/null +++ b/frontend/src/Utilities/Table/getToggledRange.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +function getToggledRange(items, id, lastToggled) { + const lastToggledIndex = _.findIndex(items, { id: lastToggled }); + const changedIndex = _.findIndex(items, { id }); + let lower = 0; + let upper = 0; + + if (lastToggledIndex > changedIndex) { + lower = changedIndex; + upper = lastToggledIndex + 1; + } else { + lower = lastToggledIndex; + upper = changedIndex; + } + + return { + lower, + upper + }; +} + +export default getToggledRange; diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js new file mode 100644 index 000000000..ff3a4fe11 --- /dev/null +++ b/frontend/src/Utilities/Table/removeOldSelectedState.js @@ -0,0 +1,16 @@ +import areAllSelected from './areAllSelected'; + +export default function removeOldSelectedState(state, prevItems) { + const selectedState = { + ...state.selectedState + }; + + prevItems.forEach((item) => { + delete selectedState[item.id]; + }); + + return { + ...areAllSelected(selectedState), + selectedState + }; +} diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js new file mode 100644 index 000000000..ffaaeaddf --- /dev/null +++ b/frontend/src/Utilities/Table/selectAll.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function selectAll(selectedState, selected) { + const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => { + result[item] = selected; + return result; + }, {}); + + return { + allSelected: selected, + allUnselected: !selected, + lastToggled: null, + selectedState: newSelectedState + }; +} + +export default selectAll; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js new file mode 100644 index 000000000..dbc0d6223 --- /dev/null +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -0,0 +1,30 @@ +import areAllSelected from './areAllSelected'; +import getToggledRange from './getToggledRange'; + +function toggleSelected(state, items, id, selected, shiftKey) { + const lastToggled = state.lastToggled; + const selectedState = { + ...state.selectedState, + [id]: selected + }; + + if (selected == null) { + delete selectedState[id]; + } + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(items, id, lastToggled); + + for (let i = lower; i < upper; i++) { + selectedState[items[i].id] = selected; + } + } + + return { + ...areAllSelected(selectedState), + lastToggled: id, + selectedState + }; +} + +export default toggleSelected; diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js new file mode 100644 index 000000000..7ad8961da --- /dev/null +++ b/frontend/src/Utilities/createAjaxRequest.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; + +export default function createAjaxRequest(ajaxOptions) { + const requestXHR = new window.XMLHttpRequest(); + let aborted = false; + let complete = false; + + function abortRequest() { + if (!complete) { + aborted = true; + requestXHR.abort(); + } + } + + const request = $.ajax({ + xhr: () => requestXHR, + ...ajaxOptions + }).then(null, (xhr, textStatus, errorThrown) => { + xhr.aborted = aborted; + + return $.Deferred().reject(xhr, textStatus, errorThrown).promise(); + }).always(() => { + complete = true; + }); + + return { + request, + abortRequest + }; +} diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js new file mode 100644 index 000000000..60533d3d3 --- /dev/null +++ b/frontend/src/Utilities/getPathWithUrlBase.js @@ -0,0 +1,3 @@ +export default function getPathWithUrlBase(path) { + return `${window.Sonarr.urlBase}${path}`; +} diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js new file mode 100644 index 000000000..dae5150b7 --- /dev/null +++ b/frontend/src/Utilities/getUniqueElementId.js @@ -0,0 +1,7 @@ +let i = 0; + +// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) + +export default function getUniqueElementId() { + return `id-${i++}`; +} diff --git a/frontend/src/Utilities/isMobile.js b/frontend/src/Utilities/isMobile.js new file mode 100644 index 000000000..489020a23 --- /dev/null +++ b/frontend/src/Utilities/isMobile.js @@ -0,0 +1,7 @@ +import MobileDetect from 'mobile-detect'; + +export default function isMobile() { + const mobileDetect = new MobileDetect(window.navigator.userAgent); + + return mobileDetect.mobile() != null; +} diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js new file mode 100644 index 000000000..f58dbe803 --- /dev/null +++ b/frontend/src/Utilities/pagePopulator.js @@ -0,0 +1,28 @@ +let currentPopulator = null; +let currentReasons = []; + +export function registerPagePopulator(populator, reasons = []) { + currentPopulator = populator; + currentReasons = reasons; +} + +export function unregisterPagePopulator(populator) { + if (currentPopulator === populator) { + currentPopulator = null; + currentReasons = []; + } +} + +export function repopulatePage(reason) { + if (!currentPopulator) { + return; + } + + if (!reason) { + currentPopulator(); + } + + if (reason && currentReasons.includes(reason)) { + currentPopulator(); + } +} diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js new file mode 100644 index 000000000..1355442d9 --- /dev/null +++ b/frontend/src/Utilities/pages.js @@ -0,0 +1,9 @@ +const pages = { + FIRST: 'first', + PREVIOUS: 'previous', + NEXT: 'next', + LAST: 'last', + EXACT: 'exact' +}; + +export default pages; diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js new file mode 100644 index 000000000..3f2564a7b --- /dev/null +++ b/frontend/src/Utilities/requestAction.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import _ from 'lodash'; + +function flattenProviderData(providerData) { + return _.reduce(Object.keys(providerData), (result, key) => { + const property = providerData[key]; + + if (key === 'fields') { + result[key] = property; + } else { + result[key] = property.value; + } + + return result; + }, {}); +} + +function requestAction(payload) { + const { + provider, + action, + providerData, + queryParams + } = payload; + + const ajaxOptions = { + url: `/${provider}/action/${action}`, + contentType: 'application/json', + method: 'POST', + data: JSON.stringify(flattenProviderData(providerData)) + }; + + if (queryParams) { + ajaxOptions.url += `?${$.param(queryParams, true)}`; + } + + return $.ajax(ajaxOptions); +} + +export default requestAction; diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js new file mode 100644 index 000000000..5479b32b9 --- /dev/null +++ b/frontend/src/Utilities/sectionTypes.js @@ -0,0 +1,6 @@ +const sectionTypes = { + COLLECTION: 'collection', + MODEL: 'model' +}; + +export default sectionTypes; diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js new file mode 100644 index 000000000..03fa39c00 --- /dev/null +++ b/frontend/src/Utilities/serverSideCollectionHandlers.js @@ -0,0 +1,12 @@ +const serverSideCollectionHandlers = { + FETCH: 'fetch', + FIRST_PAGE: 'firstPage', + PREVIOUS_PAGE: 'previousPage', + NEXT_PAGE: 'nextPage', + LAST_PAGE: 'lastPage', + EXACT_PAGE: 'exactPage', + SORT: 'sort', + FILTER: 'filter' +}; + +export default serverSideCollectionHandlers; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js new file mode 100644 index 000000000..e12d3a160 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import CutoffUnmetRowConnector from './CutoffUnmetRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class CutoffUnmet extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllCutoffUnmetModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onFilterMenuItemPress = (filterKey, filterValue) => { + this.props.onFilterSelect(filterKey, filterValue); + } + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const episodeIds = this.getSelectedIds(); + + this.props.batchToggleCutoffUnmetEpisodes({ + episodeIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true }); + } + + onSearchAllCutoffUnmetConfirmed = () => { + this.props.onSearchAllCutoffUnmetPress(); + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + onConfirmSearchAllCutoffUnmetModalClose = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForCutoffUnmetEpisodes, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllCutoffUnmetModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
    + Error fetching cutoff unmet +
    + } + + { + isPopulated && !error && !items.length && +
    + No cutoff unmet items +
    + } + + { + isPopulated && !error && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + + + + +
    + Are you sure you want to search for all {totalRecords} Cutoff Unmet episodes? +
    +
    + This cannot be cancelled once started without restarting Sonarr. +
    +
    + } + confirmLabel="Search" + onConfirm={this.onSearchAllCutoffUnmetConfirmed} + onCancel={this.onConfirmSearchAllCutoffUnmetModalClose} + /> +
    + } + + + ); + } +} + +CutoffUnmet.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForCutoffUnmetEpisodes: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleCutoffUnmetEpisodes: PropTypes.func.isRequired, + onSearchAllCutoffUnmetPress: PropTypes.func.isRequired +}; + +export default CutoffUnmet; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js new file mode 100644 index 000000000..398d1ccc5 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import * as commandNames from 'Commands/commandNames'; +import CutoffUnmet from './CutoffUnmet'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.cutoffUnmet, + createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH), + (cutoffUnmet, isSearchingForCutoffUnmetEpisodes) => { + return { + isSearchingForCutoffUnmetEpisodes, + isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1, + ...cutoffUnmet + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails, + fetchEpisodeFiles, + clearEpisodeFiles +}; + +class CutoffUnmetConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchCutoffUnmet, + gotoCutoffUnmetFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['episodeFileUpdated']); + + if (useCurrentPage) { + fetchCutoffUnmet(); + } else { + gotoCutoffUnmetFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'id'); + const episodeFileIds = selectUniqueIds(this.props.items, 'episodeFileId'); + + this.props.fetchQueueDetails({ episodeIds }); + + if (episodeFileIds.length) { + this.props.fetchEpisodeFiles({ episodeFileIds }); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCutoffUnmet(); + this.props.clearQueueDetails(); + this.props.clearEpisodeFiles(); + } + + // + // Control + + repopulate = () => { + this.props.fetchCutoffUnmet(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoCutoffUnmetFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoCutoffUnmetPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoCutoffUnmetNextPage(); + } + + onLastPagePress = () => { + this.props.gotoCutoffUnmetLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoCutoffUnmetPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setCutoffUnmetSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setCutoffUnmetFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setCutoffUnmetTableOption(payload); + + if (payload.pageSize) { + this.props.gotoCutoffUnmetFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: selected + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.props.executeCommand({ + name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CutoffUnmetConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchCutoffUnmet: PropTypes.func.isRequired, + gotoCutoffUnmetFirstPage: PropTypes.func.isRequired, + gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired, + gotoCutoffUnmetNextPage: PropTypes.func.isRequired, + gotoCutoffUnmetLastPage: PropTypes.func.isRequired, + gotoCutoffUnmetPage: PropTypes.func.isRequired, + setCutoffUnmetSort: PropTypes.func.isRequired, + setCutoffUnmetFilter: PropTypes.func.isRequired, + setCutoffUnmetTableOption: PropTypes.func.isRequired, + clearCutoffUnmet: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector) +); diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css new file mode 100644 index 000000000..934076d15 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css @@ -0,0 +1,7 @@ +.episode, +.language, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js new file mode 100644 index 000000000..de0b0e161 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -0,0 +1,175 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './CutoffUnmetRow.css'; + +function CutoffUnmetRow(props) { + const { + id, + episodeFileId, + series, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + airDateUtc, + title, + isSelected, + columns, + onSelectedChange + } = props; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +CutoffUnmetRow.propTypes = { + id: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + series: PropTypes.object.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + airDateUtc: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default CutoffUnmetRow; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js new file mode 100644 index 000000000..56a4815be --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import CutoffUnmetRow from './CutoffUnmetRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +export default connect(createMapStateToProps)(CutoffUnmetRow); diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js new file mode 100644 index 000000000..a80886e60 --- /dev/null +++ b/frontend/src/Wanted/Missing/Missing.js @@ -0,0 +1,298 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import MissingRowConnector from './MissingRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class Missing extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllMissingModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const episodeIds = this.getSelectedIds(); + + this.props.batchToggleMissingEpisodes({ + episodeIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllMissingPress = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: true }); + } + + onSearchAllMissingConfirmed = () => { + this.props.onSearchAllMissingPress(); + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onConfirmSearchAllMissingModalClose = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForMissingEpisodes, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllMissingModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
    + Error fetching missing items +
    + } + + { + isPopulated && !error && !items.length && +
    + No missing items +
    + } + + { + isPopulated && !error && !!items.length && +
    + + + { + items.map((item) => { + return ( + + ); + }) + } + +
    + + + + +
    + Are you sure you want to search for all {totalRecords} missing episodes? +
    +
    + This cannot be cancelled once started without restarting Sonarr. +
    +
    + } + confirmLabel="Search" + onConfirm={this.onSearchAllMissingConfirmed} + onCancel={this.onConfirmSearchAllMissingModalClose} + /> + + +
    + } + + + ); + } +} + +Missing.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForMissingEpisodes: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleMissingEpisodes: PropTypes.func.isRequired, + onSearchAllMissingPress: PropTypes.func.isRequired +}; + +export default Missing; diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js new file mode 100644 index 000000000..a1632c6dd --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import * as commandNames from 'Commands/commandNames'; +import Missing from './Missing'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.missing, + createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH), + (missing, isSearchingForMissingEpisodes) => { + return { + isSearchingForMissingEpisodes, + isSaving: missing.items.filter((m) => m.isSaving).length > 1, + ...missing + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails +}; + +class MissingConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchMissing, + gotoMissingFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['episodeFileUpdated']); + + if (useCurrentPage) { + fetchMissing(); + } else { + gotoMissingFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'id'); + this.props.fetchQueueDetails({ episodeIds }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearMissing(); + this.props.clearQueueDetails(); + } + + // + // Control + + repopulate = () => { + this.props.fetchMissing(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoMissingFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoMissingPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoMissingNextPage(); + } + + onLastPagePress = () => { + this.props.gotoMissingLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoMissingPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setMissingSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setMissingFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setMissingTableOption(payload); + + if (payload.pageSize) { + this.props.gotoMissingFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: selected + }); + } + + onSearchAllMissingPress = () => { + this.props.executeCommand({ + name: commandNames.MISSING_EPISODE_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MissingConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchMissing: PropTypes.func.isRequired, + gotoMissingFirstPage: PropTypes.func.isRequired, + gotoMissingPreviousPage: PropTypes.func.isRequired, + gotoMissingNextPage: PropTypes.func.isRequired, + gotoMissingLastPage: PropTypes.func.isRequired, + gotoMissingPage: PropTypes.func.isRequired, + setMissingSort: PropTypes.func.isRequired, + setMissingFilter: PropTypes.func.isRequired, + setMissingTableOption: PropTypes.func.isRequired, + clearMissing: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(MissingConnector) +); diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css new file mode 100644 index 000000000..3ec895d66 --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.css @@ -0,0 +1,6 @@ +.episode, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js new file mode 100644 index 000000000..a8de63bd4 --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './MissingRow.css'; + +function MissingRow(props) { + const { + id, + episodeFileId, + series, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + airDateUtc, + title, + isSelected, + columns, + onSelectedChange + } = props; + + if (!series) { + return null; + } + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +MissingRow.propTypes = { + id: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + series: PropTypes.object.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + airDateUtc: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default MissingRow; diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js new file mode 100644 index 000000000..f7eefca4d --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import MissingRow from './MissingRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +export default connect(createMapStateToProps)(MissingRow); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..04e0e11ef --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,15 @@ +html, +body { + height: 100%; /* needed for proper layout */ +} + +body { + overflow: hidden; + background-color: #f5f7fa; +} + +@media only screen and (max-width: $breakpointSmall) { + body { + overflow-y: auto; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 000000000..99fbfb103 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonarr (Preview) + + + + + + + +
    + + + + + + + + + diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 000000000..396a7971c --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-dom'; +import createHistory from 'history/createBrowserHistory'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; +import 'Styles/globals.css'; +import './index.css'; + +const history = createHistory(); +const store = createAppStore(history); + +render( + , + document.getElementById('root') +); diff --git a/frontend/src/jQuery/jquery.ajax.js b/frontend/src/jQuery/jquery.ajax.js new file mode 100644 index 000000000..9b217801e --- /dev/null +++ b/frontend/src/jQuery/jquery.ajax.js @@ -0,0 +1,47 @@ +import $ from 'jquery'; + +const absUrlRegex = /^(https?:)?\/\//i; +const apiRoot = window.Sonarr.apiRoot; +const urlBase = window.Sonarr.urlBase; + +function isRelative(xhr) { + return !absUrlRegex.test(xhr.url); +} + +function moveBodyToQuery(xhr) { + if (xhr.data && xhr.type === 'DELETE') { + if (xhr.url.contains('?')) { + xhr.url += '&'; + } else { + xhr.url += '?'; + } + xhr.url += $.param(xhr.data); + delete xhr.data; + } +} + +function addRootUrl(xhr) { + const url = xhr.url; + if (url.startsWith('/signalr')) { + xhr.url = urlBase + xhr.url; + } else { + xhr.url = apiRoot + xhr.url; + } +} + +function addApiKey(xhr) { + xhr.headers = xhr.headers || {}; + xhr.headers['X-Api-Key'] = window.Sonarr.apiKey; +} + +export default function() { + const originalAjax = $.ajax; + $.ajax = function(xhr) { + if (xhr && isRelative(xhr)) { + moveBodyToQuery(xhr); + addRootUrl(xhr); + addApiKey(xhr); + } + return originalAjax.apply(this, arguments); + }; +} diff --git a/frontend/src/login.html b/frontend/src/login.html new file mode 100644 index 000000000..930018edf --- /dev/null +++ b/frontend/src/login.html @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Login - Sonarr + + + + + +
    +
    +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + +
    + +
    + + + + + + Forgot your password? +
    + + + +
    +
    +
    + + +
    +
    + + + + diff --git a/frontend/src/oauth.html b/frontend/src/oauth.html new file mode 100644 index 000000000..16a34dbf3 --- /dev/null +++ b/frontend/src/oauth.html @@ -0,0 +1,13 @@ + + + + + OAuth landing page + + + + Shouldn't see this + + diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js new file mode 100644 index 000000000..b5d17d598 --- /dev/null +++ b/frontend/src/polyfills.js @@ -0,0 +1,41 @@ +/* eslint no-empty-function: 0 no-extend-native: 0 */ + +window.console = window.console || {}; +window.console.log = window.console.log || function() {}; +window.console.group = window.console.group || function() {}; +window.console.groupEnd = window.console.groupEnd || function() {}; +window.console.debug = window.console.debug || function() {}; +window.console.warn = window.console.warn || function() {}; +window.console.assert = window.console.assert || function() {}; + +if (!String.prototype.startsWith) { + Object.defineProperty(String.prototype, 'startsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || 0; + return this.indexOf(searchString, position) === position; + } + }); +} + +if (!String.prototype.endsWith) { + Object.defineProperty(String.prototype, 'endsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || this.length; + position = position - searchString.length; + const lastIndex = this.lastIndexOf(searchString); + return lastIndex !== -1 && lastIndex === position; + } + }); +} + +if (!('contains' in String.prototype)) { + String.prototype.contains = function(str, startIndex) { + return String.prototype.indexOf.call(this, str, startIndex) !== -1; + }; +} diff --git a/frontend/src/preload.js b/frontend/src/preload.js new file mode 100644 index 000000000..674699db9 --- /dev/null +++ b/frontend/src/preload.js @@ -0,0 +1,4 @@ +/* eslint no-undef: 0 */ +import 'Shims/jquery'; + +__webpack_public_path__ = `${window.Sonarr.urlBase}/`; diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js new file mode 100644 index 000000000..68394a5f7 --- /dev/null +++ b/frontend/src/vendor.js @@ -0,0 +1,28 @@ +/* Base */ +// require('jquery'); +require('lodash'); +require('moment'); +// require('signalR'); +// require('jquery-ui'); +// require('jquery.easypiechart'); +// require('jquery.dotdotdot'); +// require('typeahead'); +// require('zero.clipboard'); + +/* Bootstrap */ +// require('bootstrap'); +// require('bootstrap.tagsinput'); + +/* Backbone */ +// require('backbone'); +// require('backbone.deepmodel'); +// require('backbone.paginator'); + +// require('backbone.modelbinder'); +// require('backbone.collectionview'); +// require('backgrid'); +// require('backgrid.paginator'); +// require('backgrid.selectall'); + +// require('marionette'); // this brings in a bunch of our code into this chunk because of template helpers. +// require('vent'); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json deleted file mode 100644 index dfdacfc11..000000000 --- a/npm-shrinkwrap.json +++ /dev/null @@ -1,4809 +0,0 @@ -{ - "name": "Sonarr", - "version": "2.0.0", - "dependencies": { - "acorn": { - "version": "3.1.0", - "from": "acorn@>=3.1.0 <4.0.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.1.0.tgz" - }, - "acorn-jsx": { - "version": "3.0.1", - "from": "acorn-jsx@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz" - }, - "acorn-to-esprima": { - "version": "2.0.8", - "from": "acorn-to-esprima@>=2.0.6 <3.0.0", - "resolved": "https://registry.npmjs.org/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz" - }, - "align-text": { - "version": "0.1.4", - "from": "align-text@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz" - }, - "alphanum-sort": { - "version": "1.0.2", - "from": "alphanum-sort@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz" - }, - "amdefine": { - "version": "1.0.0", - "from": "amdefine@>=0.0.4", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz" - }, - "ansi-escapes": { - "version": "1.4.0", - "from": "ansi-escapes@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz" - }, - "ansi-regex": { - "version": "2.0.0", - "from": "ansi-regex@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" - }, - "ansi-styles": { - "version": "2.2.1", - "from": "ansi-styles@>=2.2.1 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" - }, - "anymatch": { - "version": "1.3.0", - "from": "anymatch@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.0.tgz" - }, - "archy": { - "version": "1.0.0", - "from": "archy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz" - }, - "argparse": { - "version": "1.0.7", - "from": "argparse@>=1.0.7 <2.0.0", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.7.tgz" - }, - "arr-diff": { - "version": "2.0.0", - "from": "arr-diff@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz" - }, - "arr-flatten": { - "version": "1.0.1", - "from": "arr-flatten@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.1.tgz" - }, - "array-differ": { - "version": "1.0.0", - "from": "array-differ@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz" - }, - "array-find-index": { - "version": "1.0.1", - "from": "array-find-index@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.1.tgz" - }, - "array-union": { - "version": "1.0.1", - "from": "array-union@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.1.tgz" - }, - "array-uniq": { - "version": "1.0.2", - "from": "array-uniq@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.2.tgz" - }, - "array-unique": { - "version": "0.2.1", - "from": "array-unique@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz" - }, - "arrify": { - "version": "1.0.1", - "from": "arrify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz" - }, - "asap": { - "version": "2.0.4", - "from": "asap@>=2.0.3 <2.1.0", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.4.tgz" - }, - "assert": { - "version": "1.4.0", - "from": "assert@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.0.tgz" - }, - "assertion-error": { - "version": "1.0.1", - "from": "assertion-error@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.1.tgz" - }, - "async": { - "version": "0.2.10", - "from": "async@>=0.2.6 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - }, - "async-each": { - "version": "1.0.0", - "from": "async-each@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.0.tgz" - }, - "autoprefixer": { - "version": "6.3.6", - "from": "autoprefixer@6.3.6", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.3.6.tgz" - }, - "babel-code-frame": { - "version": "6.8.0", - "from": "babel-code-frame@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.8.0.tgz" - }, - "babel-core": { - "version": "6.9.0", - "from": "babel-core@6.9.0", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.9.0.tgz" - }, - "babel-eslint": { - "version": "7.1.0", - "from": "babel-eslint@7.1.0", - "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.1.0.tgz", - "dependencies": { - "babel-code-frame": { - "version": "6.16.0", - "from": "babel-code-frame@>=6.16.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.16.0.tgz" - }, - "babel-traverse": { - "version": "6.18.0", - "from": "babel-traverse@>=6.15.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.18.0.tgz" - }, - "babel-types": { - "version": "6.18.0", - "from": "babel-types@>=6.15.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.18.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.18.0", - "from": "babel-runtime@>=6.9.1 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.18.0.tgz" - } - } - }, - "babylon": { - "version": "6.13.1", - "from": "babylon@>=6.11.2 <7.0.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.13.1.tgz" - }, - "globals": { - "version": "9.12.0", - "from": "globals@>=9.0.0 <10.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.12.0.tgz" - }, - "js-tokens": { - "version": "2.0.0", - "from": "js-tokens@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-2.0.0.tgz" - } - } - }, - "babel-generator": { - "version": "6.9.0", - "from": "babel-generator@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.9.0.tgz" - }, - "babel-helper-builder-binary-assignment-operator-visitor": { - "version": "6.8.0", - "from": "babel-helper-builder-binary-assignment-operator-visitor@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.8.0.tgz" - }, - "babel-helper-builder-react-jsx": { - "version": "6.22.0", - "from": "babel-helper-builder-react-jsx@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "babel-types": { - "version": "6.22.0", - "from": "babel-types@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-helper-call-delegate": { - "version": "6.8.0", - "from": "babel-helper-call-delegate@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.8.0.tgz" - }, - "babel-helper-define-map": { - "version": "6.9.0", - "from": "babel-helper-define-map@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.9.0.tgz" - }, - "babel-helper-explode-assignable-expression": { - "version": "6.8.0", - "from": "babel-helper-explode-assignable-expression@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.8.0.tgz" - }, - "babel-helper-function-name": { - "version": "6.8.0", - "from": "babel-helper-function-name@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.8.0.tgz" - }, - "babel-helper-get-function-arity": { - "version": "6.8.0", - "from": "babel-helper-get-function-arity@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.8.0.tgz" - }, - "babel-helper-hoist-variables": { - "version": "6.8.0", - "from": "babel-helper-hoist-variables@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.8.0.tgz" - }, - "babel-helper-optimise-call-expression": { - "version": "6.8.0", - "from": "babel-helper-optimise-call-expression@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.8.0.tgz" - }, - "babel-helper-regex": { - "version": "6.9.0", - "from": "babel-helper-regex@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.9.0.tgz" - }, - "babel-helper-remap-async-to-generator": { - "version": "6.11.2", - "from": "babel-helper-remap-async-to-generator@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.11.2.tgz" - }, - "babel-helper-replace-supers": { - "version": "6.8.0", - "from": "babel-helper-replace-supers@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.8.0.tgz" - }, - "babel-helpers": { - "version": "6.8.0", - "from": "babel-helpers@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.8.0.tgz" - }, - "babel-loader": { - "version": "6.2.4", - "from": "babel-loader@6.2.4", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-6.2.4.tgz" - }, - "babel-messages": { - "version": "6.8.0", - "from": "babel-messages@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.8.0.tgz" - }, - "babel-plugin-check-es2015-constants": { - "version": "6.8.0", - "from": "babel-plugin-check-es2015-constants@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.8.0.tgz" - }, - "babel-plugin-syntax-async-functions": { - "version": "6.8.0", - "from": "babel-plugin-syntax-async-functions@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.8.0.tgz" - }, - "babel-plugin-syntax-class-properties": { - "version": "6.13.0", - "from": "babel-plugin-syntax-class-properties@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz" - }, - "babel-plugin-syntax-decorators": { - "version": "6.8.0", - "from": "babel-plugin-syntax-decorators@>=6.1.18 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.8.0.tgz" - }, - "babel-plugin-syntax-exponentiation-operator": { - "version": "6.8.0", - "from": "babel-plugin-syntax-exponentiation-operator@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.8.0.tgz" - }, - "babel-plugin-syntax-flow": { - "version": "6.18.0", - "from": "babel-plugin-syntax-flow@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz" - }, - "babel-plugin-syntax-jsx": { - "version": "6.18.0", - "from": "babel-plugin-syntax-jsx@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz" - }, - "babel-plugin-syntax-object-rest-spread": { - "version": "6.8.0", - "from": "babel-plugin-syntax-object-rest-spread@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.8.0.tgz" - }, - "babel-plugin-syntax-trailing-function-commas": { - "version": "6.8.0", - "from": "babel-plugin-syntax-trailing-function-commas@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.8.0.tgz" - }, - "babel-plugin-transform-async-to-generator": { - "version": "6.8.0", - "from": "babel-plugin-transform-async-to-generator@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.8.0.tgz" - }, - "babel-plugin-transform-class-properties": { - "version": "6.16.0", - "from": "babel-plugin-transform-class-properties@6.16.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.16.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.11.6", - "from": "babel-runtime@>=6.9.1 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.11.6.tgz" - } - } - }, - "babel-plugin-transform-decorators-legacy": { - "version": "1.3.4", - "from": "babel-plugin-transform-decorators-legacy@>=1.3.4 <2.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz" - }, - "babel-plugin-transform-es2015-arrow-functions": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-arrow-functions@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-block-scoped-functions": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-block-scoped-functions@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-block-scoping": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-block-scoping@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-classes": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-classes@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-computed-properties": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-computed-properties@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-destructuring": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-destructuring@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-duplicate-keys": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-duplicate-keys@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-for-of": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-for-of@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-function-name": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-function-name@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-literals": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-literals@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-modules-commonjs": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-modules-commonjs@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-object-super": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-object-super@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-parameters": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-parameters@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-shorthand-properties": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-shorthand-properties@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-spread": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-spread@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-sticky-regex": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-sticky-regex@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-template-literals": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-template-literals@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-typeof-symbol": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-typeof-symbol@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-unicode-regex": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-unicode-regex@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.8.0.tgz" - }, - "babel-plugin-transform-exponentiation-operator": { - "version": "6.8.0", - "from": "babel-plugin-transform-exponentiation-operator@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.8.0.tgz" - }, - "babel-plugin-transform-flow-strip-types": { - "version": "6.22.0", - "from": "babel-plugin-transform-flow-strip-types@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-object-rest-spread": { - "version": "6.8.0", - "from": "babel-plugin-transform-object-rest-spread@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.8.0.tgz" - }, - "babel-plugin-transform-react-display-name": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-display-name@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx-self": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx-self@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx-source": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx-source@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-regenerator": { - "version": "6.9.0", - "from": "babel-plugin-transform-regenerator@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.9.0.tgz" - }, - "babel-plugin-transform-strict-mode": { - "version": "6.8.0", - "from": "babel-plugin-transform-strict-mode@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.8.0.tgz" - }, - "babel-preset-decorators-legacy": { - "version": "1.0.0", - "from": "babel-preset-decorators-legacy@1.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz" - }, - "babel-preset-es2015": { - "version": "6.9.0", - "from": "babel-preset-es2015@6.9.0", - "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.9.0.tgz" - }, - "babel-preset-react": { - "version": "6.22.0", - "from": "babel-preset-react@6.22.0", - "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.22.0.tgz" - }, - "babel-preset-stage-2": { - "version": "6.5.0", - "from": "babel-preset-stage-2@6.5.0", - "resolved": "https://registry.npmjs.org/babel-preset-stage-2/-/babel-preset-stage-2-6.5.0.tgz" - }, - "babel-preset-stage-3": { - "version": "6.11.0", - "from": "babel-preset-stage-3@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-stage-3/-/babel-preset-stage-3-6.11.0.tgz" - }, - "babel-register": { - "version": "6.9.0", - "from": "babel-register@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.9.0.tgz" - }, - "babel-runtime": { - "version": "6.9.0", - "from": "babel-runtime@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.9.0.tgz" - }, - "babel-template": { - "version": "6.9.0", - "from": "babel-template@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.9.0.tgz" - }, - "babel-traverse": { - "version": "6.9.0", - "from": "babel-traverse@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.9.0.tgz" - }, - "babel-types": { - "version": "6.9.0", - "from": "babel-types@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.9.0.tgz" - }, - "babylon": { - "version": "6.8.0", - "from": "babylon@>=6.7.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.8.0.tgz" - }, - "balanced-match": { - "version": "0.4.1", - "from": "balanced-match@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz" - }, - "Base64": { - "version": "0.2.1", - "from": "Base64@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/Base64/-/Base64-0.2.1.tgz" - }, - "base64-js": { - "version": "0.0.8", - "from": "base64-js@0.0.8", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz" - }, - "beeper": { - "version": "1.1.0", - "from": "beeper@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.0.tgz" - }, - "big.js": { - "version": "3.1.3", - "from": "big.js@>=3.1.3 <4.0.0", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.1.3.tgz" - }, - "binary-extensions": { - "version": "1.4.0", - "from": "binary-extensions@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.4.0.tgz" - }, - "bl": { - "version": "0.7.0", - "from": "bl@>=0.7.0 <0.8.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-0.7.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - } - } - }, - "block-stream": { - "version": "0.0.9", - "from": "block-stream@*", - "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz" - }, - "bluebird": { - "version": "3.4.0", - "from": "bluebird@>=3.1.1 <4.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.0.tgz" - }, - "body-parser": { - "version": "1.14.2", - "from": "body-parser@>=1.14.0 <1.15.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.14.2.tgz", - "dependencies": { - "qs": { - "version": "5.2.0", - "from": "qs@5.2.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.0.tgz" - } - } - }, - "brace-expansion": { - "version": "1.1.4", - "from": "brace-expansion@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz" - }, - "braces": { - "version": "1.8.5", - "from": "braces@>=1.8.2 <2.0.0", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz" - }, - "browserify-zlib": { - "version": "0.1.4", - "from": "browserify-zlib@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz" - }, - "browserslist": { - "version": "1.3.4", - "from": "browserslist@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.3.4.tgz" - }, - "buffer": { - "version": "3.6.0", - "from": "buffer@>=3.0.3 <4.0.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-3.6.0.tgz" - }, - "buffer-shims": { - "version": "1.0.0", - "from": "buffer-shims@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz" - }, - "bufferstreams": { - "version": "1.0.1", - "from": "bufferstreams@1.0.1", - "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.33 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "builtin-modules": { - "version": "1.1.1", - "from": "builtin-modules@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz" - }, - "bytes": { - "version": "2.2.0", - "from": "bytes@2.2.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.2.0.tgz" - }, - "caller-path": { - "version": "0.1.0", - "from": "caller-path@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz" - }, - "callsites": { - "version": "0.2.0", - "from": "callsites@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz" - }, - "camelcase": { - "version": "2.1.1", - "from": "camelcase@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz" - }, - "camelcase-keys": { - "version": "2.1.0", - "from": "camelcase-keys@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz" - }, - "caniuse-db": { - "version": "1.0.30000492", - "from": "caniuse-db@>=1.0.30000444 <2.0.0", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000492.tgz" - }, - "center-align": { - "version": "0.1.3", - "from": "center-align@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz" - }, - "chai": { - "version": "3.5.0", - "from": "chai@>=3.2.0 <4.0.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz" - }, - "chalk": { - "version": "1.1.3", - "from": "chalk@>=1.1.3 <2.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "dependencies": { - "supports-color": { - "version": "2.0.0", - "from": "supports-color@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" - } - } - }, - "chokidar": { - "version": "1.5.1", - "from": "chokidar@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.5.1.tgz" - }, - "circular-json": { - "version": "0.3.1", - "from": "circular-json@>=0.3.1 <0.4.0", - "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.1.tgz" - }, - "clap": { - "version": "1.1.1", - "from": "clap@>=1.0.9 <2.0.0", - "resolved": "https://registry.npmjs.org/clap/-/clap-1.1.1.tgz" - }, - "classnames": { - "version": "2.2.5", - "from": "classnames@2.2.5", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz" - }, - "cli-cursor": { - "version": "1.0.2", - "from": "cli-cursor@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz" - }, - "cli-width": { - "version": "2.1.0", - "from": "cli-width@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.1.0.tgz" - }, - "cliui": { - "version": "2.1.0", - "from": "cliui@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "dependencies": { - "wordwrap": { - "version": "0.0.2", - "from": "wordwrap@0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" - } - } - }, - "clone": { - "version": "1.0.2", - "from": "clone@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz" - }, - "clone-regexp": { - "version": "1.0.0", - "from": "clone-regexp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.0.tgz" - }, - "clone-stats": { - "version": "0.0.1", - "from": "clone-stats@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz" - }, - "coa": { - "version": "1.0.1", - "from": "coa@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.1.tgz" - }, - "code-point-at": { - "version": "1.0.0", - "from": "code-point-at@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.0.0.tgz" - }, - "color": { - "version": "0.11.3", - "from": "color@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/color/-/color-0.11.3.tgz" - }, - "color-convert": { - "version": "1.3.1", - "from": "color-convert@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.3.1.tgz" - }, - "color-diff": { - "version": "0.1.7", - "from": "color-diff@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-0.1.7.tgz" - }, - "color-name": { - "version": "1.1.1", - "from": "color-name@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz" - }, - "color-string": { - "version": "0.3.0", - "from": "color-string@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz" - }, - "colorguard": { - "version": "1.2.0", - "from": "colorguard@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/colorguard/-/colorguard-1.2.0.tgz", - "dependencies": { - "yargs": { - "version": "1.3.3", - "from": "yargs@>=1.2.6 <2.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz" - } - } - }, - "colormin": { - "version": "1.1.0", - "from": "colormin@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.0.tgz" - }, - "colors": { - "version": "1.1.2", - "from": "colors@>=1.1.2 <1.2.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz" - }, - "commander": { - "version": "2.9.0", - "from": "commander@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" - }, - "concat-map": { - "version": "0.0.1", - "from": "concat-map@0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" - }, - "concat-stream": { - "version": "1.5.1", - "from": "concat-stream@>=1.4.7 <2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.1.tgz" - }, - "concat-with-sourcemaps": { - "version": "1.0.4", - "from": "concat-with-sourcemaps@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz" - }, - "console-browserify": { - "version": "1.1.0", - "from": "console-browserify@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz" - }, - "consolidate": { - "version": "0.14.1", - "from": "consolidate@>=0.14.1 <0.15.0", - "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.14.1.tgz" - }, - "constants-browserify": { - "version": "0.0.1", - "from": "constants-browserify@0.0.1", - "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-0.0.1.tgz" - }, - "content-type": { - "version": "1.0.2", - "from": "content-type@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz" - }, - "convert-source-map": { - "version": "1.2.0", - "from": "convert-source-map@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.2.0.tgz" - }, - "core-js": { - "version": "2.4.0", - "from": "core-js@>=2.4.0 <3.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.0.tgz" - }, - "core-util-is": { - "version": "1.0.2", - "from": "core-util-is@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" - }, - "cosmiconfig": { - "version": "1.1.0", - "from": "cosmiconfig@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-1.1.0.tgz" - }, - "crypto-browserify": { - "version": "3.2.8", - "from": "crypto-browserify@>=3.2.6 <3.3.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.2.8.tgz" - }, - "css-color-names": { - "version": "0.0.3", - "from": "css-color-names@0.0.3", - "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.3.tgz" - }, - "css-loader": { - "version": "0.23.1", - "from": "css-loader@0.23.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.23.1.tgz" - }, - "css-rule-stream": { - "version": "1.1.0", - "from": "css-rule-stream@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/css-rule-stream/-/css-rule-stream-1.1.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "css-selector-tokenizer": { - "version": "0.5.4", - "from": "css-selector-tokenizer@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.5.4.tgz" - }, - "css-tokenize": { - "version": "1.0.1", - "from": "css-tokenize@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/css-tokenize/-/css-tokenize-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.33 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "cssesc": { - "version": "0.1.0", - "from": "cssesc@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz" - }, - "cssnano": { - "version": "3.7.1", - "from": "cssnano@>=2.6.1 <4.0.0", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.7.1.tgz" - }, - "csso": { - "version": "2.0.0", - "from": "csso@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-2.0.0.tgz" - }, - "d": { - "version": "0.1.1", - "from": "d@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz" - }, - "date-now": { - "version": "0.1.4", - "from": "date-now@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz" - }, - "dateformat": { - "version": "1.0.12", - "from": "dateformat@>=1.0.11 <2.0.0", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz" - }, - "debug": { - "version": "2.2.0", - "from": "debug@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "decamelize": { - "version": "1.2.0", - "from": "decamelize@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" - }, - "deep-eql": { - "version": "0.1.3", - "from": "deep-eql@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "dependencies": { - "type-detect": { - "version": "0.1.1", - "from": "type-detect@0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz" - } - } - }, - "deep-is": { - "version": "0.1.3", - "from": "deep-is@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz" - }, - "defaults": { - "version": "1.0.3", - "from": "defaults@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz" - }, - "defined": { - "version": "1.0.0", - "from": "defined@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz" - }, - "del": { - "version": "2.2.0", - "from": "del@2.2.0", - "resolved": "https://registry.npmjs.org/del/-/del-2.2.0.tgz" - }, - "depd": { - "version": "1.1.0", - "from": "depd@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz" - }, - "deprecated": { - "version": "0.0.1", - "from": "deprecated@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz" - }, - "detect-indent": { - "version": "3.0.1", - "from": "detect-indent@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-3.0.1.tgz" - }, - "diff": { - "version": "1.4.0", - "from": "diff@>=1.3.2 <2.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz" - }, - "disparity": { - "version": "2.0.0", - "from": "disparity@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/disparity/-/disparity-2.0.0.tgz" - }, - "disposables": { - "version": "1.0.1", - "from": "disposables@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.1.tgz" - }, - "dnd-core": { - "version": "2.0.2", - "from": "dnd-core@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.0.2.tgz" - }, - "doctrine": { - "version": "1.2.2", - "from": "doctrine@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.2.2.tgz", - "dependencies": { - "esutils": { - "version": "1.1.6", - "from": "esutils@>=1.1.6 <2.0.0", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.1.6.tgz" - } - } - }, - "doiuse": { - "version": "2.4.1", - "from": "doiuse@>=2.4.1 <3.0.0", - "resolved": "https://registry.npmjs.org/doiuse/-/doiuse-2.4.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "source-map": { - "version": "0.4.4", - "from": "source-map@>=0.4.2 <0.5.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "dom-helpers": { - "version": "3.2.0", - "from": "dom-helpers@>=2.4.0 <3.0.0||>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.2.0.tgz" - }, - "domain-browser": { - "version": "1.1.7", - "from": "domain-browser@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz" - }, - "duplexer": { - "version": "0.1.1", - "from": "duplexer@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz" - }, - "duplexer2": { - "version": "0.0.2", - "from": "duplexer2@0.0.2", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.9 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "ee-first": { - "version": "1.1.1", - "from": "ee-first@1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" - }, - "element-class": { - "version": "0.2.2", - "from": "element-class@latest", - "resolved": "https://registry.npmjs.org/element-class/-/element-class-0.2.2.tgz" - }, - "emojis-list": { - "version": "2.0.1", - "from": "emojis-list@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.0.1.tgz" - }, - "encoding": { - "version": "0.1.12", - "from": "encoding@>=0.1.11 <0.2.0", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz" - }, - "end-of-stream": { - "version": "0.1.5", - "from": "end-of-stream@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz" - }, - "enhanced-resolve": { - "version": "0.9.1", - "from": "enhanced-resolve@>=0.9.0 <0.10.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", - "dependencies": { - "memory-fs": { - "version": "0.2.0", - "from": "memory-fs@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz" - } - } - }, - "errno": { - "version": "0.1.4", - "from": "errno@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz" - }, - "error-ex": { - "version": "1.3.0", - "from": "error-ex@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz" - }, - "es5-ext": { - "version": "0.10.11", - "from": "es5-ext@>=0.10.8 <0.11.0", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.11.tgz" - }, - "es6-iterator": { - "version": "2.0.0", - "from": "es6-iterator@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.0.tgz" - }, - "es6-map": { - "version": "0.1.4", - "from": "es6-map@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.4.tgz", - "dependencies": { - "es6-symbol": { - "version": "3.1.0", - "from": "es6-symbol@>=3.1.0 <3.2.0", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.0.tgz" - } - } - }, - "es6-promise": { - "version": "3.2.1", - "from": "es6-promise@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz" - }, - "es6-set": { - "version": "0.1.4", - "from": "es6-set@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.4.tgz" - }, - "es6-symbol": { - "version": "3.0.2", - "from": "es6-symbol@>=3.0.1 <3.1.0", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.0.2.tgz" - }, - "es6-weak-map": { - "version": "2.0.1", - "from": "es6-weak-map@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.1.tgz" - }, - "escape-string-regexp": { - "version": "1.0.5", - "from": "escape-string-regexp@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" - }, - "escope": { - "version": "3.6.0", - "from": "escope@>=3.6.0 <4.0.0", - "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz" - }, - "esformatter": { - "version": "0.9.3", - "from": "esformatter@0.9.3", - "resolved": "https://registry.npmjs.org/esformatter/-/esformatter-0.9.3.tgz", - "dependencies": { - "debug": { - "version": "0.7.4", - "from": "debug@>=0.7.4 <0.8.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" - }, - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.3 <6.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - }, - "supports-color": { - "version": "1.3.1", - "from": "supports-color@>=1.3.1 <2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz" - }, - "user-home": { - "version": "2.0.0", - "from": "user-home@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz" - } - } - }, - "eslint": { - "version": "2.10.2", - "from": "eslint@2.10.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-2.10.2.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.3 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, - "globals": { - "version": "9.14.0", - "from": "globals@>=9.2.0 <10.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.14.0.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "strip-json-comments": { - "version": "1.0.4", - "from": "strip-json-comments@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz" - }, - "user-home": { - "version": "2.0.0", - "from": "user-home@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz" - } - } - }, - "eslint-loader": { - "version": "1.3.0", - "from": "eslint-loader@1.3.0", - "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-1.3.0.tgz" - }, - "eslint-plugin-filenames": { - "version": "1.0.0", - "from": "eslint-plugin-filenames@1.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-filenames/-/eslint-plugin-filenames-1.0.0.tgz" - }, - "eslint-plugin-react": { - "version": "5.2.2", - "from": "eslint-plugin-react@5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-5.2.2.tgz" - }, - "espree": { - "version": "3.1.4", - "from": "espree@3.1.4", - "resolved": "https://registry.npmjs.org/espree/-/espree-3.1.4.tgz" - }, - "esprima": { - "version": "2.7.2", - "from": "esprima@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.2.tgz" - }, - "esrecurse": { - "version": "4.1.0", - "from": "esrecurse@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.1.0.tgz", - "dependencies": { - "estraverse": { - "version": "4.1.1", - "from": "estraverse@>=4.1.0 <4.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.1.1.tgz" - } - } - }, - "estraverse": { - "version": "4.2.0", - "from": "estraverse@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz" - }, - "esutils": { - "version": "2.0.2", - "from": "esutils@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz" - }, - "event-emitter": { - "version": "0.3.4", - "from": "event-emitter@>=0.3.4 <0.4.0", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.4.tgz" - }, - "event-stream": { - "version": "3.3.2", - "from": "event-stream@>=3.1.7 <4.0.0", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.2.tgz" - }, - "events": { - "version": "1.1.0", - "from": "events@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.0.tgz" - }, - "execall": { - "version": "1.0.0", - "from": "execall@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/execall/-/execall-1.0.0.tgz" - }, - "exenv": { - "version": "1.2.1", - "from": "exenv@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.1.tgz" - }, - "exit-hook": { - "version": "1.1.1", - "from": "exit-hook@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz" - }, - "expand-brackets": { - "version": "0.1.5", - "from": "expand-brackets@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz" - }, - "expand-range": { - "version": "1.8.2", - "from": "expand-range@>=1.8.1 <2.0.0", - "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz" - }, - "extend": { - "version": "2.0.1", - "from": "extend@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/extend/-/extend-2.0.1.tgz" - }, - "extglob": { - "version": "0.3.2", - "from": "extglob@>=0.3.1 <0.4.0", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz" - }, - "extract-text-webpack-plugin": { - "version": "1.0.1", - "from": "extract-text-webpack-plugin@1.0.1", - "resolved": "https://registry.npmjs.org/extract-text-webpack-plugin/-/extract-text-webpack-plugin-1.0.1.tgz", - "dependencies": { - "async": { - "version": "1.5.2", - "from": "async@>=1.5.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" - } - } - }, - "fancy-log": { - "version": "1.2.0", - "from": "fancy-log@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.2.0.tgz" - }, - "fast-levenshtein": { - "version": "2.0.5", - "from": "fast-levenshtein@>=2.0.4 <2.1.0", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz" - }, - "fastparse": { - "version": "1.1.1", - "from": "fastparse@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz" - }, - "faye-websocket": { - "version": "0.7.3", - "from": "faye-websocket@>=0.7.2 <0.8.0", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.7.3.tgz" - }, - "fbjs": { - "version": "0.8.8", - "from": "fbjs@>=0.8.4 <0.9.0", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.8.tgz", - "dependencies": { - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "figures": { - "version": "1.7.0", - "from": "figures@>=1.3.5 <2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz" - }, - "file-entry-cache": { - "version": "1.3.1", - "from": "file-entry-cache@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-1.3.1.tgz" - }, - "file-loader": { - "version": "0.9.0", - "from": "file-loader@0.9.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-0.9.0.tgz" - }, - "filename-regex": { - "version": "2.0.0", - "from": "filename-regex@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.0.tgz" - }, - "filesize": { - "version": "3.5.4", - "from": "filesize@latest", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.5.4.tgz" - }, - "fill-range": { - "version": "2.2.3", - "from": "fill-range@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz" - }, - "find-index": { - "version": "0.1.1", - "from": "find-index@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz" - }, - "find-up": { - "version": "1.1.2", - "from": "find-up@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "dependencies": { - "path-exists": { - "version": "2.1.0", - "from": "path-exists@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz" - } - } - }, - "findup-sync": { - "version": "0.3.0", - "from": "findup-sync@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz", - "dependencies": { - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.0 <5.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - } - } - }, - "first-chunk-stream": { - "version": "1.0.0", - "from": "first-chunk-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz" - }, - "flagged-respawn": { - "version": "0.3.2", - "from": "flagged-respawn@>=0.3.2 <0.4.0", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz" - }, - "flat-cache": { - "version": "1.2.1", - "from": "flat-cache@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.2.1.tgz" - }, - "flatten": { - "version": "1.0.2", - "from": "flatten@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz" - }, - "fobject": { - "version": "0.0.4", - "from": "fobject@0.0.4", - "resolved": "https://registry.npmjs.org/fobject/-/fobject-0.0.4.tgz", - "dependencies": { - "semver": { - "version": "5.1.0", - "from": "semver@>=5.1.0 <6.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.1.0.tgz" - } - } - }, - "for-in": { - "version": "0.1.5", - "from": "for-in@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.5.tgz" - }, - "for-own": { - "version": "0.1.4", - "from": "for-own@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.4.tgz" - }, - "from": { - "version": "0.1.3", - "from": "from@>=0.0.0 <1.0.0", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.3.tgz" - }, - "fs-readfile-promise": { - "version": "2.0.1", - "from": "fs-readfile-promise@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz" - }, - "fs.realpath": { - "version": "1.0.0", - "from": "fs.realpath@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" - }, - "fstream": { - "version": "1.0.9", - "from": "fstream@>=1.0.7 <2.0.0", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz" - }, - "gather-stream": { - "version": "1.0.0", - "from": "gather-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/gather-stream/-/gather-stream-1.0.0.tgz" - }, - "gaze": { - "version": "0.5.2", - "from": "gaze@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz" - }, - "generate-function": { - "version": "2.0.0", - "from": "generate-function@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" - }, - "generate-object-property": { - "version": "1.2.0", - "from": "generate-object-property@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz" - }, - "get-node-dimensions": { - "version": "1.2.0", - "from": "get-node-dimensions@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.0.tgz" - }, - "get-stdin": { - "version": "4.0.1", - "from": "get-stdin@>=4.0.1 <5.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz" - }, - "glob": { - "version": "6.0.4", - "from": "glob@>=6.0.1 <7.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz" - }, - "glob-base": { - "version": "0.3.0", - "from": "glob-base@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz" - }, - "glob-parent": { - "version": "2.0.0", - "from": "glob-parent@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz" - }, - "glob-stream": { - "version": "3.1.18", - "from": "glob-stream@>=3.1.5 <4.0.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz", - "dependencies": { - "glob": { - "version": "4.5.3", - "from": "glob@>=4.3.1 <5.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "glob-watcher": { - "version": "0.0.6", - "from": "glob-watcher@>=0.0.6 <0.0.7", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz" - }, - "glob2base": { - "version": "0.0.12", - "from": "glob2base@>=0.0.12 <0.0.13", - "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz" - }, - "globals": { - "version": "8.18.0", - "from": "globals@>=8.3.0 <9.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-8.18.0.tgz" - }, - "globby": { - "version": "4.1.0", - "from": "globby@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-4.1.0.tgz" - }, - "globjoin": { - "version": "0.1.4", - "from": "globjoin@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz" - }, - "globule": { - "version": "0.1.0", - "from": "globule@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz", - "dependencies": { - "glob": { - "version": "3.1.21", - "from": "glob@>=3.1.21 <3.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz" - }, - "graceful-fs": { - "version": "1.2.3", - "from": "graceful-fs@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" - }, - "inherits": { - "version": "1.0.2", - "from": "inherits@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz" - }, - "lodash": { - "version": "1.0.2", - "from": "lodash@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz" - }, - "minimatch": { - "version": "0.2.14", - "from": "minimatch@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz" - } - } - }, - "glogg": { - "version": "1.0.0", - "from": "glogg@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.0.tgz" - }, - "graceful-fs": { - "version": "4.1.4", - "from": "graceful-fs@>=4.1.2 <5.0.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz" - }, - "graceful-readlink": { - "version": "1.0.1", - "from": "graceful-readlink@>=1.0.0", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" - }, - "growl": { - "version": "1.8.1", - "from": "growl@1.8.1", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz" - }, - "gulp": { - "version": "3.9.1", - "from": "gulp@3.9.1", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz" - }, - "gulp-cached": { - "version": "1.1.0", - "from": "gulp-cached@1.1.0", - "resolved": "https://registry.npmjs.org/gulp-cached/-/gulp-cached-1.1.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.5.1", - "from": "through2@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz" - }, - "xtend": { - "version": "3.0.0", - "from": "xtend@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz" - } - } - }, - "gulp-concat": { - "version": "2.6.0", - "from": "gulp-concat@2.6.0", - "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "gulp-declare": { - "version": "0.3.0", - "from": "gulp-declare@0.3.0", - "resolved": "https://registry.npmjs.org/gulp-declare/-/gulp-declare-0.3.0.tgz" - }, - "gulp-handlebars": { - "version": "3.0.1", - "from": "gulp-handlebars@3.0.1", - "resolved": "https://registry.npmjs.org/gulp-handlebars/-/gulp-handlebars-3.0.1.tgz", - "dependencies": { - "handlebars": { - "version": "2.0.0", - "from": "handlebars@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-2.0.0.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "optimist": { - "version": "0.3.7", - "from": "optimist@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "gulp-less": { - "version": "3.0.3", - "from": "gulp-less@3.0.3", - "resolved": "https://registry.npmjs.org/gulp-less/-/gulp-less-3.0.3.tgz", - "dependencies": { - "accord": { - "version": "0.15.2", - "from": "accord@>=0.15.2 <0.16.0", - "resolved": "https://registry.npmjs.org/accord/-/accord-0.15.2.tgz" - }, - "convert-source-map": { - "version": "0.4.1", - "from": "convert-source-map@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-0.4.1.tgz" - }, - "glob": { - "version": "4.5.3", - "from": "glob@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "lodash": { - "version": "3.10.1", - "from": "lodash@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" - }, - "object-assign": { - "version": "2.1.1", - "from": "object-assign@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.39 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "vinyl-sourcemaps-apply": { - "version": "0.1.4", - "from": "vinyl-sourcemaps-apply@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.1.4.tgz" - } - } - }, - "gulp-livereload": { - "version": "3.8.1", - "from": "gulp-livereload@3.8.1", - "resolved": "https://registry.npmjs.org/gulp-livereload/-/gulp-livereload-3.8.1.tgz", - "dependencies": { - "ansi-regex": { - "version": "0.2.1", - "from": "ansi-regex@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" - }, - "ansi-styles": { - "version": "1.1.0", - "from": "ansi-styles@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz" - }, - "chalk": { - "version": "0.5.1", - "from": "chalk@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz" - }, - "has-ansi": { - "version": "0.1.0", - "from": "has-ansi@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz" - }, - "lodash.assign": { - "version": "3.2.0", - "from": "lodash.assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - }, - "strip-ansi": { - "version": "0.3.0", - "from": "strip-ansi@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz" - }, - "supports-color": { - "version": "0.2.0", - "from": "supports-color@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz" - } - } - }, - "gulp-postcss": { - "version": "6.1.1", - "from": "gulp-postcss@6.1.1", - "resolved": "https://registry.npmjs.org/gulp-postcss/-/gulp-postcss-6.1.1.tgz" - }, - "gulp-print": { - "version": "2.0.1", - "from": "gulp-print@2.0.1", - "resolved": "https://registry.npmjs.org/gulp-print/-/gulp-print-2.0.1.tgz", - "dependencies": { - "map-stream": { - "version": "0.0.6", - "from": "map-stream@>=0.0.6 <0.1.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.6.tgz" - } - } - }, - "gulp-sourcemaps": { - "version": "1.6.0", - "from": "gulp-sourcemaps@1.6.0", - "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz", - "dependencies": { - "vinyl": { - "version": "1.1.1", - "from": "vinyl@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz" - } - } - }, - "gulp-stripbom": { - "version": "1.0.4", - "from": "gulp-stripbom@1.0.4", - "resolved": "https://registry.npmjs.org/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "strip-bom": { - "version": "1.0.0", - "from": "strip-bom@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz" - }, - "through2": { - "version": "0.5.1", - "from": "through2@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz" - }, - "xtend": { - "version": "3.0.0", - "from": "xtend@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz" - } - } - }, - "gulp-util": { - "version": "3.0.7", - "from": "gulp-util@3.0.7", - "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.7.tgz", - "dependencies": { - "object-assign": { - "version": "3.0.0", - "from": "object-assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" - } - } - }, - "gulp-watch": { - "version": "4.3.5", - "from": "gulp-watch@4.3.5", - "resolved": "https://registry.npmjs.org/gulp-watch/-/gulp-watch-4.3.5.tgz", - "dependencies": { - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.13 <6.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - } - } - }, - "gulp-wrap": { - "version": "0.13.0", - "from": "gulp-wrap@0.13.0", - "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.13.0.tgz" - }, - "gulplog": { - "version": "1.0.0", - "from": "gulplog@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz" - }, - "handlebars": { - "version": "3.0.3", - "from": "handlebars@3.0.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-3.0.3.tgz", - "dependencies": { - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.40 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - } - } - }, - "has-ansi": { - "version": "2.0.0", - "from": "has-ansi@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" - }, - "has-flag": { - "version": "1.0.0", - "from": "has-flag@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz" - }, - "has-gulplog": { - "version": "0.1.0", - "from": "has-gulplog@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz" - }, - "has-own": { - "version": "1.0.0", - "from": "has-own@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.0.tgz" - }, - "history": { - "version": "3.2.1", - "from": "history@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/history/-/history-3.2.1.tgz" - }, - "hoist-non-react-statics": { - "version": "1.2.0", - "from": "hoist-non-react-statics@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz" - }, - "home-or-tmp": { - "version": "1.0.0", - "from": "home-or-tmp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-1.0.0.tgz" - }, - "hosted-git-info": { - "version": "2.1.5", - "from": "hosted-git-info@>=2.1.4 <3.0.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.1.5.tgz" - }, - "html-comment-regex": { - "version": "1.1.0", - "from": "html-comment-regex@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.0.tgz" - }, - "html-tags": { - "version": "1.1.1", - "from": "html-tags@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-1.1.1.tgz" - }, - "http-browserify": { - "version": "1.7.0", - "from": "http-browserify@>=1.3.2 <2.0.0", - "resolved": "https://registry.npmjs.org/http-browserify/-/http-browserify-1.7.0.tgz" - }, - "http-errors": { - "version": "1.3.1", - "from": "http-errors@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz" - }, - "https-browserify": { - "version": "0.0.0", - "from": "https-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.0.tgz" - }, - "iconv-lite": { - "version": "0.4.13", - "from": "iconv-lite@0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" - }, - "icss-replace-symbols": { - "version": "1.0.2", - "from": "icss-replace-symbols@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz" - }, - "ieee754": { - "version": "1.1.6", - "from": "ieee754@>=1.1.4 <2.0.0", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.6.tgz" - }, - "ignore": { - "version": "3.2.0", - "from": "ignore@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.2.0.tgz" - }, - "image-size": { - "version": "0.5.0", - "from": "image-size@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.0.tgz", - "optional": true - }, - "imurmurhash": { - "version": "0.1.4", - "from": "imurmurhash@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" - }, - "indent-string": { - "version": "2.1.0", - "from": "indent-string@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "dependencies": { - "repeating": { - "version": "2.0.1", - "from": "repeating@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz" - } - } - }, - "indexes-of": { - "version": "1.0.1", - "from": "indexes-of@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz" - }, - "indexof": { - "version": "0.0.1", - "from": "indexof@0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz" - }, - "indx": { - "version": "0.2.3", - "from": "indx@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/indx/-/indx-0.2.3.tgz" - }, - "inflight": { - "version": "1.0.5", - "from": "inflight@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz" - }, - "inherits": { - "version": "2.0.1", - "from": "inherits@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" - }, - "inquirer": { - "version": "0.12.0", - "from": "inquirer@>=0.12.0 <0.13.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz" - }, - "interpret": { - "version": "1.0.1", - "from": "interpret@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.0.1.tgz" - }, - "invariant": { - "version": "2.2.1", - "from": "invariant@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.1.tgz" - }, - "irregular-plurals": { - "version": "1.2.0", - "from": "irregular-plurals@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-1.2.0.tgz" - }, - "is": { - "version": "3.1.0", - "from": "is@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/is/-/is-3.1.0.tgz" - }, - "is-absolute-url": { - "version": "2.0.0", - "from": "is-absolute-url@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.0.0.tgz" - }, - "is-arrayish": { - "version": "0.2.1", - "from": "is-arrayish@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" - }, - "is-binary-path": { - "version": "1.0.1", - "from": "is-binary-path@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz" - }, - "is-buffer": { - "version": "1.1.3", - "from": "is-buffer@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.3.tgz" - }, - "is-builtin-module": { - "version": "1.0.0", - "from": "is-builtin-module@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz" - }, - "is-dotfile": { - "version": "1.0.2", - "from": "is-dotfile@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.2.tgz" - }, - "is-equal-shallow": { - "version": "0.1.3", - "from": "is-equal-shallow@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz" - }, - "is-extendable": { - "version": "0.1.1", - "from": "is-extendable@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz" - }, - "is-extglob": { - "version": "1.0.0", - "from": "is-extglob@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz" - }, - "is-finite": { - "version": "1.0.1", - "from": "is-finite@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.1.tgz" - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "from": "is-fullwidth-code-point@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz" - }, - "is-glob": { - "version": "2.0.1", - "from": "is-glob@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" - }, - "is-my-json-valid": { - "version": "2.15.0", - "from": "is-my-json-valid@>=2.10.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz" - }, - "is-number": { - "version": "2.1.0", - "from": "is-number@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz" - }, - "is-path-cwd": { - "version": "1.0.0", - "from": "is-path-cwd@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz" - }, - "is-path-in-cwd": { - "version": "1.0.0", - "from": "is-path-in-cwd@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz" - }, - "is-path-inside": { - "version": "1.0.0", - "from": "is-path-inside@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.0.tgz" - }, - "is-plain-obj": { - "version": "1.1.0", - "from": "is-plain-obj@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz" - }, - "is-posix-bracket": { - "version": "0.1.1", - "from": "is-posix-bracket@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz" - }, - "is-primitive": { - "version": "2.0.0", - "from": "is-primitive@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz" - }, - "is-property": { - "version": "1.0.2", - "from": "is-property@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" - }, - "is-regexp": { - "version": "1.0.0", - "from": "is-regexp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz" - }, - "is-resolvable": { - "version": "1.0.0", - "from": "is-resolvable@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.0.tgz" - }, - "is-stream": { - "version": "1.1.0", - "from": "is-stream@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz" - }, - "is-supported-regexp-flag": { - "version": "1.0.0", - "from": "is-supported-regexp-flag@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.0.tgz" - }, - "is-svg": { - "version": "2.0.1", - "from": "is-svg@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.0.1.tgz" - }, - "is-utf8": { - "version": "0.2.1", - "from": "is-utf8@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz" - }, - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "isexe": { - "version": "1.1.2", - "from": "isexe@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz" - }, - "isobject": { - "version": "2.1.0", - "from": "isobject@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz" - }, - "isomorphic-fetch": { - "version": "2.2.1", - "from": "isomorphic-fetch@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz" - }, - "isstream": { - "version": "0.1.2", - "from": "isstream@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" - }, - "jade": { - "version": "0.26.3", - "from": "jade@0.26.3", - "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", - "dependencies": { - "commander": { - "version": "0.6.1", - "from": "commander@0.6.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz" - }, - "mkdirp": { - "version": "0.3.0", - "from": "mkdirp@0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" - } - } - }, - "js-base64": { - "version": "2.1.9", - "from": "js-base64@>=2.1.9 <3.0.0", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.1.9.tgz" - }, - "js-stylesheet": { - "version": "0.0.1", - "from": "js-stylesheet@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/js-stylesheet/-/js-stylesheet-0.0.1.tgz" - }, - "js-tokens": { - "version": "1.0.3", - "from": "js-tokens@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.3.tgz" - }, - "js-yaml": { - "version": "3.6.1", - "from": "js-yaml@>=3.6.1 <3.7.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz" - }, - "jsesc": { - "version": "0.5.0", - "from": "jsesc@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz" - }, - "json-stable-stringify": { - "version": "1.0.1", - "from": "json-stable-stringify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz" - }, - "json-stringify-safe": { - "version": "5.0.1", - "from": "json-stringify-safe@>=5.0.1 <6.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" - }, - "json5": { - "version": "0.4.0", - "from": "json5@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz" - }, - "jsonfilter": { - "version": "1.1.2", - "from": "jsonfilter@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/jsonfilter/-/jsonfilter-1.1.2.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "stream-combiner": { - "version": "0.2.2", - "from": "stream-combiner@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "jsonify": { - "version": "0.0.0", - "from": "jsonify@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz" - }, - "jsonparse": { - "version": "0.0.5", - "from": "jsonparse@0.0.5", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-0.0.5.tgz" - }, - "jsonpointer": { - "version": "4.0.0", - "from": "jsonpointer@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.0.tgz" - }, - "JSONStream": { - "version": "0.8.4", - "from": "JSONStream@>=0.8.4 <0.9.0", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-0.8.4.tgz" - }, - "jsx-ast-utils": { - "version": "1.3.1", - "from": "jsx-ast-utils@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-1.3.1.tgz" - }, - "kind-of": { - "version": "3.0.3", - "from": "kind-of@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.0.3.tgz" - }, - "known-css-properties": { - "version": "0.0.4", - "from": "known-css-properties@>=0.0.4 <0.0.5", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.0.4.tgz" - }, - "lazy-cache": { - "version": "1.0.4", - "from": "lazy-cache@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz" - }, - "ldjson-stream": { - "version": "1.2.1", - "from": "ldjson-stream@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/ldjson-stream/-/ldjson-stream-1.2.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "less": { - "version": "2.7.1", - "from": "less@>=2.6.0 <3.0.0", - "resolved": "https://registry.npmjs.org/less/-/less-2.7.1.tgz" - }, - "levn": { - "version": "0.3.0", - "from": "levn@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz" - }, - "liftoff": { - "version": "2.2.1", - "from": "liftoff@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.2.1.tgz" - }, - "livereload-js": { - "version": "2.2.2", - "from": "livereload-js@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.2.2.tgz" - }, - "load-json-file": { - "version": "1.1.0", - "from": "load-json-file@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz" - }, - "loader-utils": { - "version": "0.2.15", - "from": "loader-utils@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.15.tgz", - "dependencies": { - "json5": { - "version": "0.5.0", - "from": "json5@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.0.tgz" - } - } - }, - "lodash": { - "version": "4.17.4", - "from": "lodash@4.17.4", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz" - }, - "lodash-es": { - "version": "4.13.1", - "from": "lodash-es@>=4.2.1 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.13.1.tgz" - }, - "lodash._arraycopy": { - "version": "3.0.0", - "from": "lodash._arraycopy@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz" - }, - "lodash._arrayeach": { - "version": "3.0.0", - "from": "lodash._arrayeach@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz" - }, - "lodash._baseassign": { - "version": "3.2.0", - "from": "lodash._baseassign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", - "dependencies": { - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash._baseclone": { - "version": "3.3.0", - "from": "lodash._baseclone@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-3.3.0.tgz", - "dependencies": { - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash._basecopy": { - "version": "3.0.1", - "from": "lodash._basecopy@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz" - }, - "lodash._basefor": { - "version": "3.0.3", - "from": "lodash._basefor@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basefor/-/lodash._basefor-3.0.3.tgz" - }, - "lodash._basevalues": { - "version": "3.0.0", - "from": "lodash._basevalues@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz" - }, - "lodash._bindcallback": { - "version": "3.0.1", - "from": "lodash._bindcallback@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz" - }, - "lodash._createassigner": { - "version": "3.1.1", - "from": "lodash._createassigner@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz" - }, - "lodash._createcompounder": { - "version": "3.0.0", - "from": "lodash._createcompounder@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._createcompounder/-/lodash._createcompounder-3.0.0.tgz" - }, - "lodash._getnative": { - "version": "3.9.1", - "from": "lodash._getnative@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz" - }, - "lodash._isiterateecall": { - "version": "3.0.9", - "from": "lodash._isiterateecall@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz" - }, - "lodash._isnative": { - "version": "2.4.1", - "from": "lodash._isnative@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz" - }, - "lodash._objecttypes": { - "version": "2.4.1", - "from": "lodash._objecttypes@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz" - }, - "lodash._reescape": { - "version": "3.0.0", - "from": "lodash._reescape@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz" - }, - "lodash._reevaluate": { - "version": "3.0.0", - "from": "lodash._reevaluate@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz" - }, - "lodash._reinterpolate": { - "version": "3.0.0", - "from": "lodash._reinterpolate@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz" - }, - "lodash._root": { - "version": "3.0.1", - "from": "lodash._root@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz" - }, - "lodash._shimkeys": { - "version": "2.4.1", - "from": "lodash._shimkeys@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz" - }, - "lodash.camelcase": { - "version": "3.0.1", - "from": "lodash.camelcase@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-3.0.1.tgz" - }, - "lodash.clone": { - "version": "3.0.3", - "from": "lodash.clone@>=3.0.0 <3.1.0-0", - "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-3.0.3.tgz" - }, - "lodash.deburr": { - "version": "3.2.0", - "from": "lodash.deburr@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-3.2.0.tgz" - }, - "lodash.defaults": { - "version": "2.4.1", - "from": "lodash.defaults@>=2.4.1 <3.0.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-2.4.1.tgz", - "dependencies": { - "lodash.keys": { - "version": "2.4.1", - "from": "lodash.keys@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz" - } - } - }, - "lodash.escape": { - "version": "3.2.0", - "from": "lodash.escape@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz" - }, - "lodash.isarguments": { - "version": "3.0.8", - "from": "lodash.isarguments@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.0.8.tgz" - }, - "lodash.isarray": { - "version": "3.0.4", - "from": "lodash.isarray@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz" - }, - "lodash.isobject": { - "version": "2.4.1", - "from": "lodash.isobject@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - }, - "lodash.pickby": { - "version": "4.6.0", - "from": "lodash.pickby@>=4.6.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz" - }, - "lodash.restparam": { - "version": "3.6.1", - "from": "lodash.restparam@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz" - }, - "lodash.template": { - "version": "3.6.2", - "from": "lodash.template@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz", - "dependencies": { - "lodash._basetostring": { - "version": "3.0.1", - "from": "lodash._basetostring@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash.templatesettings": { - "version": "3.1.1", - "from": "lodash.templatesettings@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz" - }, - "lodash.words": { - "version": "3.2.0", - "from": "lodash.words@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.words/-/lodash.words-3.2.0.tgz" - }, - "log-symbols": { - "version": "1.0.2", - "from": "log-symbols@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz" - }, - "longest": { - "version": "1.0.1", - "from": "longest@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz" - }, - "loose-envify": { - "version": "1.2.0", - "from": "loose-envify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.2.0.tgz" - }, - "loud-rejection": { - "version": "1.3.0", - "from": "loud-rejection@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.3.0.tgz" - }, - "lru-cache": { - "version": "2.7.3", - "from": "lru-cache@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz" - }, - "map-obj": { - "version": "1.0.1", - "from": "map-obj@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz" - }, - "map-stream": { - "version": "0.1.0", - "from": "map-stream@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz" - }, - "media-typer": { - "version": "0.3.0", - "from": "media-typer@0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" - }, - "memory-fs": { - "version": "0.3.0", - "from": "memory-fs@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.3.0.tgz" - }, - "meow": { - "version": "3.7.0", - "from": "meow@>=3.3.0 <4.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz" - }, - "micromatch": { - "version": "2.3.8", - "from": "micromatch@>=2.1.5 <3.0.0", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.8.tgz" - }, - "mime": { - "version": "1.3.4", - "from": "mime@>=1.2.11 <2.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz", - "optional": true - }, - "mime-db": { - "version": "1.23.0", - "from": "mime-db@>=1.23.0 <1.24.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz" - }, - "mime-types": { - "version": "2.1.11", - "from": "mime-types@>=2.1.11 <2.2.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz" - }, - "mini-lr": { - "version": "0.1.9", - "from": "mini-lr@>=0.1.8 <0.2.0", - "resolved": "https://registry.npmjs.org/mini-lr/-/mini-lr-0.1.9.tgz" - }, - "minimatch": { - "version": "2.0.10", - "from": "minimatch@>=2.0.3 <3.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz" - }, - "minimist": { - "version": "1.2.0", - "from": "minimist@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" - }, - "mkdirp": { - "version": "0.5.1", - "from": "mkdirp@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "dependencies": { - "minimist": { - "version": "0.0.8", - "from": "minimist@0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" - } - } - }, - "mocha": { - "version": "2.4.5", - "from": "mocha@>=2.2.5 <3.0.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.4.5.tgz", - "dependencies": { - "commander": { - "version": "2.3.0", - "from": "commander@2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz" - }, - "escape-string-regexp": { - "version": "1.0.2", - "from": "escape-string-regexp@1.0.2", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz" - }, - "glob": { - "version": "3.2.3", - "from": "glob@3.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz" - }, - "graceful-fs": { - "version": "2.0.3", - "from": "graceful-fs@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz" - }, - "minimatch": { - "version": "0.2.14", - "from": "minimatch@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz" - }, - "supports-color": { - "version": "1.2.0", - "from": "supports-color@1.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.2.0.tgz" - } - } - }, - "moment": { - "version": "2.17.1", - "from": "moment@latest", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.17.1.tgz" - }, - "mout": { - "version": "1.0.0", - "from": "mout@>=0.9.0 <2.0.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-1.0.0.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "multimatch": { - "version": "2.1.0", - "from": "multimatch@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", - "dependencies": { - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - } - } - }, - "multipipe": { - "version": "0.1.2", - "from": "multipipe@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz" - }, - "mute-stream": { - "version": "0.0.5", - "from": "mute-stream@0.0.5", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz" - }, - "new-from": { - "version": "0.0.3", - "from": "new-from@0.0.3", - "resolved": "https://registry.npmjs.org/new-from/-/new-from-0.0.3.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.8 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "node-fetch": { - "version": "1.6.3", - "from": "node-fetch@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz" - }, - "node-libs-browser": { - "version": "0.5.3", - "from": "node-libs-browser@>=0.4.0 <=0.6.0", - "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-0.5.3.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.13 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "node.extend": { - "version": "1.1.5", - "from": "node.extend@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.5.tgz" - }, - "normalize-package-data": { - "version": "2.3.5", - "from": "normalize-package-data@>=2.3.4 <3.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.3.5.tgz" - }, - "normalize-path": { - "version": "2.0.1", - "from": "normalize-path@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.0.1.tgz" - }, - "normalize-range": { - "version": "0.1.2", - "from": "normalize-range@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" - }, - "normalize-selector": { - "version": "0.2.0", - "from": "normalize-selector@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz" - }, - "normalize-url": { - "version": "1.5.3", - "from": "normalize-url@>=1.4.0 <2.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.5.3.tgz" - }, - "normalize.css": { - "version": "5.0.0", - "from": "normalize.css@5.0.0", - "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-5.0.0.tgz" - }, - "npm-path": { - "version": "1.1.0", - "from": "npm-path@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-1.1.0.tgz" - }, - "npm-run": { - "version": "2.0.0", - "from": "npm-run@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/npm-run/-/npm-run-2.0.0.tgz" - }, - "npm-which": { - "version": "2.0.0", - "from": "npm-which@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-2.0.0.tgz" - }, - "nsdeclare": { - "version": "0.1.0", - "from": "nsdeclare@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/nsdeclare/-/nsdeclare-0.1.0.tgz" - }, - "num2fraction": { - "version": "1.2.2", - "from": "num2fraction@>=1.2.2 <2.0.0", - "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz" - }, - "number-is-nan": { - "version": "1.0.0", - "from": "number-is-nan@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.0.tgz" - }, - "object-assign": { - "version": "4.1.0", - "from": "object-assign@>=4.0.1 <5.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz" - }, - "object-keys": { - "version": "0.4.0", - "from": "object-keys@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz" - }, - "object.omit": { - "version": "2.0.0", - "from": "object.omit@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.0.tgz" - }, - "on-finished": { - "version": "2.3.0", - "from": "on-finished@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" - }, - "once": { - "version": "1.3.3", - "from": "once@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz" - }, - "onecolor": { - "version": "3.0.4", - "from": "onecolor@>=3.0.4 <4.0.0", - "resolved": "https://registry.npmjs.org/onecolor/-/onecolor-3.0.4.tgz" - }, - "onetime": { - "version": "1.1.0", - "from": "onetime@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz" - }, - "optimist": { - "version": "0.6.1", - "from": "optimist@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "dependencies": { - "minimist": { - "version": "0.0.10", - "from": "minimist@>=0.0.1 <0.1.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "optionator": { - "version": "0.8.2", - "from": "optionator@>=0.8.1 <0.9.0", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz" - }, - "orchestrator": { - "version": "0.3.7", - "from": "orchestrator@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.7.tgz" - }, - "ordered-read-streams": { - "version": "0.1.0", - "from": "ordered-read-streams@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz" - }, - "os-browserify": { - "version": "0.1.2", - "from": "os-browserify@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz" - }, - "os-homedir": { - "version": "1.0.1", - "from": "os-homedir@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.1.tgz" - }, - "os-shim": { - "version": "0.1.3", - "from": "os-shim@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz" - }, - "os-tmpdir": { - "version": "1.0.1", - "from": "os-tmpdir@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.1.tgz" - }, - "pako": { - "version": "0.2.8", - "from": "pako@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.8.tgz" - }, - "parse-glob": { - "version": "3.0.4", - "from": "parse-glob@>=3.0.4 <4.0.0", - "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz" - }, - "parse-json": { - "version": "2.2.0", - "from": "parse-json@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz" - }, - "parseurl": { - "version": "1.3.1", - "from": "parseurl@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz" - }, - "path-browserify": { - "version": "0.0.0", - "from": "path-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz" - }, - "path-exists": { - "version": "1.0.0", - "from": "path-exists@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-1.0.0.tgz" - }, - "path-is-absolute": { - "version": "1.0.0", - "from": "path-is-absolute@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz" - }, - "path-is-inside": { - "version": "1.0.1", - "from": "path-is-inside@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.1.tgz" - }, - "path-type": { - "version": "1.1.0", - "from": "path-type@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz" - }, - "pause-stream": { - "version": "0.0.11", - "from": "pause-stream@0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz" - }, - "pbkdf2-compat": { - "version": "2.0.1", - "from": "pbkdf2-compat@2.0.1", - "resolved": "https://registry.npmjs.org/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz" - }, - "pify": { - "version": "2.3.0", - "from": "pify@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" - }, - "pinkie": { - "version": "2.0.4", - "from": "pinkie@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" - }, - "pinkie-promise": { - "version": "2.0.1", - "from": "pinkie-promise@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" - }, - "pipetteur": { - "version": "2.0.3", - "from": "pipetteur@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pipetteur/-/pipetteur-2.0.3.tgz" - }, - "plur": { - "version": "2.1.2", - "from": "plur@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/plur/-/plur-2.1.2.tgz" - }, - "pluralize": { - "version": "1.2.1", - "from": "pluralize@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz" - }, - "postcss": { - "version": "5.0.21", - "from": "postcss@>=5.0.19 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.0.21.tgz" - }, - "postcss-calc": { - "version": "5.2.1", - "from": "postcss-calc@>=5.2.0 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-5.2.1.tgz" - }, - "postcss-colormin": { - "version": "2.2.0", - "from": "postcss-colormin@>=2.1.8 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-2.2.0.tgz" - }, - "postcss-convert-values": { - "version": "2.4.0", - "from": "postcss-convert-values@>=2.3.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-2.4.0.tgz" - }, - "postcss-discard-comments": { - "version": "2.0.4", - "from": "postcss-discard-comments@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz" - }, - "postcss-discard-duplicates": { - "version": "2.0.1", - "from": "postcss-discard-duplicates@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-2.0.1.tgz" - }, - "postcss-discard-empty": { - "version": "2.1.0", - "from": "postcss-discard-empty@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz" - }, - "postcss-discard-overridden": { - "version": "0.1.1", - "from": "postcss-discard-overridden@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz" - }, - "postcss-discard-unused": { - "version": "2.2.1", - "from": "postcss-discard-unused@>=2.2.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-2.2.1.tgz" - }, - "postcss-filter-plugins": { - "version": "2.0.0", - "from": "postcss-filter-plugins@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-filter-plugins/-/postcss-filter-plugins-2.0.0.tgz" - }, - "postcss-less": { - "version": "0.14.0", - "from": "postcss-less@>=0.14.0 <0.15.0", - "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-0.14.0.tgz" - }, - "postcss-loader": { - "version": "0.9.1", - "from": "postcss-loader@0.9.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-0.9.1.tgz" - }, - "postcss-media-query-parser": { - "version": "0.2.1", - "from": "postcss-media-query-parser@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.1.tgz" - }, - "postcss-merge-idents": { - "version": "2.1.6", - "from": "postcss-merge-idents@>=2.1.5 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-2.1.6.tgz" - }, - "postcss-merge-longhand": { - "version": "2.0.1", - "from": "postcss-merge-longhand@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-2.0.1.tgz" - }, - "postcss-merge-rules": { - "version": "2.0.9", - "from": "postcss-merge-rules@>=2.0.3 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-2.0.9.tgz" - }, - "postcss-message-helpers": { - "version": "2.0.0", - "from": "postcss-message-helpers@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz" - }, - "postcss-minify-font-values": { - "version": "1.0.5", - "from": "postcss-minify-font-values@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz" - }, - "postcss-minify-gradients": { - "version": "1.0.3", - "from": "postcss-minify-gradients@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-1.0.3.tgz" - }, - "postcss-minify-params": { - "version": "1.0.4", - "from": "postcss-minify-params@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-1.0.4.tgz" - }, - "postcss-minify-selectors": { - "version": "2.0.5", - "from": "postcss-minify-selectors@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-2.0.5.tgz" - }, - "postcss-modules-extract-imports": { - "version": "1.0.1", - "from": "postcss-modules-extract-imports@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz" - }, - "postcss-modules-local-by-default": { - "version": "1.1.0", - "from": "postcss-modules-local-by-default@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.0.tgz" - }, - "postcss-modules-scope": { - "version": "1.0.1", - "from": "postcss-modules-scope@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.0.1.tgz" - }, - "postcss-modules-values": { - "version": "1.1.3", - "from": "postcss-modules-values@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.1.3.tgz" - }, - "postcss-nested": { - "version": "1.0.0", - "from": "postcss-nested@1.0.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-1.0.0.tgz" - }, - "postcss-normalize-charset": { - "version": "1.1.0", - "from": "postcss-normalize-charset@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-1.1.0.tgz" - }, - "postcss-normalize-url": { - "version": "3.0.7", - "from": "postcss-normalize-url@>=3.0.7 <4.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-3.0.7.tgz" - }, - "postcss-ordered-values": { - "version": "2.2.1", - "from": "postcss-ordered-values@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-2.2.1.tgz" - }, - "postcss-reduce-idents": { - "version": "2.3.0", - "from": "postcss-reduce-idents@>=2.2.2 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-2.3.0.tgz" - }, - "postcss-reduce-initial": { - "version": "1.0.0", - "from": "postcss-reduce-initial@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-1.0.0.tgz" - }, - "postcss-reduce-transforms": { - "version": "1.0.3", - "from": "postcss-reduce-transforms@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.3.tgz" - }, - "postcss-reporter": { - "version": "1.4.1", - "from": "postcss-reporter@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-1.4.1.tgz" - }, - "postcss-resolve-nested-selector": { - "version": "0.1.1", - "from": "postcss-resolve-nested-selector@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz" - }, - "postcss-scss": { - "version": "0.3.0", - "from": "postcss-scss@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-0.3.0.tgz", - "dependencies": { - "postcss": { - "version": "5.2.0", - "from": "postcss@>=5.2.0 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz" - } - } - }, - "postcss-selector-parser": { - "version": "2.1.0", - "from": "postcss-selector-parser@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.1.0.tgz" - }, - "postcss-simple-vars": { - "version": "3.0.0", - "from": "postcss-simple-vars@3.0.0", - "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-3.0.0.tgz" - }, - "postcss-svgo": { - "version": "2.1.3", - "from": "postcss-svgo@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-2.1.3.tgz" - }, - "postcss-unique-selectors": { - "version": "2.0.2", - "from": "postcss-unique-selectors@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz" - }, - "postcss-value-parser": { - "version": "3.3.0", - "from": "postcss-value-parser@>=3.2.3 <4.0.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz" - }, - "postcss-zindex": { - "version": "2.1.1", - "from": "postcss-zindex@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-2.1.1.tgz" - }, - "prelude-ls": { - "version": "1.1.2", - "from": "prelude-ls@>=1.1.2 <1.2.0", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" - }, - "prepend-http": { - "version": "1.0.4", - "from": "prepend-http@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz" - }, - "preserve": { - "version": "0.2.0", - "from": "preserve@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz" - }, - "pretty-hrtime": { - "version": "1.0.2", - "from": "pretty-hrtime@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.2.tgz" - }, - "private": { - "version": "0.1.6", - "from": "private@>=0.1.6 <0.2.0", - "resolved": "https://registry.npmjs.org/private/-/private-0.1.6.tgz" - }, - "process": { - "version": "0.11.3", - "from": "process@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.3.tgz" - }, - "process-nextick-args": { - "version": "1.0.7", - "from": "process-nextick-args@>=1.0.6 <1.1.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" - }, - "progress": { - "version": "1.1.8", - "from": "progress@>=1.1.8 <2.0.0", - "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz" - }, - "promise": { - "version": "7.1.1", - "from": "promise@>=7.1.1 <8.0.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.1.1.tgz" - }, - "protochain": { - "version": "1.0.3", - "from": "protochain@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/protochain/-/protochain-1.0.3.tgz" - }, - "prr": { - "version": "0.0.0", - "from": "prr@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz" - }, - "punycode": { - "version": "1.4.1", - "from": "punycode@>=1.4.1 <2.0.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" - }, - "q": { - "version": "1.4.1", - "from": "q@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz" - }, - "qs": { - "version": "2.2.5", - "from": "qs@>=2.2.3 <2.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-2.2.5.tgz" - }, - "query-string": { - "version": "4.2.2", - "from": "query-string@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz" - }, - "querystring": { - "version": "0.2.0", - "from": "querystring@0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz" - }, - "querystring-es3": { - "version": "0.2.1", - "from": "querystring-es3@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz" - }, - "randomatic": { - "version": "1.1.5", - "from": "randomatic@>=1.1.3 <2.0.0", - "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.5.tgz" - }, - "raven-js": { - "version": "3.9.2", - "from": "raven-js@>=3.1.1 <4.0.0", - "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.9.2.tgz" - }, - "raw-body": { - "version": "2.1.6", - "from": "raw-body@>=2.1.5 <2.2.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz", - "dependencies": { - "bytes": { - "version": "2.3.0", - "from": "bytes@2.3.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz" - } - } - }, - "react": { - "version": "15.4.2", - "from": "react@15.4.2", - "resolved": "https://registry.npmjs.org/react/-/react-15.4.2.tgz" - }, - "react-addons-shallow-compare": { - "version": "15.4.2", - "from": "react-addons-shallow-compare@15.4.2", - "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz" - }, - "react-async-script": { - "version": "0.5.1", - "from": "react-async-script@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-0.5.1.tgz", - "dependencies": { - "babel-runtime": { - "version": "5.8.38", - "from": "babel-runtime@>=5.8.0 <6.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz" - }, - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "react-autosuggest": { - "version": "8.0.0", - "from": "react-autosuggest@8.0.0", - "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-8.0.0.tgz" - }, - "react-autowhatever": { - "version": "7.0.0", - "from": "react-autowhatever@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/react-autowhatever/-/react-autowhatever-7.0.0.tgz" - }, - "react-dnd": { - "version": "2.1.4", - "from": "react-dnd@2.1.4", - "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.1.4.tgz" - }, - "react-dnd-html5-backend": { - "version": "2.1.2", - "from": "react-dnd-html5-backend@2.1.2", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.1.2.tgz" - }, - "react-document-title": { - "version": "2.0.2", - "from": "react-document-title@2.0.2", - "resolved": "https://registry.npmjs.org/react-document-title/-/react-document-title-2.0.2.tgz" - }, - "react-dom": { - "version": "15.4.2", - "from": "react-dom@15.4.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.4.2.tgz" - }, - "react-google-recaptcha": { - "version": "0.5.4", - "from": "react-google-recaptcha@0.5.4", - "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-0.5.4.tgz", - "dependencies": { - "babel-runtime": { - "version": "5.8.38", - "from": "babel-runtime@>=5.8.0 <6.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz" - }, - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "react-lazyload": { - "version": "2.2.0", - "from": "react-lazyload@2.2.0", - "resolved": "https://registry.npmjs.org/react-lazyload/-/react-lazyload-2.2.0.tgz" - }, - "react-measure": { - "version": "1.4.5", - "from": "react-measure@1.4.5", - "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-1.4.5.tgz" - }, - "react-portal": { - "version": "3.0.0", - "from": "react-portal@3.0.0", - "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.0.0.tgz" - }, - "react-redux": { - "version": "5.0.2", - "from": "react-redux@5.0.2", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.2.tgz" - }, - "react-router": { - "version": "3.0.2", - "from": "react-router@3.0.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.2.tgz" - }, - "react-router-redux": { - "version": "4.0.7", - "from": "react-router-redux@4.0.7", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.7.tgz" - }, - "react-side-effect": { - "version": "1.1.0", - "from": "react-side-effect@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.0.tgz" - }, - "react-slider": { - "version": "0.7.0", - "from": "react-slider@0.7.0", - "resolved": "https://registry.npmjs.org/react-slider/-/react-slider-0.7.0.tgz" - }, - "react-tabs": { - "version": "0.8.2", - "from": "react-tabs@0.8.2", - "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-0.8.2.tgz" - }, - "react-tag-autocomplete": { - "version": "5.1.0", - "from": "react-tag-autocomplete@5.1.0", - "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.1.0.tgz" - }, - "react-tether": { - "version": "0.5.5", - "from": "react-tether@0.5.5", - "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-0.5.5.tgz" - }, - "react-themeable": { - "version": "1.1.0", - "from": "react-themeable@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", - "dependencies": { - "object-assign": { - "version": "3.0.0", - "from": "object-assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" - } - } - }, - "react-virtualized": { - "version": "8.11.3", - "from": "react-virtualized@latest", - "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-8.11.3.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@>=6.11.6 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "js-tokens": { - "version": "3.0.0", - "from": "js-tokens@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.0.tgz" - }, - "loose-envify": { - "version": "1.3.1", - "from": "loose-envify@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "read-file-stdin": { - "version": "0.2.1", - "from": "read-file-stdin@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz" - }, - "read-pkg": { - "version": "1.1.0", - "from": "read-pkg@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz" - }, - "read-pkg-up": { - "version": "1.0.1", - "from": "read-pkg-up@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz" - }, - "readable-stream": { - "version": "2.0.6", - "from": "readable-stream@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz" - }, - "readdirp": { - "version": "2.0.0", - "from": "readdirp@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.0.0.tgz" - }, - "readline2": { - "version": "1.0.1", - "from": "readline2@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz" - }, - "rechoir": { - "version": "0.6.2", - "from": "rechoir@>=0.6.2 <0.7.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz" - }, - "redent": { - "version": "1.0.0", - "from": "redent@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz" - }, - "reduce-css-calc": { - "version": "1.2.4", - "from": "reduce-css-calc@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.2.4.tgz", - "dependencies": { - "balanced-match": { - "version": "0.1.0", - "from": "balanced-match@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz" - } - } - }, - "reduce-function-call": { - "version": "1.0.1", - "from": "reduce-function-call@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.1.tgz", - "dependencies": { - "balanced-match": { - "version": "0.1.0", - "from": "balanced-match@~0.1.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz" - } - } - }, - "reduce-reducers": { - "version": "0.1.2", - "from": "reduce-reducers@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-0.1.2.tgz" - }, - "redux": { - "version": "3.6.0", - "from": "redux@3.6.0", - "resolved": "https://registry.npmjs.org/redux/-/redux-3.6.0.tgz" - }, - "redux-actions": { - "version": "1.2.0", - "from": "redux-actions@1.2.0", - "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-1.2.0.tgz" - }, - "redux-batched-actions": { - "version": "0.1.5", - "from": "redux-batched-actions@latest", - "resolved": "https://registry.npmjs.org/redux-batched-actions/-/redux-batched-actions-0.1.5.tgz" - }, - "redux-localstorage": { - "version": "0.4.1", - "from": "redux-localstorage@0.4.1", - "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz" - }, - "redux-raven-middleware": { - "version": "1.2.0", - "from": "redux-raven-middleware@latest", - "resolved": "https://registry.npmjs.org/redux-raven-middleware/-/redux-raven-middleware-1.2.0.tgz" - }, - "redux-thunk": { - "version": "2.2.0", - "from": "redux-thunk@2.2.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz" - }, - "regenerate": { - "version": "1.2.1", - "from": "regenerate@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.2.1.tgz" - }, - "regenerator-runtime": { - "version": "0.9.5", - "from": "regenerator-runtime@>=0.9.5 <0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz" - }, - "regex-cache": { - "version": "0.4.3", - "from": "regex-cache@>=0.4.2 <0.5.0", - "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz" - }, - "regexpu-core": { - "version": "1.0.0", - "from": "regexpu-core@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz" - }, - "regjsgen": { - "version": "0.2.0", - "from": "regjsgen@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz" - }, - "regjsparser": { - "version": "0.1.5", - "from": "regjsparser@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz" - }, - "repeat-element": { - "version": "1.1.2", - "from": "repeat-element@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz" - }, - "repeat-string": { - "version": "1.5.4", - "from": "repeat-string@>=1.5.0 <2.0.0", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.4.tgz" - }, - "repeating": { - "version": "1.1.3", - "from": "repeating@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz" - }, - "replace-ext": { - "version": "0.0.1", - "from": "replace-ext@0.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz" - }, - "require-from-string": { - "version": "1.2.0", - "from": "require-from-string@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-1.2.0.tgz" - }, - "require-nocache": { - "version": "1.0.0", - "from": "require-nocache@latest", - "resolved": "https://registry.npmjs.org/require-nocache/-/require-nocache-1.0.0.tgz" - }, - "require-uncached": { - "version": "1.0.3", - "from": "require-uncached@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz" - }, - "reselect": { - "version": "2.5.4", - "from": "reselect@2.5.4", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-2.5.4.tgz" - }, - "resize-observer-polyfill": { - "version": "1.3.1", - "from": "resize-observer-polyfill@1.3.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.3.1.tgz" - }, - "resolve": { - "version": "1.1.7", - "from": "resolve@>=1.1.5 <2.0.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz" - }, - "resolve-from": { - "version": "1.0.1", - "from": "resolve-from@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz" - }, - "restore-cursor": { - "version": "1.0.1", - "from": "restore-cursor@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz" - }, - "right-align": { - "version": "0.1.3", - "from": "right-align@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz" - }, - "rimraf": { - "version": "2.5.2", - "from": "rimraf@>=2.2.8 <3.0.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz", - "dependencies": { - "glob": { - "version": "7.0.3", - "from": "glob@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz" - } - } - }, - "ripemd160": { - "version": "0.2.0", - "from": "ripemd160@0.2.0", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-0.2.0.tgz" - }, - "rocambole": { - "version": "0.7.0", - "from": "rocambole@>=0.7.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole/-/rocambole-0.7.0.tgz" - }, - "rocambole-indent": { - "version": "2.0.4", - "from": "rocambole-indent@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/rocambole-indent/-/rocambole-indent-2.0.4.tgz", - "dependencies": { - "mout": { - "version": "0.11.1", - "from": "mout@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz" - } - } - }, - "rocambole-linebreak": { - "version": "1.0.1", - "from": "rocambole-linebreak@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-linebreak/-/rocambole-linebreak-1.0.1.tgz" - }, - "rocambole-node": { - "version": "1.0.0", - "from": "rocambole-node@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/rocambole-node/-/rocambole-node-1.0.0.tgz" - }, - "rocambole-token": { - "version": "1.2.1", - "from": "rocambole-token@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-token/-/rocambole-token-1.2.1.tgz" - }, - "rocambole-whitespace": { - "version": "1.0.0", - "from": "rocambole-whitespace@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz" - }, - "run-async": { - "version": "0.1.0", - "from": "run-async@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz" - }, - "run-sequence": { - "version": "1.2.0", - "from": "run-sequence@1.2.0", - "resolved": "https://registry.npmjs.org/run-sequence/-/run-sequence-1.2.0.tgz" - }, - "rx-lite": { - "version": "3.1.2", - "from": "rx-lite@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz" - }, - "sax": { - "version": "1.2.1", - "from": "sax@>=1.2.1 <1.3.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz" - }, - "section-iterator": { - "version": "2.0.0", - "from": "section-iterator@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz" - }, - "semver": { - "version": "4.3.6", - "from": "semver@>=4.3.1 <5.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz" - }, - "sequencify": { - "version": "0.0.7", - "from": "sequencify@>=0.0.7 <0.1.0", - "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz" - }, - "serializerr": { - "version": "1.0.2", - "from": "serializerr@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/serializerr/-/serializerr-1.0.2.tgz" - }, - "setimmediate": { - "version": "1.0.5", - "from": "setimmediate@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" - }, - "sha.js": { - "version": "2.2.6", - "from": "sha.js@2.2.6", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.2.6.tgz" - }, - "shallow-equal": { - "version": "1.0.0", - "from": "shallow-equal@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.0.0.tgz" - }, - "shallowequal": { - "version": "0.2.2", - "from": "shallowequal@>=0.2.2 <0.3.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-0.2.2.tgz" - }, - "shebang-regex": { - "version": "1.0.0", - "from": "shebang-regex@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz" - }, - "shelljs": { - "version": "0.6.1", - "from": "shelljs@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.6.1.tgz" - }, - "sigmund": { - "version": "1.0.1", - "from": "sigmund@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" - }, - "signal-exit": { - "version": "2.1.2", - "from": "signal-exit@>=2.1.2 <3.0.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-2.1.2.tgz" - }, - "slash": { - "version": "1.0.0", - "from": "slash@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz" - }, - "slice-ansi": { - "version": "0.0.4", - "from": "slice-ansi@0.0.4", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz" - }, - "sort-keys": { - "version": "1.1.2", - "from": "sort-keys@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz" - }, - "source-list-map": { - "version": "0.1.6", - "from": "source-list-map@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.6.tgz" - }, - "source-map": { - "version": "0.5.6", - "from": "source-map@>=0.5.6 <0.6.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" - }, - "source-map-support": { - "version": "0.2.10", - "from": "source-map-support@>=0.2.10 <0.3.0", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.2.10.tgz", - "dependencies": { - "source-map": { - "version": "0.1.32", - "from": "source-map@0.1.32", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz" - } - } - }, - "sparkles": { - "version": "1.0.0", - "from": "sparkles@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.0.tgz" - }, - "spawn-sync": { - "version": "1.0.15", - "from": "spawn-sync@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz" - }, - "spdx-correct": { - "version": "1.0.2", - "from": "spdx-correct@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz" - }, - "spdx-exceptions": { - "version": "1.0.4", - "from": "spdx-exceptions@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-1.0.4.tgz" - }, - "spdx-expression-parse": { - "version": "1.0.2", - "from": "spdx-expression-parse@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.2.tgz" - }, - "spdx-license-ids": { - "version": "1.2.1", - "from": "spdx-license-ids@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.1.tgz" - }, - "specificity": { - "version": "0.2.1", - "from": "specificity@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.2.1.tgz" - }, - "split": { - "version": "0.3.3", - "from": "split@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz" - }, - "split2": { - "version": "0.2.1", - "from": "split2@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-0.2.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "sprintf-js": { - "version": "1.0.3", - "from": "sprintf-js@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" - }, - "statuses": { - "version": "1.3.0", - "from": "statuses@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz" - }, - "stdin": { - "version": "0.0.1", - "from": "stdin@*", - "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz" - }, - "stream-browserify": { - "version": "1.0.0", - "from": "stream-browserify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-1.0.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.27-1 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "stream-combiner": { - "version": "0.0.4", - "from": "stream-combiner@>=0.0.4 <0.1.0", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz" - }, - "stream-consume": { - "version": "0.1.0", - "from": "stream-consume@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz" - }, - "streamqueue": { - "version": "1.1.1", - "from": "streamqueue@1.1.1", - "resolved": "https://registry.npmjs.org/streamqueue/-/streamqueue-1.1.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - } - } - }, - "strict-uri-encode": { - "version": "1.1.0", - "from": "strict-uri-encode@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz" - }, - "string_decoder": { - "version": "0.10.31", - "from": "string_decoder@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" - }, - "string-width": { - "version": "1.0.1", - "from": "string-width@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.1.tgz" - }, - "strip-ansi": { - "version": "3.0.1", - "from": "strip-ansi@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" - }, - "strip-bom": { - "version": "2.0.0", - "from": "strip-bom@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz" - }, - "strip-bom-stream": { - "version": "1.0.0", - "from": "strip-bom-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz" - }, - "strip-indent": { - "version": "1.0.1", - "from": "strip-indent@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz" - }, - "strip-json-comments": { - "version": "0.1.3", - "from": "strip-json-comments@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz" - }, - "style-loader": { - "version": "0.13.1", - "from": "style-loader@0.13.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.13.1.tgz" - }, - "style-search": { - "version": "0.1.0", - "from": "style-search@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz" - }, - "stylehacks": { - "version": "2.3.1", - "from": "stylehacks@>=2.3.0 <3.0.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-2.3.1.tgz" - }, - "stylelint": { - "version": "7.3.1", - "from": "stylelint@7.3.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-7.3.1.tgz", - "dependencies": { - "get-stdin": { - "version": "5.0.1", - "from": "get-stdin@>=5.0.0 <6.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz" - }, - "glob": { - "version": "7.1.0", - "from": "glob@>=7.0.3 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.0.tgz" - }, - "globby": { - "version": "6.0.0", - "from": "globby@>=6.0.0 <7.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.0.0.tgz" - }, - "ignore": { - "version": "3.1.5", - "from": "ignore@>=3.1.3 <4.0.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.1.5.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@^3.0.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "postcss-selector-parser": { - "version": "2.2.1", - "from": "postcss-selector-parser@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.1.tgz" - }, - "resolve-from": { - "version": "2.0.0", - "from": "resolve-from@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz" - } - } - }, - "sugarss": { - "version": "0.1.6", - "from": "sugarss@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-0.1.6.tgz", - "dependencies": { - "postcss": { - "version": "5.2.0", - "from": "postcss@^5.2.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz" - } - } - }, - "supports-color": { - "version": "3.1.2", - "from": "supports-color@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" - }, - "svg-tags": { - "version": "1.0.0", - "from": "svg-tags@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz" - }, - "svgo": { - "version": "0.6.6", - "from": "svgo@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.6.6.tgz" - }, - "symbol-observable": { - "version": "1.0.4", - "from": "symbol-observable@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.4.tgz" - }, - "sync-exec": { - "version": "0.5.0", - "from": "sync-exec@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/sync-exec/-/sync-exec-0.5.0.tgz" - }, - "synesthesia": { - "version": "1.0.1", - "from": "synesthesia@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/synesthesia/-/synesthesia-1.0.1.tgz" - }, - "table": { - "version": "3.7.8", - "from": "table@>=3.7.8 <4.0.0", - "resolved": "https://registry.npmjs.org/table/-/table-3.7.8.tgz" - }, - "tapable": { - "version": "0.1.10", - "from": "tapable@>=0.1.8 <0.2.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz" - }, - "tar": { - "version": "2.2.1", - "from": "tar@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz" - }, - "tar.gz": { - "version": "1.0.3", - "from": "tar.gz@1.0.3", - "resolved": "https://registry.npmjs.org/tar.gz/-/tar.gz-1.0.3.tgz", - "dependencies": { - "bluebird": { - "version": "2.10.2", - "from": "bluebird@>=2.9.34 <3.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz" - }, - "mout": { - "version": "0.11.1", - "from": "mout@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz" - } - } - }, - "tether": { - "version": "1.4.0", - "from": "tether@>=1.3.7 <2.0.0", - "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.0.tgz" - }, - "text-table": { - "version": "0.2.0", - "from": "text-table@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" - }, - "through": { - "version": "2.3.8", - "from": "through@>=2.3.6 <3.0.0", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz" - }, - "through2": { - "version": "2.0.1", - "from": "through2@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.1.tgz" - }, - "tildify": { - "version": "1.2.0", - "from": "tildify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz" - }, - "time-stamp": { - "version": "1.0.1", - "from": "time-stamp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.0.1.tgz" - }, - "timers-browserify": { - "version": "1.4.2", - "from": "timers-browserify@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz" - }, - "to-fast-properties": { - "version": "1.0.2", - "from": "to-fast-properties@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.2.tgz" - }, - "trim-newlines": { - "version": "1.0.0", - "from": "trim-newlines@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz" - }, - "tryit": { - "version": "1.0.2", - "from": "tryit@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.2.tgz" - }, - "tty-browserify": { - "version": "0.0.0", - "from": "tty-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz" - }, - "tv4": { - "version": "1.2.7", - "from": "tv4@>=1.2.7 <2.0.0", - "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.2.7.tgz" - }, - "type-check": { - "version": "0.3.2", - "from": "type-check@>=0.3.2 <0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz" - }, - "type-detect": { - "version": "1.0.0", - "from": "type-detect@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz" - }, - "type-is": { - "version": "1.6.13", - "from": "type-is@>=1.6.10 <1.7.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz" - }, - "typedarray": { - "version": "0.0.6", - "from": "typedarray@>=0.0.6 <0.0.7", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" - }, - "ua-parser-js": { - "version": "0.7.12", - "from": "ua-parser-js@>=0.7.9 <0.8.0", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.12.tgz" - }, - "uglify-js": { - "version": "2.3.6", - "from": "uglify-js@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.3.6.tgz", - "dependencies": { - "optimist": { - "version": "0.3.7", - "from": "optimist@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz" - }, - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.7 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "uglify-to-browserify": { - "version": "1.0.2", - "from": "uglify-to-browserify@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz" - }, - "uniq": { - "version": "1.0.1", - "from": "uniq@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz" - }, - "uniqid": { - "version": "1.0.0", - "from": "uniqid@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-1.0.0.tgz" - }, - "uniqs": { - "version": "2.0.0", - "from": "uniqs@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz" - }, - "unique-stream": { - "version": "1.0.0", - "from": "unique-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz" - }, - "unpipe": { - "version": "1.0.0", - "from": "unpipe@1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" - }, - "url": { - "version": "0.10.3", - "from": "url@>=0.10.1 <0.11.0", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "dependencies": { - "punycode": { - "version": "1.3.2", - "from": "punycode@1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz" - } - } - }, - "url-loader": { - "version": "0.5.7", - "from": "url-loader@0.5.7", - "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-0.5.7.tgz", - "dependencies": { - "mime": { - "version": "1.2.11", - "from": "mime@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz" - } - } - }, - "user-home": { - "version": "1.1.1", - "from": "user-home@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz" - }, - "util": { - "version": "0.10.3", - "from": "util@>=0.10.3 <0.11.0", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz" - }, - "util-deprecate": { - "version": "1.0.2", - "from": "util-deprecate@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" - }, - "v8flags": { - "version": "2.0.11", - "from": "v8flags@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.0.11.tgz" - }, - "validate-npm-package-license": { - "version": "3.0.1", - "from": "validate-npm-package-license@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz" - }, - "vinyl": { - "version": "0.5.3", - "from": "vinyl@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz" - }, - "vinyl-bufferstream": { - "version": "1.0.1", - "from": "vinyl-bufferstream@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz" - }, - "vinyl-file": { - "version": "1.3.0", - "from": "vinyl-file@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-1.3.0.tgz", - "dependencies": { - "vinyl": { - "version": "1.1.1", - "from": "vinyl@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz" - } - } - }, - "vinyl-fs": { - "version": "0.3.14", - "from": "vinyl-fs@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz", - "dependencies": { - "clone": { - "version": "0.2.0", - "from": "clone@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz" - }, - "graceful-fs": { - "version": "3.0.8", - "from": "graceful-fs@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.8.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "strip-bom": { - "version": "1.0.0", - "from": "strip-bom@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "vinyl": { - "version": "0.4.6", - "from": "vinyl@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz" - } - } - }, - "vinyl-map": { - "version": "1.0.1", - "from": "vinyl-map@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-map/-/vinyl-map-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.4.2", - "from": "through2@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz" - }, - "xtend": { - "version": "2.1.2", - "from": "xtend@>=2.1.1 <2.2.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz" - } - } - }, - "vinyl-sourcemaps-apply": { - "version": "0.2.1", - "from": "vinyl-sourcemaps-apply@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz" - }, - "vm-browserify": { - "version": "0.0.4", - "from": "vm-browserify@0.0.4", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz" - }, - "warning": { - "version": "3.0.0", - "from": "warning@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz" - }, - "watchpack": { - "version": "0.2.9", - "from": "watchpack@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-0.2.9.tgz", - "dependencies": { - "async": { - "version": "0.9.2", - "from": "async@>=0.9.0 <0.10.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz" - } - } - }, - "webpack": { - "version": "1.13.1", - "from": "webpack@1.13.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.13.1.tgz", - "dependencies": { - "async": { - "version": "1.5.2", - "from": "async@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" - }, - "interpret": { - "version": "0.6.6", - "from": "interpret@>=0.6.4 <0.7.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz" - }, - "uglify-js": { - "version": "2.6.2", - "from": "uglify-js@>=2.6.0 <2.7.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.6.2.tgz", - "dependencies": { - "async": { - "version": "0.2.10", - "from": "async@>=0.2.6 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - } - } - } - } - }, - "webpack-core": { - "version": "0.6.8", - "from": "webpack-core@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.8.tgz", - "dependencies": { - "source-map": { - "version": "0.4.4", - "from": "source-map@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" - } - } - }, - "webpack-sources": { - "version": "0.1.3", - "from": "webpack-sources@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-0.1.3.tgz" - }, - "webpack-stream": { - "version": "2.1.1", - "from": "webpack-stream@2.1.1", - "resolved": "https://registry.npmjs.org/webpack-stream/-/webpack-stream-2.1.1.tgz", - "dependencies": { - "memory-fs": { - "version": "0.2.0", - "from": "memory-fs@>=0.2.0 <0.3.0-0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz" - } - } - }, - "websocket-driver": { - "version": "0.6.5", - "from": "websocket-driver@>=0.3.6", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz" - }, - "websocket-extensions": { - "version": "0.1.1", - "from": "websocket-extensions@>=0.1.1", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.1.tgz" - }, - "whatwg-fetch": { - "version": "2.0.2", - "from": "whatwg-fetch@>=0.10.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz" - }, - "when": { - "version": "3.7.7", - "from": "when@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz" - }, - "whet.extend": { - "version": "0.9.9", - "from": "whet.extend@>=0.9.9 <0.10.0", - "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz" - }, - "which": { - "version": "1.2.9", - "from": "which@>=1.2.4 <2.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.9.tgz" - }, - "window-size": { - "version": "0.1.0", - "from": "window-size@0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" - }, - "wordwrap": { - "version": "1.0.0", - "from": "wordwrap@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" - }, - "wrappy": { - "version": "1.0.2", - "from": "wrappy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" - }, - "write": { - "version": "0.2.1", - "from": "write@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz" - }, - "write-file-stdout": { - "version": "0.0.2", - "from": "write-file-stdout@0.0.2", - "resolved": "https://registry.npmjs.org/write-file-stdout/-/write-file-stdout-0.0.2.tgz" - }, - "xregexp": { - "version": "3.1.1", - "from": "xregexp@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-3.1.1.tgz" - }, - "xtend": { - "version": "4.0.1", - "from": "xtend@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" - }, - "yargs": { - "version": "3.10.0", - "from": "yargs@>=3.10.0 <3.11.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "dependencies": { - "camelcase": { - "version": "1.2.1", - "from": "camelcase@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz" - } - } - } - } -} diff --git a/package.json b/package.json index 0e2bbdd55..85dd8eb59 100644 --- a/package.json +++ b/package.json @@ -1,96 +1,114 @@ { - "name": "Sonarr", - "version": "2.0.0", - "description": "Sonarr", + "name": "sonarr", + "version": "3.0.0", + "description": "Sonarr is a PVR for Usenet and BitTorrent users", "scripts": { "build": "gulp build", - "start": "gulp watch" - }, - "repository": { - "type": "git", - "url": "git://github.com/Sonarr/Sonarr.git" + "start": "gulp watch", + "eslint": "esprint check", + "eslint-fix": "eslint start --fix", + "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc" }, + "repository": "https://github.com/Sonarr/Sonarr", "author": "Team Sonarr", "license": "GPL-3.0", "readmeFilename": "readme.md", "dependencies": { - "autoprefixer": "6.3.6", - "babel-core": "6.9.0", - "babel-eslint": "7.1.0", - "babel-loader": "6.2.4", - "babel-plugin-transform-class-properties": "6.16.0", + "@fortawesome/fontawesome-free": "5.3.1", + "@fortawesome/fontawesome-svg-core": "1.2.4", + "@fortawesome/free-regular-svg-icons": "5.3.1", + "@fortawesome/free-solid-svg-icons": "5.3.1", + "@fortawesome/react-fontawesome": "0.1.3", + "@sentry/browser": "4.0.3", + "autoprefixer": "9.1.5", + "babel-core": "6.26.3", + "babel-eslint": "9.0.0", + "babel-loader": "7.1.2", + "babel-plugin-transform-class-properties": "6.24.1", "babel-preset-decorators-legacy": "1.0.0", - "babel-preset-es2015": "6.9.0", - "babel-preset-react": "6.22.0", - "babel-preset-stage-2": "6.5.0", - "classnames": "2.2.5", - "css-loader": "0.23.1", - "del": "2.2.0", + "babel-preset-es2015": "6.24.1", + "babel-preset-react": "6.24.1", + "babel-preset-stage-2": "6.24.1", + "classnames": "2.2.6", + "clipboard": "2.0.1", + "create-react-class": "15.6.3", + "css-loader": "0.28.9", + "del": "3.0.0", "element-class": "0.2.2", - "esformatter": "0.9.3", - "eslint": "2.10.2", - "eslint-loader": "1.3.0", - "eslint-plugin-filenames": "1.0.0", - "eslint-plugin-react": "5.2.2", - "extract-text-webpack-plugin": "1.0.1", - "file-loader": "0.9.0", - "filesize": "3.5.4", + "esformatter": "0.10.0", + "eslint": "5.6.0", + "eslint-plugin-filenames": "1.3.2", + "eslint-plugin-react": "7.11.1", + "esprint": "0.4.0", + "extract-text-webpack-plugin": "3.0.2", + "file-loader": "1.1.6", + "filesize": "3.6.1", "gulp": "3.9.1", - "gulp-cached": "1.1.0", - "gulp-clean-css": "^3.0.4", - "gulp-concat": "2.6.0", + "gulp-cached": "1.1.1", + "gulp-clean-css": "3.10.0", + "gulp-concat": "2.6.1", "gulp-declare": "0.3.0", - "gulp-handlebars": "3.0.1", - "gulp-less": "3.0.3", - "gulp-livereload": "3.8.1", - "gulp-postcss": "6.1.1", - "gulp-print": "2.0.1", - "gulp-sourcemaps": "1.6.0", + "gulp-livereload": "4.0.0", + "gulp-postcss": "8.0.0", + "gulp-print": "5.0.0", + "gulp-sourcemaps": "2.6.4", "gulp-stripbom": "1.0.4", - "gulp-util": "3.0.7", - "gulp-watch": "4.3.5", - "gulp-wrap": "0.13.0", - "handlebars": "3.0.3", - "lodash": "4.17.4", - "moment": "2.17.1", - "normalize.css": "5.0.0", - "postcss-loader": "0.9.1", - "postcss-nested": "1.0.0", - "postcss-simple-vars": "3.0.0", - "react": "15.4.2", - "react-addons-shallow-compare": "15.4.2", - "react-autosuggest": "8.0.0", - "react-dnd": "2.1.4", - "react-dnd-html5-backend": "2.1.2", - "react-document-title": "2.0.2", - "react-dom": "15.4.2", - "react-google-recaptcha": "0.5.4", - "react-lazyload": "2.2.0", - "react-measure": "1.4.5", - "react-portal": "3.0.0", - "react-redux": "5.0.2", - "react-router": "3.0.2", - "react-router-redux": "4.0.7", - "react-slider": "0.7.0", - "react-tabs": "0.8.2", - "react-tag-autocomplete": "5.1.0", - "react-tether": "0.5.5", - "react-virtualized": "8.11.3", - "redux": "3.6.0", - "redux-actions": "1.2.0", - "redux-batched-actions": "0.1.5", + "gulp-util": "3.0.8", + "gulp-watch": "5.0.1", + "gulp-wrap": "0.14.0", + "history": "4.7.2", + "jdu": "1.0.0", + "jquery": "3.3.1", + "loader-utils": "^1.1.0", + "lodash": "4.17.11", + "mobile-detect": "1.4.3", + "moment": "2.22.2", + "mousetrap": "1.6.2", + "normalize.css": "8.0.0", + "postcss-loader": "3.0.0", + "postcss-mixins": "6.2.0", + "postcss-nested": "4.1.0", + "postcss-simple-vars": "5.0.1", + "prop-types": "15.6.2", + "qs": "6.5.2", + "react": "16.5.2", + "react-addons-shallow-compare": "15.6.2", + "react-async-script": "1.0.0", + "react-autosuggest": "9.4.1", + "react-custom-scrollbars": "4.2.1", + "react-dnd": "5.0.0", + "react-dnd-html5-backend": "5.0.1", + "react-document-title": "2.0.3", + "react-dom": "16.5.2", + "react-google-recaptcha": "1.0.2", + "react-lazyload": "2.3.0", + "react-measure": "1.4.7", + "react-redux": "5.0.7", + "react-router-dom": "4.3.1", + "react-router-redux": "5.0.0-alpha.6", + "react-slider": "0.11.2", + "react-tabs": "2.3.0", + "react-tether": "1.0.1", + "react-text-truncate": "0.13.1", + "react-virtualized": "9.20.1", + "redux": "4.0.0", + "redux-actions": "2.6.1", + "redux-batched-actions": "0.4.0", "redux-localstorage": "0.4.1", - "redux-raven-middleware": "1.2.0", - "redux-thunk": "2.2.0", + "redux-thunk": "2.3.0", "require-nocache": "1.0.0", - "reselect": "2.5.4", - "run-sequence": "1.2.0", - "streamqueue": "1.1.1", - "style-loader": "0.13.1", - "stylelint": "7.3.1", - "tar.gz": "1.0.3", - "url-loader": "0.5.7", - "webpack": "1.13.1", - "webpack-stream": "2.1.1" - } + "reselect": "3.0.1", + "run-sequence": "2.2.1", + "signalr": "2.4.0", + "streamqueue": "1.1.2", + "style-loader": "0.19.1", + "stylelint": "9.5.0", + "stylelint-order": "1.0.0", + "tar.gz": "1.0.7", + "uglifyjs-webpack-plugin": "1.2.5", + "url-loader": "0.6.2", + "webpack": "3.10.0", + "webpack-stream": "^4.0.0" + }, + "main": "index.js" } diff --git a/src/NzbDrone.Common/Serializer/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs index 4a0565a4d..90023e8f3 100644 --- a/src/NzbDrone.Common/Serializer/Json.cs +++ b/src/NzbDrone.Common/Serializer/Json.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using Newtonsoft.Json; @@ -40,7 +40,7 @@ namespace NzbDrone.Common.Serializer { try { - return JsonConvert.DeserializeObject(json, SerializerSetting); + return JsonConvert.DeserializeObject(json, SerializerSettings); } catch (JsonReaderException ex) { @@ -52,7 +52,7 @@ namespace NzbDrone.Common.Serializer { try { - return JsonConvert.DeserializeObject(json, type, SerializerSetting); + return JsonConvert.DeserializeObject(json, type, SerializerSettings); } catch (JsonReaderException ex) { diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs index c64adfe80..eed192a6a 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; diff --git a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs index 1dbc4dfa4..c32fcc796 100644 --- a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs +++ b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.AspNet.SignalR; using NzbDrone.Common.Composition; using NzbDrone.SignalR; @@ -22,7 +22,7 @@ namespace NzbDrone.Host.Owin.MiddleWare public void Attach(IAppBuilder appBuilder) { - appBuilder.MapConnection("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration()); + appBuilder.MapSignalR("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs index c0676cd24..792c4d12c 100644 --- a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs +++ b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -70,7 +70,7 @@ namespace NzbDrone.Host.Owin private void BuildApp(IAppBuilder appBuilder) { - appBuilder.Properties["host.AppName"] = "NzbDrone"; + appBuilder.Properties["host.AppName"] = "Sonarr"; foreach (var middleWare in _owinMiddleWares.OrderBy(c => c.Order)) { @@ -88,4 +88,4 @@ namespace NzbDrone.Host.Owin return provider; } } -} \ No newline at end of file +} diff --git a/src/Sonarr.sln b/src/Sonarr.sln index fc53dfb4d..0d25c827a 100644 --- a/src/Sonarr.sln +++ b/src/Sonarr.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.10 +VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\NzbDrone.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}" ProjectSection(ProjectDependencies) = postProject @@ -106,6 +106,12 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.ActiveCfg = Debug|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.Build.0 = Debug|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.ActiveCfg = Release|x86 @@ -200,12 +206,6 @@ Global {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.Build.0 = Release|x86 {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.ActiveCfg = Release|x86 {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.Build.0 = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.ActiveCfg = Debug|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.Build.0 = Debug|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.ActiveCfg = Debug|x86 @@ -290,6 +290,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} = {57A04B72-8088-4F75-A582-1158CF8291F7} {FAFB5948-A222-4CF6-AD14-026BE7564802} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} {CADDFCE0-7509-4430-8364-2074E1EEFCA2} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} @@ -303,7 +304,6 @@ Global {CC26800D-F67E-464B-88DE-8EB1A0C227A3} = {57A04B72-8088-4F75-A582-1158CF8291F7} {6BCE712F-846D-4846-9D1B-A66B858DA755} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {95C11A9E-56ED-456A-8447-2C89C1139266} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {D12F7F2F-8A3C-415F-88FA-6DD061A84869} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} @@ -318,8 +318,8 @@ Global {74420A79-CC16-442C-8B1E-7C1B913844F0} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F} EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35 + SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F} EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution StartupItem = NzbDrone.Console\NzbDrone.Console.csproj diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..22d1eeec9 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8528 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@^7.0.0-rc.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.0.1.tgz#406658caed0e9686fa4feb5c2f3cefb6161c0f41" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.0.0" + "@babel/helpers" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + convert-source-map "^1.1.0" + debug "^3.1.0" + json5 "^0.5.0" + lodash "^4.17.10" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.0.0.tgz#1efd58bffa951dc846449e58ce3a1d7f02d393aa" + dependencies: + "@babel/types" "^7.0.0" + jsesc "^2.5.1" + lodash "^4.17.10" + source-map "^0.5.0" + trim-right "^1.0.1" + +"@babel/helper-function-name@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0.tgz#a68cc8d04420ccc663dd258f9cc41b8261efa2d4" + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-split-export-declaration@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0.tgz#3aae285c0311c2ab095d997b8c9a94cad547d813" + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helpers@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.0.0.tgz#7213388341eeb07417f44710fd7e1d00acfa6ac0" + dependencies: + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/highlight@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.0.0.tgz#697655183394facffb063437ddf52c0277698775" + +"@babel/template@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0.tgz#c2bc9870405959c89a9c814376a2ecb247838c80" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/traverse@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0.tgz#b1fe9b6567fdf3ab542cfad6f3b31f854d799a61" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.0.0" + "@babel/helper-function-name" "^7.0.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/types" "^7.0.0" + debug "^3.1.0" + globals "^11.1.0" + lodash "^4.17.10" + +"@babel/types@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0.tgz#6e191793d3c854d19c6749989e3bc55f0e962118" + dependencies: + esutils "^2.0.2" + lodash "^4.17.10" + to-fast-properties "^2.0.0" + +"@fortawesome/fontawesome-common-types@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.4.tgz#7c560ff732c6c7c7c179ae25227ce5449e6f6d65" + +"@fortawesome/fontawesome-free@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9" + +"@fortawesome/fontawesome-svg-core@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.4.tgz#2e40c65e66c7ad5aabc179a2d7c5827b1599905c" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/free-regular-svg-icons@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.3.1.tgz#edd019dc61a991c7e3cb9d862a6efd971675e3ba" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/free-solid-svg-icons@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.3.1.tgz#9660bece3c4850d58f1653e26c1693487ff74b08" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/react-fontawesome@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.3.tgz#266b4047892c3d10498af1075d89252f74015b11" + dependencies: + humps "^2.0.1" + prop-types "^15.5.10" + +"@gulp-sourcemaps/identity-map@1.X": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz#cfa23bc5840f9104ce32a65e74db7e7a974bbee1" + dependencies: + acorn "^5.0.3" + css "^2.2.1" + normalize-path "^2.1.1" + source-map "^0.5.6" + through2 "^2.0.3" + +"@gulp-sourcemaps/map-sources@1.X": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz#890ae7c5d8c877f6d384860215ace9d7ec945bda" + dependencies: + normalize-path "^2.0.1" + through2 "^2.0.3" + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@nodelib/fs.stat@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" + +"@sentry/browser@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-4.0.3.tgz#a748ce8b7695246d8d36f6c1fe209d259e18f1c2" + dependencies: + "@sentry/core" "4.0.3" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + md5 "2.2.1" + +"@sentry/core@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.0.3.tgz#4f8fd67888f1cf0f1a984c5fa362122b60e8bd08" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/minimal" "4.0.1" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + +"@sentry/hub@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.0.1.tgz#01870cede195029ae32d763199ff6c3e4edf99d1" + dependencies: + "@sentry/types" "4.0.0" + "@sentry/utils" "4.0.1" + +"@sentry/minimal@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.0.1.tgz#c51a2af81eba48977fb54ab187e0c0eb0ad12c15" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/types" "4.0.0" + +"@sentry/types@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.0.tgz#9dd46a7b05004871fe0cea0b0423098d9d91a173" + +"@sentry/types@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.1.tgz#f9342e905ce2aee71975574589d915b6fb691fb0" + +"@sentry/utils@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.0.1.tgz#5690058fb030c23d46ea056aa3e8ebebb8105d45" + dependencies: + "@sentry/types" "4.0.0" + +abbrev@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" + +acorn-dynamic-import@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4" + dependencies: + acorn "^4.0.3" + +acorn-jsx@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-4.1.1.tgz#e8e41e48ea2fe0c896740610ab6a4ffd8add225e" + dependencies: + acorn "^5.0.3" + +acorn-to-esprima@^2.0.6, acorn-to-esprima@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz#003f0c642eb92132f417d3708f14ada82adf2eb1" + +acorn@5.X: + version "5.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822" + +acorn@^4.0.3: + version "4.0.13" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" + +acorn@^5.0.0, acorn@^5.0.3: + version "5.1.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7" + +acorn@^5.6.0: + version "5.7.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" + +add-px-to-style@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" + +ajv-errors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59" + +ajv-keywords@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0" + +ajv-keywords@^3.0.0, ajv-keywords@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" + +ajv@^5.0.0, ajv@^5.1.5: + version "5.2.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + json-schema-traverse "^0.3.0" + json-stable-stringify "^1.0.1" + +ajv@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + json-schema-traverse "^0.3.0" + json-stable-stringify "^1.0.1" + +ajv@^6.0.1, ajv@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.3.tgz#71a569d189ecf4f4f321224fecb166f071dd90f9" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^6.1.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.0.tgz#4c8affdf80887d8f132c9c52ab8a2dc4d0b7b24c" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + uri-js "^4.2.1" + +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + +ansi-colors@1.1.0, ansi-colors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" + dependencies: + ansi-wrap "^0.1.0" + +ansi-cyan@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-cyan/-/ansi-cyan-0.1.1.tgz#538ae528af8982f28ae30d86f2f17456d2609873" + dependencies: + ansi-wrap "0.1.0" + +ansi-escapes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92" + +ansi-gray@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-gray/-/ansi-gray-0.1.1.tgz#2962cf54ec9792c48510a3deb524436861ef7251" + dependencies: + ansi-wrap "0.1.0" + +ansi-red@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" + dependencies: + ansi-wrap "0.1.0" + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + +ansi-styles@^2.0.1, ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + dependencies: + color-convert "^1.9.0" + +ansi-wrap@0.1.0, ansi-wrap@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + +anymatch@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" + dependencies: + micromatch "^2.1.5" + normalize-path "^2.0.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +aproba@^1.0.3, aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + +are-we-there-yet@~1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d" + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-1.1.0.tgz#687c32758163588fef7de7b36fabe495eb1a399a" + dependencies: + arr-flatten "^1.0.1" + array-slice "^0.2.3" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + dependencies: + arr-flatten "^1.0.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + +arr-union@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-2.1.0.tgz#20f9eab5ec70f5c7d215b1077b1c39161d292c7d" + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + +array-differ@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031" + +array-each@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" + +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" + +array-includes@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.7.0" + +array-slice@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5" + +array-slice@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.0.0.tgz#e73034f00dcc1f40876008fd20feae77bd4b7c2f" + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1, array-uniq@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + +arrify@^1.0.0, arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + +asap@^2.0.6, asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + +asn1.js@^4.0.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +asn1@~0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +assert@^1.1.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" + dependencies: + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + +async-each@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" + +async@^2.1.2, async@^2.4.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d" + dependencies: + lodash "^4.14.0" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + +atob@~1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" + +autobind-decorator@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.1.0.tgz#4451240dbfeff46361c506575a63ed40f0e5bc68" + +autoprefixer@9.1.5, autoprefixer@^9.0.0: + version "9.1.5" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.1.5.tgz#8675fd8d1c0d43069f3b19a2c316f3524e4f6671" + dependencies: + browserslist "^4.1.0" + caniuse-lite "^1.0.30000884" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.2" + postcss-value-parser "^3.2.3" + +autoprefixer@^6.3.1: + version "6.3.6" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.3.6.tgz#de772e1fcda08dce0e992cecf79252d5f008e367" + dependencies: + browserslist "~1.3.1" + caniuse-db "^1.0.30000444" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^5.0.19" + postcss-value-parser "^3.2.3" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" + +babel-code-frame@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" + dependencies: + chalk "^1.1.3" + esutils "^2.0.2" + js-tokens "^3.0.2" + +babel-core@6.26.3: + version "6.26.3" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.1" + debug "^2.6.9" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.8" + slash "^1.0.0" + source-map "^0.5.7" + +babel-core@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.0.tgz#af32f78b31a6fcef119c87b0fd8d9753f03a0bb8" + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.0" + debug "^2.6.8" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.7" + slash "^1.0.0" + source-map "^0.5.6" + +babel-eslint@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-9.0.0.tgz#7d9445f81ed9f60aff38115f838970df9f2b6220" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + eslint-scope "3.7.1" + eslint-visitor-keys "^1.0.0" + +babel-generator@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5" + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.17.4" + source-map "^0.5.6" + trim-right "^1.0.1" + +babel-helper-bindify-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz#14c19e5f142d7b47f19a52431e52b1ccbc40a330" + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-builder-binary-assignment-operator-visitor@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664" + dependencies: + babel-helper-explode-assignable-expression "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-builder-react-jsx@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz#39ff8313b75c8b65dceff1f31d383e0ff2a408a0" + dependencies: + babel-runtime "^6.26.0" + babel-types "^6.26.0" + esutils "^2.0.2" + +babel-helper-call-delegate@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d" + dependencies: + babel-helper-hoist-variables "^6.24.1" + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-define-map@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-helper-explode-assignable-expression@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa" + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-explode-class@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz#7dc2a3910dee007056e1e31d640ced3d54eaa9eb" + dependencies: + babel-helper-bindify-decorators "^6.24.1" + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-function-name@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9" + dependencies: + babel-helper-get-function-arity "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-get-function-arity@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-hoist-variables@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-optimise-call-expression@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-regex@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72" + dependencies: + babel-runtime "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-helper-remap-async-to-generator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-replace-supers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a" + dependencies: + babel-helper-optimise-call-expression "^6.24.1" + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helpers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-loader@7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.2.tgz#f6cbe122710f1aa2af4d881c6d5b54358ca24126" + dependencies: + find-cache-dir "^1.0.0" + loader-utils "^1.0.2" + mkdirp "^0.5.1" + +babel-messages@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-check-es2015-constants@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-syntax-async-functions@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" + +babel-plugin-syntax-async-generators@^6.5.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz#6bc963ebb16eccbae6b92b596eb7f35c342a8b9a" + +babel-plugin-syntax-class-properties@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz#d7eb23b79a317f8543962c505b827c7d6cac27de" + +babel-plugin-syntax-decorators@^6.1.18, babel-plugin-syntax-decorators@^6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz#312563b4dbde3cc806cee3e416cceeaddd11ac0b" + +babel-plugin-syntax-dynamic-import@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da" + +babel-plugin-syntax-exponentiation-operator@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" + +babel-plugin-syntax-flow@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz#4c3ab20a2af26aa20cd25995c398c4eb70310c8d" + +babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + +babel-plugin-syntax-object-rest-spread@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" + +babel-plugin-syntax-trailing-function-commas@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3" + +babel-plugin-transform-async-generator-functions@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz#f058900145fd3e9907a6ddf28da59f215258a5db" + dependencies: + babel-helper-remap-async-to-generator "^6.24.1" + babel-plugin-syntax-async-generators "^6.5.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-async-to-generator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761" + dependencies: + babel-helper-remap-async-to-generator "^6.24.1" + babel-plugin-syntax-async-functions "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-class-properties@6.24.1, babel-plugin-transform-class-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz#6a79763ea61d33d36f37b611aa9def81a81b46ac" + dependencies: + babel-helper-function-name "^6.24.1" + babel-plugin-syntax-class-properties "^6.8.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-decorators-legacy@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz#741b58f6c5bce9e6027e0882d9c994f04f366925" + dependencies: + babel-plugin-syntax-decorators "^6.1.18" + babel-runtime "^6.2.0" + babel-template "^6.3.0" + +babel-plugin-transform-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz#788013d8f8c6b5222bdf7b344390dfd77569e24d" + dependencies: + babel-helper-explode-class "^6.24.1" + babel-plugin-syntax-decorators "^6.13.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-arrow-functions@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-block-scoped-functions@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-block-scoping@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f" + dependencies: + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-plugin-transform-es2015-classes@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db" + dependencies: + babel-helper-define-map "^6.24.1" + babel-helper-function-name "^6.24.1" + babel-helper-optimise-call-expression "^6.24.1" + babel-helper-replace-supers "^6.24.1" + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-computed-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3" + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-destructuring@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-duplicate-keys@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-for-of@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-function-name@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-literals@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-modules-amd@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154" + dependencies: + babel-plugin-transform-es2015-modules-commonjs "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-modules-commonjs@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz#0d8394029b7dc6abe1a97ef181e00758dd2e5d8a" + dependencies: + babel-plugin-transform-strict-mode "^6.24.1" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-types "^6.26.0" + +babel-plugin-transform-es2015-modules-systemjs@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23" + dependencies: + babel-helper-hoist-variables "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-modules-umd@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468" + dependencies: + babel-plugin-transform-es2015-modules-amd "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-object-super@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d" + dependencies: + babel-helper-replace-supers "^6.24.1" + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-parameters@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b" + dependencies: + babel-helper-call-delegate "^6.24.1" + babel-helper-get-function-arity "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-shorthand-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-spread@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-sticky-regex@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc" + dependencies: + babel-helper-regex "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-template-literals@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-typeof-symbol@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-unicode-regex@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9" + dependencies: + babel-helper-regex "^6.24.1" + babel-runtime "^6.22.0" + regexpu-core "^2.0.0" + +babel-plugin-transform-exponentiation-operator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e" + dependencies: + babel-helper-builder-binary-assignment-operator-visitor "^6.24.1" + babel-plugin-syntax-exponentiation-operator "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-flow-strip-types@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf" + dependencies: + babel-plugin-syntax-flow "^6.18.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-object-rest-spread@^6.22.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" + dependencies: + babel-plugin-syntax-object-rest-spread "^6.8.0" + babel-runtime "^6.26.0" + +babel-plugin-transform-react-display-name@^6.23.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-self@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz#df6d80a9da2612a121e6ddd7558bcbecf06e636e" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-source@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz#66ac12153f5cd2d17b3c19268f4bf0197f44ecd6" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz#840a028e7df460dfc3a2d29f0c0d91f6376e66a3" + dependencies: + babel-helper-builder-react-jsx "^6.24.1" + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-regenerator@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" + dependencies: + regenerator-transform "^0.10.0" + +babel-plugin-transform-strict-mode@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-preset-decorators-legacy@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz#87772ec5303c5a3b748ce450c8400975662d1731" + dependencies: + babel-plugin-transform-decorators-legacy "^1.3.4" + +babel-preset-es2015@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz#d44050d6bc2c9feea702aaf38d727a0210538939" + dependencies: + babel-plugin-check-es2015-constants "^6.22.0" + babel-plugin-transform-es2015-arrow-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoped-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoping "^6.24.1" + babel-plugin-transform-es2015-classes "^6.24.1" + babel-plugin-transform-es2015-computed-properties "^6.24.1" + babel-plugin-transform-es2015-destructuring "^6.22.0" + babel-plugin-transform-es2015-duplicate-keys "^6.24.1" + babel-plugin-transform-es2015-for-of "^6.22.0" + babel-plugin-transform-es2015-function-name "^6.24.1" + babel-plugin-transform-es2015-literals "^6.22.0" + babel-plugin-transform-es2015-modules-amd "^6.24.1" + babel-plugin-transform-es2015-modules-commonjs "^6.24.1" + babel-plugin-transform-es2015-modules-systemjs "^6.24.1" + babel-plugin-transform-es2015-modules-umd "^6.24.1" + babel-plugin-transform-es2015-object-super "^6.24.1" + babel-plugin-transform-es2015-parameters "^6.24.1" + babel-plugin-transform-es2015-shorthand-properties "^6.24.1" + babel-plugin-transform-es2015-spread "^6.22.0" + babel-plugin-transform-es2015-sticky-regex "^6.24.1" + babel-plugin-transform-es2015-template-literals "^6.22.0" + babel-plugin-transform-es2015-typeof-symbol "^6.22.0" + babel-plugin-transform-es2015-unicode-regex "^6.24.1" + babel-plugin-transform-regenerator "^6.24.1" + +babel-preset-flow@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz#e71218887085ae9a24b5be4169affb599816c49d" + dependencies: + babel-plugin-transform-flow-strip-types "^6.22.0" + +babel-preset-react@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-react/-/babel-preset-react-6.24.1.tgz#ba69dfaea45fc3ec639b6a4ecea6e17702c91380" + dependencies: + babel-plugin-syntax-jsx "^6.3.13" + babel-plugin-transform-react-display-name "^6.23.0" + babel-plugin-transform-react-jsx "^6.24.1" + babel-plugin-transform-react-jsx-self "^6.22.0" + babel-plugin-transform-react-jsx-source "^6.22.0" + babel-preset-flow "^6.23.0" + +babel-preset-stage-2@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz#d9e2960fb3d71187f0e64eec62bc07767219bdc1" + dependencies: + babel-plugin-syntax-dynamic-import "^6.18.0" + babel-plugin-transform-class-properties "^6.24.1" + babel-plugin-transform-decorators "^6.24.1" + babel-preset-stage-3 "^6.24.1" + +babel-preset-stage-3@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz#836ada0a9e7a7fa37cb138fb9326f87934a48395" + dependencies: + babel-plugin-syntax-trailing-function-commas "^6.22.0" + babel-plugin-transform-async-generator-functions "^6.24.1" + babel-plugin-transform-async-to-generator "^6.24.1" + babel-plugin-transform-exponentiation-operator "^6.24.1" + babel-plugin-transform-object-rest-spread "^6.22.0" + +babel-register@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" + dependencies: + babel-core "^6.26.0" + babel-runtime "^6.26.0" + core-js "^2.5.0" + home-or-tmp "^2.0.0" + lodash "^4.17.4" + mkdirp "^0.5.1" + source-map-support "^0.4.15" + +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +babel-template@^6.24.1, babel-template@^6.26.0, babel-template@^6.3.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" + dependencies: + babel-runtime "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + lodash "^4.17.4" + +babel-traverse@^6.24.1, babel-traverse@^6.26.0, babel-traverse@^6.4.5, babel-traverse@^6.9.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" + dependencies: + babel-code-frame "^6.26.0" + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + debug "^2.6.8" + globals "^9.18.0" + invariant "^2.2.2" + lodash "^4.17.4" + +babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" + dependencies: + babel-runtime "^6.26.0" + esutils "^2.0.2" + lodash "^4.17.4" + to-fast-properties "^1.0.3" + +babylon@^6.18.0, babylon@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" + +bail@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3" + +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +base64-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" + dependencies: + tweetnacl "^0.14.3" + +beeper@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" + +big.js@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" + +binary-extensions@^1.0.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0" + +bindings@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.3.0.tgz#b346f6ecf6a95f5a815c5839fc7cdb22502f1ed7" + +bl@^1.1.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e" + dependencies: + readable-stream "^2.0.5" + +block-stream@*: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + dependencies: + inherits "~2.0.0" + +bluebird@^2.9.34: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + +bluebird@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" + +bluebird@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: + version "4.11.8" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + +body@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" + dependencies: + continuable-cache "^0.3.1" + error "^7.0.0" + raw-body "~1.1.0" + safe-json-parse "~1.0.1" + +boom@4.x.x: + version "4.3.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31" + dependencies: + hoek "4.x.x" + +boom@5.x.x: + version "5.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02" + dependencies: + hoek "4.x.x" + +brace-expansion@^1.0.0, brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +braces@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.0.tgz#a46941cb5fb492156b3d6a656e06c35364e3e66e" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + define-property "^1.0.0" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.0.8" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.8.tgz#c8fa3b1b7585bb7ba77c5560b60996ddec6d5309" + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.0.tgz#9988244874bf5ed4e28da95666dcd66ac8fc363a" + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.0.tgz#daa277717470922ed2fe18594118a175439721dd" + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + +browserify-rsa@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" + dependencies: + bn.js "^4.1.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" + dependencies: + bn.js "^4.1.1" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.2" + elliptic "^6.0.0" + inherits "^2.0.1" + parse-asn1 "^5.0.0" + +browserify-zlib@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" + dependencies: + pako "~0.2.0" + +browserslist@^1.3.6, browserslist@^1.5.2: + version "1.7.7" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9" + dependencies: + caniuse-db "^1.0.30000639" + electron-to-chromium "^1.2.7" + +browserslist@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.1.1.tgz#328eb4ff1215b12df6589e9ab82f8adaa4fc8cd6" + dependencies: + caniuse-lite "^1.0.30000884" + electron-to-chromium "^1.3.62" + node-releases "^1.0.0-alpha.11" + +browserslist@~1.3.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.3.6.tgz#952ff48d56463d3b538f85ef2f8eaddfd284b133" + dependencies: + caniuse-db "^1.0.30000525" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + +buffer@^4.3.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +bufferstreams@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bufferstreams/-/bufferstreams-1.0.1.tgz#cfb1ad9568d3ba3cfe935ba9abdd952de88aab2a" + dependencies: + readable-stream "^1.0.33" + +builtin-modules@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + +bytes@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" + +cacache@^10.0.4: + version "10.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460" + dependencies: + bluebird "^3.5.1" + chownr "^1.0.1" + glob "^7.1.2" + graceful-fs "^4.1.11" + lru-cache "^4.1.1" + mississippi "^2.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.2" + ssri "^5.2.4" + unique-filename "^1.1.0" + y18n "^4.0.0" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + +caller-path@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" + dependencies: + callsites "^0.2.0" + +callsites@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" + +camelcase-css@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-1.0.1.tgz#157c4238265f5cf94a1dffde86446552cbf3f705" + +camelcase-keys@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77" + dependencies: + camelcase "^4.1.0" + map-obj "^2.0.0" + quick-lru "^1.0.0" + +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + +camelcase@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + +caniuse-api@^1.5.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c" + dependencies: + browserslist "^1.3.6" + caniuse-db "^1.0.30000529" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-db@^1.0.30000444, caniuse-db@^1.0.30000525, caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000639: + version "1.0.30000733" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000733.tgz#3a625bc41c7a9f99d59d64552857dd1af0edd9d4" + +caniuse-lite@^1.0.30000884: + version "1.0.30000885" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000885.tgz#e889e9f8e7e50e769f2a49634c932b8aee622984" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + +ccount@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.3.tgz#f1cec43f332e2ea5a569fd46f9f5bde4e6102aff" + +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: + version "1.1.3" + resolved "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +change-emitter@^0.1.2: + version "0.1.6" + resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" + +character-entities-html4@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.2.tgz#c44fdde3ce66b52e8d321d6c1bf46101f0150610" + +character-entities-legacy@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz#7c6defb81648498222c9855309953d05f4d63a9c" + +character-entities@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.2.tgz#58c8f371c0774ef0ba9b2aca5f00d8f100e6e363" + +character-reference-invalid@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz#21e421ad3d84055952dab4a43a04e73cd425d3ed" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + +chokidar@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" + dependencies: + anymatch "^1.3.0" + async-each "^1.0.0" + glob-parent "^2.0.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^2.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chokidar@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.0.tgz#6686313c541d3274b2a5c01233342037948c911b" + dependencies: + anymatch "^2.0.0" + async-each "^1.0.0" + braces "^2.3.0" + glob-parent "^3.1.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^2.1.1" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chownr@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + +clap@^1.0.9: + version "1.2.2" + resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.2.tgz#683f6f93a320794d129386d74b2a1d2d66fede7e" + dependencies: + chalk "^1.1.3" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + +classnames@^2.2.0, classnames@^2.2.3: + version "2.2.5" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" + +clean-css@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" + dependencies: + source-map "~0.6.0" + +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + dependencies: + restore-cursor "^2.0.0" + +cli-width@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" + +clipboard@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a" + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + +clone-buffer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" + +clone-regexp@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-1.0.1.tgz#051805cd33173375d82118fc0918606da39fd60f" + dependencies: + is-regexp "^1.0.0" + is-supported-regexp-flag "^1.0.0" + +clone-stats@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" + +clone-stats@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" + +clone@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/clone/-/clone-0.2.0.tgz#c6126a90ad4f72dbf5acdb243cc37724fe93fc1f" + +clone@^1.0.0, clone@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" + +clone@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb" + +cloneable-readable@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.0.0.tgz#a6290d413f217a61232f95e458ff38418cfb0117" + dependencies: + inherits "^2.0.1" + process-nextick-args "^1.0.6" + through2 "^2.0.1" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + +coa@~1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd" + dependencies: + q "^1.1.2" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + +collapse-white-space@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.4.tgz#ce05cf49e54c3277ae573036a26851ba430a0091" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.3.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" + dependencies: + color-name "^1.1.1" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + dependencies: + color-name "1.1.3" + +color-name@1.1.3, color-name@^1.0.0, color-name@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + +color-string@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" + dependencies: + color-name "^1.0.0" + +color-support@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + +color@^0.11.0: + version "0.11.4" + resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" + dependencies: + clone "^1.0.2" + color-convert "^1.3.0" + color-string "^0.3.0" + +colormin@^1.0.5: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" + dependencies: + color "^0.11.0" + css-color-names "0.0.4" + has "^1.0.1" + +colors@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" + +combined-stream@^1.0.5, combined-stream@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" + dependencies: + delayed-stream "~1.0.0" + +commander@^2.2.0, commander@^2.8.1: + version "2.11.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" + +commander@~2.13.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + +component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-with-sourcemaps@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz#f55b3be2aeb47601b10a2d5259ccfb70fd2f1dd6" + dependencies: + source-map "^0.5.1" + +console-browserify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" + dependencies: + date-now "^0.1.4" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + +consolidate@^0.14.1: + version "0.14.5" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.14.5.tgz#5a25047bc76f73072667c8cb52c989888f494c63" + dependencies: + bluebird "^3.1.1" + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + +continuable-cache@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" + +convert-source-map@1.X, convert-source-map@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" + +convert-source-map@^1.1.0, convert-source-map@^1.5.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + dependencies: + safe-buffer "~5.1.1" + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + +core-js@^2.4.0, core-js@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b" + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + +cosmiconfig@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + require-from-string "^2.0.1" + +cosmiconfig@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.6.tgz#dca6cf680a0bd03589aff684700858c81abeeb39" + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + +create-ecdh@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" + dependencies: + bn.js "^4.1.0" + elliptic "^6.0.0" + +create-hash@^1.1.0, create-hash@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd" + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + ripemd160 "^2.0.0" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: + version "1.1.6" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06" + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-react-class@15.6.3: + version "15.6.3" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + +cryptiles@3.x.x: + version "3.1.2" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe" + dependencies: + boom "5.x.x" + +crypto-browserify@^3.11.0: + version "3.11.1" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f" + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + +css-color-names@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + +css-loader@0.28.9: + version "0.28.9" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.9.tgz#68064b85f4e271d7ce4c48a58300928e535d1c95" + dependencies: + babel-code-frame "^6.26.0" + css-selector-tokenizer "^0.7.0" + cssnano "^3.10.0" + icss-utils "^2.1.0" + loader-utils "^1.0.2" + lodash.camelcase "^4.3.0" + object-assign "^4.1.1" + postcss "^5.0.6" + postcss-modules-extract-imports "^1.2.0" + postcss-modules-local-by-default "^1.2.0" + postcss-modules-scope "^1.1.0" + postcss-modules-values "^1.3.0" + postcss-value-parser "^3.3.0" + source-list-map "^2.0.0" + +css-selector-tokenizer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" + dependencies: + cssesc "^0.1.0" + fastparse "^1.1.1" + regexpu-core "^1.0.0" + +css@2.X, css@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/css/-/css-2.2.1.tgz#73a4c81de85db664d4ee674f7d47085e3b2d55dc" + dependencies: + inherits "^2.0.1" + source-map "^0.1.38" + source-map-resolve "^0.3.0" + urix "^0.1.0" + +cssesc@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" + +cssnano@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38" + dependencies: + autoprefixer "^6.3.1" + decamelize "^1.1.2" + defined "^1.0.0" + has "^1.0.1" + object-assign "^4.0.1" + postcss "^5.0.14" + postcss-calc "^5.2.0" + postcss-colormin "^2.1.8" + postcss-convert-values "^2.3.4" + postcss-discard-comments "^2.0.4" + postcss-discard-duplicates "^2.0.1" + postcss-discard-empty "^2.0.1" + postcss-discard-overridden "^0.1.1" + postcss-discard-unused "^2.2.1" + postcss-filter-plugins "^2.0.0" + postcss-merge-idents "^2.1.5" + postcss-merge-longhand "^2.0.1" + postcss-merge-rules "^2.0.3" + postcss-minify-font-values "^1.0.2" + postcss-minify-gradients "^1.0.1" + postcss-minify-params "^1.0.4" + postcss-minify-selectors "^2.0.4" + postcss-normalize-charset "^1.1.0" + postcss-normalize-url "^3.0.7" + postcss-ordered-values "^2.1.0" + postcss-reduce-idents "^2.2.2" + postcss-reduce-initial "^1.0.0" + postcss-reduce-transforms "^1.0.3" + postcss-svgo "^2.1.1" + postcss-unique-selectors "^2.0.2" + postcss-value-parser "^3.2.3" + postcss-zindex "^2.0.1" + +csso@~2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85" + dependencies: + clap "^1.0.9" + source-map "^0.5.3" + +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" + dependencies: + array-find-index "^1.0.1" + +cyclist@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" + +d@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" + dependencies: + es5-ext "^0.10.9" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + dependencies: + assert-plus "^1.0.0" + +date-now@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" + +dateformat@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.0.0.tgz#2743e3abb5c3fc2462e527dca445e04e9f4dee17" + +debug-fabulous@1.X: + version "1.0.0" + resolved "https://registry.yarnpkg.com/debug-fabulous/-/debug-fabulous-1.0.0.tgz#57f6648646097b1b0849dcda0017362c1ec00f8b" + dependencies: + debug "3.X" + memoizee "0.4.X" + object-assign "4.X" + +debug@3.X: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + dependencies: + ms "2.0.0" + +debug@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39" + +debug@^2.1.3, debug@^2.6.8: + version "2.6.8" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" + dependencies: + ms "2.0.0" + +debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + dependencies: + ms "2.0.0" + +debug@^3.0.0, debug@^3.1.0: + version "3.2.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.5.tgz#c2418fbfd7a29f4d4f70ff4cea604d4b64c46407" + dependencies: + ms "^2.1.1" + +decamelize-keys@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" + dependencies: + decamelize "^1.1.0" + map-obj "^1.0.0" + +decamelize@^1.0.0, decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + +deep-extend@~0.4.0: + version "0.4.2" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + +defaults@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + dependencies: + clone "^1.0.2" + +define-properties@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" + dependencies: + foreach "^2.0.5" + object-keys "^1.0.8" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +defined@^1.0.0, defined@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" + +del@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" + dependencies: + globby "^6.1.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + p-map "^1.1.1" + pify "^3.0.0" + rimraf "^2.2.8" + +del@^2.0.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" + dependencies: + globby "^5.0.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + rimraf "^2.2.8" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + +deprecated@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/deprecated/-/deprecated-0.0.1.tgz#f9c9af5464afa1e7a971458a8bdef2aa94d5bb19" + +des.js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +detect-file@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63" + dependencies: + fs-exists-sync "^0.1.0" + +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + dependencies: + repeating "^2.0.0" + +detect-newline@2.X: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + +diff@^1.3.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" + +diffie-hellman@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" + dependencies: + arrify "^1.0.1" + path-type "^3.0.0" + +disparity@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/disparity/-/disparity-2.0.0.tgz#57ddacb47324ae5f58d2cc0da886db4ce9eeb718" + dependencies: + ansi-styles "^2.0.1" + diff "^1.3.2" + +dnd-core@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-4.0.5.tgz#3b83d138d0d5e265c73ec978dec5e1ed441dc665" + dependencies: + asap "^2.0.6" + invariant "^2.2.4" + lodash "^4.17.10" + redux "^4.0.0" + +dnode-protocol@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dnode-protocol/-/dnode-protocol-0.2.2.tgz#51151d16fc3b5f84815ee0b9497a1061d0d1949d" + dependencies: + jsonify "~0.0.0" + traverse "~0.6.3" + +dnode@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/dnode/-/dnode-1.2.2.tgz#4ac3cfe26e292b3b39b8258ae7d94edc58132efa" + dependencies: + dnode-protocol "~0.2.2" + jsonify "~0.0.0" + optionalDependencies: + weak "^1.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + dependencies: + esutils "^2.0.2" + +dom-css@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202" + dependencies: + add-px-to-style "1.0.0" + prefix-style "2.0.1" + to-camel-case "1.0.0" + +"dom-helpers@^2.4.0 || ^3.0.0": + version "3.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" + +dom-serializer@0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" + dependencies: + domelementtype "~1.1.1" + entities "~1.1.1" + +domain-browser@^1.1.1: + version "1.1.7" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" + +domelementtype@1, domelementtype@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" + +domelementtype@~1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + dependencies: + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" + dependencies: + is-obj "^1.0.0" + +duplexer2@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db" + dependencies: + readable-stream "~1.1.9" + +duplexer@^0.1.1, duplexer@~0.1.1: + version "0.1.1" + resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.6.0.tgz#592903f5d80b38d037220541264d69a198fb3410" + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +ecc-jsbn@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" + dependencies: + jsbn "~0.1.0" + +electron-to-chromium@^1.2.7: + version "1.3.21" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.21.tgz#a967ebdcfe8ed0083fc244d1894022a8e8113ea2" + +electron-to-chromium@^1.3.62: + version "1.3.67" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.67.tgz#5e8f3ffac89b4b0402c7e1a565be06f3a109abbc" + +element-class@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e" + +elliptic@^6.0.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + dependencies: + once "^1.4.0" + +end-of-stream@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-0.1.5.tgz#8e177206c3c80837d85632e8b9359dfe8b2f6eaf" + dependencies: + once "~1.3.0" + +enhanced-resolve@^3.4.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.4.0" + object-assign "^4.0.1" + tapable "^0.2.7" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" + +errno@^0.1.3, errno@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" + dependencies: + prr "~0.0.0" + +errno@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" + dependencies: + prr "~1.0.1" + +error-ex@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc" + dependencies: + is-arrayish "^0.2.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + dependencies: + is-arrayish "^0.2.1" + +error@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" + dependencies: + string-template "~0.2.1" + xtend "~4.0.0" + +es-abstract@^1.5.0, es-abstract@^1.7.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.2.tgz#25103263dc4decbda60e0c737ca32313518027ee" + dependencies: + es-to-primitive "^1.1.1" + function-bind "^1.1.1" + has "^1.0.1" + is-callable "^1.1.3" + is-regex "^1.0.4" + +es-to-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d" + dependencies: + is-callable "^1.1.1" + is-date-object "^1.0.1" + is-symbol "^1.0.1" + +es5-ext@^0.10.14, es5-ext@^0.10.30, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.30" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.30.tgz#7141a16836697dbabfaaaeee41495ce29f52c939" + dependencies: + es6-iterator "2" + es6-symbol "~3.1" + +es6-iterator@2, es6-iterator@^2.0.1, es6-iterator@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" + dependencies: + d "1" + es5-ext "^0.10.14" + es6-symbol "^3.1" + +es6-map@^0.1.3: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-set "~0.1.5" + es6-symbol "~3.1.1" + event-emitter "~0.3.5" + +es6-promise@^3.1.2: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + +es6-set@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-symbol "3.1.1" + event-emitter "~0.3.5" + +es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@^3.1.1, es6-symbol@~3.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" + dependencies: + d "1" + es5-ext "~0.10.14" + +es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f" + dependencies: + d "1" + es5-ext "^0.10.14" + es6-iterator "^2.0.1" + es6-symbol "^3.1.1" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + +escope@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" + dependencies: + es6-map "^0.1.3" + es6-weak-map "^2.0.1" + esrecurse "^4.1.0" + estraverse "^4.1.1" + +esformatter-parser@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/esformatter-parser/-/esformatter-parser-1.0.0.tgz#0854072d0487539ed39cae38d8a5432c17ec11d3" + dependencies: + acorn-to-esprima "^2.0.8" + babel-traverse "^6.9.0" + babylon "^6.8.0" + rocambole "^0.7.0" + +esformatter@0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/esformatter/-/esformatter-0.10.0.tgz#e321ecc3d94083372cdfcf5c6f942cef6fec59d3" + dependencies: + acorn-to-esprima "^2.0.6" + babel-traverse "^6.4.5" + debug "^0.7.4" + disparity "^2.0.0" + esformatter-parser "^1.0.0" + glob "^7.0.5" + minimatch "^3.0.2" + minimist "^1.1.1" + mout ">=0.9 <2.0" + npm-run "^3.0.0" + resolve "^1.1.5" + rocambole ">=0.7 <2.0" + rocambole-indent "^2.0.4" + rocambole-linebreak "^1.0.2" + rocambole-node "~1.0" + rocambole-token "^1.1.2" + rocambole-whitespace "^1.0.0" + stdin "*" + strip-json-comments "~0.1.1" + supports-color "^1.3.1" + user-home "^2.0.0" + +eslint-plugin-filenames@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-filenames/-/eslint-plugin-filenames-1.3.2.tgz#7094f00d7aefdd6999e3ac19f72cea058e590cf7" + dependencies: + lodash.camelcase "4.3.0" + lodash.kebabcase "4.1.1" + lodash.snakecase "4.1.1" + lodash.upperfirst "4.3.1" + +eslint-plugin-react@7.11.1: + version "7.11.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.11.1.tgz#c01a7af6f17519457d6116aa94fc6d2ccad5443c" + dependencies: + array-includes "^3.0.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.0.1" + prop-types "^15.6.2" + +eslint-scope@3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8" + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-scope@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-utils@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" + +eslint-visitor-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" + +eslint@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.6.0.tgz#b6f7806041af01f71b3f1895cbb20971ea4b6223" + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.5.3" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^3.1.0" + doctrine "^2.1.0" + eslint-scope "^4.0.0" + eslint-utils "^1.3.1" + eslint-visitor-keys "^1.0.0" + espree "^4.0.0" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^2.0.0" + functional-red-black-tree "^1.0.1" + glob "^7.1.2" + globals "^11.7.0" + ignore "^4.0.6" + imurmurhash "^0.1.4" + inquirer "^6.1.0" + is-resolvable "^1.1.0" + js-yaml "^3.12.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.5" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.2" + path-is-inside "^1.0.2" + pluralize "^7.0.0" + progress "^2.0.0" + regexpp "^2.0.0" + require-uncached "^1.0.3" + semver "^5.5.1" + strip-ansi "^4.0.0" + strip-json-comments "^2.0.1" + table "^4.0.3" + text-table "^0.2.0" + +espree@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-4.0.0.tgz#253998f20a0f82db5d866385799d912a83a36634" + dependencies: + acorn "^5.6.0" + acorn-jsx "^4.1.1" + +esprima@^2.1, esprima@^2.6.0: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + +esprint@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.4.0.tgz#f89c9bace36d90407968a8f9ceb0800ff786aab0" + dependencies: + dnode "^1.2.2" + fb-watchman "^2.0.0" + glob "^7.1.1" + sane "^1.6.0" + worker-farm "^1.3.1" + yargs "^8.0.1" + +esquery@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" + dependencies: + estraverse "^4.0.0" + +esrecurse@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.0.tgz#fa9568d98d3823f9a41d91e902dcab9ea6e5b163" + dependencies: + estraverse "^4.1.0" + object-assign "^4.0.1" + +estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + +event-emitter@^0.3.5, event-emitter@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + dependencies: + d "1" + es5-ext "~0.10.14" + +event-stream@^3.3.4: + version "3.3.6" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef" + dependencies: + duplexer "^0.1.1" + flatmap-stream "^0.1.0" + from "^0.1.7" + map-stream "0.0.7" + pause-stream "^0.0.11" + split "^1.0.1" + stream-combiner "^0.2.2" + through "^2.3.8" + +events@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +exec-sh@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38" + dependencies: + merge "^1.1.3" + +execa@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execall@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execall/-/execall-1.0.0.tgz#73d0904e395b3cab0658b08d09ec25307f29bb73" + dependencies: + clone-regexp "^1.0.0" + +exenv@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + dependencies: + is-posix-bracket "^0.1.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + dependencies: + fill-range "^2.1.0" + +expand-tilde@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449" + dependencies: + os-homedir "^1.0.1" + +expand-tilde@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" + dependencies: + homedir-polyfill "^1.0.1" + +extend-shallow@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-1.1.4.tgz#19d6bf94dfc09d76ba711f39b872d21ff4dd9071" + dependencies: + kind-of "^1.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + +extend@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" + +external-editor@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + dependencies: + is-extglob "^1.0.0" + +extglob@^2.0.2, extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extract-text-webpack-plugin@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.2.tgz#5f043eaa02f9750a9258b78c0a6e0dc1408fb2f7" + dependencies: + async "^2.4.1" + loader-utils "^1.1.0" + schema-utils "^0.3.0" + webpack-sources "^1.0.1" + +extsprintf@1.3.0, extsprintf@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + +fancy-log@1.3.2, fancy-log@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.2.tgz#f41125e3d84f2e7d89a43d06d958c8f78be16be1" + dependencies: + ansi-gray "^0.1.1" + color-support "^1.1.3" + time-stamp "^1.0.0" + +fancy-log@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.0.tgz#45be17d02bb9917d60ccffd4995c999e6c8c9948" + dependencies: + chalk "^1.1.1" + time-stamp "^1.0.0" + +fast-deep-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + +fast-glob@^2.0.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.2.tgz#71723338ac9b4e0e2fff1d6748a2a13d5ed352bf" + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.0.1" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.1" + micromatch "^3.1.10" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + +fastparse@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" + +faye-websocket@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + dependencies: + websocket-driver ">=0.5.1" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + dependencies: + bser "^2.0.0" + +fbjs@^0.8.1, fbjs@^0.8.16: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +fbjs@^0.8.4, fbjs@^0.8.9: + version "0.8.15" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.9" + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" + dependencies: + flat-cache "^1.2.1" + object-assign "^4.0.1" + +file-loader@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.6.tgz#7b9a8f2c58f00a77fddf49e940f7ac978a3ea0e8" + dependencies: + loader-utils "^1.0.2" + schema-utils "^0.3.0" + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + +filesize@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +find-cache-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f" + dependencies: + commondir "^1.0.1" + make-dir "^1.0.0" + pkg-dir "^2.0.0" + +find-index@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/find-index/-/find-index-0.1.1.tgz#675d358b2ca3892d795a1ab47232f8b6e2e0dde4" + +find-up@^2.0.0, find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + dependencies: + locate-path "^2.0.0" + +findup-sync@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12" + dependencies: + detect-file "^0.1.0" + is-glob "^2.0.1" + micromatch "^2.3.7" + resolve-dir "^0.1.0" + +fined@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fined/-/fined-1.1.0.tgz#b37dc844b76a2f5e7081e884f7c0ae344f153476" + dependencies: + expand-tilde "^2.0.2" + is-plain-object "^2.0.3" + object.defaults "^1.1.0" + object.pick "^1.2.0" + parse-filepath "^1.0.1" + +first-chunk-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e" + +first-chunk-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70" + dependencies: + readable-stream "^2.0.2" + +flagged-respawn@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-0.3.2.tgz#ff191eddcd7088a675b2610fffc976be9b8074b5" + +flat-cache@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.0.tgz#d3030b32b38154f4e3b7e9c709f490f7ef97c481" + dependencies: + circular-json "^0.3.1" + del "^2.0.2" + graceful-fs "^4.1.2" + write "^0.2.1" + +flatmap-stream@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.0.tgz#ed54e01422cd29281800914fcb968d58b685d5f1" + +flatten@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" + +flush-write-stream@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.4" + +for-each@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" + dependencies: + is-function "~1.0.0" + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + dependencies: + for-in "^1.0.1" + +for-own@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" + dependencies: + for-in "^1.0.1" + +foreach@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + dependencies: + map-cache "^0.2.2" + +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +from@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" + +fs-exists-sync@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" + +fs-readfile-promise@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz#80023823981f9ffffe01609e8be668f69ae49e70" + dependencies: + graceful-fs "^4.1.2" + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +fsevents@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4" + dependencies: + nan "^2.3.0" + node-pre-gyp "^0.6.36" + +fstream-ignore@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" + dependencies: + fstream "^1.0.0" + inherits "2" + minimatch "^3.0.0" + +fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2, fstream@^1.0.8: + version "1.0.11" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + +function-bind@^1.0.2, function-bind@^1.1.1, function-bind@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +gaze@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/gaze/-/gaze-0.5.2.tgz#40b709537d24d1d45767db5a908689dfe69ac44f" + dependencies: + globule "~0.1.0" + +get-caller-file@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" + +get-node-dimensions@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.2.tgz#7a71e8624cf9e1ab74599bb05b7e5116e995e45b" + +get-stdin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + dependencies: + assert-plus "^1.0.0" + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + dependencies: + is-glob "^2.0.0" + +glob-parent@^3.0.1, glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-stream@^3.1.5: + version "3.1.18" + resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-3.1.18.tgz#9170a5f12b790306fdfe598f313f8f7954fd143b" + dependencies: + glob "^4.3.1" + glob2base "^0.0.12" + minimatch "^2.0.1" + ordered-read-streams "^0.1.0" + through2 "^0.6.1" + unique-stream "^1.0.0" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + +glob-watcher@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-0.0.6.tgz#b95b4a8df74b39c83298b0c05c978b4d9a3b710b" + dependencies: + gaze "^0.5.1" + +glob2base@^0.0.12: + version "0.0.12" + resolved "https://registry.yarnpkg.com/glob2base/-/glob2base-0.0.12.tgz#9d419b3e28f12e83a362164a277055922c9c0d56" + dependencies: + find-index "^0.1.1" + +glob@^4.3.1: + version "4.5.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-4.5.3.tgz#c6cb73d3226c1efef04de3c56d012f03377ee15f" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "^2.0.1" + once "^1.3.0" + +glob@^7.0.3, glob@^7.1.1, glob@~7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.5, glob@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@~3.1.21: + version "3.1.21" + resolved "https://registry.yarnpkg.com/glob/-/glob-3.1.21.tgz#d29e0a055dea5138f4d07ed40e8982e83c2066cd" + dependencies: + graceful-fs "~1.2.0" + inherits "1" + minimatch "~0.2.11" + +global-modules@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" + dependencies: + global-prefix "^0.1.4" + is-windows "^0.2.0" + +global-prefix@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f" + dependencies: + homedir-polyfill "^1.0.0" + ini "^1.3.4" + is-windows "^0.2.0" + which "^1.2.12" + +globals@^11.1.0, globals@^11.7.0: + version "11.7.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.7.0.tgz#a583faa43055b1aca771914bf68258e2fc125673" + +globals@^9.18.0: + version "9.18.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" + +globby@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" + dependencies: + array-union "^1.0.1" + arrify "^1.0.0" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.1.tgz#b5ad48b8aa80b35b814fc1281ecc851f1d2b5b50" + dependencies: + array-union "^1.0.1" + dir-glob "^2.0.0" + fast-glob "^2.0.2" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + +globjoin@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" + +globule@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/globule/-/globule-0.1.0.tgz#d9c8edde1da79d125a151b79533b978676346ae5" + dependencies: + glob "~3.1.21" + lodash "~1.0.1" + minimatch "~0.2.11" + +glogg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.0.tgz#7fe0f199f57ac906cf512feead8f90ee4a284fc5" + dependencies: + sparkles "^1.0.0" + +gonzales-pe@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.2.3.tgz#41091703625433285e0aee3aa47829fc1fbeb6f2" + dependencies: + minimist "1.1.x" + +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + dependencies: + delegate "^3.1.2" + +graceful-fs@4.X, graceful-fs@^4.1.11, graceful-fs@^4.1.2: + version "4.1.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" + +graceful-fs@^3.0.0: + version "3.0.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818" + dependencies: + natives "^1.1.0" + +graceful-fs@~1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-1.2.3.tgz#15a4806a57547cb2d2dbf27f42e89a8c3451b364" + +gulp-cached@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/gulp-cached/-/gulp-cached-1.1.1.tgz#fe7cd4f87f37601e6073cfedee5c2bdaf8b6acce" + dependencies: + lodash.defaults "^4.2.0" + through2 "^2.0.1" + +gulp-clean-css@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/gulp-clean-css/-/gulp-clean-css-3.10.0.tgz#bccd4605eff104bfa4980014cc4b3c24c571736d" + dependencies: + clean-css "4.2.1" + plugin-error "1.0.1" + through2 "2.0.3" + vinyl-sourcemaps-apply "0.2.1" + +gulp-concat@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353" + dependencies: + concat-with-sourcemaps "^1.0.0" + through2 "^2.0.0" + vinyl "^2.0.0" + +gulp-declare@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/gulp-declare/-/gulp-declare-0.3.0.tgz#86830fc6faa88e06382162c8664b8e94957afcd9" + dependencies: + nsdeclare "^0.1.0" + vinyl-map "^1.0.1" + xtend "^4.0.0" + +gulp-livereload@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/gulp-livereload/-/gulp-livereload-4.0.0.tgz#be4a6b01731a93f76f4fb29c9e62e323affe7d03" + dependencies: + chalk "^2.4.1" + debug "^3.1.0" + event-stream "^3.3.4" + fancy-log "^1.3.2" + lodash.assign "^4.2.0" + tiny-lr "^1.1.1" + vinyl "^2.2.0" + +gulp-postcss@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gulp-postcss/-/gulp-postcss-8.0.0.tgz#8d3772cd4d27bca55ec8cb4c8e576e3bde4dc550" + dependencies: + fancy-log "^1.3.2" + plugin-error "^1.0.1" + postcss "^7.0.2" + postcss-load-config "^2.0.0" + vinyl-sourcemaps-apply "^0.2.1" + +gulp-print@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gulp-print/-/gulp-print-5.0.0.tgz#0fa2791dc4589633f4015f054e4f39a8d285120b" + dependencies: + ansi-colors "^1.0.1" + fancy-log "^1.3.2" + map-stream "0.0.7" + vinyl "^2.1.0" + +gulp-sourcemaps@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-2.6.4.tgz#cbb2008450b1bcce6cd23bf98337be751bf6e30a" + dependencies: + "@gulp-sourcemaps/identity-map" "1.X" + "@gulp-sourcemaps/map-sources" "1.X" + acorn "5.X" + convert-source-map "1.X" + css "2.X" + debug-fabulous "1.X" + detect-newline "2.X" + graceful-fs "4.X" + source-map "~0.6.0" + strip-bom-string "1.X" + through2 "2.X" + +gulp-stripbom@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz#58c1d03e85e008a7aab47d81b1297c8c1bc828eb" + dependencies: + gulp-util "^3.0.0" + log-symbols "^1.0.0" + strip-bom "^1.0.0" + through2 "^0.5.1" + +gulp-util@3.0.8, gulp-util@^3.0.0, gulp-util@^3.0.7: + version "3.0.8" + resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.8.tgz#0054e1e744502e27c04c187c3ecc505dd54bbb4f" + dependencies: + array-differ "^1.0.0" + array-uniq "^1.0.2" + beeper "^1.0.0" + chalk "^1.0.0" + dateformat "^2.0.0" + fancy-log "^1.1.0" + gulplog "^1.0.0" + has-gulplog "^0.1.0" + lodash._reescape "^3.0.0" + lodash._reevaluate "^3.0.0" + lodash._reinterpolate "^3.0.0" + lodash.template "^3.0.0" + minimist "^1.1.0" + multipipe "^0.1.2" + object-assign "^3.0.0" + replace-ext "0.0.1" + through2 "^2.0.0" + vinyl "^0.5.0" + +gulp-watch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/gulp-watch/-/gulp-watch-5.0.1.tgz#83d378752f5bfb46da023e73c17ed1da7066215d" + dependencies: + ansi-colors "1.1.0" + anymatch "^1.3.0" + chokidar "^2.0.0" + fancy-log "1.3.2" + glob-parent "^3.0.1" + object-assign "^4.1.0" + path-is-absolute "^1.0.1" + plugin-error "1.0.1" + readable-stream "^2.2.2" + slash "^1.0.0" + vinyl "^2.1.0" + vinyl-file "^2.0.0" + +gulp-wrap@0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/gulp-wrap/-/gulp-wrap-0.14.0.tgz#15a5c2048e2721e70539a61baf1c34a0bc5f2729" + dependencies: + consolidate "^0.14.1" + es6-promise "^3.1.2" + fs-readfile-promise "^2.0.1" + js-yaml "^3.2.6" + lodash "^4.11.1" + node.extend "^1.1.2" + plugin-error "^0.1.2" + through2 "^2.0.1" + tryit "^1.0.1" + vinyl-bufferstream "^1.0.1" + +gulp@3.9.1: + version "3.9.1" + resolved "https://registry.yarnpkg.com/gulp/-/gulp-3.9.1.tgz#571ce45928dd40af6514fc4011866016c13845b4" + dependencies: + archy "^1.0.0" + chalk "^1.0.0" + deprecated "^0.0.1" + gulp-util "^3.0.0" + interpret "^1.0.0" + liftoff "^2.1.0" + minimist "^1.1.0" + orchestrator "^0.3.0" + pretty-hrtime "^1.0.0" + semver "^4.1.0" + tildify "^1.0.0" + v8flags "^2.0.2" + vinyl-fs "^0.3.0" + +gulplog@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gulplog/-/gulplog-1.0.0.tgz#e28c4d45d05ecbbed818363ce8f9c5926229ffe5" + dependencies: + glogg "^1.0.0" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + +har-validator@~5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd" + dependencies: + ajv "^5.1.0" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + dependencies: + ansi-regex "^2.0.0" + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + +has-flag@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + +has-gulplog@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/has-gulplog/-/has-gulplog-0.1.0.tgz#6414c82913697da51590397dafb12f22967811ce" + dependencies: + sparkles "^1.0.0" + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.1, has@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" + dependencies: + function-bind "^1.0.2" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + dependencies: + function-bind "^1.1.1" + +hash-base@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1" + dependencies: + inherits "^2.0.1" + +hash-base@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846" + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.0" + +hawk@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038" + dependencies: + boom "4.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + sntp "2.x.x" + +history@4.7.2, history@^4.7.2: + version "4.7.2" + resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b" + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + value-equal "^0.4.0" + warning "^3.0.0" + +history@^4.5.1: + version "4.6.3" + resolved "https://registry.yarnpkg.com/history/-/history-4.6.3.tgz#6d723a8712c581d6bef37e8c26f4aedc6eb86967" + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.0.0" + value-equal "^0.2.0" + warning "^3.0.0" + +hmac-drbg@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoek@4.x.x: + version "4.2.0" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" + +hoist-non-react-statics@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" + +hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + +hoist-non-react-statics@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364" + dependencies: + react-is "^16.3.2" + +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + +homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc" + dependencies: + parse-passwd "^1.0.0" + +hosted-git-info@^2.1.4: + version "2.7.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" + +html-comment-regex@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" + +html-tags@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b" + +htmlparser2@^3.9.2: + version "3.9.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338" + dependencies: + domelementtype "^1.3.0" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^2.0.2" + +http-parser-js@>=0.4.0: + version "0.4.6" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.6.tgz#195273f58704c452d671076be201329dd341dc55" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" + +humps@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + +iconv-lite@^0.4.24, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-replace-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" + +icss-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962" + dependencies: + postcss "^6.0.1" + +ieee754@^1.1.4: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + +ignore@^3.3.5: + version "3.3.10" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" + +ignore@^4.0.0, ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + dependencies: + import-from "^2.1.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + dependencies: + resolve-from "^3.0.0" + +import-lazy@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-3.1.0.tgz#891279202c8a2280fdbd6674dbd8da1a1dfc67cc" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + +indent-string@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-1.0.2.tgz#ca4309dadee6b54cc0b8d247e8d7c7a0975bdc9b" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + +ini@^1.3.4, ini@~1.3.0: + version "1.3.4" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" + +inquirer@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.0" + figures "^2.0.0" + lodash "^4.17.10" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.1.0" + string-width "^2.1.0" + strip-ansi "^4.0.0" + through "^2.3.6" + +interpret@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" + +invariant@^2.0.0, invariant@^2.1.0, invariant@^2.2.1, invariant@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" + dependencies: + loose-envify "^1.0.0" + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + +is-absolute@^0.2.3: + version "0.2.6" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-0.2.6.tgz#20de69f3db942ef2d87b9c2da36f172235b1b5eb" + dependencies: + is-relative "^0.2.1" + is-windows "^0.2.0" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + dependencies: + kind-of "^6.0.0" + +is-alphabetical@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.2.tgz#1fa6e49213cb7885b75d15862fb3f3d96c884f41" + +is-alphanumeric@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4" + +is-alphanumerical@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz#1138e9ae5040158dc6ff76b820acd6b7a181fd40" + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + dependencies: + binary-extensions "^1.0.0" + +is-buffer@^1.1.4, is-buffer@^1.1.5, is-buffer@~1.1.1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +is-builtin-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" + dependencies: + builtin-modules "^1.0.0" + +is-callable@^1.1.1, is-callable@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + +is-decimal@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.2.tgz#894662d6a8709d307f3a276ca4339c8fa5dff0ff" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + +is-function@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + dependencies: + is-extglob "^1.0.0" + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" + dependencies: + is-extglob "^2.1.1" + +is-hexadecimal@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835" + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + +is-obj@^1.0.0: + version "1.0.1" + resolved "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + +is-odd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-1.0.0.tgz#3b8a932eb028b3775c39bb09e91767accdb69088" + dependencies: + is-number "^3.0.0" + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + +is-path-in-cwd@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" + dependencies: + is-path-inside "^1.0.0" + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + dependencies: + path-is-inside "^1.0.1" + +is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + dependencies: + isobject "^3.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + +is-promise@^2.1, is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + dependencies: + has "^1.0.1" + +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + +is-relative@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-0.2.1.tgz#d27f4c7d516d175fb610db84bbeef23c3bc97aa5" + dependencies: + is-unc-path "^0.1.1" + +is-resolvable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + +is-supported-regexp-flag@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.1.tgz#21ee16518d2c1dd3edd3e9a0d57e50207ac364ca" + +is-svg@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9" + dependencies: + html-comment-regex "^1.1.0" + +is-symbol@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + +is-unc-path@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-0.1.2.tgz#6ab053a72573c10250ff416a3814c35178af39b9" + dependencies: + unc-path-regex "^0.1.0" + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + +is-whitespace-character@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed" + +is-windows@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + +is-word-character@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.2.tgz#46a5dac3f2a1840898b91e576cd40d493f3ae553" + +is@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/is/-/is-3.2.1.tgz#d0ac2ad55eb7b0bec926a5266f6c662aaa83dca5" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@^0.1.2, isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +jdu@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jdu/-/jdu-1.0.0.tgz#28f1e388501785ae0a1d93e93ed0b14dd41e51ce" + +jquery@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" + +jquery@>=1.6.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-2.2.4.tgz#2c89d6889b5eac522a7eea32c14521559c6cbf02" + +js-base64@^2.1.9: + version "2.4.9" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.9.tgz#748911fb04f48a60c4771b375cac45a80df11c03" + +js-tokens@^3.0.0, js-tokens@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + +js-yaml@^3.12.0, js-yaml@^3.9.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^3.2.6: + version "3.10.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@~3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" + dependencies: + argparse "^1.0.7" + esprima "^2.6.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + +jsesc@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe" + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + +json-loader@^0.5.4: + version "0.5.7" + resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" + +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + +json-schema-traverse@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + +json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +json5@^0.5.0, json5@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +jsx-ast-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f" + dependencies: + array-includes "^3.0.3" + +kind-of@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-1.1.0.tgz#140a3d2d41a36d2efcfa9377b62c24f8495a5c44" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0, kind-of@^5.0.2: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + +known-css-properties@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.6.1.tgz#31b5123ad03d8d1a3f36bd4155459c981173478b" + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + dependencies: + invert-kv "^1.0.0" + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +liftoff@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385" + dependencies: + extend "^3.0.0" + findup-sync "^0.4.2" + fined "^1.0.1" + flagged-respawn "^0.3.2" + lodash.isplainobject "^4.0.4" + lodash.isstring "^4.0.1" + lodash.mapvalues "^4.4.0" + rechoir "^0.6.2" + resolve "^1.1.7" + +livereload-js@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.3.0.tgz#c3ab22e8aaf5bf3505d80d098cbad67726548c9a" + +load-json-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + strip-bom "^3.0.0" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +loader-runner@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" + +loader-utils@^1.0.2, loader-utils@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +lodash-es@^4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" + +lodash._basecopy@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" + +lodash._basetostring@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz#d1861d877f824a52f669832dcaf3ee15566a07d5" + +lodash._basevalues@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz#5b775762802bde3d3297503e26300820fdf661b7" + +lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + +lodash._isiterateecall@^3.0.0: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" + +lodash._reescape@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reescape/-/lodash._reescape-3.0.0.tgz#2b1d6f5dfe07c8a355753e5f27fac7f1cde1616a" + +lodash._reevaluate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz#58bc74c40664953ae0b124d806996daca431e2ed" + +lodash._reinterpolate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" + +lodash._root@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" + +lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + +lodash.camelcase@4.3.0, lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + +lodash.clone@^4.3.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" + +lodash.curry@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + +lodash.escape@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-3.2.0.tgz#995ee0dc18c1b48cc92effae71a10aab5b487698" + dependencies: + lodash._root "^3.0.0" + +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + +lodash.isarray@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + +lodash.isplainobject@^4.0.4: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + +lodash.kebabcase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" + +lodash.keys@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + dependencies: + lodash._getnative "^3.0.0" + lodash.isarguments "^3.0.0" + lodash.isarray "^3.0.0" + +lodash.mapvalues@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + +lodash.restparam@^3.0.0: + version "3.6.1" + resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" + +lodash.snakecase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" + +lodash.some@^4.2.2: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + +lodash.template@^3.0.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" + dependencies: + lodash._basecopy "^3.0.0" + lodash._basetostring "^3.0.0" + lodash._basevalues "^3.0.0" + lodash._isiterateecall "^3.0.0" + lodash._reinterpolate "^3.0.0" + lodash.escape "^3.0.0" + lodash.keys "^3.0.0" + lodash.restparam "^3.0.0" + lodash.templatesettings "^3.0.0" + +lodash.templatesettings@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz#fb307844753b66b9f1afa54e262c745307dba8e5" + dependencies: + lodash._reinterpolate "^3.0.0" + lodash.escape "^3.0.0" + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + +lodash.upperfirst@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" + +lodash@4.17.11, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + +lodash@^4.11.1, lodash@^4.14.0: + version "4.17.4" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" + +lodash@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-1.0.2.tgz#8f57560c83b59fc270bd3d561b690043430e2551" + +log-symbols@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + dependencies: + chalk "^1.0.0" + +log-symbols@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + dependencies: + chalk "^2.0.1" + +longest-streak@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e" + +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loose-envify@^1.2.0, loose-envify@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" + dependencies: + js-tokens "^3.0.0" + +loud-rejection@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" + dependencies: + currently-unhandled "^0.4.1" + signal-exit "^3.0.0" + +lru-cache@2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" + +lru-cache@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-queue@0.1: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + dependencies: + es5-ext "~0.10.2" + +macaddress@^0.2.8: + version "0.2.8" + resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" + +make-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" + dependencies: + pify "^2.3.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + dependencies: + tmpl "1.0.x" + +map-cache@^0.2.0, map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + +map-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" + +map-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9" + +map-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + dependencies: + object-visit "^1.0.0" + +markdown-escapes@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.2.tgz#e639cbde7b99c841c0bacc8a07982873b46d2122" + +markdown-table@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.2.tgz#c78db948fa879903a41bce522e3b96f801c63786" + +math-expression-evaluator@^1.2.14: + version "1.2.17" + resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" + +math-random@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" + +mathml-tag-names@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.0.tgz#490b70e062ee24636536e3d9481e333733d00f2c" + +md5.js@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +md5@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + +mdast-util-compact@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.2.tgz#c12ebe16fffc84573d3e19767726de226e95f649" + dependencies: + unist-util-visit "^1.1.0" + +mem@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" + dependencies: + mimic-fn "^1.0.0" + +memoizee@0.4.X: + version "0.4.11" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.11.tgz#bde9817663c9e40fdb2a4ea1c367296087ae8c8f" + dependencies: + d "1" + es5-ext "^0.10.30" + es6-weak-map "^2.0.2" + event-emitter "^0.3.5" + is-promise "^2.1" + lru-queue "0.1" + next-tick "1" + timers-ext "^0.1.2" + +memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +meow@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" + dependencies: + camelcase-keys "^4.0.0" + decamelize-keys "^1.0.0" + loud-rejection "^1.0.0" + minimist-options "^3.0.1" + normalize-package-data "^2.3.4" + read-pkg-up "^3.0.0" + redent "^2.0.0" + trim-newlines "^2.0.0" + yargs-parser "^10.0.0" + +merge2@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.2.tgz#03212e3da8d86c4d8523cebd6318193414f94e34" + +merge@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da" + +micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^3.1.4: + version "3.1.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.5.tgz#d05e168c206472dfbca985bfef4f57797b4cd4ba" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.0" + define-property "^1.0.0" + extend-shallow "^2.0.1" + extglob "^2.0.2" + fragment-cache "^0.2.1" + kind-of "^6.0.0" + nanomatch "^1.2.5" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +miller-rabin@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d" + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime-db@~1.30.0: + version "1.30.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" + +mime-types@^2.1.12, mime-types@~2.1.17: + version "2.1.17" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" + dependencies: + mime-db "~1.30.0" + +mime@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" + +mimic-fn@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" + +minimalistic-assert@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3" + +minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + +minimatch@^2.0.1: + version "2.0.10" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-2.0.10.tgz#8d087c39c6b38c001b97fca7ce6d0e1e80afbac7" + dependencies: + brace-expansion "^1.0.0" + +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +minimatch@~0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a" + dependencies: + lru-cache "2" + sigmund "~1.0.0" + +minimist-options@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" + dependencies: + arrify "^1.0.1" + is-plain-obj "^1.1.0" + +minimist@0.0.8: + version "0.0.8" + resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +minimist@1.1.x: + version "1.1.3" + resolved "http://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + +mississippi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^2.0.1" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: + version "0.5.1" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +mobile-detect@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.3.tgz#e436a3839f5807dd4d3cd4e081f7d3a51ffda2dd" + +moment@2.22.2: + version "2.22.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + +mousetrap@1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587" + +"mout@>=0.9 <2.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/mout/-/mout-1.0.0.tgz#9bdf1d4af57d66d47cb353a6335a3281098e1501" + +mout@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/mout/-/mout-0.11.1.tgz#ba3611df5f0e5b1ffbfd01166b8f02d1f5fa2b99" + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + +multipipe@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-0.1.2.tgz#2a8f2ddf70eed564dff2d57f1e1a137d9f05078b" + dependencies: + duplexer2 "0.0.2" + +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + +nan@^2.0.5, nan@^2.3.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46" + +nanomatch@^1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^1.0.0" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + is-odd "^1.0.0" + kind-of "^5.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natives@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + +new-from@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/new-from/-/new-from-0.0.3.tgz#1c4ad13613de3e15d6321b70ed5c23937ea25e67" + dependencies: + readable-stream "~1.1.8" + +next-tick@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + +node-libs-browser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.0.0.tgz#a3a59ec97024985b46e958379646f96c4b616646" + dependencies: + assert "^1.1.1" + browserify-zlib "^0.1.4" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^1.0.0" + https-browserify "0.0.1" + os-browserify "^0.2.0" + path-browserify "0.0.0" + process "^0.11.0" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.0.5" + stream-browserify "^2.0.1" + stream-http "^2.3.1" + string_decoder "^0.10.25" + timers-browserify "^2.0.2" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.10.3" + vm-browserify "0.0.4" + +node-pre-gyp@^0.6.36: + version "0.6.37" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.37.tgz#3c872b236b2e266e4140578fe1ee88f693323a05" + dependencies: + mkdirp "^0.5.1" + nopt "^4.0.1" + npmlog "^4.0.2" + rc "^1.1.7" + request "^2.81.0" + rimraf "^2.6.1" + semver "^5.3.0" + tape "^4.6.3" + tar "^2.2.1" + tar-pack "^3.4.0" + +node-releases@^1.0.0-alpha.11: + version "1.0.0-alpha.11" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.11.tgz#73c810acc2e5b741a17ddfbb39dfca9ab9359d8a" + dependencies: + semver "^5.3.0" + +node.extend@^1.1.2: + version "1.1.6" + resolved "https://registry.yarnpkg.com/node.extend/-/node.extend-1.1.6.tgz#a7b882c82d6c93a4863a5504bd5de8ec86258b96" + dependencies: + is "^3.1.0" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: + version "2.4.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" + dependencies: + hosted-git-info "^2.1.4" + is-builtin-module "^1.0.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + +normalize-selector@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" + +normalize-url@^1.4.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + +normalize.css@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.0.tgz#14ac5e461612538a4ce9be90a7da23f86e718493" + +npm-path@^1.0.0, npm-path@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-1.1.0.tgz#0474ae00419c327d54701b7cf2cd05dc88be1140" + dependencies: + which "^1.2.4" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + dependencies: + path-key "^2.0.0" + +npm-run@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/npm-run/-/npm-run-3.0.0.tgz#568920f840a98fd8e2299db66b2616e2476caf69" + dependencies: + minimist "^1.1.1" + npm-path "^1.0.1" + npm-which "^2.0.0" + serializerr "^1.0.1" + sync-exec "^0.6.2" + +npm-which@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-2.0.0.tgz#0c46982160b783093661d1d01bd4496d2feabbac" + dependencies: + commander "^2.2.0" + npm-path "^1.0.0" + which "^1.0.5" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +nsdeclare@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nsdeclare/-/nsdeclare-0.1.0.tgz#10daa153642382d3cf2c01a916f4eb20a128b19f" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + +oauth-sign@~0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + +object-assign@4.X, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.3.0.tgz#5b1eb8e6742e2ee83342a637034d844928ba2f6d" + +object-keys@^1.0.8: + version "1.0.11" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" + +object-keys@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + dependencies: + isobject "^3.0.0" + +object.defaults@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" + dependencies: + array-each "^1.0.1" + array-slice "^1.0.0" + for-own "^1.0.0" + isobject "^3.0.0" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +object.pick@^1.2.0, object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + dependencies: + isobject "^3.0.1" + +once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +once@~1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" + dependencies: + wrappy "1" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + dependencies: + mimic-fn "^1.0.0" + +optionator@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +orchestrator@^0.3.0: + version "0.3.8" + resolved "https://registry.yarnpkg.com/orchestrator/-/orchestrator-0.3.8.tgz#14e7e9e2764f7315fbac184e506c7aa6df94ad7e" + dependencies: + end-of-stream "~0.1.5" + sequencify "~0.0.7" + stream-consume "~0.1.0" + +ordered-read-streams@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz#fd565a9af8eb4473ba69b6ed8a34352cb552f126" + +os-browserify@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" + +os-homedir@^1.0.0, os-homedir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + +os-locale@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" + dependencies: + execa "^0.7.0" + lcid "^1.0.0" + mem "^1.1.0" + +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + +osenv@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + dependencies: + p-try "^1.0.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + dependencies: + p-limit "^1.1.0" + +p-map@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + +pako@~0.2.0: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + +parallel-transform@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" + dependencies: + cyclist "~0.2.2" + inherits "^2.0.3" + readable-stream "^2.1.5" + +parse-asn1@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712" + dependencies: + asn1.js "^4.0.0" + browserify-aes "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + +parse-entities@^1.0.2, parse-entities@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.1.2.tgz#9eaf719b29dc3bd62246b4332009072e01527777" + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + +parse-filepath@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.1.tgz#159d6155d43904d16c10ef698911da1e91969b73" + dependencies: + is-absolute "^0.2.3" + map-cache "^0.2.0" + path-root "^0.1.1" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + dependencies: + error-ex "^1.2.0" + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-passwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + +path-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + +path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +path-is-inside@^1.0.1, path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + +path-parse@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + +path-root-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" + +path-root@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" + dependencies: + path-root-regex "^0.1.0" + +path-to-regexp@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" + dependencies: + isarray "0.0.1" + +path-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" + dependencies: + pify "^2.0.0" + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + dependencies: + pify "^3.0.0" + +pause-stream@^0.0.11: + version "0.0.11" + resolved "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + dependencies: + through "~2.3" + +pbkdf2@^3.0.3: + version "3.0.14" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade" + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + +pify@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.0.tgz#db04c982b632fd0df9090d14aaf1c8413cadb695" + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + +pkg-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" + dependencies: + find-up "^2.1.0" + +plugin-error@1.0.1, plugin-error@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c" + dependencies: + ansi-colors "^1.0.1" + arr-diff "^4.0.0" + arr-union "^3.1.0" + extend-shallow "^3.0.2" + +plugin-error@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-0.1.2.tgz#3b9bb3335ccf00f425e07437e19276967da47ace" + dependencies: + ansi-cyan "^0.1.1" + ansi-red "^0.1.1" + arr-diff "^1.0.1" + arr-union "^2.0.1" + extend-shallow "^1.1.2" + +pluralize@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + +postcss-calc@^5.2.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e" + dependencies: + postcss "^5.0.2" + postcss-message-helpers "^2.0.0" + reduce-css-calc "^1.2.6" + +postcss-colormin@^2.1.8: + version "2.2.2" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b" + dependencies: + colormin "^1.0.5" + postcss "^5.0.13" + postcss-value-parser "^3.2.3" + +postcss-convert-values@^2.3.4: + version "2.6.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz#bbd8593c5c1fd2e3d1c322bb925dcae8dae4d62d" + dependencies: + postcss "^5.0.11" + postcss-value-parser "^3.1.2" + +postcss-discard-comments@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d" + dependencies: + postcss "^5.0.14" + +postcss-discard-duplicates@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz#b9abf27b88ac188158a5eb12abcae20263b91932" + dependencies: + postcss "^5.0.4" + +postcss-discard-empty@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5" + dependencies: + postcss "^5.0.14" + +postcss-discard-overridden@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58" + dependencies: + postcss "^5.0.16" + +postcss-discard-unused@^2.2.1: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433" + dependencies: + postcss "^5.0.14" + uniqs "^2.0.0" + +postcss-filter-plugins@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz#6d85862534d735ac420e4a85806e1f5d4286d84c" + dependencies: + postcss "^5.0.4" + uniqid "^4.0.0" + +postcss-html@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.33.0.tgz#8ab6067d7a8a234e1937920b38760e3be1dca070" + dependencies: + htmlparser2 "^3.9.2" + +postcss-js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-1.0.1.tgz#ffaf29226e399ea74b5dce02cab1729d7addbc7b" + dependencies: + camelcase-css "^1.0.1" + postcss "^6.0.11" + +postcss-jsx@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-jsx/-/postcss-jsx-0.33.0.tgz#433f8aadd6f3b0ee403a62b441bca8db9c87471c" + dependencies: + "@babel/core" "^7.0.0-rc.1" + optionalDependencies: + postcss-styled ">=0.33.0" + +postcss-less@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-less/-/postcss-less-2.0.0.tgz#5d190b8e057ca446d60fe2e2587ad791c9029fb8" + dependencies: + postcss "^5.2.16" + +postcss-load-config@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.0.0.tgz#f1312ddbf5912cd747177083c5ef7a19d62ee484" + dependencies: + cosmiconfig "^4.0.0" + import-cwd "^2.0.0" + +postcss-loader@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-markdown@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.33.0.tgz#2d0462742ee108c9d6020780184b499630b8b33a" + dependencies: + remark "^9.0.0" + unist-util-find-all-after "^1.0.2" + +postcss-media-query-parser@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" + +postcss-merge-idents@^2.1.5: + version "2.1.7" + resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270" + dependencies: + has "^1.0.1" + postcss "^5.0.10" + postcss-value-parser "^3.1.1" + +postcss-merge-longhand@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz#23d90cd127b0a77994915332739034a1a4f3d658" + dependencies: + postcss "^5.0.4" + +postcss-merge-rules@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz#d1df5dfaa7b1acc3be553f0e9e10e87c61b5f721" + dependencies: + browserslist "^1.5.2" + caniuse-api "^1.5.2" + postcss "^5.0.4" + postcss-selector-parser "^2.2.2" + vendors "^1.0.0" + +postcss-message-helpers@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" + +postcss-minify-font-values@^1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69" + dependencies: + object-assign "^4.0.1" + postcss "^5.0.4" + postcss-value-parser "^3.0.2" + +postcss-minify-gradients@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1" + dependencies: + postcss "^5.0.12" + postcss-value-parser "^3.3.0" + +postcss-minify-params@^1.0.4: + version "1.2.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz#ad2ce071373b943b3d930a3fa59a358c28d6f1f3" + dependencies: + alphanum-sort "^1.0.1" + postcss "^5.0.2" + postcss-value-parser "^3.0.2" + uniqs "^2.0.0" + +postcss-minify-selectors@^2.0.4: + version "2.1.1" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz#b2c6a98c0072cf91b932d1a496508114311735bf" + dependencies: + alphanum-sort "^1.0.2" + has "^1.0.1" + postcss "^5.0.14" + postcss-selector-parser "^2.0.0" + +postcss-mixins@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/postcss-mixins/-/postcss-mixins-6.2.0.tgz#fa9d2c2166b2ae7745956c727ab9dd2de4b96a40" + dependencies: + globby "^6.1.0" + postcss "^6.0.13" + postcss-js "^1.0.1" + postcss-simple-vars "^4.1.0" + sugarss "^1.0.0" + +postcss-modules-extract-imports@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" + dependencies: + postcss "^6.0.1" + +postcss-modules-local-by-default@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-scope@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-values@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" + dependencies: + icss-replace-symbols "^1.1.0" + postcss "^6.0.1" + +postcss-nested@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.1.0.tgz#271da8a047f2ee378139410ae2400b1c67d0bf30" + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^3.1.1" + +postcss-normalize-charset@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1" + dependencies: + postcss "^5.0.5" + +postcss-normalize-url@^3.0.7: + version "3.0.8" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz#108f74b3f2fcdaf891a2ffa3ea4592279fc78222" + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^1.4.0" + postcss "^5.0.14" + postcss-value-parser "^3.2.3" + +postcss-ordered-values@^2.1.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d" + dependencies: + postcss "^5.0.4" + postcss-value-parser "^3.0.1" + +postcss-reduce-idents@^2.2.2: + version "2.4.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz#c2c6d20cc958284f6abfbe63f7609bf409059ad3" + dependencies: + postcss "^5.0.4" + postcss-value-parser "^3.0.2" + +postcss-reduce-initial@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz#68f80695f045d08263a879ad240df8dd64f644ea" + dependencies: + postcss "^5.0.4" + +postcss-reduce-transforms@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1" + dependencies: + has "^1.0.1" + postcss "^5.0.8" + postcss-value-parser "^3.0.1" + +postcss-reporter@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-5.0.0.tgz#a14177fd1342829d291653f2786efd67110332c3" + dependencies: + chalk "^2.0.1" + lodash "^4.17.4" + log-symbols "^2.0.0" + postcss "^6.0.8" + +postcss-resolve-nested-selector@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz#29ccbc7c37dedfac304e9fff0bf1596b3f6a0e4e" + +postcss-safe-parser@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea" + dependencies: + postcss "^7.0.0" + +postcss-sass@^0.3.0: + version "0.3.3" + resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.3.3.tgz#bec188ac285d21ac8feba194c2f327fdda31e671" + dependencies: + gonzales-pe "^4.2.3" + postcss "^7.0.1" + +postcss-scss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.0.0.tgz#248b0a28af77ea7b32b1011aba0f738bda27dea1" + dependencies: + postcss "^7.0.0" + +postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz#f9437788606c3c9acee16ffe8d8b16297f27bb90" + dependencies: + flatten "^1.0.2" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^3.1.0, postcss-selector-parser@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" + dependencies: + dot-prop "^4.1.1" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-simple-vars@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-5.0.1.tgz#850971fdfedf236ea1c815569ce261dab8623aa2" + dependencies: + postcss "^7.0.2" + +postcss-simple-vars@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-4.1.0.tgz#043248cfef8d3f51b3486a28c09f8375dbf1b2f9" + dependencies: + postcss "^6.0.9" + +postcss-sorting@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-sorting/-/postcss-sorting-4.0.0.tgz#abfdf41ff8f7710f66f5dc7e78a3a3cce3983c21" + dependencies: + lodash "^4.17.4" + postcss "^7.0.0" + +postcss-styled@>=0.33.0, postcss-styled@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-styled/-/postcss-styled-0.33.0.tgz#69be377584105a582fda7e4f76888e5b97eed737" + +postcss-svgo@^2.1.1: + version "2.1.6" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d" + dependencies: + is-svg "^2.0.0" + postcss "^5.0.14" + postcss-value-parser "^3.2.3" + svgo "^0.7.0" + +postcss-syntax@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.33.0.tgz#59c0c678d2f9ecefa84c6ce9ef46fc805c54ab3a" + +postcss-unique-selectors@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d" + dependencies: + alphanum-sort "^1.0.1" + postcss "^5.0.4" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" + +postcss-zindex@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22" + dependencies: + has "^1.0.1" + postcss "^5.0.4" + uniqs "^2.0.0" + +postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8: + version "5.2.17" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.17.tgz#cf4f597b864d65c8a492b2eabe9d706c879c388b" + dependencies: + chalk "^1.1.3" + js-base64 "^2.1.9" + source-map "^0.5.6" + supports-color "^3.2.3" + +postcss@^5.2.16: + version "5.2.18" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5" + dependencies: + chalk "^1.1.3" + js-base64 "^2.1.9" + source-map "^0.5.6" + supports-color "^3.2.3" + +postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11: + version "6.0.11" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.11.tgz#f48db210b1d37a7f7ab6499b7a54982997ab6f72" + dependencies: + chalk "^2.1.0" + source-map "^0.5.7" + supports-color "^4.4.0" + +postcss@^6.0.13, postcss@^6.0.8: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postcss@^6.0.9: + version "6.0.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.12.tgz#6b0155089d2d212f7bd6a0cecd4c58c007403535" + dependencies: + chalk "^2.1.0" + source-map "^0.5.7" + supports-color "^4.4.0" + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.2.tgz#7b5a109de356804e27f95a960bef0e4d5bc9bb18" + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +prefix-style@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + +pretty-hrtime@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" + +private@^0.1.6, private@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" + +private@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" + +process-nextick-args@^1.0.6, process-nextick-args@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" + +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + +process@^0.11.0: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + +progress@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + +prop-types@15.6.2, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" + dependencies: + loose-envify "^1.3.1" + object-assign "^4.1.1" + +prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + +prop-types@^15.5.7: + version "15.6.0" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.3.1" + object-assign "^4.1.1" + +protochain@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/protochain/-/protochain-1.0.5.tgz#991c407e99de264aadf8f81504b5e7faf7bfa260" + +prr@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + +public-encrypt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + +pump@^2.0.0, pump@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + +punycode@^1.2.4, punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + +q@^1.1.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" + +qs@6.5.2, qs@^6.4.0: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + +qs@~6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" + +query-string@^4.1.0: + version "4.2.2" + resolved "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz#888a6fcb6f76070ba39f2f3025c87099defa1645" + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + +quick-lru@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" + +raf@^3.1.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.2.tgz#0c13be0b5b49b46f76d6669248d527cf2b02fe27" + dependencies: + performance-now "^2.1.0" + +randomatic@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.0.tgz#36f2ca708e9e567f5ed2ec01949026d50aa10116" + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +randombytes@^2.0.0, randombytes@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.5.tgz#dc009a246b8d09a177b4b7a0ae77bc570f4b1b79" + dependencies: + safe-buffer "^5.1.0" + +raw-body@~1.1.0: + version "1.1.7" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" + dependencies: + bytes "1" + string_decoder "0.10" + +rc@^1.1.7: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95" + dependencies: + deep-extend "~0.4.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +react-addons-shallow-compare@15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f" + dependencies: + fbjs "^0.8.4" + object-assign "^4.1.0" + +react-async-script@1.0.0, react-async-script@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.0.0.tgz#3578153247bc3f9654a5878c4142539ffbf65c2d" + dependencies: + hoist-non-react-statics "^3.0.1" + prop-types "^15.5.0" + +react-autosuggest@9.4.1: + version "9.4.1" + resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.1.tgz#fe636b196eaffaf1d29283c2fc55f8a93cf3666d" + dependencies: + prop-types "^15.5.10" + react-autowhatever "^10.1.2" + shallow-equal "^1.0.0" + +react-autowhatever@^10.1.2: + version "10.1.2" + resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.2.tgz#200ffc41373b2189e3f6140ac7bdb82363a79fd3" + dependencies: + prop-types "^15.5.8" + react-themeable "^1.1.0" + section-iterator "^2.0.0" + +react-custom-scrollbars@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-custom-scrollbars/-/react-custom-scrollbars-4.2.1.tgz#830fd9502927e97e8a78c2086813899b2a8b66db" + dependencies: + dom-css "^2.0.0" + prop-types "^15.5.10" + raf "^3.1.0" + +react-dnd-html5-backend@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-5.0.1.tgz#0b578d79c5c01317c70414c8d717f632b919d4f1" + dependencies: + autobind-decorator "^2.1.0" + dnd-core "^4.0.5" + lodash "^4.17.10" + shallowequal "^1.0.2" + +react-dnd@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-5.0.0.tgz#c4a17c70109e456dad8906be838e6ee8f32b06b5" + dependencies: + dnd-core "^4.0.5" + hoist-non-react-statics "^2.5.0" + invariant "^2.1.0" + lodash "^4.17.10" + recompose "^0.27.1" + shallowequal "^1.0.2" + +react-document-title@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/react-document-title/-/react-document-title-2.0.3.tgz#bbf922a0d71412fc948245e4283b2412df70f2b9" + dependencies: + prop-types "^15.5.6" + react-side-effect "^1.0.2" + +react-dom@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + schedule "^0.5.0" + +react-google-recaptcha@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-1.0.2.tgz#d77d0c91558d07c2f9f22b8ec05c383cbdbcabac" + dependencies: + prop-types "^15.5.0" + react-async-script "^1.0.0" + +react-is@^16.3.2: + version "16.5.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.1.tgz#c6e8734fd548a22e1cef4fd0833afbeb433b85ee" + +react-lazyload@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-2.3.0.tgz#ccb134223012447074a96543954f44b055dc6185" + +react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + +react-measure@1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-1.4.7.tgz#a1d2ca0dcfef04978b7ac263a765dcb6a0936fdb" + dependencies: + get-node-dimensions "^1.2.0" + prop-types "^15.5.4" + resize-observer-polyfill "^1.4.1" + +react-redux@5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" + dependencies: + hoist-non-react-statics "^2.5.0" + invariant "^2.0.0" + lodash "^4.17.5" + lodash-es "^4.17.5" + loose-envify "^1.1.0" + prop-types "^15.6.0" + +react-router-dom@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" + dependencies: + history "^4.7.2" + invariant "^2.2.4" + loose-envify "^1.3.1" + prop-types "^15.6.1" + react-router "^4.3.1" + warning "^4.0.1" + +react-router-redux@5.0.0-alpha.6: + version "5.0.0-alpha.6" + resolved "https://registry.yarnpkg.com/react-router-redux/-/react-router-redux-5.0.0-alpha.6.tgz#7418663c2ecd3c51be856fcf28f3d1deecc1a576" + dependencies: + history "^4.5.1" + prop-types "^15.5.4" + react-router "^4.1.1" + +react-router@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986" + dependencies: + history "^4.7.2" + hoist-non-react-statics "^2.3.0" + invariant "^2.2.2" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.5.4" + warning "^3.0.0" + +react-router@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.3.1.tgz#aada4aef14c809cb2e686b05cee4742234506c4e" + dependencies: + history "^4.7.2" + hoist-non-react-statics "^2.5.0" + invariant "^2.2.4" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.6.1" + warning "^4.0.1" + +react-side-effect@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.3.tgz#512c25abe0dec172834c4001ec5c51e04d41bc5c" + dependencies: + exenv "^1.2.1" + shallowequal "^1.0.1" + +react-slider@0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-0.11.2.tgz#ae014e1454c3cdd5f28b5c2495b2a08abd9971e6" + +react-tabs@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-2.3.0.tgz#0c37e786f288d369824acd06a96bd1818ab8b0dc" + dependencies: + classnames "^2.2.0" + prop-types "^15.5.0" + +react-tether@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-tether/-/react-tether-1.0.1.tgz#6e5173764d4f9b8bef6d1b20ff51972909674942" + dependencies: + prop-types "^15.5.8" + tether "^1.4.3" + +react-text-truncate@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/react-text-truncate/-/react-text-truncate-0.13.1.tgz#0632cbf8bdd5f7826865c7cc55dc8a2c3a2c9147" + dependencies: + prop-types "^15.5.7" + +react-themeable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" + dependencies: + object-assign "^3.0.0" + +react-virtualized@9.20.1: + version "9.20.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.20.1.tgz#02dc08fe9070386b8c48e2ac56bce7af0208d22d" + dependencies: + babel-runtime "^6.26.0" + classnames "^2.2.3" + dom-helpers "^2.4.0 || ^3.0.0" + loose-envify "^1.3.0" + prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" + +react@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + schedule "^0.5.0" + +read-pkg-up@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" + dependencies: + find-up "^2.0.0" + read-pkg "^2.0.0" + +read-pkg-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" + dependencies: + find-up "^2.0.0" + read-pkg "^3.0.0" + +read-pkg@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" + dependencies: + load-json-file "^2.0.0" + normalize-package-data "^2.3.2" + path-type "^2.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.4: + version "2.3.6" + resolved "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.17: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^1.0.33, readable-stream@~1.1.8, readable-stream@~1.1.9: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + safe-buffer "~5.1.1" + string_decoder "~1.0.3" + util-deprecate "~1.0.1" + +readdirp@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" + dependencies: + graceful-fs "^4.1.2" + minimatch "^3.0.2" + readable-stream "^2.0.2" + set-immediate-shim "^1.0.1" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + dependencies: + resolve "^1.1.6" + +recompose@^0.27.1: + version "0.27.1" + resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba" + dependencies: + babel-runtime "^6.26.0" + change-emitter "^0.1.2" + fbjs "^0.8.1" + hoist-non-react-statics "^2.3.1" + react-lifecycles-compat "^3.0.2" + symbol-observable "^1.0.4" + +redent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" + dependencies: + indent-string "^3.0.0" + strip-indent "^2.0.0" + +reduce-css-calc@^1.2.6: + version "1.3.0" + resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99" + dependencies: + balanced-match "^0.4.2" + +reduce-reducers@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b" + +redux-actions@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.1.tgz#42c06e94739fbe6db35db3605abb105bdb3724d8" + dependencies: + invariant "^2.2.1" + lodash.camelcase "^4.3.0" + lodash.curry "^4.1.1" + reduce-reducers "^0.1.0" + +redux-batched-actions@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.4.0.tgz#ad7ae145bffc0ff4eca2509314731ab358910429" + +redux-localstorage@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/redux-localstorage/-/redux-localstorage-0.4.1.tgz#faf6d719c581397294d811473ffcedee065c933c" + +redux-thunk@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + +redux@4.0.0, redux@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" + dependencies: + loose-envify "^1.1.0" + symbol-observable "^1.2.0" + +regenerate@^1.2.1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f" + +regenerator-runtime@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz#7e54fe5b5ccd5d6624ea6255c3473be090b802e1" + +regenerator-transform@^0.10.0: + version "0.10.1" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd" + dependencies: + babel-runtime "^6.18.0" + babel-types "^6.19.0" + private "^0.1.6" + +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + dependencies: + is-equal-shallow "^0.1.3" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexpp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.0.tgz#b2a7534a85ca1b033bcf5ce9ff8e56d4e0755365" + +regexpu-core@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +regexpu-core@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +regjsgen@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" + +regjsparser@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" + dependencies: + jsesc "~0.5.0" + +remark-parse@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" + dependencies: + collapse-white-space "^1.0.2" + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + is-word-character "^1.0.0" + markdown-escapes "^1.0.0" + parse-entities "^1.1.0" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + trim "0.0.1" + trim-trailing-lines "^1.0.0" + unherit "^1.0.4" + unist-util-remove-position "^1.0.0" + vfile-location "^2.0.0" + xtend "^4.0.1" + +remark-stringify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba" + dependencies: + ccount "^1.0.0" + is-alphanumeric "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + longest-streak "^2.0.1" + markdown-escapes "^1.0.0" + markdown-table "^1.1.0" + mdast-util-compact "^1.0.0" + parse-entities "^1.0.2" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + stringify-entities "^1.0.1" + unherit "^1.0.4" + xtend "^4.0.1" + +remark@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60" + dependencies: + remark-parse "^5.0.0" + remark-stringify "^5.0.0" + unified "^6.0.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + +repeat-string@^1.5.0, repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + dependencies: + is-finite "^1.0.0" + +replace-ext@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" + +replace-ext@1.0.0, replace-ext@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + +request@^2.81.0: + version "2.82.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.82.0.tgz#2ba8a92cd7ac45660ea2b10a53ae67cd247516ea" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.6.0" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.1" + forever-agent "~0.6.1" + form-data "~2.3.1" + har-validator "~5.0.3" + hawk "~6.0.2" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.17" + oauth-sign "~0.8.2" + performance-now "^2.1.0" + qs "~6.5.1" + safe-buffer "^5.1.1" + stringstream "~0.0.5" + tough-cookie "~2.3.2" + tunnel-agent "^0.6.0" + uuid "^3.1.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + +require-from-string@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.1.tgz#c545233e9d7da6616e9d59adfb39fc9f588676ff" + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + +require-nocache@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/require-nocache/-/require-nocache-1.0.0.tgz#a665d0b60a07e8249875790a4d350219d3c85fa3" + +require-uncached@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" + dependencies: + caller-path "^0.1.0" + resolve-from "^1.0.0" + +reselect@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" + +resize-observer-polyfill@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.tgz#a37198e6209e888acb1532a9968e06d38b6788e5" + +resolve-dir@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" + dependencies: + expand-tilde "^1.2.2" + global-modules "^0.2.3" + +resolve-from@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + +resolve-pathname@^2.0.0, resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + +resolve-url@^0.2.1, resolve-url@~0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + +resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" + dependencies: + path-parse "^1.0.5" + +resolve@^1.3.2: + version "1.8.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" + dependencies: + path-parse "^1.0.5" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +resumer@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759" + dependencies: + through "~2.3.4" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + dependencies: + align-text "^0.1.1" + +rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" + dependencies: + glob "^7.0.5" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" + dependencies: + hash-base "^2.0.0" + inherits "^2.0.1" + +rocambole-indent@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/rocambole-indent/-/rocambole-indent-2.0.4.tgz#a18a24977ca0400b861daa4631e861dcb52d085c" + dependencies: + debug "^2.1.3" + mout "^0.11.0" + rocambole-token "^1.2.1" + +rocambole-linebreak@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/rocambole-linebreak/-/rocambole-linebreak-1.0.2.tgz#03621515b43b4721c97e5a1c1bca5a0366368f2f" + dependencies: + debug "^2.1.3" + rocambole-token "^1.2.1" + semver "^4.3.1" + +rocambole-node@~1.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rocambole-node/-/rocambole-node-1.0.0.tgz#db5b49de7407b0080dd514872f28e393d0f7ff3f" + +rocambole-token@^1.1.2, rocambole-token@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rocambole-token/-/rocambole-token-1.2.1.tgz#c785df7428dc3cb27ad7897047bd5238cc070d35" + +rocambole-whitespace@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz#63330949256b29941f59b190459f999c6b1d3bf9" + dependencies: + debug "^2.1.3" + repeat-string "^1.5.0" + rocambole-token "^1.2.1" + +"rocambole@>=0.7 <2.0", rocambole@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/rocambole/-/rocambole-0.7.0.tgz#f6c79505517dc42b6fb840842b8b953b0f968585" + dependencies: + esprima "^2.1" + +run-async@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" + dependencies: + is-promise "^2.1.0" + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + dependencies: + aproba "^1.1.1" + +run-sequence@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-2.2.1.tgz#1ce643da36fd8c7ea7e1a9329da33fc2b8898495" + dependencies: + chalk "^1.1.3" + fancy-log "^1.3.2" + plugin-error "^0.1.2" + +rxjs@^6.1.0: + version "6.3.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.2.tgz#6a688b16c4e6e980e62ea805ec30648e1c60907f" + dependencies: + tslib "^1.9.0" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +safe-json-parse@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57" + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + +sane@^1.6.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-1.7.0.tgz#b3579bccb45c94cf20355cc81124990dfd346e30" + dependencies: + anymatch "^1.3.0" + exec-sh "^0.2.0" + fb-watchman "^2.0.0" + minimatch "^3.0.2" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.10.0" + +sax@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + +schedule@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.5.0.tgz#c128fffa0b402488b08b55ae74bb9df55cc29cc8" + dependencies: + object-assign "^4.1.1" + +schema-utils@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" + dependencies: + ajv "^5.0.0" + +schema-utils@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e" + dependencies: + ajv "^6.1.0" + ajv-keywords "^3.1.0" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +section-iterator@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" + +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" + +semver@^4.1.0, semver@^4.3.1: + version "4.3.6" + resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da" + +sequencify@~0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/sequencify/-/sequencify-0.0.7.tgz#90cff19d02e07027fd767f5ead3e7b95d1e7380c" + +serialize-javascript@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.5.0.tgz#1aa336162c88a890ddad5384baebc93a655161fe" + +serializerr@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91" + dependencies: + protochain "^1.0.5" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + +set-immediate-shim@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4, setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.9" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.9.tgz#98f64880474b74f4a38b8da9d3c0f2d104633e7d" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" + +shallowequal@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.0.2.tgz#1561dbdefb8c01408100319085764da3fcf83f8f" + +shallowequal@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + +sigmund@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + +signalr@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/signalr/-/signalr-2.4.0.tgz#92af008e6b527ad4b36e94fbb340e135087a60d2" + dependencies: + jquery ">=1.6.4" + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + +slice-ansi@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d" + dependencies: + is-fullwidth-code-point "^2.0.0" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sntp@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b" + dependencies: + hoek "4.x.x" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + dependencies: + is-plain-obj "^1.0.0" + +source-list-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" + +source-map-resolve@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.3.1.tgz#610f6122a445b8dd51535a2a71b783dfc1248761" + dependencies: + atob "~1.1.0" + resolve-url "~0.2.1" + source-map-url "~0.3.0" + urix "~0.1.0" + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.4.15: + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" + dependencies: + source-map "^0.5.6" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + +source-map-url@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.3.0.tgz#7ecaf13b57bcd09da8a40c5d269db33799d4aaf9" + +source-map@^0.1.38: + version "0.1.43" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1, source-map@~0.5.3: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + +source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + +sparkles@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.0.tgz#1acbbfb592436d10bbe8f785b7cc6f82815012c3" + +spdx-correct@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz#2c7ae61056c714a5b9b9b2b2af7d311ef5c78fe9" + +spdx-expression-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f" + +specificity@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + dependencies: + extend-shallow "^3.0.0" + +split@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + dependencies: + through "2" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + +sshpk@^1.7.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + dashdash "^1.12.0" + getpass "^0.1.1" + optionalDependencies: + bcrypt-pbkdf "^1.0.0" + ecc-jsbn "~0.1.1" + jsbn "~0.1.0" + tweetnacl "~0.14.0" + +ssri@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06" + dependencies: + safe-buffer "^5.1.1" + +state-toggle@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.1.tgz#c3cb0974f40a6a0f8e905b96789eb41afa1cde3a" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stdin@*: + version "0.0.1" + resolved "https://registry.yarnpkg.com/stdin/-/stdin-0.0.1.tgz#d3041981aaec3dfdbc77a1b38d6372e38f5fb71e" + +stream-browserify@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-combiner@^0.2.2: + version "0.2.2" + resolved "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858" + dependencies: + duplexer "~0.1.1" + through "~2.3.4" + +stream-consume@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f" + +stream-each@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd" + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-http@^2.3.1: + version "2.7.2" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad" + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.2.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + +streamqueue@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/streamqueue/-/streamqueue-1.1.2.tgz#6c99c7c20d62b57f5819296bf9ec942542380192" + dependencies: + isstream "^0.1.2" + readable-stream "^2.3.3" + +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + +string-template@~0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" + +string-width@^1.0.1, string-width@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string.prototype.trim@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.0" + function-bind "^1.0.2" + +string_decoder@0.10, string_decoder@^0.10.25, string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + +string_decoder@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" + dependencies: + safe-buffer "~5.1.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + dependencies: + safe-buffer "~5.1.0" + +stringify-entities@^1.0.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.2.tgz#a98417e5471fd227b3e45d3db1861c11caf668f7" + dependencies: + character-entities-html4 "^1.0.0" + character-entities-legacy "^1.0.0" + is-alphanumerical "^1.0.0" + is-hexadecimal "^1.0.0" + +stringstream@~0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + dependencies: + ansi-regex "^3.0.0" + +strip-bom-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca" + dependencies: + first-chunk-stream "^2.0.0" + strip-bom "^2.0.0" + +strip-bom-string@1.X: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + +strip-bom@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-1.0.0.tgz#85b8862f3844b5a6d5ec8467a93598173a36f794" + dependencies: + first-chunk-stream "^1.0.0" + is-utf8 "^0.2.0" + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + dependencies: + is-utf8 "^0.2.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + +strip-indent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" + +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + +strip-json-comments@~0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-0.1.3.tgz#164c64e370a8a3cc00c9e01b539e569823f0ee54" + +style-loader@0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85" + dependencies: + loader-utils "^1.0.2" + schema-utils "^0.3.0" + +style-search@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" + +stylelint-order@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stylelint-order/-/stylelint-order-1.0.0.tgz#089fc3d5cdf7e7d4ac1882f65b60b25db750413c" + dependencies: + lodash "^4.17.10" + postcss "^7.0.2" + postcss-sorting "^4.0.0" + +stylelint@9.5.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-9.5.0.tgz#f7afb45342abc4acf28a8da8a48373e9f79c1fb4" + dependencies: + autoprefixer "^9.0.0" + balanced-match "^1.0.0" + chalk "^2.4.1" + cosmiconfig "^5.0.0" + debug "^3.0.0" + execall "^1.0.0" + file-entry-cache "^2.0.0" + get-stdin "^6.0.0" + globby "^8.0.0" + globjoin "^0.1.4" + html-tags "^2.0.0" + ignore "^4.0.0" + import-lazy "^3.1.0" + imurmurhash "^0.1.4" + known-css-properties "^0.6.0" + lodash "^4.17.4" + log-symbols "^2.0.0" + mathml-tag-names "^2.0.1" + meow "^5.0.0" + micromatch "^2.3.11" + normalize-selector "^0.2.0" + pify "^4.0.0" + postcss "^7.0.0" + postcss-html "^0.33.0" + postcss-jsx "^0.33.0" + postcss-less "^2.0.0" + postcss-markdown "^0.33.0" + postcss-media-query-parser "^0.2.3" + postcss-reporter "^5.0.0" + postcss-resolve-nested-selector "^0.1.1" + postcss-safe-parser "^4.0.0" + postcss-sass "^0.3.0" + postcss-scss "^2.0.0" + postcss-selector-parser "^3.1.0" + postcss-styled "^0.33.0" + postcss-syntax "^0.33.0" + postcss-value-parser "^3.3.0" + resolve-from "^4.0.0" + signal-exit "^3.0.2" + specificity "^0.4.0" + string-width "^2.1.0" + style-search "^0.1.0" + sugarss "^2.0.0" + svg-tags "^1.0.0" + table "^4.0.1" + +sugarss@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-1.0.0.tgz#65e51b3958432fb70d5451a68bb33e32d0cf1ef7" + dependencies: + postcss "^6.0.0" + +sugarss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d" + dependencies: + postcss "^7.0.2" + +supports-color@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-1.3.1.tgz#15758df09d8ff3b4acc307539fabe27095e1042d" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + +supports-color@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + dependencies: + has-flag "^1.0.0" + +supports-color@^4.2.1: + version "4.4.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" + dependencies: + has-flag "^2.0.0" + +supports-color@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" + dependencies: + has-flag "^2.0.0" + +supports-color@^5.3.0, supports-color@^5.4.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + dependencies: + has-flag "^3.0.0" + +svg-tags@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" + +svgo@^0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" + dependencies: + coa "~1.0.1" + colors "~1.1.2" + csso "~2.3.1" + js-yaml "~3.7.0" + mkdirp "~0.5.1" + sax "~1.2.1" + whet.extend "~0.9.9" + +symbol-observable@^1.0.4, symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + +sync-exec@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" + +table@^4.0.1, table@^4.0.3: + version "4.0.3" + resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc" + dependencies: + ajv "^6.0.1" + ajv-keywords "^3.0.0" + chalk "^2.1.0" + lodash "^4.17.4" + slice-ansi "1.0.0" + string-width "^2.1.1" + +tapable@^0.2.7: + version "0.2.8" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22" + +tape@^4.6.3: + version "4.8.0" + resolved "https://registry.yarnpkg.com/tape/-/tape-4.8.0.tgz#f6a9fec41cc50a1de50fa33603ab580991f6068e" + dependencies: + deep-equal "~1.0.1" + defined "~1.0.0" + for-each "~0.3.2" + function-bind "~1.1.0" + glob "~7.1.2" + has "~1.0.1" + inherits "~2.0.3" + minimist "~1.2.0" + object-inspect "~1.3.0" + resolve "~1.4.0" + resumer "~0.0.0" + string.prototype.trim "~1.1.2" + through "~2.3.8" + +tar-pack@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" + dependencies: + debug "^2.2.0" + fstream "^1.0.10" + fstream-ignore "^1.0.5" + once "^1.3.3" + readable-stream "^2.1.4" + rimraf "^2.5.1" + tar "^2.2.1" + uid-number "^0.0.6" + +tar.gz@1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/tar.gz/-/tar.gz-1.0.7.tgz#577ef2c595faaa73452ef0415fed41113212257b" + dependencies: + bluebird "^2.9.34" + commander "^2.8.1" + fstream "^1.0.8" + mout "^0.11.0" + tar "^2.1.1" + +tar@^2.1.1, tar@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" + dependencies: + block-stream "*" + fstream "^1.0.2" + inherits "2" + +tether@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.3.tgz#fd547024c47b6e5c9b87e1880f997991a9a6ad54" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + +through2@2.0.3, through2@2.X, through2@^2.0.0, through2@^2.0.1, through2@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" + dependencies: + readable-stream "^2.1.5" + xtend "~4.0.1" + +through2@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.4.2.tgz#dbf5866031151ec8352bb6c4db64a2292a840b9b" + dependencies: + readable-stream "~1.0.17" + xtend "~2.1.1" + +through2@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.5.1.tgz#dfdd012eb9c700e2323fd334f38ac622ab372da7" + dependencies: + readable-stream "~1.0.17" + xtend "~3.0.0" + +through2@^0.6.1: + version "0.6.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48" + dependencies: + readable-stream ">=1.0.33-1 <1.1.0-0" + xtend ">=4.0.0 <4.1.0-0" + +through@2, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.4, through@~2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + +tildify@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a" + dependencies: + os-homedir "^1.0.0" + +time-stamp@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" + +timers-browserify@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.4.tgz#96ca53f4b794a5e7c0e1bd7cc88a372298fa01e6" + dependencies: + setimmediate "^1.0.4" + +timers-ext@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.2.tgz#61cc47a76c1abd3195f14527f978d58ae94c5204" + dependencies: + es5-ext "~0.10.14" + next-tick "1" + +tiny-emitter@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" + +tiny-lr@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" + dependencies: + body "^5.1.0" + debug "^3.1.0" + faye-websocket "~0.10.0" + livereload-js "^2.3.0" + object-assign "^4.1.0" + qs "^6.4.0" + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + dependencies: + os-tmpdir "~1.0.2" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + +to-camel-case@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + dependencies: + to-space-case "^1.0.0" + +to-fast-properties@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + dependencies: + to-no-case "^1.0.0" + +tough-cookie@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" + dependencies: + punycode "^1.4.1" + +traverse@~0.6.3: + version "0.6.6" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" + +trim-newlines@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20" + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + +trim-trailing-lines@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz#e0ec0810fd3c3f1730516b45f49083caaf2774d9" + +trim@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" + +trough@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.3.tgz#e29bd1614c6458d44869fc28b255ab7857ef7c24" + +tryit@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" + +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + dependencies: + prelude-ls "~1.1.2" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + +ua-parser-js@^0.7.18, ua-parser-js@^0.7.9: + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" + +uglify-es@^3.3.4: + version "3.3.9" + resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677" + dependencies: + commander "~2.13.0" + source-map "~0.6.1" + +uglify-js@^2.8.29: + version "2.8.29" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" + dependencies: + source-map "~0.5.1" + yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + +uglifyjs-webpack-plugin@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.5.tgz#2ef8387c8f1a903ec5e44fa36f9f3cbdcea67641" + dependencies: + cacache "^10.0.4" + find-cache-dir "^1.0.0" + schema-utils "^0.4.5" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + uglify-es "^3.3.4" + webpack-sources "^1.1.0" + worker-farm "^1.5.2" + +uglifyjs-webpack-plugin@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309" + dependencies: + source-map "^0.5.6" + uglify-js "^2.8.29" + webpack-sources "^1.0.1" + +uid-number@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" + +unc-path-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" + +unherit@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.1.tgz#132748da3e88eab767e08fabfbb89c5e9d28628c" + dependencies: + inherits "^2.0.1" + xtend "^4.0.1" + +unified@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-plain-obj "^1.1.0" + trough "^1.0.0" + vfile "^2.0.0" + x-is-string "^0.1.0" + +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + +uniqid@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/uniqid/-/uniqid-4.1.1.tgz#89220ddf6b751ae52b5f72484863528596bb84c1" + dependencies: + macaddress "^0.2.8" + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + +unique-filename@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" + dependencies: + imurmurhash "^0.1.4" + +unique-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-1.0.0.tgz#d59a4a75427447d9aa6c91e70263f8d26a4b104b" + +unist-util-find-all-after@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz#9be49cfbae5ca1566b27536670a92836bf2f8d6d" + dependencies: + unist-util-is "^2.0.0" + +unist-util-is@^2.0.0, unist-util-is@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db" + +unist-util-remove-position@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz#86b5dad104d0bbfbeb1db5f5c92f3570575c12cb" + dependencies: + unist-util-visit "^1.1.0" + +unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" + +unist-util-visit-parents@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz#63fffc8929027bee04bfef7d2cce474f71cb6217" + dependencies: + unist-util-is "^2.1.2" + +unist-util-visit@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.0.tgz#1cb763647186dc26f5e1df5db6bd1e48b3cc2fb1" + dependencies: + unist-util-visit-parents "^2.0.0" + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +uri-js@^4.2.1, uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + dependencies: + punycode "^2.1.0" + +urix@^0.1.0, urix@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + +url-loader@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7" + dependencies: + loader-utils "^1.0.2" + mime "^1.4.1" + schema-utils "^0.3.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + +user-home@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" + +user-home@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" + dependencies: + os-homedir "^1.0.0" + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + +util@0.10.3, util@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + dependencies: + inherits "2.0.1" + +uuid@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" + +v8flags@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" + dependencies: + user-home "^1.1.1" + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +value-equal@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d" + +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + +vendors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vfile-location@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.3.tgz#083ba80e50968e8d420be49dd1ea9a992131df77" + +vfile-message@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.1.tgz#51a2ccd8a6b97a7980bb34efb9ebde9632e93677" + dependencies: + unist-util-stringify-position "^1.1.1" + +vfile@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" + dependencies: + is-buffer "^1.1.4" + replace-ext "1.0.0" + unist-util-stringify-position "^1.0.0" + vfile-message "^1.0.0" + +vinyl-bufferstream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz#0537869f580effa4ca45acb47579e4b9fe63081a" + dependencies: + bufferstreams "1.0.1" + +vinyl-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a" + dependencies: + graceful-fs "^4.1.2" + pify "^2.3.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + strip-bom-stream "^2.0.0" + vinyl "^1.1.0" + +vinyl-fs@^0.3.0: + version "0.3.14" + resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-0.3.14.tgz#9a6851ce1cac1c1cea5fe86c0931d620c2cfa9e6" + dependencies: + defaults "^1.0.0" + glob-stream "^3.1.5" + glob-watcher "^0.0.6" + graceful-fs "^3.0.0" + mkdirp "^0.5.0" + strip-bom "^1.0.0" + through2 "^0.6.1" + vinyl "^0.4.0" + +vinyl-map@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/vinyl-map/-/vinyl-map-1.0.2.tgz#a8b296025f973fa7cad62817967a48f1d176bf7c" + dependencies: + bl "^1.1.2" + new-from "0.0.3" + through2 "^0.4.1" + +vinyl-sourcemaps-apply@0.2.1, vinyl-sourcemaps-apply@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705" + dependencies: + source-map "^0.5.1" + +vinyl@^0.4.0: + version "0.4.6" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.4.6.tgz#2f356c87a550a255461f36bbeb2a5ba8bf784847" + dependencies: + clone "^0.2.0" + clone-stats "^0.0.1" + +vinyl@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.5.3.tgz#b0455b38fc5e0cf30d4325132e461970c2091cde" + dependencies: + clone "^1.0.0" + clone-stats "^0.0.1" + replace-ext "0.0.1" + +vinyl@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884" + dependencies: + clone "^1.0.0" + clone-stats "^0.0.1" + replace-ext "0.0.1" + +vinyl@^2.0.0, vinyl@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.1.0.tgz#021f9c2cf951d6b939943c89eb5ee5add4fd924c" + dependencies: + clone "^2.1.1" + clone-buffer "^1.0.0" + clone-stats "^1.0.0" + cloneable-readable "^1.0.0" + remove-trailing-separator "^1.0.1" + replace-ext "^1.0.0" + +vinyl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" + dependencies: + clone "^2.1.1" + clone-buffer "^1.0.0" + clone-stats "^1.0.0" + cloneable-readable "^1.0.0" + remove-trailing-separator "^1.0.1" + replace-ext "^1.0.0" + +vm-browserify@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" + dependencies: + indexof "0.0.1" + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + dependencies: + makeerror "1.0.x" + +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + dependencies: + loose-envify "^1.0.0" + +warning@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607" + dependencies: + loose-envify "^1.0.0" + +watch@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc" + +watchpack@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" + dependencies: + async "^2.1.2" + chokidar "^1.7.0" + graceful-fs "^4.1.2" + +weak@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/weak/-/weak-1.0.1.tgz#ab99aab30706959aa0200cb8cf545bb9cb33b99e" + dependencies: + bindings "^1.2.1" + nan "^2.0.5" + +webpack-sources@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf" + dependencies: + source-list-map "^2.0.0" + source-map "~0.5.3" + +webpack-sources@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54" + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack-stream@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/webpack-stream/-/webpack-stream-4.0.0.tgz#f3673dd907d6d9b1ea7bf51fcd1db85b5fd9e0f2" + dependencies: + gulp-util "^3.0.7" + lodash.clone "^4.3.2" + lodash.some "^4.2.2" + memory-fs "^0.4.1" + through "^2.3.8" + vinyl "^2.1.0" + webpack "^3.4.1" + +webpack@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.10.0.tgz#5291b875078cf2abf42bdd23afe3f8f96c17d725" + dependencies: + acorn "^5.0.0" + acorn-dynamic-import "^2.0.0" + ajv "^5.1.5" + ajv-keywords "^2.0.0" + async "^2.1.2" + enhanced-resolve "^3.4.0" + escope "^3.6.0" + interpret "^1.0.0" + json-loader "^0.5.4" + json5 "^0.5.1" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + mkdirp "~0.5.0" + node-libs-browser "^2.0.0" + source-map "^0.5.3" + supports-color "^4.2.1" + tapable "^0.2.7" + uglifyjs-webpack-plugin "^0.4.6" + watchpack "^1.4.0" + webpack-sources "^1.0.1" + yargs "^8.0.2" + +webpack@^3.4.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.6.0.tgz#a89a929fbee205d35a4fa2cc487be9cbec8898bc" + dependencies: + acorn "^5.0.0" + acorn-dynamic-import "^2.0.0" + ajv "^5.1.5" + ajv-keywords "^2.0.0" + async "^2.1.2" + enhanced-resolve "^3.4.0" + escope "^3.6.0" + interpret "^1.0.0" + json-loader "^0.5.4" + json5 "^0.5.1" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + mkdirp "~0.5.0" + node-libs-browser "^2.0.0" + source-map "^0.5.3" + supports-color "^4.2.1" + tapable "^0.2.7" + uglifyjs-webpack-plugin "^0.4.6" + watchpack "^1.4.0" + webpack-sources "^1.0.1" + yargs "^8.0.2" + +websocket-driver@>=0.5.1: + version "0.7.0" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb" + dependencies: + http-parser-js ">=0.4.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d" + +whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + +whet.extend@~0.9.9: + version "0.9.9" + resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + +which@^1.0.5, which@^1.2.12, which@^1.2.4, which@^1.2.9: + version "1.3.0" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" + dependencies: + string-width "^1.0.2" + +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + +worker-farm@^1.3.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d" + dependencies: + errno "^0.1.4" + xtend "^4.0.1" + +worker-farm@^1.5.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0" + dependencies: + errno "~0.1.7" + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +write@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" + dependencies: + mkdirp "^0.5.1" + +x-is-string@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" + +"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + +xtend@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b" + dependencies: + object-keys "~0.4.0" + +xtend@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a" + +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + +yargs-parser@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" + dependencies: + camelcase "^4.1.0" + +yargs-parser@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" + dependencies: + camelcase "^4.1.0" + +yargs@^8.0.1, yargs@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360" + dependencies: + camelcase "^4.1.0" + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + read-pkg-up "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^7.0.0" + +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0"