From 67ed6552b831df66713bac9e672738796128d33f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Jun 2020 14:10:17 +0200 Subject: Reorganize client shared modules --- client/src/app/shared/account/account.model.ts | 30 -- client/src/app/shared/account/account.service.ts | 29 -- client/src/app/shared/actor/actor.model.ts | 66 ---- client/src/app/shared/angular/from-now.pipe.ts | 39 -- client/src/app/shared/angular/highlight.pipe.ts | 54 --- .../app/shared/angular/number-formatter.pipe.ts | 19 - .../src/app/shared/angular/object-length.pipe.ts | 8 - .../shared/angular/peertube-template.directive.ts | 12 - .../timestamp-route-transformer.directive.ts | 39 -- .../angular/video-duration-formatter.pipe.ts | 28 -- .../app/shared/auth/auth-interceptor.service.ts | 60 --- client/src/app/shared/auth/index.ts | 1 - .../app/shared/blocklist/account-block.model.ts | 14 - .../blocklist/account-blocklist.component.html | 64 ---- .../blocklist/account-blocklist.component.scss | 16 - .../blocklist/account-blocklist.component.ts | 79 ---- .../src/app/shared/blocklist/blocklist.service.ts | 153 -------- client/src/app/shared/blocklist/index.ts | 4 - .../blocklist/server-blocklist.component.html | 59 --- .../blocklist/server-blocklist.component.scss | 34 -- .../shared/blocklist/server-blocklist.component.ts | 101 ----- client/src/app/shared/bulk/bulk.service.ts | 24 -- .../shared/buttons/action-dropdown.component.html | 55 --- .../shared/buttons/action-dropdown.component.scss | 72 ---- .../shared/buttons/action-dropdown.component.ts | 52 --- .../src/app/shared/buttons/button.component.html | 6 - .../src/app/shared/buttons/button.component.scss | 46 --- client/src/app/shared/buttons/button.component.ts | 20 - .../shared/buttons/delete-button.component.html | 6 - .../app/shared/buttons/delete-button.component.ts | 20 - .../app/shared/buttons/edit-button.component.html | 6 - .../app/shared/buttons/edit-button.component.ts | 12 - .../src/app/shared/channel/avatar.component.html | 8 - .../src/app/shared/channel/avatar.component.scss | 40 -- client/src/app/shared/channel/avatar.component.ts | 31 -- .../src/app/shared/confirm/confirm.component.html | 30 -- .../src/app/shared/confirm/confirm.component.scss | 21 -- client/src/app/shared/confirm/confirm.component.ts | 73 ---- .../src/app/shared/date/date-toggle.component.html | 6 - .../src/app/shared/date/date-toggle.component.scss | 5 - .../src/app/shared/date/date-toggle.component.ts | 47 --- client/src/app/shared/forms/form-reactive.ts | 69 ---- .../custom-config-validators.service.ts | 98 ----- .../form-validators/form-validator.service.ts | 87 ----- .../src/app/shared/forms/form-validators/host.ts | 8 - .../src/app/shared/forms/form-validators/index.ts | 16 - .../form-validators/instance-validators.service.ts | 62 ---- .../form-validators/login-validators.service.ts | 30 -- .../reset-password-validators.service.ts | 20 - .../form-validators/user-validators.service.ts | 151 -------- .../video-abuse-validators.service.ts | 30 -- .../video-accept-ownership-validators.service.ts | 18 - .../video-block-validators.service.ts | 19 - .../video-captions-validators.service.ts | 27 -- .../video-change-ownership-validators.service.ts | 27 -- .../video-channel-validators.service.ts | 64 ---- .../video-comment-validators.service.ts | 20 - .../video-playlist-validators.service.ts | 66 ---- .../form-validators/video-validators.service.ts | 102 ----- client/src/app/shared/forms/index.ts | 4 - .../forms/input-readonly-copy.component.html | 9 - .../forms/input-readonly-copy.component.scss | 3 - .../shared/forms/input-readonly-copy.component.ts | 21 -- .../shared/forms/markdown-textarea.component.html | 36 -- .../shared/forms/markdown-textarea.component.scss | 251 ------------- .../shared/forms/markdown-textarea.component.ts | 114 ------ .../shared/forms/peertube-checkbox.component.html | 45 --- .../shared/forms/peertube-checkbox.component.scss | 52 --- .../shared/forms/peertube-checkbox.component.ts | 73 ---- .../app/shared/forms/reactive-file.component.html | 15 - .../app/shared/forms/reactive-file.component.scss | 22 -- .../app/shared/forms/reactive-file.component.ts | 91 ----- .../shared/forms/textarea-autoresize.directive.ts | 25 -- .../shared/forms/timestamp-input.component.html | 4 - .../shared/forms/timestamp-input.component.scss | 15 - .../app/shared/forms/timestamp-input.component.ts | 61 --- .../shared/guards/can-deactivate-guard.service.ts | 30 -- .../src/app/shared/i18n/i18n-primeng-calendar.ts | 94 ----- client/src/app/shared/i18n/i18n-utils.ts | 14 - .../app/shared/images/global-icon.component.scss | 6 - .../src/app/shared/images/global-icon.component.ts | 93 ----- .../shared/images/preview-upload.component.html | 11 - .../shared/images/preview-upload.component.scss | 29 -- .../app/shared/images/preview-upload.component.ts | 92 ----- client/src/app/shared/index.ts | 7 - .../shared/instance/feature-boolean.component.html | 3 - .../shared/instance/feature-boolean.component.scss | 10 - .../shared/instance/feature-boolean.component.ts | 10 - client/src/app/shared/instance/follow.service.ts | 116 ------ .../instance-features-table.component.html | 107 ------ .../instance-features-table.component.scss | 40 -- .../instance/instance-features-table.component.ts | 81 ---- .../instance/instance-statistics.component.html | 101 ----- .../instance/instance-statistics.component.scss | 40 -- .../instance/instance-statistics.component.ts | 22 -- client/src/app/shared/instance/instance.service.ts | 92 ----- client/src/app/shared/locale/oc.ts | 104 ------ .../shared/menu/top-menu-dropdown.component.html | 50 --- .../shared/menu/top-menu-dropdown.component.scss | 56 --- .../app/shared/menu/top-menu-dropdown.component.ts | 138 ------- client/src/app/shared/misc/constants.ts | 1 - client/src/app/shared/misc/help.component.html | 40 -- client/src/app/shared/misc/help.component.scss | 42 --- client/src/app/shared/misc/help.component.ts | 94 ----- .../app/shared/misc/list-overflow.component.html | 35 -- .../app/shared/misc/list-overflow.component.scss | 61 --- .../src/app/shared/misc/list-overflow.component.ts | 120 ------ client/src/app/shared/misc/loader.component.html | 8 - client/src/app/shared/misc/loader.component.scss | 45 --- client/src/app/shared/misc/loader.component.ts | 10 - client/src/app/shared/misc/peertube-web-storage.ts | 81 ---- client/src/app/shared/misc/screen.service.ts | 66 ---- .../app/shared/misc/small-loader.component.html | 3 - .../src/app/shared/misc/small-loader.component.ts | 11 - client/src/app/shared/misc/storage.service.ts | 40 -- client/src/app/shared/misc/utils.ts | 210 ----------- client/src/app/shared/moderation/index.ts | 2 - .../moderation/user-ban-modal.component.html | 38 -- .../moderation/user-ban-modal.component.scss | 6 - .../shared/moderation/user-ban-modal.component.ts | 70 ---- .../user-moderation-dropdown.component.html | 9 - .../user-moderation-dropdown.component.ts | 382 ------------------- client/src/app/shared/overview/index.ts | 1 - client/src/app/shared/overview/overview.service.ts | 79 ---- .../app/shared/overview/videos-overview.model.ts | 20 - .../app/shared/renderer/html-renderer.service.ts | 40 -- client/src/app/shared/renderer/index.ts | 3 - .../src/app/shared/renderer/linkifier.service.ts | 114 ------ client/src/app/shared/renderer/markdown.service.ts | 145 -------- .../app/shared/rest/component-pagination.model.ts | 18 - client/src/app/shared/rest/index.ts | 4 - .../src/app/shared/rest/rest-extractor.service.ts | 109 ------ client/src/app/shared/rest/rest-pagination.ts | 4 - client/src/app/shared/rest/rest-table.ts | 105 ------ client/src/app/shared/rest/rest.service.ts | 111 ------ client/src/app/shared/rxjs/zone.ts | 40 -- .../src/app/shared/shared-forms/form-reactive.ts | 69 ++++ .../batch-domains-validators.service.ts | 69 ++++ .../custom-config-validators.service.ts | 98 +++++ .../form-validators/form-validator.service.ts | 87 +++++ .../shared/shared-forms/form-validators/host.ts | 8 + .../shared/shared-forms/form-validators/index.ts | 17 + .../form-validators/instance-validators.service.ts | 62 ++++ .../form-validators/login-validators.service.ts | 30 ++ .../reset-password-validators.service.ts | 20 + .../form-validators/user-validators.service.ts | 151 ++++++++ .../video-abuse-validators.service.ts | 30 ++ .../video-accept-ownership-validators.service.ts | 18 + .../video-block-validators.service.ts | 19 + .../video-captions-validators.service.ts | 27 ++ .../video-change-ownership-validators.service.ts | 27 ++ .../video-channel-validators.service.ts | 64 ++++ .../video-comment-validators.service.ts | 20 + .../video-playlist-validators.service.ts | 66 ++++ .../form-validators/video-validators.service.ts | 102 +++++ client/src/app/shared/shared-forms/index.ts | 10 + .../input-readonly-copy.component.html | 9 + .../input-readonly-copy.component.scss | 3 + .../shared-forms/input-readonly-copy.component.ts | 21 ++ .../shared-forms/markdown-textarea.component.html | 36 ++ .../shared-forms/markdown-textarea.component.scss | 251 +++++++++++++ .../shared-forms/markdown-textarea.component.ts | 110 ++++++ .../shared-forms/peertube-checkbox.component.html | 45 +++ .../shared-forms/peertube-checkbox.component.scss | 52 +++ .../shared-forms/peertube-checkbox.component.ts | 73 ++++ .../shared-forms/preview-upload.component.html | 11 + .../shared-forms/preview-upload.component.scss | 29 ++ .../shared-forms/preview-upload.component.ts | 92 +++++ .../shared-forms/reactive-file.component.html | 15 + .../shared-forms/reactive-file.component.scss | 22 ++ .../shared/shared-forms/reactive-file.component.ts | 91 +++++ .../app/shared/shared-forms/shared-form.module.ts | 84 +++++ .../shared-forms/textarea-autoresize.directive.ts | 25 ++ .../shared-forms/timestamp-input.component.html | 4 + .../shared-forms/timestamp-input.component.scss | 15 + .../shared-forms/timestamp-input.component.ts | 61 +++ .../shared/shared-icons/global-icon.component.scss | 6 + .../shared/shared-icons/global-icon.component.ts | 93 +++++ client/src/app/shared/shared-icons/index.ts | 3 + .../shared-icons/shared-global-icon.module.ts | 21 ++ .../shared-instance/feature-boolean.component.html | 3 + .../shared-instance/feature-boolean.component.scss | 10 + .../shared-instance/feature-boolean.component.ts | 10 + client/src/app/shared/shared-instance/index.ts | 6 + .../instance-features-table.component.html | 107 ++++++ .../instance-features-table.component.scss | 40 ++ .../instance-features-table.component.ts | 81 ++++ .../shared-instance/instance-follow.service.ts | 116 ++++++ .../instance-statistics.component.html | 101 +++++ .../instance-statistics.component.scss | 40 ++ .../instance-statistics.component.ts | 22 ++ .../app/shared/shared-instance/instance.service.ts | 88 +++++ .../shared-instance/shared-instance.module.ts | 32 ++ .../shared/shared-main/account/account.model.ts | 30 ++ .../shared/shared-main/account/account.service.ts | 29 ++ .../account/actor-avatar-info.component.html | 24 ++ .../account/actor-avatar-info.component.scss | 71 ++++ .../account/actor-avatar-info.component.ts | 64 ++++ .../app/shared/shared-main/account/actor.model.ts | 65 ++++ .../shared-main/account/avatar.component.html | 8 + .../shared-main/account/avatar.component.scss | 40 ++ .../shared/shared-main/account/avatar.component.ts | 31 ++ client/src/app/shared/shared-main/account/index.ts | 5 + .../shared/shared-main/angular/from-now.pipe.ts | 39 ++ client/src/app/shared/shared-main/angular/index.ts | 4 + .../angular/infinite-scroller.directive.ts | 96 +++++ .../shared-main/angular/number-formatter.pipe.ts | 19 + .../angular/peertube-template.directive.ts | 12 + .../shared-main/auth/auth-interceptor.service.ts | 60 +++ client/src/app/shared/shared-main/auth/index.ts | 1 + .../buttons/action-dropdown.component.html | 55 +++ .../buttons/action-dropdown.component.scss | 72 ++++ .../buttons/action-dropdown.component.ts | 52 +++ .../shared-main/buttons/button.component.html | 6 + .../shared-main/buttons/button.component.scss | 46 +++ .../shared/shared-main/buttons/button.component.ts | 20 + .../buttons/delete-button.component.html | 6 + .../shared-main/buttons/delete-button.component.ts | 20 + .../shared-main/buttons/edit-button.component.html | 6 + .../shared-main/buttons/edit-button.component.ts | 12 + client/src/app/shared/shared-main/buttons/index.ts | 4 + .../shared-main/date/date-toggle.component.html | 6 + .../shared-main/date/date-toggle.component.scss | 5 + .../shared-main/date/date-toggle.component.ts | 46 +++ client/src/app/shared/shared-main/date/index.ts | 1 + .../shared/shared-main/feeds/feed.component.html | 15 + .../shared/shared-main/feeds/feed.component.scss | 20 + .../app/shared/shared-main/feeds/feed.component.ts | 11 + client/src/app/shared/shared-main/feeds/index.ts | 2 + .../shared/shared-main/feeds/syndication.model.ts | 7 + client/src/app/shared/shared-main/index.ts | 12 + client/src/app/shared/shared-main/loaders/index.ts | 2 + .../shared-main/loaders/loader.component.html | 8 + .../shared-main/loaders/loader.component.scss | 45 +++ .../shared/shared-main/loaders/loader.component.ts | 10 + .../loaders/small-loader.component.html | 3 + .../shared-main/loaders/small-loader.component.ts | 11 + .../shared/shared-main/misc/help.component.html | 40 ++ .../shared/shared-main/misc/help.component.scss | 42 +++ .../app/shared/shared-main/misc/help.component.ts | 94 +++++ client/src/app/shared/shared-main/misc/index.ts | 2 + .../shared-main/misc/list-overflow.component.html | 35 ++ .../shared-main/misc/list-overflow.component.scss | 61 +++ .../shared-main/misc/list-overflow.component.ts | 120 ++++++ .../app/shared/shared-main/shared-main.module.ts | 164 +++++++++ client/src/app/shared/shared-main/users/index.ts | 4 + .../shared-main/users/user-history.service.ts | 43 +++ .../shared-main/users/user-notification.model.ts | 184 +++++++++ .../shared-main/users/user-notification.service.ts | 81 ++++ .../users/user-notifications.component.html | 166 +++++++++ .../users/user-notifications.component.scss | 53 +++ .../users/user-notifications.component.ts | 100 +++++ .../app/shared/shared-main/video-caption/index.ts | 2 + .../video-caption/video-caption-edit.model.ts | 9 + .../video-caption/video-caption.service.ts | 74 ++++ .../app/shared/shared-main/video-channel/index.ts | 2 + .../video-channel/video-channel.model.ts | 42 +++ .../video-channel/video-channel.service.ts | 94 +++++ client/src/app/shared/shared-main/video/index.ts | 7 + .../shared/shared-main/video/redundancy.service.ts | 73 ++++ .../shared-main/video/video-details.model.ts | 69 ++++ .../shared/shared-main/video/video-edit.model.ts | 120 ++++++ .../shared-main/video/video-import.service.ts | 100 +++++ .../shared-main/video/video-ownership.service.ts | 64 ++++ .../app/shared/shared-main/video/video.model.ts | 188 ++++++++++ .../app/shared/shared-main/video/video.service.ts | 380 +++++++++++++++++++ .../shared-moderation/account-block.model.ts | 14 + .../account-blocklist.component.html | 64 ++++ .../account-blocklist.component.scss | 16 + .../account-blocklist.component.ts | 78 ++++ .../batch-domains-modal.component.html | 43 +++ .../batch-domains-modal.component.scss | 3 + .../batch-domains-modal.component.ts | 52 +++ .../shared/shared-moderation/blocklist.service.ts | 153 ++++++++ .../app/shared/shared-moderation/bulk.service.ts | 23 ++ client/src/app/shared/shared-moderation/index.ts | 13 + .../server-blocklist.component.html | 59 +++ .../server-blocklist.component.scss | 34 ++ .../server-blocklist.component.ts | 100 +++++ .../shared-moderation/shared-moderation.module.ts | 46 +++ .../user-ban-modal.component.html | 38 ++ .../user-ban-modal.component.scss | 6 + .../shared-moderation/user-ban-modal.component.ts | 68 ++++ .../user-moderation-dropdown.component.html | 9 + .../user-moderation-dropdown.component.ts | 379 +++++++++++++++++++ .../shared-moderation/video-abuse.service.ts | 98 +++++ .../shared-moderation/video-block.component.html | 45 +++ .../shared-moderation/video-block.component.scss | 6 + .../shared-moderation/video-block.component.ts | 74 ++++ .../shared-moderation/video-block.service.ts | 78 ++++ .../shared-moderation/video-report.component.html | 97 +++++ .../shared-moderation/video-report.component.scss | 27 ++ .../shared-moderation/video-report.component.ts | 161 ++++++++ client/src/app/shared/shared-thumbnail/index.ts | 2 + .../shared-thumbnail/shared-thumbnail.module.ts | 23 ++ .../video-thumbnail.component.html | 33 ++ .../video-thumbnail.component.scss | 74 ++++ .../shared-thumbnail/video-thumbnail.component.ts | 63 ++++ .../src/app/shared/shared-user-settings/index.ts | 4 + .../shared-user-settings.module.ts | 26 ++ .../user-interface-settings.component.html | 17 + .../user-interface-settings.component.scss | 21 ++ .../user-interface-settings.component.ts | 86 +++++ .../user-video-settings.component.html | 75 ++++ .../user-video-settings.component.scss | 24 ++ .../user-video-settings.component.ts | 139 +++++++ .../app/shared/shared-user-subscription/index.ts | 5 + .../remote-subscribe.component.html | 32 ++ .../remote-subscribe.component.scss | 6 + .../remote-subscribe.component.ts | 58 +++ .../shared-user-subscription.module.ts | 29 ++ .../subscribe-button.component.html | 67 ++++ .../subscribe-button.component.scss | 112 ++++++ .../subscribe-button.component.ts | 196 ++++++++++ .../user-subscription.service.ts | 182 +++++++++ .../abstract-video-list.html | 49 +++ .../abstract-video-list.scss | 75 ++++ .../shared-video-miniature/abstract-video-list.ts | 310 ++++++++++++++++ .../src/app/shared/shared-video-miniature/index.ts | 7 + .../shared-video-miniature.module.ts | 40 ++ .../video-actions-dropdown.component.html | 21 ++ .../video-actions-dropdown.component.scss | 12 + .../video-actions-dropdown.component.ts | 269 ++++++++++++++ .../video-download.component.html | 108 ++++++ .../video-download.component.scss | 64 ++++ .../video-download.component.ts | 206 +++++++++++ .../video-miniature.component.html | 66 ++++ .../video-miniature.component.scss | 200 ++++++++++ .../video-miniature.component.ts | 283 ++++++++++++++ .../videos-selection.component.html | 30 ++ .../videos-selection.component.scss | 57 +++ .../videos-selection.component.ts | 118 ++++++ .../src/app/shared/shared-video-playlist/index.ts | 8 + .../shared-video-playlist.module.ts | 36 ++ .../video-add-to-playlist.component.html | 82 +++++ .../video-add-to-playlist.component.scss | 107 ++++++ .../video-add-to-playlist.component.ts | 278 ++++++++++++++ ...video-playlist-element-miniature.component.html | 92 +++++ ...video-playlist-element-miniature.component.scss | 224 +++++++++++ .../video-playlist-element-miniature.component.ts | 182 +++++++++ .../video-playlist-element.model.ts | 24 ++ .../video-playlist-miniature.component.html | 34 ++ .../video-playlist-miniature.component.scss | 78 ++++ .../video-playlist-miniature.component.ts | 22 ++ .../shared-video-playlist/video-playlist.model.ts | 98 +++++ .../video-playlist.service.ts | 355 ++++++++++++++++++ client/src/app/shared/shared.module.ts | 337 ----------------- client/src/app/shared/user-subscription/index.ts | 3 - .../remote-subscribe.component.html | 32 -- .../remote-subscribe.component.scss | 6 - .../remote-subscribe.component.ts | 62 ---- .../subscribe-button.component.html | 67 ---- .../subscribe-button.component.scss | 112 ------ .../subscribe-button.component.ts | 198 ---------- .../user-subscription/user-subscription.service.ts | 163 -------- client/src/app/shared/users/index.ts | 3 - .../src/app/shared/users/user-history.service.ts | 45 --- .../app/shared/users/user-notification.model.ts | 184 --------- .../app/shared/users/user-notification.service.ts | 86 ----- .../shared/users/user-notifications.component.html | 166 --------- .../shared/users/user-notifications.component.scss | 53 --- .../shared/users/user-notifications.component.ts | 101 ----- client/src/app/shared/users/user.model.ts | 150 -------- client/src/app/shared/users/user.service.ts | 367 ------------------ client/src/app/shared/video-abuse/index.ts | 1 - .../app/shared/video-abuse/video-abuse.service.ts | 98 ----- client/src/app/shared/video-block/index.ts | 1 - .../app/shared/video-block/video-block.service.ts | 78 ---- client/src/app/shared/video-caption/index.ts | 1 - .../video-caption/video-caption-edit.model.ts | 9 - .../shared/video-caption/video-caption.service.ts | 76 ---- .../shared/video-channel/video-channel.model.ts | 43 --- .../shared/video-channel/video-channel.service.ts | 98 ----- client/src/app/shared/video-import/index.ts | 1 - .../shared/video-import/video-import.service.ts | 105 ------ client/src/app/shared/video-ownership/index.ts | 1 - .../video-ownership/video-ownership.service.ts | 67 ---- .../video-add-to-playlist.component.html | 82 ----- .../video-add-to-playlist.component.scss | 107 ------ .../video-add-to-playlist.component.ts | 280 -------------- ...video-playlist-element-miniature.component.html | 92 ----- ...video-playlist-element-miniature.component.scss | 224 ----------- .../video-playlist-element-miniature.component.ts | 187 ---------- .../video-playlist/video-playlist-element.model.ts | 24 -- .../video-playlist-miniature.component.html | 34 -- .../video-playlist-miniature.component.scss | 78 ---- .../video-playlist-miniature.component.ts | 22 -- .../shared/video-playlist/video-playlist.model.ts | 97 ----- .../video-playlist/video-playlist.service.ts | 357 ------------------ .../src/app/shared/video/abstract-video-list.html | 49 --- .../src/app/shared/video/abstract-video-list.scss | 75 ---- client/src/app/shared/video/abstract-video-list.ts | 308 ---------------- client/src/app/shared/video/feed.component.html | 15 - client/src/app/shared/video/feed.component.scss | 20 - client/src/app/shared/video/feed.component.ts | 11 - .../shared/video/infinite-scroller.directive.ts | 96 ----- .../shared/video/modals/video-block.component.html | 45 --- .../shared/video/modals/video-block.component.scss | 6 - .../shared/video/modals/video-block.component.ts | 75 ---- .../video/modals/video-download.component.html | 108 ------ .../video/modals/video-download.component.scss | 64 ---- .../video/modals/video-download.component.ts | 208 ----------- .../video/modals/video-report.component.html | 97 ----- .../video/modals/video-report.component.scss | 27 -- .../shared/video/modals/video-report.component.ts | 163 -------- .../app/shared/video/recommendation-info.model.ts | 4 - client/src/app/shared/video/redundancy.service.ts | 73 ---- client/src/app/shared/video/sort-field.type.ts | 10 - client/src/app/shared/video/syndication.model.ts | 7 - .../video/video-actions-dropdown.component.html | 21 -- .../video/video-actions-dropdown.component.scss | 12 - .../video/video-actions-dropdown.component.ts | 276 -------------- client/src/app/shared/video/video-details.model.ts | 64 ---- client/src/app/shared/video/video-edit.model.ts | 123 ------- .../shared/video/video-miniature.component.html | 66 ---- .../shared/video/video-miniature.component.scss | 200 ---------- .../app/shared/video/video-miniature.component.ts | 285 -------------- .../shared/video/video-thumbnail.component.html | 33 -- .../shared/video/video-thumbnail.component.scss | 74 ---- .../app/shared/video/video-thumbnail.component.ts | 63 ---- client/src/app/shared/video/video.model.ts | 182 --------- client/src/app/shared/video/video.service.ts | 409 --------------------- .../shared/video/videos-selection.component.html | 30 -- .../shared/video/videos-selection.component.scss | 57 --- .../app/shared/video/videos-selection.component.ts | 124 ------- 425 files changed, 12929 insertions(+), 14535 deletions(-) delete mode 100644 client/src/app/shared/account/account.model.ts delete mode 100644 client/src/app/shared/account/account.service.ts delete mode 100644 client/src/app/shared/actor/actor.model.ts delete mode 100644 client/src/app/shared/angular/from-now.pipe.ts delete mode 100644 client/src/app/shared/angular/highlight.pipe.ts delete mode 100644 client/src/app/shared/angular/number-formatter.pipe.ts delete mode 100644 client/src/app/shared/angular/object-length.pipe.ts delete mode 100644 client/src/app/shared/angular/peertube-template.directive.ts delete mode 100644 client/src/app/shared/angular/timestamp-route-transformer.directive.ts delete mode 100644 client/src/app/shared/angular/video-duration-formatter.pipe.ts delete mode 100644 client/src/app/shared/auth/auth-interceptor.service.ts delete mode 100644 client/src/app/shared/auth/index.ts delete mode 100644 client/src/app/shared/blocklist/account-block.model.ts delete mode 100644 client/src/app/shared/blocklist/account-blocklist.component.html delete mode 100644 client/src/app/shared/blocklist/account-blocklist.component.scss delete mode 100644 client/src/app/shared/blocklist/account-blocklist.component.ts delete mode 100644 client/src/app/shared/blocklist/blocklist.service.ts delete mode 100644 client/src/app/shared/blocklist/index.ts delete mode 100644 client/src/app/shared/blocklist/server-blocklist.component.html delete mode 100644 client/src/app/shared/blocklist/server-blocklist.component.scss delete mode 100644 client/src/app/shared/blocklist/server-blocklist.component.ts delete mode 100644 client/src/app/shared/bulk/bulk.service.ts delete mode 100644 client/src/app/shared/buttons/action-dropdown.component.html delete mode 100644 client/src/app/shared/buttons/action-dropdown.component.scss delete mode 100644 client/src/app/shared/buttons/action-dropdown.component.ts delete mode 100644 client/src/app/shared/buttons/button.component.html delete mode 100644 client/src/app/shared/buttons/button.component.scss delete mode 100644 client/src/app/shared/buttons/button.component.ts delete mode 100644 client/src/app/shared/buttons/delete-button.component.html delete mode 100644 client/src/app/shared/buttons/delete-button.component.ts delete mode 100644 client/src/app/shared/buttons/edit-button.component.html delete mode 100644 client/src/app/shared/buttons/edit-button.component.ts delete mode 100644 client/src/app/shared/channel/avatar.component.html delete mode 100644 client/src/app/shared/channel/avatar.component.scss delete mode 100644 client/src/app/shared/channel/avatar.component.ts delete mode 100644 client/src/app/shared/confirm/confirm.component.html delete mode 100644 client/src/app/shared/confirm/confirm.component.scss delete mode 100644 client/src/app/shared/confirm/confirm.component.ts delete mode 100644 client/src/app/shared/date/date-toggle.component.html delete mode 100644 client/src/app/shared/date/date-toggle.component.scss delete mode 100644 client/src/app/shared/date/date-toggle.component.ts delete mode 100644 client/src/app/shared/forms/form-reactive.ts delete mode 100644 client/src/app/shared/forms/form-validators/custom-config-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/form-validator.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/host.ts delete mode 100644 client/src/app/shared/forms/form-validators/index.ts delete mode 100644 client/src/app/shared/forms/form-validators/instance-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/login-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/reset-password-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/user-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-block-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-captions-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-channel-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-comment-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts delete mode 100644 client/src/app/shared/forms/form-validators/video-validators.service.ts delete mode 100644 client/src/app/shared/forms/index.ts delete mode 100644 client/src/app/shared/forms/input-readonly-copy.component.html delete mode 100644 client/src/app/shared/forms/input-readonly-copy.component.scss delete mode 100644 client/src/app/shared/forms/input-readonly-copy.component.ts delete mode 100644 client/src/app/shared/forms/markdown-textarea.component.html delete mode 100644 client/src/app/shared/forms/markdown-textarea.component.scss delete mode 100644 client/src/app/shared/forms/markdown-textarea.component.ts delete mode 100644 client/src/app/shared/forms/peertube-checkbox.component.html delete mode 100644 client/src/app/shared/forms/peertube-checkbox.component.scss delete mode 100644 client/src/app/shared/forms/peertube-checkbox.component.ts delete mode 100644 client/src/app/shared/forms/reactive-file.component.html delete mode 100644 client/src/app/shared/forms/reactive-file.component.scss delete mode 100644 client/src/app/shared/forms/reactive-file.component.ts delete mode 100644 client/src/app/shared/forms/textarea-autoresize.directive.ts delete mode 100644 client/src/app/shared/forms/timestamp-input.component.html delete mode 100644 client/src/app/shared/forms/timestamp-input.component.scss delete mode 100644 client/src/app/shared/forms/timestamp-input.component.ts delete mode 100644 client/src/app/shared/guards/can-deactivate-guard.service.ts delete mode 100644 client/src/app/shared/i18n/i18n-primeng-calendar.ts delete mode 100644 client/src/app/shared/i18n/i18n-utils.ts delete mode 100644 client/src/app/shared/images/global-icon.component.scss delete mode 100644 client/src/app/shared/images/global-icon.component.ts delete mode 100644 client/src/app/shared/images/preview-upload.component.html delete mode 100644 client/src/app/shared/images/preview-upload.component.scss delete mode 100644 client/src/app/shared/images/preview-upload.component.ts delete mode 100644 client/src/app/shared/index.ts delete mode 100644 client/src/app/shared/instance/feature-boolean.component.html delete mode 100644 client/src/app/shared/instance/feature-boolean.component.scss delete mode 100644 client/src/app/shared/instance/feature-boolean.component.ts delete mode 100644 client/src/app/shared/instance/follow.service.ts delete mode 100644 client/src/app/shared/instance/instance-features-table.component.html delete mode 100644 client/src/app/shared/instance/instance-features-table.component.scss delete mode 100644 client/src/app/shared/instance/instance-features-table.component.ts delete mode 100644 client/src/app/shared/instance/instance-statistics.component.html delete mode 100644 client/src/app/shared/instance/instance-statistics.component.scss delete mode 100644 client/src/app/shared/instance/instance-statistics.component.ts delete mode 100644 client/src/app/shared/instance/instance.service.ts delete mode 100644 client/src/app/shared/locale/oc.ts delete mode 100644 client/src/app/shared/menu/top-menu-dropdown.component.html delete mode 100644 client/src/app/shared/menu/top-menu-dropdown.component.scss delete mode 100644 client/src/app/shared/menu/top-menu-dropdown.component.ts delete mode 100644 client/src/app/shared/misc/constants.ts delete mode 100644 client/src/app/shared/misc/help.component.html delete mode 100644 client/src/app/shared/misc/help.component.scss delete mode 100644 client/src/app/shared/misc/help.component.ts delete mode 100644 client/src/app/shared/misc/list-overflow.component.html delete mode 100644 client/src/app/shared/misc/list-overflow.component.scss delete mode 100644 client/src/app/shared/misc/list-overflow.component.ts delete mode 100644 client/src/app/shared/misc/loader.component.html delete mode 100644 client/src/app/shared/misc/loader.component.scss delete mode 100644 client/src/app/shared/misc/loader.component.ts delete mode 100644 client/src/app/shared/misc/peertube-web-storage.ts delete mode 100644 client/src/app/shared/misc/screen.service.ts delete mode 100644 client/src/app/shared/misc/small-loader.component.html delete mode 100644 client/src/app/shared/misc/small-loader.component.ts delete mode 100644 client/src/app/shared/misc/storage.service.ts delete mode 100644 client/src/app/shared/misc/utils.ts delete mode 100644 client/src/app/shared/moderation/index.ts delete mode 100644 client/src/app/shared/moderation/user-ban-modal.component.html delete mode 100644 client/src/app/shared/moderation/user-ban-modal.component.scss delete mode 100644 client/src/app/shared/moderation/user-ban-modal.component.ts delete mode 100644 client/src/app/shared/moderation/user-moderation-dropdown.component.html delete mode 100644 client/src/app/shared/moderation/user-moderation-dropdown.component.ts delete mode 100644 client/src/app/shared/overview/index.ts delete mode 100644 client/src/app/shared/overview/overview.service.ts delete mode 100644 client/src/app/shared/overview/videos-overview.model.ts delete mode 100644 client/src/app/shared/renderer/html-renderer.service.ts delete mode 100644 client/src/app/shared/renderer/index.ts delete mode 100644 client/src/app/shared/renderer/linkifier.service.ts delete mode 100644 client/src/app/shared/renderer/markdown.service.ts delete mode 100644 client/src/app/shared/rest/component-pagination.model.ts delete mode 100644 client/src/app/shared/rest/index.ts delete mode 100644 client/src/app/shared/rest/rest-extractor.service.ts delete mode 100644 client/src/app/shared/rest/rest-pagination.ts delete mode 100644 client/src/app/shared/rest/rest-table.ts delete mode 100644 client/src/app/shared/rest/rest.service.ts delete mode 100644 client/src/app/shared/rxjs/zone.ts create mode 100644 client/src/app/shared/shared-forms/form-reactive.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/form-validator.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/host.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/index.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/login-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/user-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/index.ts create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.html create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.scss create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.ts create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.html create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.scss create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.ts create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.html create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.scss create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.ts create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.html create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.scss create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.ts create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.html create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.scss create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.ts create mode 100644 client/src/app/shared/shared-forms/shared-form.module.ts create mode 100644 client/src/app/shared/shared-forms/textarea-autoresize.directive.ts create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.html create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.scss create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.ts create mode 100644 client/src/app/shared/shared-icons/global-icon.component.scss create mode 100644 client/src/app/shared/shared-icons/global-icon.component.ts create mode 100644 client/src/app/shared/shared-icons/index.ts create mode 100644 client/src/app/shared/shared-icons/shared-global-icon.module.ts create mode 100644 client/src/app/shared/shared-instance/feature-boolean.component.html create mode 100644 client/src/app/shared/shared-instance/feature-boolean.component.scss create mode 100644 client/src/app/shared/shared-instance/feature-boolean.component.ts create mode 100644 client/src/app/shared/shared-instance/index.ts create mode 100644 client/src/app/shared/shared-instance/instance-features-table.component.html create mode 100644 client/src/app/shared/shared-instance/instance-features-table.component.scss create mode 100644 client/src/app/shared/shared-instance/instance-features-table.component.ts create mode 100644 client/src/app/shared/shared-instance/instance-follow.service.ts create mode 100644 client/src/app/shared/shared-instance/instance-statistics.component.html create mode 100644 client/src/app/shared/shared-instance/instance-statistics.component.scss create mode 100644 client/src/app/shared/shared-instance/instance-statistics.component.ts create mode 100644 client/src/app/shared/shared-instance/instance.service.ts create mode 100644 client/src/app/shared/shared-instance/shared-instance.module.ts create mode 100644 client/src/app/shared/shared-main/account/account.model.ts create mode 100644 client/src/app/shared/shared-main/account/account.service.ts create mode 100644 client/src/app/shared/shared-main/account/actor-avatar-info.component.html create mode 100644 client/src/app/shared/shared-main/account/actor-avatar-info.component.scss create mode 100644 client/src/app/shared/shared-main/account/actor-avatar-info.component.ts create mode 100644 client/src/app/shared/shared-main/account/actor.model.ts create mode 100644 client/src/app/shared/shared-main/account/avatar.component.html create mode 100644 client/src/app/shared/shared-main/account/avatar.component.scss create mode 100644 client/src/app/shared/shared-main/account/avatar.component.ts create mode 100644 client/src/app/shared/shared-main/account/index.ts create mode 100644 client/src/app/shared/shared-main/angular/from-now.pipe.ts create mode 100644 client/src/app/shared/shared-main/angular/index.ts create mode 100644 client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts create mode 100644 client/src/app/shared/shared-main/angular/number-formatter.pipe.ts create mode 100644 client/src/app/shared/shared-main/angular/peertube-template.directive.ts create mode 100644 client/src/app/shared/shared-main/auth/auth-interceptor.service.ts create mode 100644 client/src/app/shared/shared-main/auth/index.ts create mode 100644 client/src/app/shared/shared-main/buttons/action-dropdown.component.html create mode 100644 client/src/app/shared/shared-main/buttons/action-dropdown.component.scss create mode 100644 client/src/app/shared/shared-main/buttons/action-dropdown.component.ts create mode 100644 client/src/app/shared/shared-main/buttons/button.component.html create mode 100644 client/src/app/shared/shared-main/buttons/button.component.scss create mode 100644 client/src/app/shared/shared-main/buttons/button.component.ts create mode 100644 client/src/app/shared/shared-main/buttons/delete-button.component.html create mode 100644 client/src/app/shared/shared-main/buttons/delete-button.component.ts create mode 100644 client/src/app/shared/shared-main/buttons/edit-button.component.html create mode 100644 client/src/app/shared/shared-main/buttons/edit-button.component.ts create mode 100644 client/src/app/shared/shared-main/buttons/index.ts create mode 100644 client/src/app/shared/shared-main/date/date-toggle.component.html create mode 100644 client/src/app/shared/shared-main/date/date-toggle.component.scss create mode 100644 client/src/app/shared/shared-main/date/date-toggle.component.ts create mode 100644 client/src/app/shared/shared-main/date/index.ts create mode 100644 client/src/app/shared/shared-main/feeds/feed.component.html create mode 100644 client/src/app/shared/shared-main/feeds/feed.component.scss create mode 100644 client/src/app/shared/shared-main/feeds/feed.component.ts create mode 100644 client/src/app/shared/shared-main/feeds/index.ts create mode 100644 client/src/app/shared/shared-main/feeds/syndication.model.ts create mode 100644 client/src/app/shared/shared-main/index.ts create mode 100644 client/src/app/shared/shared-main/loaders/index.ts create mode 100644 client/src/app/shared/shared-main/loaders/loader.component.html create mode 100644 client/src/app/shared/shared-main/loaders/loader.component.scss create mode 100644 client/src/app/shared/shared-main/loaders/loader.component.ts create mode 100644 client/src/app/shared/shared-main/loaders/small-loader.component.html create mode 100644 client/src/app/shared/shared-main/loaders/small-loader.component.ts create mode 100644 client/src/app/shared/shared-main/misc/help.component.html create mode 100644 client/src/app/shared/shared-main/misc/help.component.scss create mode 100644 client/src/app/shared/shared-main/misc/help.component.ts create mode 100644 client/src/app/shared/shared-main/misc/index.ts create mode 100644 client/src/app/shared/shared-main/misc/list-overflow.component.html create mode 100644 client/src/app/shared/shared-main/misc/list-overflow.component.scss create mode 100644 client/src/app/shared/shared-main/misc/list-overflow.component.ts create mode 100644 client/src/app/shared/shared-main/shared-main.module.ts create mode 100644 client/src/app/shared/shared-main/users/index.ts create mode 100644 client/src/app/shared/shared-main/users/user-history.service.ts create mode 100644 client/src/app/shared/shared-main/users/user-notification.model.ts create mode 100644 client/src/app/shared/shared-main/users/user-notification.service.ts create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.html create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.scss create mode 100644 client/src/app/shared/shared-main/users/user-notifications.component.ts create mode 100644 client/src/app/shared/shared-main/video-caption/index.ts create mode 100644 client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts create mode 100644 client/src/app/shared/shared-main/video-caption/video-caption.service.ts create mode 100644 client/src/app/shared/shared-main/video-channel/index.ts create mode 100644 client/src/app/shared/shared-main/video-channel/video-channel.model.ts create mode 100644 client/src/app/shared/shared-main/video-channel/video-channel.service.ts create mode 100644 client/src/app/shared/shared-main/video/index.ts create mode 100644 client/src/app/shared/shared-main/video/redundancy.service.ts create mode 100644 client/src/app/shared/shared-main/video/video-details.model.ts create mode 100644 client/src/app/shared/shared-main/video/video-edit.model.ts create mode 100644 client/src/app/shared/shared-main/video/video-import.service.ts create mode 100644 client/src/app/shared/shared-main/video/video-ownership.service.ts create mode 100644 client/src/app/shared/shared-main/video/video.model.ts create mode 100644 client/src/app/shared/shared-main/video/video.service.ts create mode 100644 client/src/app/shared/shared-moderation/account-block.model.ts create mode 100644 client/src/app/shared/shared-moderation/account-blocklist.component.html create mode 100644 client/src/app/shared/shared-moderation/account-blocklist.component.scss create mode 100644 client/src/app/shared/shared-moderation/account-blocklist.component.ts create mode 100644 client/src/app/shared/shared-moderation/batch-domains-modal.component.html create mode 100644 client/src/app/shared/shared-moderation/batch-domains-modal.component.scss create mode 100644 client/src/app/shared/shared-moderation/batch-domains-modal.component.ts create mode 100644 client/src/app/shared/shared-moderation/blocklist.service.ts create mode 100644 client/src/app/shared/shared-moderation/bulk.service.ts create mode 100644 client/src/app/shared/shared-moderation/index.ts create mode 100644 client/src/app/shared/shared-moderation/server-blocklist.component.html create mode 100644 client/src/app/shared/shared-moderation/server-blocklist.component.scss create mode 100644 client/src/app/shared/shared-moderation/server-blocklist.component.ts create mode 100644 client/src/app/shared/shared-moderation/shared-moderation.module.ts create mode 100644 client/src/app/shared/shared-moderation/user-ban-modal.component.html create mode 100644 client/src/app/shared/shared-moderation/user-ban-modal.component.scss create mode 100644 client/src/app/shared/shared-moderation/user-ban-modal.component.ts create mode 100644 client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html create mode 100644 client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts create mode 100644 client/src/app/shared/shared-moderation/video-abuse.service.ts create mode 100644 client/src/app/shared/shared-moderation/video-block.component.html create mode 100644 client/src/app/shared/shared-moderation/video-block.component.scss create mode 100644 client/src/app/shared/shared-moderation/video-block.component.ts create mode 100644 client/src/app/shared/shared-moderation/video-block.service.ts create mode 100644 client/src/app/shared/shared-moderation/video-report.component.html create mode 100644 client/src/app/shared/shared-moderation/video-report.component.scss create mode 100644 client/src/app/shared/shared-moderation/video-report.component.ts create mode 100644 client/src/app/shared/shared-thumbnail/index.ts create mode 100644 client/src/app/shared/shared-thumbnail/shared-thumbnail.module.ts create mode 100644 client/src/app/shared/shared-thumbnail/video-thumbnail.component.html create mode 100644 client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss create mode 100644 client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts create mode 100644 client/src/app/shared/shared-user-settings/index.ts create mode 100644 client/src/app/shared/shared-user-settings/shared-user-settings.module.ts create mode 100644 client/src/app/shared/shared-user-settings/user-interface-settings.component.html create mode 100644 client/src/app/shared/shared-user-settings/user-interface-settings.component.scss create mode 100644 client/src/app/shared/shared-user-settings/user-interface-settings.component.ts create mode 100644 client/src/app/shared/shared-user-settings/user-video-settings.component.html create mode 100644 client/src/app/shared/shared-user-settings/user-video-settings.component.scss create mode 100644 client/src/app/shared/shared-user-settings/user-video-settings.component.ts create mode 100644 client/src/app/shared/shared-user-subscription/index.ts create mode 100644 client/src/app/shared/shared-user-subscription/remote-subscribe.component.html create mode 100644 client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss create mode 100644 client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts create mode 100644 client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts create mode 100644 client/src/app/shared/shared-user-subscription/subscribe-button.component.html create mode 100644 client/src/app/shared/shared-user-subscription/subscribe-button.component.scss create mode 100644 client/src/app/shared/shared-user-subscription/subscribe-button.component.ts create mode 100644 client/src/app/shared/shared-user-subscription/user-subscription.service.ts create mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.html create mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.scss create mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.ts create mode 100644 client/src/app/shared/shared-video-miniature/index.ts create mode 100644 client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts create mode 100644 client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html create mode 100644 client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts create mode 100644 client/src/app/shared/shared-video-miniature/video-download.component.html create mode 100644 client/src/app/shared/shared-video-miniature/video-download.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/video-download.component.ts create mode 100644 client/src/app/shared/shared-video-miniature/video-miniature.component.html create mode 100644 client/src/app/shared/shared-video-miniature/video-miniature.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/video-miniature.component.ts create mode 100644 client/src/app/shared/shared-video-miniature/videos-selection.component.html create mode 100644 client/src/app/shared/shared-video-miniature/videos-selection.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/videos-selection.component.ts create mode 100644 client/src/app/shared/shared-video-playlist/index.ts create mode 100644 client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html create mode 100644 client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss create mode 100644 client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist.model.ts create mode 100644 client/src/app/shared/shared-video-playlist/video-playlist.service.ts delete mode 100644 client/src/app/shared/shared.module.ts delete mode 100644 client/src/app/shared/user-subscription/index.ts delete mode 100644 client/src/app/shared/user-subscription/remote-subscribe.component.html delete mode 100644 client/src/app/shared/user-subscription/remote-subscribe.component.scss delete mode 100644 client/src/app/shared/user-subscription/remote-subscribe.component.ts delete mode 100644 client/src/app/shared/user-subscription/subscribe-button.component.html delete mode 100644 client/src/app/shared/user-subscription/subscribe-button.component.scss delete mode 100644 client/src/app/shared/user-subscription/subscribe-button.component.ts delete mode 100644 client/src/app/shared/user-subscription/user-subscription.service.ts delete mode 100644 client/src/app/shared/users/index.ts delete mode 100644 client/src/app/shared/users/user-history.service.ts delete mode 100644 client/src/app/shared/users/user-notification.model.ts delete mode 100644 client/src/app/shared/users/user-notification.service.ts delete mode 100644 client/src/app/shared/users/user-notifications.component.html delete mode 100644 client/src/app/shared/users/user-notifications.component.scss delete mode 100644 client/src/app/shared/users/user-notifications.component.ts delete mode 100644 client/src/app/shared/users/user.model.ts delete mode 100644 client/src/app/shared/users/user.service.ts delete mode 100644 client/src/app/shared/video-abuse/index.ts delete mode 100644 client/src/app/shared/video-abuse/video-abuse.service.ts delete mode 100644 client/src/app/shared/video-block/index.ts delete mode 100644 client/src/app/shared/video-block/video-block.service.ts delete mode 100644 client/src/app/shared/video-caption/index.ts delete mode 100644 client/src/app/shared/video-caption/video-caption-edit.model.ts delete mode 100644 client/src/app/shared/video-caption/video-caption.service.ts delete mode 100644 client/src/app/shared/video-channel/video-channel.model.ts delete mode 100644 client/src/app/shared/video-channel/video-channel.service.ts delete mode 100644 client/src/app/shared/video-import/index.ts delete mode 100644 client/src/app/shared/video-import/video-import.service.ts delete mode 100644 client/src/app/shared/video-ownership/index.ts delete mode 100644 client/src/app/shared/video-ownership/video-ownership.service.ts delete mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.html delete mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.scss delete mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.ts delete mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html delete mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss delete mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts delete mode 100644 client/src/app/shared/video-playlist/video-playlist-element.model.ts delete mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.html delete mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.scss delete mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.ts delete mode 100644 client/src/app/shared/video-playlist/video-playlist.model.ts delete mode 100644 client/src/app/shared/video-playlist/video-playlist.service.ts delete mode 100644 client/src/app/shared/video/abstract-video-list.html delete mode 100644 client/src/app/shared/video/abstract-video-list.scss delete mode 100644 client/src/app/shared/video/abstract-video-list.ts delete mode 100644 client/src/app/shared/video/feed.component.html delete mode 100644 client/src/app/shared/video/feed.component.scss delete mode 100644 client/src/app/shared/video/feed.component.ts delete mode 100644 client/src/app/shared/video/infinite-scroller.directive.ts delete mode 100644 client/src/app/shared/video/modals/video-block.component.html delete mode 100644 client/src/app/shared/video/modals/video-block.component.scss delete mode 100644 client/src/app/shared/video/modals/video-block.component.ts delete mode 100644 client/src/app/shared/video/modals/video-download.component.html delete mode 100644 client/src/app/shared/video/modals/video-download.component.scss delete mode 100644 client/src/app/shared/video/modals/video-download.component.ts delete mode 100644 client/src/app/shared/video/modals/video-report.component.html delete mode 100644 client/src/app/shared/video/modals/video-report.component.scss delete mode 100644 client/src/app/shared/video/modals/video-report.component.ts delete mode 100644 client/src/app/shared/video/recommendation-info.model.ts delete mode 100644 client/src/app/shared/video/redundancy.service.ts delete mode 100644 client/src/app/shared/video/sort-field.type.ts delete mode 100644 client/src/app/shared/video/syndication.model.ts delete mode 100644 client/src/app/shared/video/video-actions-dropdown.component.html delete mode 100644 client/src/app/shared/video/video-actions-dropdown.component.scss delete mode 100644 client/src/app/shared/video/video-actions-dropdown.component.ts delete mode 100644 client/src/app/shared/video/video-details.model.ts delete mode 100644 client/src/app/shared/video/video-edit.model.ts delete mode 100644 client/src/app/shared/video/video-miniature.component.html delete mode 100644 client/src/app/shared/video/video-miniature.component.scss delete mode 100644 client/src/app/shared/video/video-miniature.component.ts delete mode 100644 client/src/app/shared/video/video-thumbnail.component.html delete mode 100644 client/src/app/shared/video/video-thumbnail.component.scss delete mode 100644 client/src/app/shared/video/video-thumbnail.component.ts delete mode 100644 client/src/app/shared/video/video.model.ts delete mode 100644 client/src/app/shared/video/video.service.ts delete mode 100644 client/src/app/shared/video/videos-selection.component.html delete mode 100644 client/src/app/shared/video/videos-selection.component.scss delete mode 100644 client/src/app/shared/video/videos-selection.component.ts (limited to 'client/src/app/shared') diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts deleted file mode 100644 index 61f09fc06..000000000 --- a/client/src/app/shared/account/account.model.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' -import { Actor } from '../actor/actor.model' - -export class Account extends Actor implements ServerAccount { - displayName: string - description: string - nameWithHost: string - nameWithHostForced: string - mutedByUser: boolean - mutedByInstance: boolean - mutedServerByUser: boolean - mutedServerByInstance: boolean - - userId?: number - - constructor (hash: ServerAccount) { - super(hash) - - this.displayName = hash.displayName - this.description = hash.description - this.userId = hash.userId - this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) - this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) - - this.mutedByUser = false - this.mutedByInstance = false - this.mutedServerByUser = false - this.mutedServerByInstance = false - } -} diff --git a/client/src/app/shared/account/account.service.ts b/client/src/app/shared/account/account.service.ts deleted file mode 100644 index 6b261cf53..000000000 --- a/client/src/app/shared/account/account.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { map, tap, catchError } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { Observable, ReplaySubject } from 'rxjs' -import { Account } from '@app/shared/account/account.model' -import { RestExtractor } from '@app/shared/rest/rest-extractor.service' -import { HttpClient } from '@angular/common/http' -import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' - -@Injectable() -export class AccountService { - static BASE_ACCOUNT_URL = environment.apiUrl + '/api/v1/accounts/' - - accountLoaded = new ReplaySubject(1) - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor - ) {} - - getAccount (id: number | string): Observable { - return this.authHttp.get(AccountService.BASE_ACCOUNT_URL + id) - .pipe( - map(accountHash => new Account(accountHash)), - tap(account => this.accountLoaded.next(account)), - catchError(res => this.restExtractor.handleError(res)) - ) - } -} diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts deleted file mode 100644 index a78303a2f..000000000 --- a/client/src/app/shared/actor/actor.model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Actor as ActorServer } from '../../../../../shared/models/actors/actor.model' -import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' - -export abstract class Actor implements ActorServer { - id: number - url: string - name: string - host: string - followingCount: number - followersCount: number - createdAt: Date | string - updatedAt: Date | string - avatar: Avatar - - avatarUrl: string - - static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) { - if (actor?.avatar?.url) return actor.avatar.url - - if (actor && actor.avatar) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - - return absoluteAPIUrl + actor.avatar.path - } - - return this.GET_DEFAULT_AVATAR_URL() - } - - static GET_DEFAULT_AVATAR_URL () { - return window.location.origin + '/client/assets/images/default-avatar.png' - } - - static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - const thisHost = new URL(absoluteAPIUrl).host - - if (host.trim() === thisHost && !forceHostname) return accountName - - return accountName + '@' + host - } - - protected constructor (hash: ActorServer) { - this.id = hash.id - this.url = hash.url - this.name = hash.name - this.host = hash.host - this.followingCount = hash.followingCount - this.followersCount = hash.followersCount - this.createdAt = new Date(hash.createdAt.toString()) - this.updatedAt = new Date(hash.updatedAt.toString()) - this.avatar = hash.avatar - - this.updateComputedAttributes() - } - - updateAvatar (newAvatar: Avatar) { - this.avatar = newAvatar - - this.updateComputedAttributes() - } - - private updateComputedAttributes () { - this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this) - } -} diff --git a/client/src/app/shared/angular/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts deleted file mode 100644 index 9851468ee..000000000 --- a/client/src/app/shared/angular/from-now.pipe.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' - -// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site -@Pipe({ name: 'myFromNow' }) -export class FromNowPipe implements PipeTransform { - - constructor (private i18n: I18n) { } - - transform (arg: number | Date | string) { - const argDate = new Date(arg) - const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) - - let interval = Math.floor(seconds / 31536000) - if (interval > 1) return this.i18n('{{interval}} years ago', { interval }) - if (interval === 1) return this.i18n('{{interval}} year ago', { interval }) - - interval = Math.floor(seconds / 2592000) - if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) - if (interval === 1) return this.i18n('{{interval}} month ago', { interval }) - - interval = Math.floor(seconds / 604800) - if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval }) - if (interval === 1) return this.i18n('{{interval}} week ago', { interval }) - - interval = Math.floor(seconds / 86400) - if (interval > 1) return this.i18n('{{interval}} days ago', { interval }) - if (interval === 1) return this.i18n('{{interval}} day ago', { interval }) - - interval = Math.floor(seconds / 3600) - if (interval > 1) return this.i18n('{{interval}} hours ago', { interval }) - if (interval === 1) return this.i18n('{{interval}} hour ago', { interval }) - - interval = Math.floor(seconds / 60) - if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) - - return this.i18n('just now') - } -} diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts deleted file mode 100644 index 50ee5c1bd..000000000 --- a/client/src/app/shared/angular/highlight.pipe.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { PipeTransform, Pipe } from '@angular/core' -import { SafeHtml } from '@angular/platform-browser' - -// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369 -@Pipe({ name: 'highlight' }) -export class HighlightPipe implements PipeTransform { - /* use this for single match search */ - static SINGLE_MATCH = 'Single-Match' - /* use this for single match search with a restriction that target should start with search string */ - static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match' - /* use this for global search */ - static MULTI_MATCH = 'Multi-Match' - - transform ( - contentString: string = null, - stringToHighlight: string = null, - option = 'Single-And-StartsWith-Match', - caseSensitive = false, - highlightStyleName = 'search-highlight' - ): SafeHtml { - if (stringToHighlight && contentString && option) { - let regex: any = '' - const caseFlag: string = !caseSensitive ? 'i' : '' - - switch (option) { - case 'Single-Match': { - regex = new RegExp(stringToHighlight, caseFlag) - break - } - case 'Single-And-StartsWith-Match': { - regex = new RegExp('^' + stringToHighlight, caseFlag) - break - } - case 'Multi-Match': { - regex = new RegExp(stringToHighlight, 'g' + caseFlag) - break - } - default: { - // default will be a global case-insensitive match - regex = new RegExp(stringToHighlight, 'gi') - } - } - - const replaced = contentString.replace( - regex, - (match) => `${match}` - ) - - return replaced - } else { - return contentString - } - } -} diff --git a/client/src/app/shared/angular/number-formatter.pipe.ts b/client/src/app/shared/angular/number-formatter.pipe.ts deleted file mode 100644 index 8a0756a36..000000000 --- a/client/src/app/shared/angular/number-formatter.pipe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' - -// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts - -@Pipe({ name: 'myNumberFormatter' }) -export class NumberFormatterPipe implements PipeTransform { - private dictionary: Array<{max: number, type: string}> = [ - { max: 1000, type: '' }, - { max: 1000000, type: 'K' }, - { max: 1000000000, type: 'M' } - ] - - transform (value: number) { - const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1] - const calc = Math.floor(value / (format.max / 1000)) - - return `${calc}${format.type}` - } -} diff --git a/client/src/app/shared/angular/object-length.pipe.ts b/client/src/app/shared/angular/object-length.pipe.ts deleted file mode 100644 index 84d182052..000000000 --- a/client/src/app/shared/angular/object-length.pipe.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' - -@Pipe({ name: 'myObjectLength' }) -export class ObjectLengthPipe implements PipeTransform { - transform (value: Object) { - return Object.keys(value).length - } -} diff --git a/client/src/app/shared/angular/peertube-template.directive.ts b/client/src/app/shared/angular/peertube-template.directive.ts deleted file mode 100644 index e04c25d9a..000000000 --- a/client/src/app/shared/angular/peertube-template.directive.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Directive, Input, TemplateRef } from '@angular/core' - -@Directive({ - selector: '[ptTemplate]' -}) -export class PeerTubeTemplateDirective { - @Input('ptTemplate') name: T - - constructor (public template: TemplateRef) { - // empty - } -} diff --git a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts deleted file mode 100644 index 45e023695..000000000 --- a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core' - -@Directive({ - selector: '[timestampRouteTransformer]' -}) -export class TimestampRouteTransformerDirective { - @Output() timestampClicked = new EventEmitter() - - @HostListener('click', ['$event']) - public onClick ($event: Event) { - const target = $event.target as HTMLLinkElement - - if (target.hasAttribute('href') !== true) return - - const ngxLink = document.createElement('a') - ngxLink.href = target.getAttribute('href') - - // we only care about reflective links - if (ngxLink.host !== window.location.host) return - - const ngxLinkParams = new URLSearchParams(ngxLink.search) - if (ngxLinkParams.has('start') !== true) return - - const separators = ['h', 'm', 's'] - const start = ngxLinkParams - .get('start') - .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator - .map(t => { - if (t.includes('h')) return parseInt(t, 10) * 3600 - if (t.includes('m')) return parseInt(t, 10) * 60 - return parseInt(t, 10) - }) - .reduce((acc, t) => acc + t) - - this.timestampClicked.emit(start) - - $event.preventDefault() - } -} diff --git a/client/src/app/shared/angular/video-duration-formatter.pipe.ts b/client/src/app/shared/angular/video-duration-formatter.pipe.ts deleted file mode 100644 index 4b6767415..000000000 --- a/client/src/app/shared/angular/video-duration-formatter.pipe.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Pipe({ - name: 'myVideoDurationFormatter' -}) -export class VideoDurationPipe implements PipeTransform { - - constructor (private i18n: I18n) { - - } - - transform (value: number): string { - const hours = Math.floor(value / 3600) - const minutes = Math.floor((value % 3600) / 60) - const seconds = value % 60 - - if (hours > 0) { - return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds }) - } - - if (minutes > 0) { - return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds }) - } - - return this.i18n('{{seconds}} sec', { seconds }) - } -} diff --git a/client/src/app/shared/auth/auth-interceptor.service.ts b/client/src/app/shared/auth/auth-interceptor.service.ts deleted file mode 100644 index bb236bf8c..000000000 --- a/client/src/app/shared/auth/auth-interceptor.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Observable, throwError as observableThrowError } from 'rxjs' -import { catchError, switchMap } from 'rxjs/operators' -import { Injectable, Injector } from '@angular/core' -import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http' -import { AuthService } from '../../core' - -@Injectable() -export class AuthInterceptor implements HttpInterceptor { - private authService: AuthService - - // https://github.com/angular/angular/issues/18224#issuecomment-316957213 - constructor (private injector: Injector) {} - - intercept (req: HttpRequest, next: HttpHandler): Observable> { - if (this.authService === undefined) { - this.authService = this.injector.get(AuthService) - } - - const authReq = this.cloneRequestWithAuth(req) - - // Pass on the cloned request instead of the original request - // Catch 401 errors (refresh token expired) - return next.handle(authReq) - .pipe( - catchError(err => { - if (err.status === 401 && err.error && err.error.code === 'invalid_token') { - return this.handleTokenExpired(req, next) - } - - return observableThrowError(err) - }) - ) - } - - private handleTokenExpired (req: HttpRequest, next: HttpHandler): Observable> { - return this.authService.refreshAccessToken() - .pipe( - switchMap(() => { - const authReq = this.cloneRequestWithAuth(req) - - return next.handle(authReq) - }) - ) - } - - private cloneRequestWithAuth (req: HttpRequest) { - const authHeaderValue = this.authService.getRequestHeaderValue() - - if (authHeaderValue === null) return req - - // Clone the request to add the new header - return req.clone({ headers: req.headers.set('Authorization', authHeaderValue) }) - } -} - -export const AUTH_INTERCEPTOR_PROVIDER = { - provide: HTTP_INTERCEPTORS, - useClass: AuthInterceptor, - multi: true -} diff --git a/client/src/app/shared/auth/index.ts b/client/src/app/shared/auth/index.ts deleted file mode 100644 index 84a07196f..000000000 --- a/client/src/app/shared/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './auth-interceptor.service' diff --git a/client/src/app/shared/blocklist/account-block.model.ts b/client/src/app/shared/blocklist/account-block.model.ts deleted file mode 100644 index e7b433d88..000000000 --- a/client/src/app/shared/blocklist/account-block.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AccountBlock as AccountBlockServer } from '../../../../../shared' -import { Account } from '../account/account.model' - -export class AccountBlock implements AccountBlockServer { - byAccount: Account - blockedAccount: Account - createdAt: Date | string - - constructor (block: AccountBlockServer) { - this.byAccount = new Account(block.byAccount) - this.blockedAccount = new Account(block.blockedAccount) - this.createdAt = block.createdAt - } -} diff --git a/client/src/app/shared/blocklist/account-blocklist.component.html b/client/src/app/shared/blocklist/account-blocklist.component.html deleted file mode 100644 index 486785f35..000000000 --- a/client/src/app/shared/blocklist/account-blocklist.component.html +++ /dev/null @@ -1,64 +0,0 @@ - - -
-
- - - Clear filters -
-
-
- - - - Account - Muted at - - - - - - - - -
- Avatar -
- {{ accountBlock.blockedAccount.displayName }} - {{ accountBlock.blockedAccount.nameWithHost }} -
-
-
- - - {{ accountBlock.createdAt | date: 'short' }} - - - - -
- - - - -
- No account found matching current filters. - No account found. -
- - -
-
diff --git a/client/src/app/shared/blocklist/account-blocklist.component.scss b/client/src/app/shared/blocklist/account-blocklist.component.scss deleted file mode 100644 index aa8363ff4..000000000 --- a/client/src/app/shared/blocklist/account-blocklist.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.caption { - justify-content: flex-end; - - input { - @include peertube-input-text(250px); - flex-grow: 1; - } -} - -.unblock-button { - @include peertube-button; - @include grey-button; -} \ No newline at end of file diff --git a/client/src/app/shared/blocklist/account-blocklist.component.ts b/client/src/app/shared/blocklist/account-blocklist.component.ts deleted file mode 100644 index dc5ac4044..000000000 --- a/client/src/app/shared/blocklist/account-blocklist.component.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { OnInit } from '@angular/core' -import { Notifier } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { RestPagination, RestTable } from '@app/shared/rest' -import { SortMeta } from 'primeng/api' -import { AccountBlock } from './account-block.model' -import { BlocklistService, BlocklistComponentType } from './blocklist.service' -import { Actor } from '@app/shared/actor/actor.model' - -export class GenericAccountBlocklistComponent extends RestTable implements OnInit { - // @ts-ignore: "Abstract methods can only appear within an abstract class" - abstract mode: BlocklistComponentType - - blockedAccounts: AccountBlock[] = [] - totalRecords = 0 - sort: SortMeta = { field: 'createdAt', order: -1 } - pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - - constructor ( - private notifier: Notifier, - private blocklistService: BlocklistService, - private i18n: I18n - ) { - super() - } - - // @ts-ignore: "Abstract methods can only appear within an abstract class" - abstract getIdentifier (): string - - ngOnInit () { - this.initialize() - } - - switchToDefaultAvatar ($event: Event) { - ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() - } - - unblockAccount (accountBlock: AccountBlock) { - const blockedAccount = accountBlock.blockedAccount - const operation = this.mode === BlocklistComponentType.Account - ? this.blocklistService.unblockAccountByUser(blockedAccount) - : this.blocklistService.unblockAccountByInstance(blockedAccount) - - operation.subscribe( - () => { - this.notifier.success( - this.mode === BlocklistComponentType.Account - ? this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: blockedAccount.nameWithHost }) - : this.i18n('Account {{nameWithHost}} unmuted by your instance.', { nameWithHost: blockedAccount.nameWithHost }) - ) - - this.loadData() - } - ) - } - - protected loadData () { - const operation = this.mode === BlocklistComponentType.Account - ? this.blocklistService.getUserAccountBlocklist({ - pagination: this.pagination, - sort: this.sort, - search: this.search - }) - : this.blocklistService.getInstanceAccountBlocklist({ - pagination: this.pagination, - sort: this.sort, - search: this.search - }) - - return operation.subscribe( - resultList => { - this.blockedAccounts = resultList.data - this.totalRecords = resultList.total - }, - - err => this.notifier.error(err.message) - ) - } -} diff --git a/client/src/app/shared/blocklist/blocklist.service.ts b/client/src/app/shared/blocklist/blocklist.service.ts deleted file mode 100644 index c70a8173a..000000000 --- a/client/src/app/shared/blocklist/blocklist.service.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { HttpClient, HttpParams } from '@angular/common/http' -import { RestExtractor, RestPagination, RestService } from '../rest' -import { SortMeta } from 'primeng/api' -import { catchError, map } from 'rxjs/operators' -import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '../../../../../shared' -import { Account } from '@app/shared/account/account.model' -import { AccountBlock } from '@app/shared/blocklist/account-block.model' - -export enum BlocklistComponentType { Account, Instance } - -@Injectable() -export class BlocklistService { - static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist' - static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService - ) { } - - /*********************** User -> Account blocklist ***********************/ - - getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { - const { pagination, sort, search } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - - return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - blockAccountByUser (account: Account) { - const body = { accountName: account.nameWithHost } - - return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - unblockAccountByUser (account: Account) { - const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost - - return this.authHttp.delete(path) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - /*********************** User -> Server blocklist ***********************/ - - getUserServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { - const { pagination, sort, search } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - - return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - blockServerByUser (host: string) { - const body = { host } - - return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - unblockServerByUser (host: string) { - const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host - - return this.authHttp.delete(path) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - /*********************** Instance -> Account blocklist ***********************/ - - getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { - const { pagination, sort, search } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - - return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - blockAccountByInstance (account: Account) { - const body = { accountName: account.nameWithHost } - - return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - unblockAccountByInstance (account: Account) { - const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost - - return this.authHttp.delete(path) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - /*********************** Instance -> Server blocklist ***********************/ - - getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { - const { pagination, sort, search } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - - return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - blockServerByInstance (host: string) { - const body = { host } - - return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - unblockServerByInstance (host: string) { - const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host - - return this.authHttp.delete(path) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - private formatAccountBlock (accountBlock: AccountBlockServer) { - return new AccountBlock(accountBlock) - } -} diff --git a/client/src/app/shared/blocklist/index.ts b/client/src/app/shared/blocklist/index.ts deleted file mode 100644 index 188057b19..000000000 --- a/client/src/app/shared/blocklist/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './blocklist.service' -export * from './account-block.model' -export * from './server-blocklist.component' -export * from './account-blocklist.component' diff --git a/client/src/app/shared/blocklist/server-blocklist.component.html b/client/src/app/shared/blocklist/server-blocklist.component.html deleted file mode 100644 index 977e0e141..000000000 --- a/client/src/app/shared/blocklist/server-blocklist.component.html +++ /dev/null @@ -1,59 +0,0 @@ - - -
-
- - - Clear filters -
- - - Mute domain - -
-
- - - - Instance - Muted at - - - - - - - - - {{ serverBlock.blockedServer.host }} - - - - {{ serverBlock.createdAt | date: 'short' }} - - - - - - - - - -
- No server found matching current filters. - No server found. -
- - -
-
- - diff --git a/client/src/app/shared/blocklist/server-blocklist.component.scss b/client/src/app/shared/blocklist/server-blocklist.component.scss deleted file mode 100644 index 9ddb76850..000000000 --- a/client/src/app/shared/blocklist/server-blocklist.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -a { - @include disable-default-a-behaviour; - display: inline-block; - - &, &:hover { - color: pvar(--mainForegroundColor); - } - - span { - font-size: 80%; - color: pvar(--inputPlaceholderColor); - } -} - -.caption { - justify-content: flex-end; - - input { - @include peertube-input-text(250px); - flex-grow: 1; - } -} - -.unblock-button { - @include peertube-button; - @include grey-button; -} - -.block-button { - @include create-button; -} diff --git a/client/src/app/shared/blocklist/server-blocklist.component.ts b/client/src/app/shared/blocklist/server-blocklist.component.ts deleted file mode 100644 index f2b36badc..000000000 --- a/client/src/app/shared/blocklist/server-blocklist.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { OnInit, ViewChild } from '@angular/core' -import { Notifier } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { RestPagination, RestTable } from '@app/shared/rest' -import { SortMeta } from 'primeng/api' -import { BlocklistService, BlocklistComponentType } from './blocklist.service' -import { ServerBlock } from '../../../../../shared/models/blocklist/server-block.model' -import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component' - -export class GenericServerBlocklistComponent extends RestTable implements OnInit { - @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent - - // @ts-ignore: "Abstract methods can only appear within an abstract class" - public abstract mode: BlocklistComponentType - - blockedServers: ServerBlock[] = [] - totalRecords = 0 - sort: SortMeta = { field: 'createdAt', order: -1 } - pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - - constructor ( - protected notifier: Notifier, - protected blocklistService: BlocklistService, - protected i18n: I18n - ) { - super() - } - - ngOnInit () { - this.initialize() - } - - // @ts-ignore: "Abstract methods can only appear within an abstract class" - public abstract getIdentifier (): string - - unblockServer (serverBlock: ServerBlock) { - const operation = (host: string) => this.mode === BlocklistComponentType.Account - ? this.blocklistService.unblockServerByUser(host) - : this.blocklistService.unblockServerByInstance(host) - const host = serverBlock.blockedServer.host - - operation(host).subscribe( - () => { - this.notifier.success( - this.mode === BlocklistComponentType.Account - ? this.i18n('Instance {{host}} unmuted.', { host }) - : this.i18n('Instance {{host}} unmuted by your instance.', { host }) - ) - - this.loadData() - } - ) - } - - addServersToBlock () { - this.batchDomainsModal.openModal() - } - - onDomainsToBlock (domains: string[]) { - const operation = (domain: string) => this.mode === BlocklistComponentType.Account - ? this.blocklistService.blockServerByUser(domain) - : this.blocklistService.blockServerByInstance(domain) - - domains.forEach(domain => { - operation(domain).subscribe( - () => { - this.notifier.success( - this.mode === BlocklistComponentType.Account - ? this.i18n('Instance {{domain}} muted.', { domain }) - : this.i18n('Instance {{domain}} muted by your instance.', { domain }) - ) - - this.loadData() - } - ) - }) - } - - protected loadData () { - const operation = this.mode === BlocklistComponentType.Account - ? this.blocklistService.getUserServerBlocklist({ - pagination: this.pagination, - sort: this.sort, - search: this.search - }) - : this.blocklistService.getInstanceServerBlocklist({ - pagination: this.pagination, - sort: this.sort, - search: this.search - }) - - return operation.subscribe( - resultList => { - this.blockedServers = resultList.data - this.totalRecords = resultList.total - }, - - err => this.notifier.error(err.message) - ) - } -} diff --git a/client/src/app/shared/bulk/bulk.service.ts b/client/src/app/shared/bulk/bulk.service.ts deleted file mode 100644 index b00db31ec..000000000 --- a/client/src/app/shared/bulk/bulk.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { HttpClient } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestService } from '../rest' -import { BulkRemoveCommentsOfBody } from '../../../../../shared' -import { catchError } from 'rxjs/operators' - -@Injectable() -export class BulkService { - static BASE_BULK_URL = environment.apiUrl + '/api/v1/bulk' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService - ) { } - - removeCommentsOf (body: BulkRemoveCommentsOfBody) { - const url = BulkService.BASE_BULK_URL + '/remove-comments-of' - - return this.authHttp.post(url, body) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } -} diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html deleted file mode 100644 index 12933d4ca..000000000 --- a/client/src/app/shared/buttons/action-dropdown.component.html +++ /dev/null @@ -1,55 +0,0 @@ - diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss deleted file mode 100644 index 724a04efc..000000000 --- a/client/src/app/shared/buttons/action-dropdown.component.scss +++ /dev/null @@ -1,72 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.dropdown-divider:last-child { - display: none; -} - -.action-button { - @include peertube-button; - - &.button-styled { - - &.grey { - @include grey-button; - } - - &.orange { - @include orange-button; - } - - &:hover, &:active, &:focus { - background-color: $grey-background-color; - } - } - - display: inline-block; - padding: 0 10px; - - &::after { - display: none; - } - - .more-icon { - width: 21px; - - ::ng-deep { - @include apply-svg-color(pvar(--actionButtonColor)); - } - } - - &.small { - font-size: 14px; - height: 20px; - line-height: 20px; - } -} - -.dropdown-toggle::after { - position: relative; - top: 1px; -} - -.dropdown-menu { - .dropdown-header { - padding: 0.2rem 1rem; - } - - .dropdown-item { - display: flex; - cursor: pointer; - color: #000 !important; - - &.with-icon { - @include dropdown-with-icon-item; - } - - a, span { - display: block; - width: 100%; - } - } -} diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts deleted file mode 100644 index 15f9556dc..000000000 --- a/client/src/app/shared/buttons/action-dropdown.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, Input } from '@angular/core' -import { GlobalIconName } from '@app/shared/images/global-icon.component' - -export type DropdownAction = { - label?: string - iconName?: GlobalIconName - description?: string - title?: string - handler?: (a: T) => any - linkBuilder?: (a: T) => (string | number)[] - isDisplayed?: (a: T) => boolean - isHeader?: boolean -} - -export type DropdownButtonSize = 'normal' | 'small' -export type DropdownTheme = 'orange' | 'grey' -export type DropdownDirection = 'horizontal' | 'vertical' - -@Component({ - selector: 'my-action-dropdown', - styleUrls: [ './action-dropdown.component.scss' ], - templateUrl: './action-dropdown.component.html' -}) - -export class ActionDropdownComponent { - @Input() actions: DropdownAction[] | DropdownAction[][] = [] - @Input() entry: T - - @Input() placement = 'bottom-left auto' - @Input() container: null | 'body' - - @Input() buttonSize: DropdownButtonSize = 'normal' - @Input() buttonDirection: DropdownDirection = 'horizontal' - @Input() buttonStyled = true - - @Input() label: string - @Input() theme: DropdownTheme = 'grey' - - getActions (): DropdownAction[][] { - if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction[][] - - return [ this.actions as DropdownAction[] ] - } - - areActionsDisplayed (actions: Array | DropdownAction[]>, entry: T): boolean { - return actions.some(a => { - if (Array.isArray(a)) return this.areActionsDisplayed(a, entry) - - return a.isDisplayed === undefined || a.isDisplayed(entry) - }) - } -} diff --git a/client/src/app/shared/buttons/button.component.html b/client/src/app/shared/buttons/button.component.html deleted file mode 100644 index d2b0eb81a..000000000 --- a/client/src/app/shared/buttons/button.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - {{ label }} - diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss deleted file mode 100644 index 3ccfefd7e..000000000 --- a/client/src/app/shared/buttons/button.component.scss +++ /dev/null @@ -1,46 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -my-small-loader ::ng-deep .root { - display: inline-block; - margin: 0 3px 0 0; - width: 20px; -} - -.action-button { - @include peertube-button-link; - @include button-with-icon(21px, 0, -2px); -} - -.orange-button { - @include peertube-button; - @include orange-button; -} - -.orange-button-link { - @include peertube-button-link; - @include orange-button; -} - -.grey-button { - @include peertube-button; - @include grey-button; -} - -.grey-button-link { - @include peertube-button-link; - @include grey-button; -} - -// In a table, try to minimize the space taken by this button -@media screen and (max-width: 1400px) { - :host-context(td) { - .action-button { - padding: 0 13px; - } - - .button-label { - display: none; - } - } -} diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts deleted file mode 100644 index cac5ad210..000000000 --- a/client/src/app/shared/buttons/button.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, Input } from '@angular/core' -import { GlobalIconName } from '@app/shared/images/global-icon.component' - -@Component({ - selector: 'my-button', - styleUrls: ['./button.component.scss'], - templateUrl: './button.component.html' -}) - -export class ButtonComponent { - @Input() label = '' - @Input() className = 'grey-button' - @Input() icon: GlobalIconName = undefined - @Input() title: string = undefined - @Input() loading = false - - getTitle () { - return this.title || this.label - } -} diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html deleted file mode 100644 index 398b6db1e..000000000 --- a/client/src/app/shared/buttons/delete-button.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - {{ label }} - Delete - diff --git a/client/src/app/shared/buttons/delete-button.component.ts b/client/src/app/shared/buttons/delete-button.component.ts deleted file mode 100644 index 39e31900f..000000000 --- a/client/src/app/shared/buttons/delete-button.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'my-delete-button', - styleUrls: [ './button.component.scss' ], - templateUrl: './delete-button.component.html' -}) - -export class DeleteButtonComponent implements OnInit { - @Input() label: string - - title: string - - constructor (private i18n: I18n) { } - - ngOnInit () { - this.title = this.label || this.i18n('Delete') - } -} diff --git a/client/src/app/shared/buttons/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html deleted file mode 100644 index b852bb38a..000000000 --- a/client/src/app/shared/buttons/edit-button.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - {{ label }} - Edit - diff --git a/client/src/app/shared/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts deleted file mode 100644 index 9cfe1a3bb..000000000 --- a/client/src/app/shared/buttons/edit-button.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component, Input } from '@angular/core' - -@Component({ - selector: 'my-edit-button', - styleUrls: [ './button.component.scss' ], - templateUrl: './edit-button.component.html' -}) - -export class EditButtonComponent { - @Input() label: string - @Input() routerLink: string[] | string = [] -} diff --git a/client/src/app/shared/channel/avatar.component.html b/client/src/app/shared/channel/avatar.component.html deleted file mode 100644 index 09871fca4..000000000 --- a/client/src/app/shared/channel/avatar.component.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/client/src/app/shared/channel/avatar.component.scss b/client/src/app/shared/channel/avatar.component.scss deleted file mode 100644 index 37709fce6..000000000 --- a/client/src/app/shared/channel/avatar.component.scss +++ /dev/null @@ -1,40 +0,0 @@ -@import '_mixins'; - -.wrapper { - $avatar-size: 35px; - - width: $avatar-size; - height: $avatar-size; - position: relative; - margin-right: 5px; - margin-bottom: 5px; - - &.avatar-sm { - width: 28px; - height: 28px; - margin-bottom: 3px; - } - - a { - @include disable-outline; - } - - a img { - height: 100%; - object-fit: cover; - position: absolute; - top:50%; - left:50%; - border-radius: 50%; - transform: translate(-50%,-50%) - } - - a:nth-of-type(2) img { - height: 60%; - width: 60%; - border: 2px solid pvar(--mainBackgroundColor); - transform: translateX(15%); - position: relative; - background-color: pvar(--mainBackgroundColor); - } -} diff --git a/client/src/app/shared/channel/avatar.component.ts b/client/src/app/shared/channel/avatar.component.ts deleted file mode 100644 index 31f39c200..000000000 --- a/client/src/app/shared/channel/avatar.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core' -import { Video } from '../video/video.model' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'avatar-channel', - templateUrl: './avatar.component.html', - styleUrls: [ './avatar.component.scss' ] -}) -export class AvatarComponent implements OnInit { - @Input() video: Video - @Input() size: 'md' | 'sm' = 'md' - - channelLinkTitle = '' - accountLinkTitle = '' - - constructor ( - private i18n: I18n - ) {} - - ngOnInit () { - this.channelLinkTitle = this.i18n( - '{{name}} (channel page)', - { name: this.video.channel.name, handle: this.video.byVideoChannel } - ) - this.accountLinkTitle = this.i18n( - '{{name}} (account page)', - { name: this.video.account.name, handle: this.video.byAccount } - ) - } -} diff --git a/client/src/app/shared/confirm/confirm.component.html b/client/src/app/shared/confirm/confirm.component.html deleted file mode 100644 index dbc8c23e3..000000000 --- a/client/src/app/shared/confirm/confirm.component.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - diff --git a/client/src/app/shared/confirm/confirm.component.scss b/client/src/app/shared/confirm/confirm.component.scss deleted file mode 100644 index ed226bc09..000000000 --- a/client/src/app/shared/confirm/confirm.component.scss +++ /dev/null @@ -1,21 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.modal-body { - font-size: 15px; -} - -.button { - padding: 0 13px; -} - -input[type=text] { - @include peertube-input-text(100%); - display: block; -} - -.form-group { - margin: 20px 0; -} - - diff --git a/client/src/app/shared/confirm/confirm.component.ts b/client/src/app/shared/confirm/confirm.component.ts deleted file mode 100644 index c6e40fe72..000000000 --- a/client/src/app/shared/confirm/confirm.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core' -import { ConfirmService } from '@app/core/confirm/confirm.service' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants' - -@Component({ - selector: 'my-confirm', - templateUrl: './confirm.component.html', - styleUrls: [ './confirm.component.scss' ] -}) -export class ConfirmComponent implements OnInit { - @ViewChild('confirmModal', { static: true }) confirmModal: ElementRef - - title = '' - message = '' - expectedInputValue = '' - inputLabel = '' - - inputValue = '' - confirmButtonText = '' - - private openedModal: NgbModalRef - - constructor ( - private modalService: NgbModal, - private confirmService: ConfirmService, - private i18n: I18n - ) { } - - ngOnInit () { - this.confirmService.showConfirm.subscribe( - ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => { - this.title = title - this.message = message - - this.inputLabel = inputLabel - this.expectedInputValue = expectedInputValue - - this.confirmButtonText = confirmButtonText || this.i18n('Confirm') - - this.showModal() - } - ) - } - - confirm () { - if (this.openedModal) this.openedModal.close() - } - - isConfirmationDisabled () { - // No input validation - if (!this.inputLabel || !this.expectedInputValue) return false - - return this.expectedInputValue !== this.inputValue - } - - showModal () { - this.inputValue = '' - - this.openedModal = this.modalService.open(this.confirmModal, { centered: true }) - - this.openedModal.result - .then(() => this.confirmService.confirmResponse.next(true)) - .catch((reason: string) => { - // If the reason was that the user used the back button, we don't care about the confirm dialog result - if (!reason || reason !== POP_STATE_MODAL_DISMISS) { - this.confirmService.confirmResponse.next(false) - } - }) - } -} diff --git a/client/src/app/shared/date/date-toggle.component.html b/client/src/app/shared/date/date-toggle.component.html deleted file mode 100644 index ebd4ce442..000000000 --- a/client/src/app/shared/date/date-toggle.component.html +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/client/src/app/shared/date/date-toggle.component.scss b/client/src/app/shared/date/date-toggle.component.scss deleted file mode 100644 index 86700d1d4..000000000 --- a/client/src/app/shared/date/date-toggle.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -.date-toggle { - &:hover { - cursor: default - } -} diff --git a/client/src/app/shared/date/date-toggle.component.ts b/client/src/app/shared/date/date-toggle.component.ts deleted file mode 100644 index fa48da8e8..000000000 --- a/client/src/app/shared/date/date-toggle.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Component, Input, OnInit, OnChanges } from '@angular/core' -import { DatePipe } from '@angular/common' -import { FromNowPipe } from '../angular/from-now.pipe' - -@Component({ - selector: 'my-date-toggle', - templateUrl: './date-toggle.component.html', - styleUrls: [ './date-toggle.component.scss' ], - providers: [ DatePipe, FromNowPipe ] -}) -export class DateToggleComponent implements OnInit, OnChanges { - @Input() date: Date - @Input() toggled = false - - dateRelative: string - dateAbsolute: string - - constructor ( - private datePipe: DatePipe, - private fromNowPipe: FromNowPipe - ) { } - - ngOnInit () { - this.updateDates() - } - - ngOnChanges () { - this.updateDates() - } - - toggle () { - this.toggled = !this.toggled - } - - getTitle () { - return this.toggled ? this.dateRelative : this.dateAbsolute - } - - getContent () { - return this.toggled ? this.dateAbsolute : this.dateRelative - } - - private updateDates () { - this.dateRelative = this.fromNowPipe.transform(this.date) - this.dateAbsolute = this.datePipe.transform(this.date, 'long') - } -} diff --git a/client/src/app/shared/forms/form-reactive.ts b/client/src/app/shared/forms/form-reactive.ts deleted file mode 100644 index 6aec2937d..000000000 --- a/client/src/app/shared/forms/form-reactive.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { FormGroup } from '@angular/forms' -import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' - -export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } -export type FormReactiveValidationMessages = { - [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages -} - -export abstract class FormReactive { - protected abstract formValidatorService: FormValidatorService - protected formChanged = false - - form: FormGroup - formErrors: any // To avoid casting in template because of string | FormReactiveErrors - validationMessages: FormReactiveValidationMessages - - buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { - const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues) - - this.form = form - this.formErrors = formErrors - this.validationMessages = validationMessages - - this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)) - } - - protected forceCheck () { - return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true) - } - - protected check () { - return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false) - } - - private onValueChanged ( - form: FormGroup, - formErrors: FormReactiveErrors, - validationMessages: FormReactiveValidationMessages, - forceCheck = false - ) { - for (const field of Object.keys(formErrors)) { - if (formErrors[field] && typeof formErrors[field] === 'object') { - this.onValueChanged( - form.controls[field] as FormGroup, - formErrors[field] as FormReactiveErrors, - validationMessages[field] as FormReactiveValidationMessages, - forceCheck - ) - continue - } - - // clear previous error message (if any) - formErrors[ field ] = '' - const control = form.get(field) - - if (control.dirty) this.formChanged = true - - // Don't care if dirty on force check - const isDirty = control.dirty || forceCheck === true - if (control && isDirty && control.enabled && !control.valid) { - const messages = validationMessages[ field ] - for (const key of Object.keys(control.errors)) { - formErrors[ field ] += messages[ key ] + ' ' - } - } - } - } - -} diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts deleted file mode 100644 index fdb19e06a..000000000 --- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Validators } from '@angular/forms' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { BuildFormValidator } from '@app/shared' -import { Injectable } from '@angular/core' - -@Injectable() -export class CustomConfigValidatorsService { - readonly INSTANCE_NAME: BuildFormValidator - readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator - readonly SERVICES_TWITTER_USERNAME: BuildFormValidator - readonly CACHE_PREVIEWS_SIZE: BuildFormValidator - readonly CACHE_CAPTIONS_SIZE: BuildFormValidator - readonly SIGNUP_LIMIT: BuildFormValidator - readonly ADMIN_EMAIL: BuildFormValidator - readonly TRANSCODING_THREADS: BuildFormValidator - readonly INDEX_URL: BuildFormValidator - readonly SEARCH_INDEX_URL: BuildFormValidator - - constructor (private i18n: I18n) { - this.INSTANCE_NAME = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Instance name is required.') - } - } - - this.INSTANCE_SHORT_DESCRIPTION = { - VALIDATORS: [ Validators.max(250) ], - MESSAGES: { - 'max': this.i18n('Short description should not be longer than 250 characters.') - } - } - - this.SERVICES_TWITTER_USERNAME = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Twitter username is required.') - } - } - - this.CACHE_PREVIEWS_SIZE = { - VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], - MESSAGES: { - 'required': this.i18n('Previews cache size is required.'), - 'min': this.i18n('Previews cache size must be greater than 1.'), - 'pattern': this.i18n('Previews cache size must be a number.') - } - } - - this.CACHE_CAPTIONS_SIZE = { - VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], - MESSAGES: { - 'required': this.i18n('Captions cache size is required.'), - 'min': this.i18n('Captions cache size must be greater than 1.'), - 'pattern': this.i18n('Captions cache size must be a number.') - } - } - - this.SIGNUP_LIMIT = { - VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ], - MESSAGES: { - 'required': this.i18n('Signup limit is required.'), - 'min': this.i18n('Signup limit must be greater than 1.'), - 'pattern': this.i18n('Signup limit must be a number.') - } - } - - this.ADMIN_EMAIL = { - VALIDATORS: [ Validators.required, Validators.email ], - MESSAGES: { - 'required': this.i18n('Admin email is required.'), - 'email': this.i18n('Admin email must be valid.') - } - } - - this.TRANSCODING_THREADS = { - VALIDATORS: [ Validators.required, Validators.min(0) ], - MESSAGES: { - 'required': this.i18n('Transcoding threads is required.'), - 'min': this.i18n('Transcoding threads must be greater or equal to 0.') - } - } - - this.INDEX_URL = { - VALIDATORS: [ Validators.pattern(/^https:\/\//) ], - MESSAGES: { - 'pattern': this.i18n('Index URL should be a URL') - } - } - - this.SEARCH_INDEX_URL = { - VALIDATORS: [ Validators.pattern(/^https?:\/\//) ], - MESSAGES: { - 'pattern': this.i18n('Search index URL should be a URL') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/form-validator.service.ts b/client/src/app/shared/forms/form-validators/form-validator.service.ts deleted file mode 100644 index 249fdf119..000000000 --- a/client/src/app/shared/forms/form-validators/form-validator.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' -import { Injectable } from '@angular/core' -import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/forms/form-reactive' - -export type BuildFormValidator = { - VALIDATORS: ValidatorFn[], - MESSAGES: { [ name: string ]: string } -} -export type BuildFormArgument = { - [ id: string ]: BuildFormValidator | BuildFormArgument -} -export type BuildFormDefaultValues = { - [ name: string ]: string | string[] | BuildFormDefaultValues -} - -@Injectable() -export class FormValidatorService { - - constructor ( - private formBuilder: FormBuilder - ) {} - - buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { - const formErrors: FormReactiveErrors = {} - const validationMessages: FormReactiveValidationMessages = {} - const group: { [key: string]: any } = {} - - for (const name of Object.keys(obj)) { - formErrors[name] = '' - - const field = obj[name] - if (this.isRecursiveField(field)) { - const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues) - group[name] = result.form - formErrors[name] = result.formErrors - validationMessages[name] = result.validationMessages - - continue - } - - if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } - - const defaultValue = defaultValues[name] || '' - - if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] - else group[name] = [ defaultValue ] - } - - const form = this.formBuilder.group(group) - return { form, formErrors, validationMessages } - } - - updateForm ( - form: FormGroup, - formErrors: FormReactiveErrors, - validationMessages: FormReactiveValidationMessages, - obj: BuildFormArgument, - defaultValues: BuildFormDefaultValues = {} - ) { - for (const name of Object.keys(obj)) { - formErrors[name] = '' - - const field = obj[name] - if (this.isRecursiveField(field)) { - this.updateForm( - form[name], - formErrors[name] as FormReactiveErrors, - validationMessages[name] as FormReactiveValidationMessages, - obj[name] as BuildFormArgument, - defaultValues[name] as BuildFormDefaultValues - ) - continue - } - - if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } - - const defaultValue = defaultValues[name] || '' - - if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[])) - else form.addControl(name, new FormControl(defaultValue)) - } - } - - private isRecursiveField (field: any) { - return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS - } -} diff --git a/client/src/app/shared/forms/form-validators/host.ts b/client/src/app/shared/forms/form-validators/host.ts deleted file mode 100644 index c18a35f9b..000000000 --- a/client/src/app/shared/forms/form-validators/host.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function validateHost (value: string) { - // Thanks to http://stackoverflow.com/a/106223 - const HOST_REGEXP = new RegExp( - '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' - ) - - return HOST_REGEXP.test(value) -} diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts deleted file mode 100644 index 4a01b1622..000000000 --- a/client/src/app/shared/forms/form-validators/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * from './custom-config-validators.service' -export * from './form-validator.service' -export * from './host' -export * from './instance-validators.service' -export * from './login-validators.service' -export * from './reset-password-validators.service' -export * from './user-validators.service' -export * from './video-abuse-validators.service' -export * from './video-block-validators.service' -export * from './video-channel-validators.service' -export * from './video-comment-validators.service' -export * from './video-validators.service' -export * from './video-playlist-validators.service' -export * from './video-captions-validators.service' -export * from './video-change-ownership-validators.service' -export * from './video-accept-ownership-validators.service' diff --git a/client/src/app/shared/forms/form-validators/instance-validators.service.ts b/client/src/app/shared/forms/form-validators/instance-validators.service.ts deleted file mode 100644 index cc5f3c5a1..000000000 --- a/client/src/app/shared/forms/form-validators/instance-validators.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { BuildFormValidator } from '@app/shared' -import { Injectable } from '@angular/core' - -@Injectable() -export class InstanceValidatorsService { - readonly FROM_EMAIL: BuildFormValidator - readonly FROM_NAME: BuildFormValidator - readonly SUBJECT: BuildFormValidator - readonly BODY: BuildFormValidator - - constructor (private i18n: I18n) { - - this.FROM_EMAIL = { - VALIDATORS: [ Validators.required, Validators.email ], - MESSAGES: { - 'required': this.i18n('Email is required.'), - 'email': this.i18n('Email must be valid.') - } - } - - this.FROM_NAME = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(120) - ], - MESSAGES: { - 'required': this.i18n('Your name is required.'), - 'minlength': this.i18n('Your name must be at least 1 character long.'), - 'maxlength': this.i18n('Your name cannot be more than 120 characters long.') - } - } - - this.SUBJECT = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(120) - ], - MESSAGES: { - 'required': this.i18n('A subject is required.'), - 'minlength': this.i18n('The subject must be at least 1 character long.'), - 'maxlength': this.i18n('The subject cannot be more than 120 characters long.') - } - } - - this.BODY = { - VALIDATORS: [ - Validators.required, - Validators.minLength(3), - Validators.maxLength(5000) - ], - MESSAGES: { - 'required': this.i18n('A message is required.'), - 'minlength': this.i18n('The message must be at least 3 characters long.'), - 'maxlength': this.i18n('The message cannot be more than 5000 characters long.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/login-validators.service.ts b/client/src/app/shared/forms/form-validators/login-validators.service.ts deleted file mode 100644 index 9d68f830c..000000000 --- a/client/src/app/shared/forms/form-validators/login-validators.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class LoginValidatorsService { - readonly LOGIN_USERNAME: BuildFormValidator - readonly LOGIN_PASSWORD: BuildFormValidator - - constructor (private i18n: I18n) { - this.LOGIN_USERNAME = { - VALIDATORS: [ - Validators.required - ], - MESSAGES: { - 'required': this.i18n('Username is required.') - } - } - - this.LOGIN_PASSWORD = { - VALIDATORS: [ - Validators.required - ], - MESSAGES: { - 'required': this.i18n('Password is required.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/reset-password-validators.service.ts b/client/src/app/shared/forms/form-validators/reset-password-validators.service.ts deleted file mode 100644 index df206254d..000000000 --- a/client/src/app/shared/forms/form-validators/reset-password-validators.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class ResetPasswordValidatorsService { - readonly RESET_PASSWORD_CONFIRM: BuildFormValidator - - constructor (private i18n: I18n) { - this.RESET_PASSWORD_CONFIRM = { - VALIDATORS: [ - Validators.required - ], - MESSAGES: { - 'required': this.i18n('Confirmation of the password is required.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts deleted file mode 100644 index 13b9228d4..000000000 --- a/client/src/app/shared/forms/form-validators/user-validators.service.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { BuildFormValidator } from '@app/shared' -import { Injectable } from '@angular/core' - -@Injectable() -export class UserValidatorsService { - readonly USER_USERNAME: BuildFormValidator - readonly USER_EMAIL: BuildFormValidator - readonly USER_PASSWORD: BuildFormValidator - readonly USER_PASSWORD_OPTIONAL: BuildFormValidator - readonly USER_CONFIRM_PASSWORD: BuildFormValidator - readonly USER_VIDEO_QUOTA: BuildFormValidator - readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator - readonly USER_ROLE: BuildFormValidator - readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator - readonly USER_DESCRIPTION: BuildFormValidator - readonly USER_TERMS: BuildFormValidator - - readonly USER_BAN_REASON: BuildFormValidator - - constructor (private i18n: I18n) { - - this.USER_USERNAME = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(50), - Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) - ], - MESSAGES: { - 'required': this.i18n('Username is required.'), - 'minlength': this.i18n('Username must be at least 1 character long.'), - 'maxlength': this.i18n('Username cannot be more than 50 characters long.'), - 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.') - } - } - - this.USER_EMAIL = { - VALIDATORS: [ Validators.required, Validators.email ], - MESSAGES: { - 'required': this.i18n('Email is required.'), - 'email': this.i18n('Email must be valid.') - } - } - - this.USER_PASSWORD = { - VALIDATORS: [ - Validators.required, - Validators.minLength(6), - Validators.maxLength(255) - ], - MESSAGES: { - 'required': this.i18n('Password is required.'), - 'minlength': this.i18n('Password must be at least 6 characters long.'), - 'maxlength': this.i18n('Password cannot be more than 255 characters long.') - } - } - - this.USER_PASSWORD_OPTIONAL = { - VALIDATORS: [ - Validators.minLength(6), - Validators.maxLength(255) - ], - MESSAGES: { - 'minlength': this.i18n('Password must be at least 6 characters long.'), - 'maxlength': this.i18n('Password cannot be more than 255 characters long.') - } - } - - this.USER_CONFIRM_PASSWORD = { - VALIDATORS: [], - MESSAGES: { - 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.') - } - } - - this.USER_VIDEO_QUOTA = { - VALIDATORS: [ Validators.required, Validators.min(-1) ], - MESSAGES: { - 'required': this.i18n('Video quota is required.'), - 'min': this.i18n('Quota must be greater than -1.') - } - } - this.USER_VIDEO_QUOTA_DAILY = { - VALIDATORS: [ Validators.required, Validators.min(-1) ], - MESSAGES: { - 'required': this.i18n('Daily upload limit is required.'), - 'min': this.i18n('Daily upload limit must be greater than -1.') - } - } - - this.USER_ROLE = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('User role is required.') - } - } - - this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true) - - this.USER_DESCRIPTION = { - VALIDATORS: [ - Validators.minLength(3), - Validators.maxLength(1000) - ], - MESSAGES: { - 'minlength': this.i18n('Description must be at least 3 characters long.'), - 'maxlength': this.i18n('Description cannot be more than 1000 characters long.') - } - } - - this.USER_TERMS = { - VALIDATORS: [ - Validators.requiredTrue - ], - MESSAGES: { - 'required': this.i18n('You must agree with the instance terms in order to register on it.') - } - } - - this.USER_BAN_REASON = { - VALIDATORS: [ - Validators.minLength(3), - Validators.maxLength(250) - ], - MESSAGES: { - 'minlength': this.i18n('Ban reason must be at least 3 characters long.'), - 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.') - } - } - } - - private getDisplayName (required: boolean) { - const control = { - VALIDATORS: [ - Validators.minLength(1), - Validators.maxLength(120) - ], - MESSAGES: { - 'required': this.i18n('Display name is required.'), - 'minlength': this.i18n('Display name must be at least 1 character long.'), - 'maxlength': this.i18n('Display name cannot be more than 50 characters long.') - } - } - - if (required) control.VALIDATORS.push(Validators.required) - - return control - } -} diff --git a/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts deleted file mode 100644 index fcc966b84..000000000 --- a/client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoAbuseValidatorsService { - readonly VIDEO_ABUSE_REASON: BuildFormValidator - readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator - - constructor (private i18n: I18n) { - this.VIDEO_ABUSE_REASON = { - VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], - MESSAGES: { - 'required': this.i18n('Report reason is required.'), - 'minlength': this.i18n('Report reason must be at least 2 characters long.'), - 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.') - } - } - - this.VIDEO_ABUSE_MODERATION_COMMENT = { - VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], - MESSAGES: { - 'required': this.i18n('Moderation comment is required.'), - 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'), - 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts deleted file mode 100644 index 48c7054a4..000000000 --- a/client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoAcceptOwnershipValidatorsService { - readonly CHANNEL: BuildFormValidator - - constructor (private i18n: I18n) { - this.CHANNEL = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('The channel is required.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-block-validators.service.ts b/client/src/app/shared/forms/form-validators/video-block-validators.service.ts deleted file mode 100644 index dc8257761..000000000 --- a/client/src/app/shared/forms/form-validators/video-block-validators.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoBlockValidatorsService { - readonly VIDEO_BLOCK_REASON: BuildFormValidator - - constructor (private i18n: I18n) { - this.VIDEO_BLOCK_REASON = { - VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ], - MESSAGES: { - 'minlength': this.i18n('Block reason must be at least 2 characters long.'), - 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts b/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts deleted file mode 100644 index d1b4667bb..000000000 --- a/client/src/app/shared/forms/form-validators/video-captions-validators.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoCaptionsValidatorsService { - readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator - readonly VIDEO_CAPTION_FILE: BuildFormValidator - - constructor (private i18n: I18n) { - - this.VIDEO_CAPTION_LANGUAGE = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Video caption language is required.') - } - } - - this.VIDEO_CAPTION_FILE = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Video caption file is required.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts deleted file mode 100644 index c6fbb7538..000000000 --- a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoChangeOwnershipValidatorsService { - readonly USERNAME: BuildFormValidator - - constructor (private i18n: I18n) { - this.USERNAME = { - VALIDATORS: [ Validators.required, this.localAccountValidator ], - MESSAGES: { - 'required': this.i18n('The username is required.'), - 'localAccountOnly': this.i18n('You can only transfer ownership to a local account') - } - } - } - - localAccountValidator (control: AbstractControl): ValidationErrors { - if (control.value.includes('@')) { - return { 'localAccountOnly': true } - } - - return null - } -} diff --git a/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts b/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts deleted file mode 100644 index 1c519c10a..000000000 --- a/client/src/app/shared/forms/form-validators/video-channel-validators.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoChannelValidatorsService { - readonly VIDEO_CHANNEL_NAME: BuildFormValidator - readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator - readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator - readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator - - constructor (private i18n: I18n) { - this.VIDEO_CHANNEL_NAME = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(50), - Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) - ], - MESSAGES: { - 'required': this.i18n('Name is required.'), - 'minlength': this.i18n('Name must be at least 1 character long.'), - 'maxlength': this.i18n('Name cannot be more than 50 characters long.'), - 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.') - } - } - - this.VIDEO_CHANNEL_DISPLAY_NAME = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(50) - ], - MESSAGES: { - 'required': i18n('Display name is required.'), - 'minlength': i18n('Display name must be at least 1 character long.'), - 'maxlength': i18n('Display name cannot be more than 50 characters long.') - } - } - - this.VIDEO_CHANNEL_DESCRIPTION = { - VALIDATORS: [ - Validators.minLength(3), - Validators.maxLength(1000) - ], - MESSAGES: { - 'minlength': i18n('Description must be at least 3 characters long.'), - 'maxlength': i18n('Description cannot be more than 1000 characters long.') - } - } - - this.VIDEO_CHANNEL_SUPPORT = { - VALIDATORS: [ - Validators.minLength(3), - Validators.maxLength(1000) - ], - MESSAGES: { - 'minlength': i18n('Support text must be at least 3 characters long.'), - 'maxlength': i18n('Support text cannot be more than 1000 characters long.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-comment-validators.service.ts b/client/src/app/shared/forms/form-validators/video-comment-validators.service.ts deleted file mode 100644 index 45c7081ef..000000000 --- a/client/src/app/shared/forms/form-validators/video-comment-validators.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoCommentValidatorsService { - readonly VIDEO_COMMENT_TEXT: BuildFormValidator - - constructor (private i18n: I18n) { - this.VIDEO_COMMENT_TEXT = { - VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ], - MESSAGES: { - 'required': this.i18n('Comment is required.'), - 'minlength': this.i18n('Comment must be at least 2 characters long.'), - 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.') - } - } - } -} diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts deleted file mode 100644 index a2c9a5368..000000000 --- a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { AbstractControl, FormControl, Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' -import { VideoPlaylistPrivacy } from '@shared/models' - -@Injectable() -export class VideoPlaylistValidatorsService { - readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator - readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator - readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator - readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator - - constructor (private i18n: I18n) { - this.VIDEO_PLAYLIST_DISPLAY_NAME = { - VALIDATORS: [ - Validators.required, - Validators.minLength(1), - Validators.maxLength(120) - ], - MESSAGES: { - 'required': this.i18n('Display name is required.'), - 'minlength': this.i18n('Display name must be at least 1 character long.'), - 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') - } - } - - this.VIDEO_PLAYLIST_PRIVACY = { - VALIDATORS: [ - Validators.required - ], - MESSAGES: { - 'required': this.i18n('Privacy is required.') - } - } - - this.VIDEO_PLAYLIST_DESCRIPTION = { - VALIDATORS: [ - Validators.minLength(3), - Validators.maxLength(1000) - ], - MESSAGES: { - 'minlength': i18n('Description must be at least 3 characters long.'), - 'maxlength': i18n('Description cannot be more than 1000 characters long.') - } - } - - this.VIDEO_PLAYLIST_CHANNEL_ID = { - VALIDATORS: [ ], - MESSAGES: { - 'required': this.i18n('The channel is required when the playlist is public.') - } - } - } - - setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) { - if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) { - channelControl.setValidators([ Validators.required ]) - } else { - channelControl.setValidators(null) - } - - channelControl.markAsDirty() - channelControl.updateValueAndValidity() - } -} diff --git a/client/src/app/shared/forms/form-validators/video-validators.service.ts b/client/src/app/shared/forms/form-validators/video-validators.service.ts deleted file mode 100644 index e3f7a0969..000000000 --- a/client/src/app/shared/forms/form-validators/video-validators.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Validators } from '@angular/forms' -import { Injectable } from '@angular/core' -import { BuildFormValidator } from '@app/shared' - -@Injectable() -export class VideoValidatorsService { - readonly VIDEO_NAME: BuildFormValidator - readonly VIDEO_PRIVACY: BuildFormValidator - readonly VIDEO_CATEGORY: BuildFormValidator - readonly VIDEO_LICENCE: BuildFormValidator - readonly VIDEO_LANGUAGE: BuildFormValidator - readonly VIDEO_IMAGE: BuildFormValidator - readonly VIDEO_CHANNEL: BuildFormValidator - readonly VIDEO_DESCRIPTION: BuildFormValidator - readonly VIDEO_TAGS: BuildFormValidator - readonly VIDEO_SUPPORT: BuildFormValidator - readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator - readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator - - constructor (private i18n: I18n) { - - this.VIDEO_NAME = { - VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ], - MESSAGES: { - 'required': this.i18n('Video name is required.'), - 'minlength': this.i18n('Video name must be at least 3 characters long.'), - 'maxlength': this.i18n('Video name cannot be more than 120 characters long.') - } - } - - this.VIDEO_PRIVACY = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Video privacy is required.') - } - } - - this.VIDEO_CATEGORY = { - VALIDATORS: [ ], - MESSAGES: {} - } - - this.VIDEO_LICENCE = { - VALIDATORS: [ ], - MESSAGES: {} - } - - this.VIDEO_LANGUAGE = { - VALIDATORS: [ ], - MESSAGES: {} - } - - this.VIDEO_IMAGE = { - VALIDATORS: [ ], - MESSAGES: {} - } - - this.VIDEO_CHANNEL = { - VALIDATORS: [ Validators.required ], - MESSAGES: { - 'required': this.i18n('Video channel is required.') - } - } - - this.VIDEO_DESCRIPTION = { - VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ], - MESSAGES: { - 'minlength': this.i18n('Video description must be at least 3 characters long.'), - 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.') - } - } - - this.VIDEO_TAGS = { - VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], - MESSAGES: { - 'minlength': this.i18n('A tag should be more than 2 characters long.'), - 'maxlength': this.i18n('A tag should be less than 30 characters long.') - } - } - - this.VIDEO_SUPPORT = { - VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], - MESSAGES: { - 'minlength': this.i18n('Video support must be at least 3 characters long.'), - 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.') - } - } - - this.VIDEO_SCHEDULE_PUBLICATION_AT = { - VALIDATORS: [ ], - MESSAGES: { - 'required': this.i18n('A date is required to schedule video update.') - } - } - - this.VIDEO_ORIGINALLY_PUBLISHED_AT = { - VALIDATORS: [ ], - MESSAGES: {} - } - } -} diff --git a/client/src/app/shared/forms/index.ts b/client/src/app/shared/forms/index.ts deleted file mode 100644 index 8febbfee9..000000000 --- a/client/src/app/shared/forms/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './form-validators' -export * from './form-reactive' -export * from './reactive-file.component' -export * from './textarea-autoresize.directive' diff --git a/client/src/app/shared/forms/input-readonly-copy.component.html b/client/src/app/shared/forms/input-readonly-copy.component.html deleted file mode 100644 index 9566e9741..000000000 --- a/client/src/app/shared/forms/input-readonly-copy.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- - -
- -
-
diff --git a/client/src/app/shared/forms/input-readonly-copy.component.scss b/client/src/app/shared/forms/input-readonly-copy.component.scss deleted file mode 100644 index 8dc4f113c..000000000 --- a/client/src/app/shared/forms/input-readonly-copy.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -input.readonly { - font-size: 15px; -} diff --git a/client/src/app/shared/forms/input-readonly-copy.component.ts b/client/src/app/shared/forms/input-readonly-copy.component.ts deleted file mode 100644 index 7528fb7a1..000000000 --- a/client/src/app/shared/forms/input-readonly-copy.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, Input } from '@angular/core' -import { Notifier } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'my-input-readonly-copy', - templateUrl: './input-readonly-copy.component.html', - styleUrls: [ './input-readonly-copy.component.scss' ] -}) -export class InputReadonlyCopyComponent { - @Input() value = '' - - constructor ( - private notifier: Notifier, - private i18n: I18n - ) { } - - activateCopiedMessage () { - this.notifier.success(this.i18n('Copied')) - } -} diff --git a/client/src/app/shared/forms/markdown-textarea.component.html b/client/src/app/shared/forms/markdown-textarea.component.html deleted file mode 100644 index a519f3e0a..000000000 --- a/client/src/app/shared/forms/markdown-textarea.component.html +++ /dev/null @@ -1,36 +0,0 @@ -
- - - - -
-
diff --git a/client/src/app/shared/forms/markdown-textarea.component.scss b/client/src/app/shared/forms/markdown-textarea.component.scss deleted file mode 100644 index f2c76f7a1..000000000 --- a/client/src/app/shared/forms/markdown-textarea.component.scss +++ /dev/null @@ -1,251 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -$nav-preview-tab-height: 30px; -$base-padding: 15px; -$input-border-color: #C6C6C6; -$input-border-radius: 3px; - -@mixin in-small-view { - .root { - display: flex; - flex-direction: column; - - textarea { - @include peertube-textarea(100%, 150px); - - background-color: pvar(--markdownTextareaBackgroundColor); - - font-family: monospace; - font-size: 13px; - border-bottom: none; - border-bottom-left-radius: unset; - border-bottom-right-radius: unset; - } - - .nav-preview { - display: block; - text-align: right; - padding-top: 10px; - padding-bottom: 10px; - padding-left: 10px; - padding-right: 10px; - border-top: 1px dashed $input-border-color; - border-left: 1px solid $input-border-color; - border-right: 1px solid $input-border-color; - border-bottom: 1px solid $input-border-color; - border-bottom-right-radius: $input-border-radius; - - border-bottom-left-radius: $input-border-radius; - ::ng-deep { - .nav-link { - display: none !important; - } - - .grey-button { - padding: 0 12px 0 12px; - } - } - } - - ::ng-deep { - .tab-content { - display: none; - } - } - } -} - -@mixin nav-preview-medium { - display: flex; - flex-grow: 1; - border-bottom-left-radius: unset; - border-bottom-right-radius: unset; - border-bottom: 2px solid pvar(--mainColor); - - :first-child { - margin-left: auto; - } - - ::ng-deep { - .nav-link { - display: flex !important; - align-items: center; - height: $nav-preview-tab-height !important; - padding: 0 15px !important; - font-size: 85% !important; - opacity: .7; - } - - .grey-button { - margin-left: 5px; - } - } -} - -@mixin content-preview-base { - display: block; - min-height: 75px; - padding: $base-padding; - overflow-y: auto; - font-size: 15px; - word-wrap: break-word; -} - -@mixin maximized-base { - flex-direction: row; - z-index: #{z(header) - 1}; - position: fixed; - top: $header-height; - left: $menu-width; - max-height: none !important; - max-width: none !important; - width: calc(100% - #{$menu-width}); - height: calc(100vh - #{$header-height}) !important; - - $nav-preview-vertical-padding: 40px; - - .nav-preview { - @include nav-preview-medium(); - padding-top: #{$nav-preview-vertical-padding / 2}; - padding-bottom: #{$nav-preview-vertical-padding / 2}; - padding-left: 0px; - padding-right: 0px; - position: absolute; - background-color: pvar(--mainBackgroundColor); - width: 100% !important; - border-top: none; - border-left: none; - border-right: none; - - :last-child { - margin-right: $not-expanded-horizontal-margins; - } - } - - ::ng-deep .tab-content { - @include content-preview-base(); - background-color: pvar(--mainBackgroundColor); - scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor); - } - - textarea, - ::ng-deep .tab-content { - max-height: none !important; - max-width: none !important; - margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important; - height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important; - width: 50% !important; - border: none !important; - border-radius: unset !important; - } - - :host-context(.expanded) { - .root.maximized { - left: 0; - width: 100%; - } - } -} - -@mixin maximized-in-small-view { - .root.maximized { - @include maximized-base(); - - textarea { - display: none; - } - - ::ng-deep .tab-content { - width: 100% !important; - } - } -} - -@mixin maximized-tabs-in-mobile-view { - // Ellipsis on tabs for mobile view - .root.maximized { - .nav-preview { - ::ng-deep .nav-link { - @include ellipsis(); - - display: block !important; - max-width: 45% !important; - padding: 5px 0 !important; - margin-right: 10px !important; - text-align: center; - - &:not(.active) { - max-width: 15% !important; - } - - &.active { - padding: 5px 15px !important; - } - } - } - } -} - -@mixin in-medium-view { - .root { - .nav-preview { - @include nav-preview-medium(); - } - - ::ng-deep .tab-content { - @include content-preview-base(); - max-height: 210px; - border-bottom: 1px solid $input-border-color; - border-left: 1px solid $input-border-color; - border-right: 1px solid $input-border-color; - border-bottom-left-radius: $input-border-radius; - border-bottom-right-radius: $input-border-radius; - } - } -} - -@mixin maximized-in-medium-view { - .root.maximized { - @include maximized-base(); - - textarea { - display: block; - padding: $base-padding; - border-right: 1px dashed $input-border-color !important; - resize: none; - scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor); - - &:focus { - box-shadow: none; - } - } - } -} - -@include in-small-view(); -@include maximized-in-small-view(); - -@media only screen and (max-width: $mobile-view) { - @include maximized-tabs-in-mobile-view(); -} - -@media only screen and (max-width: #{$mobile-view + $menu-width}) { - :host-context(.main-col:not(.expanded)) { - @include maximized-tabs-in-mobile-view(); - } -} - -@media only screen and (min-width: $small-view) { - :host-context(.expanded) { - @include in-medium-view(); - } - - @include maximized-in-medium-view(); -} - -@media only screen and (min-width: #{$small-view + $menu-width}) { - :host-context(.main-col:not(.expanded)) { - @include in-medium-view(); - } -} diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts deleted file mode 100644 index dde7b4d98..000000000 --- a/client/src/app/shared/forms/markdown-textarea.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { debounceTime, distinctUntilChanged } from 'rxjs/operators' -import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { Subject } from 'rxjs' -import truncate from 'lodash-es/truncate' -import { ScreenService } from '@app/shared/misc/screen.service' -import { MarkdownService } from '@app/shared/renderer' - -@Component({ - selector: 'my-markdown-textarea', - templateUrl: './markdown-textarea.component.html', - styleUrls: [ './markdown-textarea.component.scss' ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkdownTextareaComponent), - multi: true - } - ] -}) - -export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { - @Input() content = '' - @Input() classes: string[] | { [klass: string]: any[] | any } = [] - @Input() textareaMaxWidth = '100%' - @Input() textareaHeight = '150px' - @Input() truncate: number - @Input() markdownType: 'text' | 'enhanced' = 'text' - @Input() markdownVideo = false - @Input() name = 'description' - - @ViewChild('textarea') textareaElement: ElementRef - - truncatedPreviewHTML = '' - previewHTML = '' - isMaximized = false - - private contentChanged = new Subject() - - constructor ( - private screenService: ScreenService, - private markdownService: MarkdownService -) {} - - ngOnInit () { - this.contentChanged - .pipe( - debounceTime(150), - distinctUntilChanged() - ) - .subscribe(() => this.updatePreviews()) - - this.contentChanged.next(this.content) - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (description: string) { - this.content = description - - this.contentChanged.next(this.content) - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } - - onModelChange () { - this.propagateChange(this.content) - - this.contentChanged.next(this.content) - } - - onMaximizeClick () { - this.isMaximized = !this.isMaximized - - // Make sure textarea have the focus - this.textareaElement.nativeElement.focus() - - // Make sure the window has no scrollbars - if (!this.isMaximized) { - this.unlockBodyScroll() - } else { - this.lockBodyScroll() - } - } - - private lockBodyScroll () { - document.getElementById('content').classList.add('lock-scroll') - } - - private unlockBodyScroll () { - document.getElementById('content').classList.remove('lock-scroll') - } - - private async updatePreviews () { - if (this.content === null || this.content === undefined) return - - this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate })) - this.previewHTML = await this.markdownRender(this.content) - } - - private async markdownRender (text: string) { - const html = this.markdownType === 'text' ? - await this.markdownService.textMarkdownToHTML(text) : - await this.markdownService.enhancedMarkdownToHTML(text) - - return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html - } -} diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html deleted file mode 100644 index 704f3e696..000000000 --- a/client/src/app/shared/forms/peertube-checkbox.component.html +++ /dev/null @@ -1,45 +0,0 @@ -
-
- - - - - - - - - -
- -
- - - - - - - -
-
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss deleted file mode 100644 index cf8540dc3..000000000 --- a/client/src/app/shared/forms/peertube-checkbox.component.scss +++ /dev/null @@ -1,52 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.root { - display: flex; - - .form-group-checkbox { - display: flex; - align-items: center; - - .label-text { - font-weight: $font-regular; - margin: 0; - } - - input { - @include peertube-checkbox(1px); - } - } - - label { - margin-bottom: 0; - } - - my-help { - position: relative; - top: 2px; - } - - small { - font-size: 90%; - } - - .wrapper:empty { - display: none; - } - - .recommended { - margin-left: .5rem; - align-self: baseline; - display: inline-block; - padding: 4px 6px; - cursor: default; - border-radius: 3px; - font-size: 12px; - line-height: 12px; - font-weight: 500; - color: pvar(--inputPlaceholderColor); - background-color: rgba(217,225,232,.1); - border: 1px solid rgba(217,225,232,.5); - } -} \ No newline at end of file diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts deleted file mode 100644 index 89e79fecd..000000000 --- a/client/src/app/shared/forms/peertube-checkbox.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' - -@Component({ - selector: 'my-peertube-checkbox', - styleUrls: [ './peertube-checkbox.component.scss' ], - templateUrl: './peertube-checkbox.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PeertubeCheckboxComponent), - multi: true - } - ] -}) -export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit { - @Input() checked = false - @Input() inputName: string - @Input() labelText: string - @Input() labelInnerHTML: string - @Input() helpPlacement = 'top auto' - @Input() disabled = false - @Input() recommended = false - - @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> - - // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836 - @Input() onPushWorkaround = false - - labelTemplate: TemplateRef - helpTemplate: TemplateRef - - constructor (private cdr: ChangeDetectorRef) { } - - ngAfterContentInit () { - { - const t = this.templates.find(t => t.name === 'label') - if (t) this.labelTemplate = t.template - } - - { - const t = this.templates.find(t => t.name === 'help') - if (t) this.helpTemplate = t.template - } - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (checked: boolean) { - this.checked = checked - - if (this.onPushWorkaround) { - this.cdr.markForCheck() - } - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } - - onModelChange () { - this.propagateChange(this.checked) - } - - setDisabledState (isDisabled: boolean) { - this.disabled = isDisabled - } -} diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html deleted file mode 100644 index f6bf5f9ae..000000000 --- a/client/src/app/shared/forms/reactive-file.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
-
- - - {{ inputLabel }} - - -
- -
{{ filename }}
-
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss deleted file mode 100644 index 84c23c1d6..000000000 --- a/client/src/app/shared/forms/reactive-file.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.root { - height: auto; - display: flex; - align-items: center; - - .button-file { - @include peertube-button-file(auto); - @include grey-button; - - &.with-icon { - @include button-with-icon; - } - } - - .filename { - font-weight: $font-semibold; - margin-left: 5px; - } -} diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts deleted file mode 100644 index b7a821d4f..000000000 --- a/client/src/app/shared/forms/reactive-file.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { Notifier } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { GlobalIconName } from '@app/shared/images/global-icon.component' - -@Component({ - selector: 'my-reactive-file', - styleUrls: [ './reactive-file.component.scss' ], - templateUrl: './reactive-file.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ReactiveFileComponent), - multi: true - } - ] -}) -export class ReactiveFileComponent implements OnInit, ControlValueAccessor { - @Input() inputLabel: string - @Input() inputName: string - @Input() extensions: string[] = [] - @Input() maxFileSize: number - @Input() displayFilename = false - @Input() icon: GlobalIconName - - @Output() fileChanged = new EventEmitter() - - allowedExtensionsMessage = '' - fileInputValue: any - - private file: File - - constructor ( - private notifier: Notifier, - private i18n: I18n - ) {} - - get filename () { - if (!this.file) return '' - - return this.file.name - } - - ngOnInit () { - this.allowedExtensionsMessage = this.extensions.join(', ') - } - - fileChange (event: any) { - if (event.target.files && event.target.files.length) { - const [ file ] = event.target.files - - if (file.size > this.maxFileSize) { - this.notifier.error(this.i18n('This file is too large.')) - return - } - - const extension = '.' + file.name.split('.').pop() - if (this.extensions.includes(extension) === false) { - const message = this.i18n( - 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.', - { extensions: this.allowedExtensionsMessage } - ) - this.notifier.error(message) - - return - } - - this.file = file - - this.propagateChange(this.file) - this.fileChanged.emit(this.file) - } - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (file: any) { - this.file = file - - if (!this.file) this.fileInputValue = null - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } -} diff --git a/client/src/app/shared/forms/textarea-autoresize.directive.ts b/client/src/app/shared/forms/textarea-autoresize.directive.ts deleted file mode 100644 index f8c855c16..000000000 --- a/client/src/app/shared/forms/textarea-autoresize.directive.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Thanks: https://github.com/evseevdev/ngx-textarea-autosize -import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core' - -@Directive({ - selector: 'textarea[myAutoResize]' -}) -export class TextareaAutoResizeDirective implements AfterViewInit { - @HostBinding('attr.rows') rows = '1' - @HostBinding('style.overflow') overflow = 'hidden' - - constructor (private elem: ElementRef) { } - - public ngAfterViewInit () { - this.resize() - } - - @HostListener('input') - resize () { - const textarea = this.elem.nativeElement as HTMLTextAreaElement - // Reset textarea height to auto that correctly calculate the new height - textarea.style.height = 'auto' - // Set new height - textarea.style.height = `${textarea.scrollHeight}px` - } -} diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html deleted file mode 100644 index c57a4b32c..000000000 --- a/client/src/app/shared/forms/timestamp-input.component.html +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss deleted file mode 100644 index 8092b095b..000000000 --- a/client/src/app/shared/forms/timestamp-input.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import 'variables'; - -p-inputmask { - ::ng-deep input { - width: 80px; - font-size: 15px; - - border: none; - - &:focus-within, - &:focus { - box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest); - } - } -} diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts deleted file mode 100644 index 8d67a96ac..000000000 --- a/client/src/app/shared/forms/timestamp-input.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { secondsToTime, timeToInt } from '../../../assets/player/utils' - -@Component({ - selector: 'my-timestamp-input', - styleUrls: [ './timestamp-input.component.scss' ], - templateUrl: './timestamp-input.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimestampInputComponent), - multi: true - } - ] -}) -export class TimestampInputComponent implements ControlValueAccessor, OnInit { - @Input() maxTimestamp: number - @Input() timestamp: number - @Input() disabled = false - - timestampString: string - - constructor (private changeDetector: ChangeDetectorRef) {} - - ngOnInit () { - this.writeValue(this.timestamp || 0) - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (timestamp: number) { - this.timestamp = timestamp - - this.timestampString = secondsToTime(this.timestamp, true, ':') - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } - - onModelChange () { - this.timestamp = timeToInt(this.timestampString) - - this.propagateChange(this.timestamp) - } - - onBlur () { - if (this.maxTimestamp && this.timestamp > this.maxTimestamp) { - this.writeValue(this.maxTimestamp) - - this.changeDetector.detectChanges() - - this.propagateChange(this.timestamp) - } - } -} diff --git a/client/src/app/shared/guards/can-deactivate-guard.service.ts b/client/src/app/shared/guards/can-deactivate-guard.service.ts deleted file mode 100644 index 3a35fcfb3..000000000 --- a/client/src/app/shared/guards/can-deactivate-guard.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable } from '@angular/core' -import { CanDeactivate } from '@angular/router' -import { Observable } from 'rxjs' -import { ConfirmService } from '../../core/index' -import { I18n } from '@ngx-translate/i18n-polyfill' - -export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable | boolean } - -export interface CanComponentDeactivate { - canDeactivate: () => CanComponentDeactivateResult -} - -@Injectable() -export class CanDeactivateGuard implements CanDeactivate { - constructor ( - private confirmService: ConfirmService, - private i18n: I18n - ) { } - - canDeactivate (component: CanComponentDeactivate) { - const result = component.canDeactivate() - const text = result.text || this.i18n('All unsaved data will be lost, are you sure you want to leave this page?') - - return result.canDeactivate || this.confirmService.confirm( - text, - this.i18n('Warning') - ) - } - -} diff --git a/client/src/app/shared/i18n/i18n-primeng-calendar.ts b/client/src/app/shared/i18n/i18n-primeng-calendar.ts deleted file mode 100644 index b05852ff8..000000000 --- a/client/src/app/shared/i18n/i18n-primeng-calendar.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { I18n } from '@ngx-translate/i18n-polyfill' -import { Injectable } from '@angular/core' - -@Injectable() -export class I18nPrimengCalendarService { - private readonly calendarLocale: any = {} - - constructor (private i18n: I18n) { - this.calendarLocale = { - firstDayOfWeek: 0, - dayNames: [ - this.i18n('Sunday'), - this.i18n('Monday'), - this.i18n('Tuesday'), - this.i18n('Wednesday'), - this.i18n('Thursday'), - this.i18n('Friday'), - this.i18n('Saturday') - ], - - dayNamesShort: [ - this.i18n({ value: 'Sun', description: 'Day name short' }), - this.i18n({ value: 'Mon', description: 'Day name short' }), - this.i18n({ value: 'Tue', description: 'Day name short' }), - this.i18n({ value: 'Wed', description: 'Day name short' }), - this.i18n({ value: 'Thu', description: 'Day name short' }), - this.i18n({ value: 'Fri', description: 'Day name short' }), - this.i18n({ value: 'Sat', description: 'Day name short' }) - ], - - dayNamesMin: [ - this.i18n({ value: 'Su', description: 'Day name min' }), - this.i18n({ value: 'Mo', description: 'Day name min' }), - this.i18n({ value: 'Tu', description: 'Day name min' }), - this.i18n({ value: 'We', description: 'Day name min' }), - this.i18n({ value: 'Th', description: 'Day name min' }), - this.i18n({ value: 'Fr', description: 'Day name min' }), - this.i18n({ value: 'Sa', description: 'Day name min' }) - ], - - monthNames: [ - this.i18n('January'), - this.i18n('February'), - this.i18n('March'), - this.i18n('April'), - this.i18n('May'), - this.i18n('June'), - this.i18n('July'), - this.i18n('August'), - this.i18n('September'), - this.i18n('October'), - this.i18n('November'), - this.i18n('December') - ], - - monthNamesShort: [ - this.i18n({ value: 'Jan', description: 'Month name short' }), - this.i18n({ value: 'Feb', description: 'Month name short' }), - this.i18n({ value: 'Mar', description: 'Month name short' }), - this.i18n({ value: 'Apr', description: 'Month name short' }), - this.i18n({ value: 'May', description: 'Month name short' }), - this.i18n({ value: 'Jun', description: 'Month name short' }), - this.i18n({ value: 'Jul', description: 'Month name short' }), - this.i18n({ value: 'Aug', description: 'Month name short' }), - this.i18n({ value: 'Sep', description: 'Month name short' }), - this.i18n({ value: 'Oct', description: 'Month name short' }), - this.i18n({ value: 'Nov', description: 'Month name short' }), - this.i18n({ value: 'Dec', description: 'Month name short' }) - ], - - today: this.i18n('Today'), - - clear: this.i18n('Clear') - } - } - - getCalendarLocale () { - return this.calendarLocale - } - - getTimezone () { - const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1] - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone - - return `${timezone} - ${gmt}` - } - - getDateFormat () { - return this.i18n({ - value: 'yy-mm-dd ', - description: 'Date format in this locale.' - }) - } -} diff --git a/client/src/app/shared/i18n/i18n-utils.ts b/client/src/app/shared/i18n/i18n-utils.ts deleted file mode 100644 index 30d65a2a2..000000000 --- a/client/src/app/shared/i18n/i18n-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { environment } from '../../../environments/environment' - -function isOnDevLocale () { - return environment.production === false && window.location.search === '?lang=fr' -} - -function getDevLocale () { - return 'fr-FR' -} - -export { - getDevLocale, - isOnDevLocale -} diff --git a/client/src/app/shared/images/global-icon.component.scss b/client/src/app/shared/images/global-icon.component.scss deleted file mode 100644 index 6795d6628..000000000 --- a/client/src/app/shared/images/global-icon.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -::ng-deep { - svg { - width: inherit; - height: inherit; - } -} diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts deleted file mode 100644 index 169882685..000000000 --- a/client/src/app/shared/images/global-icon.component.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' -import { HooksService } from '@app/core/plugins/hooks.service' - -const icons = { - 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default, - 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default, - 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default, - 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default, - 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default, - 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default, - 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default, - 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default, - 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default, - 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default, - 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default, - 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default, - 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default, - 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default, - 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default, - 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default, - 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default, - 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default, - 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default, - 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default, - 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default, - 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default, - 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default, - 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default, - 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default, - 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default, - 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default, - 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default, - 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default, - 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default, - 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default, - 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default, - 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default, - 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default, - 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default, - 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default, - 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default, - 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default, - 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default, - 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default, - 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default, - 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default, - 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default, - 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default, - 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default, - 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default, - 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default, - 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default, - 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default, - 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default, - 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default, - 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default, - 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default, - 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default, - 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default, - 'robot': require('!!raw-loader?!../../../assets/images/global/robot.svg').default -} - -export type GlobalIconName = keyof typeof icons - -@Component({ - selector: 'my-global-icon', - template: '', - styleUrls: [ './global-icon.component.scss' ], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class GlobalIconComponent implements OnInit { - @Input() iconName: GlobalIconName - - constructor ( - private el: ElementRef, - private hooks: HooksService - ) { } - - async ngOnInit () { - const nativeElement = this.el.nativeElement as HTMLElement - nativeElement.innerHTML = await this.hooks.wrapFun( - this.getSVGContent.bind(this), - { name: this.iconName }, - 'common', - 'filter:internal.common.svg-icons.get-content.params', - 'filter:internal.common.svg-icons.get-content.result' - ) - } - - private getSVGContent (options: { name: string }) { - return icons[options.name] - } -} diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html deleted file mode 100644 index 7c3a2b588..000000000 --- a/client/src/app/shared/images/preview-upload.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - - -
-
-
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss deleted file mode 100644 index 88eccd5f7..000000000 --- a/client/src/app/shared/images/preview-upload.component.scss +++ /dev/null @@ -1,29 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.root { - height: auto; - display: flex; - flex-direction: column; - - .preview-container { - position: relative; - - my-reactive-file { - position: absolute; - bottom: 10px; - left: 10px; - } - - .preview { - object-fit: cover; - border-radius: 4px; - max-width: 100%; - - &.no-image { - border: 2px solid grey; - background-color: pvar(--mainBackgroundColor); - } - } - } -} diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts deleted file mode 100644 index 7519734ba..000000000 --- a/client/src/app/shared/images/preview-upload.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Component, forwardRef, Input, OnInit } from '@angular/core' -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' -import { ServerService } from '@app/core' -import { ServerConfig } from '@shared/models' -import { BytesPipe } from 'ngx-pipes' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'my-preview-upload', - styleUrls: [ './preview-upload.component.scss' ], - templateUrl: './preview-upload.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PreviewUploadComponent), - multi: true - } - ] -}) -export class PreviewUploadComponent implements OnInit, ControlValueAccessor { - @Input() inputLabel: string - @Input() inputName: string - @Input() previewWidth: string - @Input() previewHeight: string - - imageSrc: SafeResourceUrl - allowedExtensionsMessage = '' - maxSizeText: string - - private serverConfig: ServerConfig - private bytesPipe: BytesPipe - private file: Blob - - constructor ( - private sanitizer: DomSanitizer, - private serverService: ServerService, - private i18n: I18n - ) { - this.bytesPipe = new BytesPipe() - this.maxSizeText = this.i18n('max size') - } - - get videoImageExtensions () { - return this.serverConfig.video.image.extensions - } - - get maxVideoImageSize () { - return this.serverConfig.video.image.size.max - } - - get maxVideoImageSizeInBytes () { - return this.bytesPipe.transform(this.maxVideoImageSize) - } - - ngOnInit () { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) - - this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') - } - - onFileChanged (file: Blob) { - this.file = file - - this.propagateChange(this.file) - this.updatePreview() - } - - propagateChange = (_: any) => { /* empty */ } - - writeValue (file: any) { - this.file = file - this.updatePreview() - } - - registerOnChange (fn: (_: any) => void) { - this.propagateChange = fn - } - - registerOnTouched () { - // Unused - } - - private updatePreview () { - if (this.file) { - const url = URL.createObjectURL(this.file) - this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url) - } - } -} diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts deleted file mode 100644 index 8be578d9f..000000000 --- a/client/src/app/shared/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './auth' -export * from './forms' -export * from './rest' -export * from './users' -export * from './video-abuse' -export * from './video-block' -export * from './shared.module' diff --git a/client/src/app/shared/instance/feature-boolean.component.html b/client/src/app/shared/instance/feature-boolean.component.html deleted file mode 100644 index ccb8a30cc..000000000 --- a/client/src/app/shared/instance/feature-boolean.component.html +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/client/src/app/shared/instance/feature-boolean.component.scss b/client/src/app/shared/instance/feature-boolean.component.scss deleted file mode 100644 index 56d08af06..000000000 --- a/client/src/app/shared/instance/feature-boolean.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.glyphicon-ok { - color: $green; -} - -.glyphicon-remove { - color: $red; -} diff --git a/client/src/app/shared/instance/feature-boolean.component.ts b/client/src/app/shared/instance/feature-boolean.component.ts deleted file mode 100644 index d02d513d6..000000000 --- a/client/src/app/shared/instance/feature-boolean.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component, Input } from '@angular/core' - -@Component({ - selector: 'my-feature-boolean', - templateUrl: './feature-boolean.component.html', - styleUrls: [ './feature-boolean.component.scss' ] -}) -export class FeatureBooleanComponent { - @Input() value: boolean -} diff --git a/client/src/app/shared/instance/follow.service.ts b/client/src/app/shared/instance/follow.service.ts deleted file mode 100644 index ef4c09583..000000000 --- a/client/src/app/shared/instance/follow.service.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { catchError, map } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { Observable } from 'rxjs' -import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/index' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestPagination, RestService } from '../rest' -import { SortMeta } from 'primeng/api' - -@Injectable() -export class FollowService { - private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/server' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) { - } - - getFollowing (options: { - pagination: RestPagination, - sort: SortMeta, - search?: string, - actorType?: ActivityPubActorType, - state?: FollowState - }): Observable> { - const { pagination, sort, search, state, actorType } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - if (state) params = params.append('state', state) - if (actorType) params = params.append('actorType', actorType) - - return this.authHttp.get>(FollowService.BASE_APPLICATION_URL + '/following', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - getFollowers (options: { - pagination: RestPagination, - sort: SortMeta, - search?: string, - actorType?: ActivityPubActorType, - state?: FollowState - }): Observable> { - const { pagination, sort, search, state, actorType } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - if (state) params = params.append('state', state) - if (actorType) params = params.append('actorType', actorType) - - return this.authHttp.get>(FollowService.BASE_APPLICATION_URL + '/followers', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - follow (notEmptyHosts: string[]) { - const body = { - hosts: notEmptyHosts - } - - return this.authHttp.post(FollowService.BASE_APPLICATION_URL + '/following', body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - unfollow (follow: ActorFollow) { - return this.authHttp.delete(FollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - acceptFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host - - return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {}) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - rejectFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host - - return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {}) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - removeFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host - - return this.authHttp.delete(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}`) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } -} diff --git a/client/src/app/shared/instance/instance-features-table.component.html b/client/src/app/shared/instance/instance-features-table.component.html deleted file mode 100644 index f6a3b7f0b..000000000 --- a/client/src/app/shared/instance/instance-features-table.component.html +++ /dev/null @@ -1,107 +0,0 @@ -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Features found on this instance
PeerTube version{{ getServerVersionAndCommit() }}
-
Default NSFW/sensitive videos policy
-
can be redefined by the users
-
{{ buildNSFWLabel() }}
User registration allowed - -
Video uploads
Transcoding in multiple resolutions - -
Video uploads - Requires manual validation by moderators - Automatically published -
Video quota - - {{ initialUserVideoQuota | bytes: 0 }} ({{ dailyUserVideoQuota | bytes: 0 }} per day) - - - -
-
-
-
- - - Unlimited ({{ dailyUserVideoQuota | bytes: 0 }} per day) - -
Import
HTTP import (YouTube, Vimeo, direct URL...) - -
Torrent import - -
Player
P2P enabled - -
Search
Users can resolve distant content - -
-
diff --git a/client/src/app/shared/instance/instance-features-table.component.scss b/client/src/app/shared/instance/instance-features-table.component.scss deleted file mode 100644 index a51574741..000000000 --- a/client/src/app/shared/instance/instance-features-table.component.scss +++ /dev/null @@ -1,40 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -table { - font-size: 14px; - color: pvar(--mainForegroundColor); - - .label, - .sub-label { - min-width: 330px; - - &.label { - font-weight: $font-semibold; - } - - &.sub-label { - font-weight: $font-regular; - padding-left: 30px; - } - - .more-info { - font-style: italic; - font-weight: initial; - font-size: 14px - } - } - - td { - vertical-align: middle; - } - - caption { - caption-side: top; - font-size: 15px; - font-weight: $font-semibold; - color: pvar(--mainForegroundColor); - } -} - - diff --git a/client/src/app/shared/instance/instance-features-table.component.ts b/client/src/app/shared/instance/instance-features-table.component.ts deleted file mode 100644 index 8fd15ebad..000000000 --- a/client/src/app/shared/instance/instance-features-table.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, OnInit } from '@angular/core' -import { ServerService } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { ServerConfig } from '@shared/models' - -@Component({ - selector: 'my-instance-features-table', - templateUrl: './instance-features-table.component.html', - styleUrls: [ './instance-features-table.component.scss' ] -}) -export class InstanceFeaturesTableComponent implements OnInit { - quotaHelpIndication = '' - serverConfig: ServerConfig - - constructor ( - private i18n: I18n, - private serverService: ServerService - ) { - } - - get initialUserVideoQuota () { - return this.serverConfig.user.videoQuota - } - - get dailyUserVideoQuota () { - return Math.min(this.initialUserVideoQuota, this.serverConfig.user.videoQuotaDaily) - } - - ngOnInit () { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => { - this.serverConfig = config - this.buildQuotaHelpIndication() - }) - } - - buildNSFWLabel () { - const policy = this.serverConfig.instance.defaultNSFWPolicy - - if (policy === 'do_not_list') return this.i18n('Hidden') - if (policy === 'blur') return this.i18n('Blurred with confirmation request') - if (policy === 'display') return this.i18n('Displayed') - } - - getServerVersionAndCommit () { - return this.serverService.getServerVersionAndCommit() - } - - private getApproximateTime (seconds: number) { - const hours = Math.floor(seconds / 3600) - let pluralSuffix = '' - if (hours > 1) pluralSuffix = 's' - if (hours > 0) return `~ ${hours} hour${pluralSuffix}` - - const minutes = Math.floor(seconds % 3600 / 60) - - return this.i18n('~ {{minutes}} {minutes, plural, =1 {minute} other {minutes}}', { minutes }) - } - - private buildQuotaHelpIndication () { - if (this.initialUserVideoQuota === -1) return - - const initialUserVideoQuotaBit = this.initialUserVideoQuota * 8 - - // 1080p: ~ 6Mbps - // 720p: ~ 4Mbps - // 360p: ~ 1.5Mbps - const fullHdSeconds = initialUserVideoQuotaBit / (6 * 1000 * 1000) - const hdSeconds = initialUserVideoQuotaBit / (4 * 1000 * 1000) - const normalSeconds = initialUserVideoQuotaBit / (1.5 * 1000 * 1000) - - const lines = [ - this.i18n('{{seconds}} of full HD videos', { seconds: this.getApproximateTime(fullHdSeconds) }), - this.i18n('{{seconds}} of HD videos', { seconds: this.getApproximateTime(hdSeconds) }), - this.i18n('{{seconds}} of average quality videos', { seconds: this.getApproximateTime(normalSeconds) }) - ] - - this.quotaHelpIndication = lines.join('
') - } -} diff --git a/client/src/app/shared/instance/instance-statistics.component.html b/client/src/app/shared/instance/instance-statistics.component.html deleted file mode 100644 index 399cf10fe..000000000 --- a/client/src/app/shared/instance/instance-statistics.component.html +++ /dev/null @@ -1,101 +0,0 @@ -

Loading instance statistics...

- -
-

Local

- -
-
-
-
-

{{ serverStats.totalUsers }}

-

users

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideos }}

-

videos

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoViews }}

-

video views

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoComments }}

-

video comments

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}

-

of hosted video

-
- -
-
-
- -

Federation

- -
-
-
-
-

{{ serverStats.totalVideos }}

-

videos

-
- -
-
- -
-
-
-

{{ serverStats.totalVideoComments }}

-

video comments

-
- -
-
- -
-
-
-

{{ serverStats.totalInstanceFollowers }}

-

followers

-
- -
-
- -
-
-
-

{{ serverStats.totalInstanceFollowing }}

-

following

-
- -
-
-
-
diff --git a/client/src/app/shared/instance/instance-statistics.component.scss b/client/src/app/shared/instance/instance-statistics.component.scss deleted file mode 100644 index 5286ab03a..000000000 --- a/client/src/app/shared/instance/instance-statistics.component.scss +++ /dev/null @@ -1,40 +0,0 @@ - -h3 { - font-size: 1.25rem; -} - -.stat { - text-align: center; - margin-bottom: 1em; - overflow: hidden; - - .stat-value { - font-size: 2.25em; - line-height: 1em; - margin: 0; - } - - .stat-label { - font-size: 1.15em; - margin: 0; - } - - .glyphicon { - opacity: 0.12; - position: absolute; - left: 16px; - top: -24px; - - &.icon-bottom { - top: 4px; - } - - &::before { - font-size: 8em; - } - } - - .card-body { - z-index: 2; - } -} diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts deleted file mode 100644 index 40aa8a4c0..000000000 --- a/client/src/app/shared/instance/instance-statistics.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, OnInit } from '@angular/core' -import { ServerStats } from '@shared/models/server' -import { ServerService } from '@app/core' - -@Component({ - selector: 'my-instance-statistics', - templateUrl: './instance-statistics.component.html', - styleUrls: [ './instance-statistics.component.scss' ] -}) -export class InstanceStatisticsComponent implements OnInit { - serverStats: ServerStats = null - - constructor ( - private serverService: ServerService - ) { - } - - ngOnInit () { - this.serverService.getServerStats() - .subscribe(res => this.serverStats = res) - } -} diff --git a/client/src/app/shared/instance/instance.service.ts b/client/src/app/shared/instance/instance.service.ts deleted file mode 100644 index 8b26063fb..000000000 --- a/client/src/app/shared/instance/instance.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { catchError, map } from 'rxjs/operators' -import { HttpClient } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestService } from '../rest' -import { About } from '../../../../../shared/models/server' -import { MarkdownService } from '@app/shared/renderer' -import { peertubeTranslate } from '@shared/models' -import { ServerService } from '@app/core' -import { forkJoin } from 'rxjs' - -@Injectable() -export class InstanceService { - private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' - private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor, - private markdownService: MarkdownService, - private serverService: ServerService - ) { - } - - getAbout () { - return this.authHttp.get(InstanceService.BASE_CONFIG_URL + '/about') - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) { - const body = { - fromEmail, - fromName, - subject, - body: message - } - - return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body) - .pipe(catchError(res => this.restExtractor.handleError(res))) - - } - - async buildHtml (about: About) { - const html = { - description: '', - terms: '', - codeOfConduct: '', - moderationInformation: '', - administrator: '', - hardwareInformation: '' - } - - for (const key of Object.keys(html)) { - html[ key ] = await this.markdownService.textMarkdownToHTML(about.instance[ key ]) - } - - return html - } - - buildTranslatedLanguages (about: About) { - return forkJoin([ - this.serverService.getVideoLanguages(), - this.serverService.getServerLocale() - ]).pipe( - map(([ languagesArray, translations ]) => { - return about.instance.languages - .map(l => { - const languageObj = languagesArray.find(la => la.id === l) - - return peertubeTranslate(languageObj.label, translations) - }) - }) - ) - } - - buildTranslatedCategories (about: About) { - return forkJoin([ - this.serverService.getVideoCategories(), - this.serverService.getServerLocale() - ]).pipe( - map(([ categoriesArray, translations ]) => { - return about.instance.categories - .map(c => { - const categoryObj = categoriesArray.find(ca => ca.id === c) - - return peertubeTranslate(categoryObj.label, translations) - }) - }) - ) - } -} diff --git a/client/src/app/shared/locale/oc.ts b/client/src/app/shared/locale/oc.ts deleted file mode 100644 index d3b2e8407..000000000 --- a/client/src/app/shared/locale/oc.ts +++ /dev/null @@ -1,104 +0,0 @@ - -// This code is not generated -// See angular/tools/gulp-tasks/cldr/extract.js - -const u: any = undefined - -function plural (n: number): number { - const i = Math.floor(Math.abs(n)) - if (i === 0 || i === 1) return 1 - return 5 -} - -export default [ - 'oc', - [['a. m.', 'p. m.'], u, u], - u, - [ - ['dg', 'dl', 'dm', 'dc', 'dj', 'dv', 'ds'], ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.'], - ['dimenge', 'diluns', 'dimars', 'dimècres', 'dijòus', 'divendres', 'dissabte'], - ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.'] - ], - u, - [ - ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'], - [ - 'de gen.', 'de febr.', 'de març', 'd’abr.', 'de mai', 'de junh', 'de jul.', 'd’ag.', - 'de set.', 'd’oct.', 'de nov.', 'de dec.' - ], - [ - 'de genièr', 'de febrièr', 'de març', 'd’abril', 'de mai', 'de junh', 'de julhet', - 'd’agòst', 'de setembre', 'd’octòbre', 'de novembre', 'de decembre' - ] - ], - [ - ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'], - [ - 'gen.', 'febr.', 'març', 'abr.', 'mai', 'junh', 'jul.', 'ag.', 'set.', 'oct.', 'nov.', - 'dec.' - ], - [ - 'genièr', 'febrièr', 'març', 'abril', 'mai', 'junh', 'julhet', 'agòst', 'setembre', 'octòbre', - 'novembre', 'decembre' - ] - ], - [['aC', 'dC'], u, ['abans Jèsus-Crist', 'aprèp Jèsus-Crist']], - 1, - [6, 0], - ['d/M/yy', 'd MMM y', 'd MMMM \'de\' y', 'EEEE, d MMMM \'de\' y'], - ['H:mm', 'H:mm:ss', 'H:mm:ss z', 'H:mm:ss zzzz'], - ['{1} {0}', '{1}, {0}', '{1} \'a\' \'les\' {0}', u], - [',', '.', ';', '%', '+', '-', 'E', '×', '‰', '∞', 'NaN', ':'], - ['#,##0.###', '#,##0%', '#,##0.00 ¤', '#E0'], - 'EUR', - '€', - 'euro', - { - 'ARS': ['$AR', '$'], - 'AUD': ['$AU', '$'], - 'BEF': ['FB'], - 'BMD': ['$BM', '$'], - 'BND': ['$BN', '$'], - 'BZD': ['$BZ', '$'], - 'CAD': ['$CA', '$'], - 'CLP': ['$CL', '$'], - 'CNY': [u, '¥'], - 'COP': ['$CO', '$'], - 'CYP': ['£CY'], - 'EGP': [u, '£E'], - 'FJD': ['$FJ', '$'], - 'FKP': ['£FK', '£'], - 'FRF': ['F'], - 'GBP': ['£GB', '£'], - 'GIP': ['£GI', '£'], - 'HKD': [u, '$'], - 'IEP': ['£IE'], - 'ILP': ['£IL'], - 'ITL': ['₤IT'], - 'JPY': [u, '¥'], - 'KMF': [u, 'FC'], - 'LBP': ['£LB', '£L'], - 'MTP': ['£MT'], - 'MXN': ['$MX', '$'], - 'NAD': ['$NA', '$'], - 'NIO': [u, '$C'], - 'NZD': ['$NZ', '$'], - 'RHD': ['$RH'], - 'RON': [u, 'L'], - 'RWF': [u, 'FR'], - 'SBD': ['$SB', '$'], - 'SGD': ['$SG', '$'], - 'SRD': ['$SR', '$'], - 'TOP': [u, '$T'], - 'TTD': ['$TT', '$'], - 'TWD': [u, 'NT$'], - 'USD': ['$US', '$'], - 'UYU': ['$UY', '$'], - 'WST': ['$WS'], - 'XCD': [u, '$'], - 'XPF': ['FCFP'], - 'ZMW': [u, 'Kw'] - }, - 'ltr', - plural -] diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.html b/client/src/app/shared/menu/top-menu-dropdown.component.html deleted file mode 100644 index aeaceb662..000000000 --- a/client/src/app/shared/menu/top-menu-dropdown.component.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.scss b/client/src/app/shared/menu/top-menu-dropdown.component.scss deleted file mode 100644 index 84dd7dce3..000000000 --- a/client/src/app/shared/menu/top-menu-dropdown.component.scss +++ /dev/null @@ -1,56 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.parent-entry { - span[role=button] { - cursor: pointer; - } - - a { - display: block; - } -} - -::ng-deep .dropdown-toggle::after { - position: relative; - top: 2px; -} - -::ng-deep .dropdown-menu { - margin-top: 0 !important; -} - -.icon { - @include dropdown-with-icon-item; - - top: -1px; -} - -.sub-menu.no-scroll { - overflow-x: hidden; -} - -.modal-body { - .hidden { - display: none; - } - - a { - @include disable-default-a-behaviour; - - color: currentColor; - box-sizing: border-box; - display: block; - font-size: 1.2rem; - padding: 9px 12px; - text-align: initial; - text-transform: unset; - width: 100%; - - &.active { - color: pvar(--mainBackgroundColor) !important; - background-color: pvar(--mainHoverColor); - opacity: .9; - } - } -} diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts deleted file mode 100644 index 3f121e785..000000000 --- a/client/src/app/shared/menu/top-menu-dropdown.component.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - Component, - Input, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core' -import { filter, take } from 'rxjs/operators' -import { NavigationEnd, Router } from '@angular/router' -import { Subscription } from 'rxjs' -import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { GlobalIconName } from '@app/shared/images/global-icon.component' -import { ScreenService } from '@app/shared/misc/screen.service' -import { MenuService } from '@app/core/menu' - -export type TopMenuDropdownParam = { - label: string - routerLink?: string - - children?: { - label: string - routerLink: string - - iconName?: GlobalIconName - }[] -} - -@Component({ - selector: 'my-top-menu-dropdown', - templateUrl: './top-menu-dropdown.component.html', - styleUrls: [ './top-menu-dropdown.component.scss' ] -}) -export class TopMenuDropdownComponent implements OnInit, OnDestroy { - @Input() menuEntries: TopMenuDropdownParam[] = [] - - @ViewChild('modal', { static: true }) modal: NgbModal - - suffixLabels: { [ parentLabel: string ]: string } - hasIcons = false - isModalOpened = false - currentMenuEntryIndex: number - - private openedOnHover = false - private routeSub: Subscription - - constructor ( - private router: Router, - private modalService: NgbModal, - private screen: ScreenService, - private menuService: MenuService - ) { } - - get isInSmallView () { - let marginLeft = 0 - if (this.menuService.isMenuDisplayed) { - marginLeft = this.menuService.menuWidth - } - - return this.screen.isInSmallView(marginLeft) - } - - ngOnInit () { - this.updateChildLabels(window.location.pathname) - - this.routeSub = this.router.events - .pipe(filter(event => event instanceof NavigationEnd)) - .subscribe(() => this.updateChildLabels(window.location.pathname)) - - this.hasIcons = this.menuEntries.some( - e => e.children && e.children.some(c => !!c.iconName) - ) - } - - ngOnDestroy () { - if (this.routeSub) this.routeSub.unsubscribe() - } - - openDropdownOnHover (dropdown: NgbDropdown) { - this.openedOnHover = true - dropdown.open() - - // Menu was closed - dropdown.openChange - .pipe(take(1)) - .subscribe(() => this.openedOnHover = false) - } - - dropdownAnchorClicked (dropdown: NgbDropdown) { - if (this.openedOnHover) { - this.openedOnHover = false - return - } - - return dropdown.toggle() - } - - closeDropdownIfHovered (dropdown: NgbDropdown) { - if (this.openedOnHover === false) return - - dropdown.close() - this.openedOnHover = false - } - - openModal (index: number) { - this.currentMenuEntryIndex = index - this.isModalOpened = true - - this.modalService.open(this.modal, { - centered: true, - beforeDismiss: async () => { - this.onModalDismiss() - return true - } - }) - } - - onModalDismiss () { - this.isModalOpened = false - } - - dismissOtherModals () { - this.modalService.dismissAll() - } - - private updateChildLabels (path: string) { - this.suffixLabels = {} - - for (const entry of this.menuEntries) { - if (!entry.children) continue - - for (const child of entry.children) { - if (path.startsWith(child.routerLink)) { - this.suffixLabels[entry.label] = child.label - } - } - } - } -} diff --git a/client/src/app/shared/misc/constants.ts b/client/src/app/shared/misc/constants.ts deleted file mode 100644 index bb4a0884e..000000000 --- a/client/src/app/shared/misc/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const POP_STATE_MODAL_DISMISS = 'pop state dismiss' diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html deleted file mode 100644 index 9a6d3e48e..000000000 --- a/client/src/app/shared/misc/help.component.html +++ /dev/null @@ -1,40 +0,0 @@ - -

- -

- - -

-
- -

- -

- -

- - -

-
- -

- -

-
- - - - diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss deleted file mode 100644 index 43f33a53a..000000000 --- a/client/src/app/shared/misc/help.component.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.help-tooltip-button { - cursor: pointer; - border: none; - - my-global-icon { - width: 17px; - position: relative; - top: -2px; - margin: 5px; - - @include apply-svg-color(pvar(--mainForegroundColor)) - } -} - -::ng-deep { - .help-popover { - z-index: z(help-popover) !important; - max-width: 300px; - - .popover-body { - font-family: $main-fonts; - text-align: left; - padding: 10px; - font-size: 13px; - background-color: pvar(--mainBackgroundColor); - color: pvar(--mainForegroundColor); - border-radius: 3px; - - p { - margin-bottom: 0; - } - - ul { - padding-left: 20px; - margin-bottom: 0; - } - } - } -} diff --git a/client/src/app/shared/misc/help.component.ts b/client/src/app/shared/misc/help.component.ts deleted file mode 100644 index e8c199e7d..000000000 --- a/client/src/app/shared/misc/help.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { MarkdownService } from '@app/shared/renderer' -import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' - -@Component({ - selector: 'my-help', - styleUrls: [ './help.component.scss' ], - templateUrl: './help.component.html' -}) - -export class HelpComponent implements OnInit, OnChanges, AfterContentInit { - @Input() helpType: 'custom' | 'markdownText' | 'markdownEnhanced' = 'custom' - @Input() tooltipPlacement = 'right auto' - - @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> - - isPopoverOpened = false - mainHtml = '' - - preHtmlTemplate: TemplateRef - customHtmlTemplate: TemplateRef - postHtmlTemplate: TemplateRef - - constructor (private i18n: I18n) { } - - ngOnInit () { - this.init() - } - - ngAfterContentInit () { - { - const t = this.templates.find(t => t.name === 'preHtml') - if (t) this.preHtmlTemplate = t.template - } - - { - const t = this.templates.find(t => t.name === 'customHtml') - if (t) this.customHtmlTemplate = t.template - } - - { - const t = this.templates.find(t => t.name === 'postHtml') - if (t) this.postHtmlTemplate = t.template - } - } - - ngOnChanges () { - this.init() - } - - onPopoverHidden () { - this.isPopoverOpened = false - } - - onPopoverShown () { - this.isPopoverOpened = true - } - - private init () { - if (this.helpType === 'markdownText') { - this.mainHtml = this.formatMarkdownSupport(MarkdownService.TEXT_RULES) - return - } - - if (this.helpType === 'markdownEnhanced') { - this.mainHtml = this.formatMarkdownSupport(MarkdownService.ENHANCED_RULES) - return - } - } - - private formatMarkdownSupport (rules: string[]) { - // tslint:disable:max-line-length - return this.i18n('Markdown compatible that supports:') + - this.createMarkdownList(rules) - } - - private createMarkdownList (rules: string[]) { - const rulesToText = { - 'emphasis': this.i18n('Emphasis'), - 'link': this.i18n('Links'), - 'newline': this.i18n('New lines'), - 'list': this.i18n('Lists'), - 'image': this.i18n('Images') - } - - const bullets = rules.map(r => rulesToText[r]) - .filter(text => text) - .map(text => '
  • ' + text + '
  • ') - .join('') - - return '
      ' + bullets + '
    ' - } -} diff --git a/client/src/app/shared/misc/list-overflow.component.html b/client/src/app/shared/misc/list-overflow.component.html deleted file mode 100644 index 986572801..000000000 --- a/client/src/app/shared/misc/list-overflow.component.html +++ /dev/null @@ -1,35 +0,0 @@ -
    - - - - - - - -
    - - - -
    -
    -
    - - - - diff --git a/client/src/app/shared/misc/list-overflow.component.scss b/client/src/app/shared/misc/list-overflow.component.scss deleted file mode 100644 index 1ec044489..000000000 --- a/client/src/app/shared/misc/list-overflow.component.scss +++ /dev/null @@ -1,61 +0,0 @@ -@import '_mixins'; - -:host { - width: 100%; -} - -.list-overflow-parent { - overflow: hidden; -} - -.list-overflow-menu { - position: absolute; - right: 25px; -} - -button { - width: 30px; - border: none; - - &::after { - display: none; - } - - &.routeActive { - &::after { - display: inherit; - border: 2px solid pvar(--mainColor); - position: relative; - right: 95%; - top: 50%; - } - } -} - -::ng-deep .dropdown-menu { - margin-top: 0 !important; - position: static; - right: auto; - bottom: auto -} - -.modal-body { - a { - @include disable-default-a-behaviour; - - color: currentColor; - box-sizing: border-box; - display: block; - font-size: 1.2rem; - padding: 9px 12px; - text-align: initial; - text-transform: unset; - width: 100%; - - &.active { - color: pvar(--mainBackgroundColor) !important; - background-color: pvar(--mainHoverColor); - opacity: .9; - } - } -} diff --git a/client/src/app/shared/misc/list-overflow.component.ts b/client/src/app/shared/misc/list-overflow.component.ts deleted file mode 100644 index 30f43ba43..000000000 --- a/client/src/app/shared/misc/list-overflow.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - HostListener, - Input, - QueryList, - TemplateRef, - ViewChild, - ViewChildren -} from '@angular/core' -import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { lowerFirst, uniqueId } from 'lodash-es' -import { ScreenService } from './screen.service' -import { take } from 'rxjs/operators' - -export interface ListOverflowItem { - label: string - routerLink: string | any[] -} - -@Component({ - selector: 'list-overflow', - templateUrl: './list-overflow.component.html', - styleUrls: [ './list-overflow.component.scss' ], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ListOverflowComponent implements AfterViewInit { - @Input() items: T[] - @Input() itemTemplate: TemplateRef<{item: T}> - - @ViewChild('modal', { static: true }) modal: ElementRef - @ViewChild('itemsParent', { static: true }) parent: ElementRef - @ViewChildren('itemsRendered') itemsRendered: QueryList - - showItemsUntilIndexExcluded: number - active = false - isInTouchScreen = false - isInMobileView = false - - private openedOnHover = false - - constructor ( - private cdr: ChangeDetectorRef, - private modalService: NgbModal, - private screenService: ScreenService - ) {} - - ngAfterViewInit () { - setTimeout(() => this.onWindowResize(), 0) - } - - isMenuDisplayed () { - return !!this.showItemsUntilIndexExcluded - } - - @HostListener('window:resize') - onWindowResize () { - this.isInTouchScreen = !!this.screenService.isInTouchScreen() - this.isInMobileView = !!this.screenService.isInMobileView() - - const parentWidth = this.parent.nativeElement.getBoundingClientRect().width - let showItemsUntilIndexExcluded: number - let accWidth = 0 - - for (const [index, el] of this.itemsRendered.toArray().entries()) { - accWidth += el.nativeElement.getBoundingClientRect().width - if (showItemsUntilIndexExcluded === undefined) { - showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined - } - - const e = document.getElementById(this.getId(index)) - const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true - e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden' - } - - this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded - this.cdr.markForCheck() - } - - openDropdownOnHover (dropdown: NgbDropdown) { - this.openedOnHover = true - dropdown.open() - - // Menu was closed - dropdown.openChange - .pipe(take(1)) - .subscribe(() => this.openedOnHover = false) - } - - dropdownAnchorClicked (dropdown: NgbDropdown) { - if (this.openedOnHover) { - this.openedOnHover = false - return - } - - return dropdown.toggle() - } - - closeDropdownIfHovered (dropdown: NgbDropdown) { - if (this.openedOnHover === false) return - - dropdown.close() - this.openedOnHover = false - } - - toggleModal () { - this.modalService.open(this.modal, { centered: true }) - } - - dismissOtherModals () { - this.modalService.dismissAll() - } - - getId (id: number | string = uniqueId()): string { - return lowerFirst(this.constructor.name) + '_' + id - } -} diff --git a/client/src/app/shared/misc/loader.component.html b/client/src/app/shared/misc/loader.component.html deleted file mode 100644 index ca8ed063e..000000000 --- a/client/src/app/shared/misc/loader.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
    -
    -
    -
    -
    -
    -
    -
    diff --git a/client/src/app/shared/misc/loader.component.scss b/client/src/app/shared/misc/loader.component.scss deleted file mode 100644 index ffac9c707..000000000 --- a/client/src/app/shared/misc/loader.component.scss +++ /dev/null @@ -1,45 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -// Thanks to https://loading.io/css/ (CC0 License) - -.loader { - display: inline-block; - position: relative; - width: 50px; - height: 50px; -} - -.loader div { - box-sizing: border-box; - display: block; - position: absolute; - width: 44px; - height: 44px; - margin: 6px; - border: 4px solid; - border-radius: 50%; - animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: #999999 transparent transparent transparent; -} - -.loader div:nth-child(1) { - animation-delay: -0.45s; -} - -.loader div:nth-child(2) { - animation-delay: -0.3s; -} - -.loader div:nth-child(3) { - animation-delay: -0.15s; -} - -@keyframes loader { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/client/src/app/shared/misc/loader.component.ts b/client/src/app/shared/misc/loader.component.ts deleted file mode 100644 index e3b1eea3a..000000000 --- a/client/src/app/shared/misc/loader.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component, Input } from '@angular/core' - -@Component({ - selector: 'my-loader', - styleUrls: [ './loader.component.scss' ], - templateUrl: './loader.component.html' -}) -export class LoaderComponent { - @Input() loading: boolean -} diff --git a/client/src/app/shared/misc/peertube-web-storage.ts b/client/src/app/shared/misc/peertube-web-storage.ts deleted file mode 100644 index 0db1301bd..000000000 --- a/client/src/app/shared/misc/peertube-web-storage.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Thanks: https://github.com/capaj/localstorage-polyfill - -const valuesMap = new Map() - -function proxify (instance: MemoryStorage) { - return new Proxy(instance, { - set: function (obj, prop: string | number, value) { - if (MemoryStorage.prototype.hasOwnProperty(prop)) { - instance[prop] = value - } else { - instance.setItem(prop, value) - } - return true - }, - get: function (target, name: string | number) { - if (MemoryStorage.prototype.hasOwnProperty(name)) { - return instance[name] - } - if (valuesMap.has(name)) { - return instance.getItem(name) - } - } - }) -} - -class MemoryStorage { - [key: string]: any - [index: number]: string - - getItem (key: any) { - const stringKey = String(key) - if (valuesMap.has(key)) { - return String(valuesMap.get(stringKey)) - } - - return null - } - - setItem (key: any, val: any) { - valuesMap.set(String(key), String(val)) - } - - removeItem (key: any) { - valuesMap.delete(key) - } - - clear () { - valuesMap.clear() - } - - key (i: any) { - if (arguments.length === 0) { - throw new TypeError('Failed to execute "key" on "Storage": 1 argument required, but only 0 present.') - } - - const arr = Array.from(valuesMap.keys()) - return arr[i] - } - - get length () { - return valuesMap.size - } -} - -let peertubeLocalStorage: Storage -let peertubeSessionStorage: Storage -try { - peertubeLocalStorage = localStorage - peertubeSessionStorage = sessionStorage -} catch (err) { - const instanceLocalStorage = new MemoryStorage() - const instanceSessionStorage = new MemoryStorage() - - peertubeLocalStorage = proxify(instanceLocalStorage) - peertubeSessionStorage = proxify(instanceSessionStorage) -} - -export { - peertubeLocalStorage, - peertubeSessionStorage -} diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts deleted file mode 100644 index a69fad31d..000000000 --- a/client/src/app/shared/misc/screen.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Injectable } from '@angular/core' - -@Injectable() -export class ScreenService { - private windowInnerWidth: number - private lastFunctionCallTime: number - private cacheForMs = 500 - - constructor () { - this.refreshWindowInnerWidth() - } - - isInSmallView (marginLeft = 0) { - if (marginLeft > 0) { - const contentWidth = this.getWindowInnerWidth() - marginLeft - return contentWidth < 800 - } - - return this.getWindowInnerWidth() < 800 - } - - isInMediumView () { - return this.getWindowInnerWidth() < 1100 - } - - isInMobileView () { - return this.getWindowInnerWidth() < 500 - } - - isInTouchScreen () { - return 'ontouchstart' in window || navigator.msMaxTouchPoints - } - - getNumberOfAvailableMiniatures () { - const screenWidth = this.getWindowInnerWidth() - - let numberOfVideos = 1 - - if (screenWidth > 1850) numberOfVideos = 7 - else if (screenWidth > 1600) numberOfVideos = 6 - else if (screenWidth > 1370) numberOfVideos = 5 - else if (screenWidth > 1100) numberOfVideos = 4 - else if (screenWidth > 850) numberOfVideos = 3 - - return numberOfVideos - } - - // Cache window inner width, because it's an expensive call - getWindowInnerWidth () { - if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth() - - return this.windowInnerWidth - } - - private refreshWindowInnerWidth () { - this.lastFunctionCallTime = new Date().getTime() - - this.windowInnerWidth = window.innerWidth - } - - private cacheWindowInnerWidthExpired () { - if (!this.lastFunctionCallTime) return true - - return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs) - } -} diff --git a/client/src/app/shared/misc/small-loader.component.html b/client/src/app/shared/misc/small-loader.component.html deleted file mode 100644 index 7886f8918..000000000 --- a/client/src/app/shared/misc/small-loader.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
    -
    -
    diff --git a/client/src/app/shared/misc/small-loader.component.ts b/client/src/app/shared/misc/small-loader.component.ts deleted file mode 100644 index 191877f14..000000000 --- a/client/src/app/shared/misc/small-loader.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component, Input } from '@angular/core' - -@Component({ - selector: 'my-small-loader', - styleUrls: [ ], - templateUrl: './small-loader.component.html' -}) - -export class SmallLoaderComponent { - @Input() loading: boolean -} diff --git a/client/src/app/shared/misc/storage.service.ts b/client/src/app/shared/misc/storage.service.ts deleted file mode 100644 index 0d4a8ab53..000000000 --- a/client/src/app/shared/misc/storage.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@angular/core' -import { Observable, Subject } from 'rxjs' -import { - peertubeLocalStorage, - peertubeSessionStorage -} from './peertube-web-storage' -import { filter } from 'rxjs/operators' - -abstract class StorageService { - protected instance: Storage - static storageSub = new Subject() - - watch (keys?: string[]): Observable { - return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true)) - } - - getItem (key: string) { - return this.instance.getItem(key) - } - - setItem (key: string, data: any, notifyOfUpdate = true) { - this.instance.setItem(key, data) - if (notifyOfUpdate) StorageService.storageSub.next(key) - } - - removeItem (key: string, notifyOfUpdate = true) { - this.instance.removeItem(key) - if (notifyOfUpdate) StorageService.storageSub.next(key) - } -} - -@Injectable() -export class LocalStorageService extends StorageService { - protected instance: Storage = peertubeLocalStorage -} - -@Injectable() -export class SessionStorageService extends StorageService { - protected instance: Storage = peertubeSessionStorage -} diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts deleted file mode 100644 index bc3ab85b3..000000000 --- a/client/src/app/shared/misc/utils.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { DatePipe } from '@angular/common' -import { environment } from '../../../environments/environment' -import { AuthService } from '../../core/auth' - -// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript -function getParameterByName (name: string, url: string) { - if (!url) url = window.location.href - name = name.replace(/[\[\]]/g, '\\$&') - - const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') - const results = regex.exec(url) - - if (!results) return null - if (!results[2]) return '' - - return decodeURIComponent(results[2].replace(/\+/g, ' ')) -} - -function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) { - return new Promise(res => { - authService.userInformationLoaded - .subscribe( - () => { - const user = authService.getUser() - if (!user) return - - const videoChannels = user.videoChannels - if (Array.isArray(videoChannels) === false) return - - videoChannels.forEach(c => channel.push({ id: c.id, label: c.displayName, support: c.support })) - - return res() - } - ) - }) -} - -function getAbsoluteAPIUrl () { - let absoluteAPIUrl = environment.apiUrl - if (!absoluteAPIUrl) { - // The API is on the same domain - absoluteAPIUrl = window.location.origin - } - - return absoluteAPIUrl -} - -const datePipe = new DatePipe('en') -function dateToHuman (date: string) { - return datePipe.transform(date, 'medium') -} - -function durationToString (duration: number) { - const hours = Math.floor(duration / 3600) - const minutes = Math.floor((duration % 3600) / 60) - const seconds = duration % 60 - - const minutesPadding = minutes >= 10 ? '' : '0' - const secondsPadding = seconds >= 10 ? '' : '0' - const displayedHours = hours > 0 ? hours.toString() + ':' : '' - - return ( - displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() - ).replace(/^0/, '') -} - -function immutableAssign (target: A, source: B) { - return Object.assign({}, target, source) -} - -function objectToUrlEncoded (obj: any) { - const str: string[] = [] - for (const key of Object.keys(obj)) { - str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])) - } - - return str.join('&') -} - -// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 -function objectToFormData (obj: any, form?: FormData, namespace?: string) { - const fd = form || new FormData() - let formKey - - for (const key of Object.keys(obj)) { - if (namespace) formKey = `${namespace}[${key}]` - else formKey = key - - if (obj[key] === undefined) continue - - if (Array.isArray(obj[key]) && obj[key].length === 0) { - fd.append(key, null) - continue - } - - if (obj[key] !== null && typeof obj[ key ] === 'object' && !(obj[ key ] instanceof File)) { - objectToFormData(obj[ key ], fd, formKey) - } else { - fd.append(formKey, obj[ key ]) - } - } - - return fd -} - -function objectLineFeedToHtml (obj: any, keyToNormalize: string) { - return immutableAssign(obj, { - [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize]) - }) -} - -function lineFeedToHtml (text: string) { - if (!text) return text - - return text.replace(/\r?\n|\r/g, '
    ') -} - -function removeElementFromArray (arr: T[], elem: T) { - const index = arr.indexOf(elem) - if (index !== -1) arr.splice(index, 1) -} - -function sortBy (obj: any[], key1: string, key2?: string) { - return obj.sort((a, b) => { - const elem1 = key2 ? a[key1][key2] : a[key1] - const elem2 = key2 ? b[key1][key2] : b[key1] - - if (elem1 < elem2) return -1 - if (elem1 === elem2) return 0 - return 1 - }) -} - -function scrollToTop () { - window.scroll(0, 0) -} - -// Thanks: https://github.com/uupaa/dynamic-import-polyfill -function importModule (path: string) { - return new Promise((resolve, reject) => { - const vector = '$importModule$' + Math.random().toString(32).slice(2) - const script = document.createElement('script') - - const destructor = () => { - delete window[ vector ] - script.onerror = null - script.onload = null - script.remove() - URL.revokeObjectURL(script.src) - script.src = '' - } - - script.defer = true - script.type = 'module' - - script.onerror = () => { - reject(new Error(`Failed to import: ${path}`)) - destructor() - } - script.onload = () => { - resolve(window[ vector ]) - destructor() - } - const absURL = (environment.apiUrl || window.location.origin) + path - const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module - const blob = new Blob([ loader ], { type: 'text/javascript' }) - script.src = URL.createObjectURL(blob) - - document.head.appendChild(script) - }) -} - -function isInViewport (el: HTMLElement) { - const bounding = el.getBoundingClientRect() - return ( - bounding.top >= 0 && - bounding.left >= 0 && - bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - bounding.right <= (window.innerWidth || document.documentElement.clientWidth) - ) -} - -function isXPercentInViewport (el: HTMLElement, percentVisible: number) { - const rect = el.getBoundingClientRect() - const windowHeight = (window.innerHeight || document.documentElement.clientHeight) - - return !( - Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible || - Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible - ) -} - -export { - sortBy, - durationToString, - lineFeedToHtml, - objectToUrlEncoded, - getParameterByName, - populateAsyncUserVideoChannels, - getAbsoluteAPIUrl, - dateToHuman, - immutableAssign, - objectToFormData, - objectLineFeedToHtml, - removeElementFromArray, - importModule, - scrollToTop, - isInViewport, - isXPercentInViewport -} diff --git a/client/src/app/shared/moderation/index.ts b/client/src/app/shared/moderation/index.ts deleted file mode 100644 index 9a77c64c0..000000000 --- a/client/src/app/shared/moderation/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './user-ban-modal.component' -export * from './user-moderation-dropdown.component' diff --git a/client/src/app/shared/moderation/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html deleted file mode 100644 index 365eb1938..000000000 --- a/client/src/app/shared/moderation/user-ban-modal.component.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - diff --git a/client/src/app/shared/moderation/user-ban-modal.component.scss b/client/src/app/shared/moderation/user-ban-modal.component.scss deleted file mode 100644 index 84562f15c..000000000 --- a/client/src/app/shared/moderation/user-ban-modal.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -textarea { - @include peertube-textarea(100%, 60px); -} diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts deleted file mode 100644 index 1647e3691..000000000 --- a/client/src/app/shared/moderation/user-ban-modal.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' -import { Notifier } from '@app/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' -import { FormReactive, UserValidatorsService } from '@app/shared/forms' -import { UserService } from '@app/shared/users' -import { User } from '../../../../../shared' - -@Component({ - selector: 'my-user-ban-modal', - templateUrl: './user-ban-modal.component.html', - styleUrls: [ './user-ban-modal.component.scss' ] -}) -export class UserBanModalComponent extends FormReactive implements OnInit { - @ViewChild('modal', { static: true }) modal: NgbModal - @Output() userBanned = new EventEmitter() - - private usersToBan: User | User[] - private openedModal: NgbModalRef - - constructor ( - protected formValidatorService: FormValidatorService, - private modalService: NgbModal, - private notifier: Notifier, - private userService: UserService, - private userValidatorsService: UserValidatorsService, - private i18n: I18n - ) { - super() - } - - ngOnInit () { - this.buildForm({ - reason: this.userValidatorsService.USER_BAN_REASON - }) - } - - openModal (user: User | User[]) { - this.usersToBan = user - this.openedModal = this.modalService.open(this.modal, { centered: true }) - } - - hide () { - this.usersToBan = undefined - this.openedModal.close() - } - - async banUser () { - const reason = this.form.value['reason'] || undefined - - this.userService.banUsers(this.usersToBan, reason) - .subscribe( - () => { - const message = Array.isArray(this.usersToBan) - ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length }) - : this.i18n('User {{username}} banned.', { username: this.usersToBan.username }) - - this.notifier.success(message) - - this.userBanned.emit(this.usersToBan) - this.hide() - }, - - err => this.notifier.error(err.message) - ) - } - -} diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.html b/client/src/app/shared/moderation/user-moderation-dropdown.component.html deleted file mode 100644 index 4d562387a..000000000 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts deleted file mode 100644 index 82f39050e..000000000 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' -import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component' -import { UserService } from '@app/shared/users' -import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' -import { User, UserRight } from '../../../../../shared/models/users' -import { Account } from '@app/shared/account/account.model' -import { BlocklistService } from '@app/shared/blocklist' -import { ServerConfig, BulkRemoveCommentsOfBody } from '@shared/models' -import { BulkService } from '../bulk/bulk.service' - -@Component({ - selector: 'my-user-moderation-dropdown', - templateUrl: './user-moderation-dropdown.component.html' -}) -export class UserModerationDropdownComponent implements OnInit, OnChanges { - @ViewChild('userBanModal') userBanModal: UserBanModalComponent - - @Input() user: User - @Input() account: Account - - @Input() buttonSize: 'normal' | 'small' = 'normal' - @Input() placement = 'left-top left-bottom auto' - @Input() label: string - @Input() container: 'body' | undefined = undefined - - @Output() userChanged = new EventEmitter() - @Output() userDeleted = new EventEmitter() - - userActions: DropdownAction<{ user: User, account: Account }>[][] = [] - - private serverConfig: ServerConfig - - constructor ( - private authService: AuthService, - private notifier: Notifier, - private confirmService: ConfirmService, - private serverService: ServerService, - private userService: UserService, - private blocklistService: BlocklistService, - private bulkService: BulkService, - private i18n: I18n - ) { } - - get requiresEmailVerification () { - return this.serverConfig.signup.requiresEmailVerification - } - - ngOnInit (): void { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) - } - - ngOnChanges () { - this.buildActions() - } - - openBanUserModal (user: User) { - if (user.username === 'root') { - this.notifier.error(this.i18n('You cannot ban root.')) - return - } - - this.userBanModal.openModal(user) - } - - onUserBanned () { - this.userChanged.emit() - } - - async unbanUser (user: User) { - const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username }) - const res = await this.confirmService.confirm(message, this.i18n('Unban')) - if (res === false) return - - this.userService.unbanUsers(user) - .subscribe( - () => { - this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username })) - - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - async removeUser (user: User) { - if (user.username === 'root') { - this.notifier.error(this.i18n('You cannot delete root.')) - return - } - - const message = this.i18n('If you remove this user, you will not be able to create another with the same username!') - const res = await this.confirmService.confirm(message, this.i18n('Delete')) - if (res === false) return - - this.userService.removeUser(user).subscribe( - () => { - this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username })) - this.userDeleted.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - setEmailAsVerified (user: User) { - this.userService.updateUser(user.id, { emailVerified: true }).subscribe( - () => { - this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username })) - - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - blockAccountByUser (account: Account) { - this.blocklistService.blockAccountByUser(account) - .subscribe( - () => { - this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost })) - - this.account.mutedByUser = true - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - unblockAccountByUser (account: Account) { - this.blocklistService.unblockAccountByUser(account) - .subscribe( - () => { - this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost })) - - this.account.mutedByUser = false - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - blockServerByUser (host: string) { - this.blocklistService.blockServerByUser(host) - .subscribe( - () => { - this.notifier.success(this.i18n('Instance {{host}} muted.', { host })) - - this.account.mutedServerByUser = true - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - unblockServerByUser (host: string) { - this.blocklistService.unblockServerByUser(host) - .subscribe( - () => { - this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host })) - - this.account.mutedServerByUser = false - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - blockAccountByInstance (account: Account) { - this.blocklistService.blockAccountByInstance(account) - .subscribe( - () => { - this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })) - - this.account.mutedByInstance = true - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - unblockAccountByInstance (account: Account) { - this.blocklistService.unblockAccountByInstance(account) - .subscribe( - () => { - this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost })) - - this.account.mutedByInstance = false - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - blockServerByInstance (host: string) { - this.blocklistService.blockServerByInstance(host) - .subscribe( - () => { - this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host })) - - this.account.mutedServerByInstance = true - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - unblockServerByInstance (host: string) { - this.blocklistService.unblockServerByInstance(host) - .subscribe( - () => { - this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host })) - - this.account.mutedServerByInstance = false - this.userChanged.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - async bulkRemoveCommentsOf (body: BulkRemoveCommentsOfBody) { - const message = this.i18n('Are you sure you want to remove all the comments of this account?') - const res = await this.confirmService.confirm(message, this.i18n('Delete account comments')) - if (res === false) return - - this.bulkService.removeCommentsOf(body) - .subscribe( - () => { - this.notifier.success(this.i18n('Will remove comments of this account (may take several minutes).')) - }, - - err => this.notifier.error(err.message) - ) - } - - getRouterUserEditLink (user: User) { - return [ '/admin', 'users', 'update', user.id ] - } - - private buildActions () { - this.userActions = [] - - if (this.authService.isLoggedIn()) { - const authUser = this.authService.getUser() - - if (this.user && authUser.id === this.user.id) return - - if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) { - this.userActions.push([ - { - label: this.i18n('Edit user'), - description: this.i18n('Change quota, role, and more.'), - linkBuilder: ({ user }) => this.getRouterUserEditLink(user) - }, - { - label: this.i18n('Delete user'), - description: this.i18n('Videos will be deleted, comments will be tombstoned.'), - handler: ({ user }) => this.removeUser(user) - }, - { - label: this.i18n('Ban'), - description: this.i18n('User won\'t be able to login anymore, but videos and comments will be kept as is.'), - handler: ({ user }) => this.openBanUserModal(user), - isDisplayed: ({ user }) => !user.blocked - }, - { - label: this.i18n('Unban user'), - description: this.i18n('Allow the user to login and create videos/comments again'), - handler: ({ user }) => this.unbanUser(user), - isDisplayed: ({ user }) => user.blocked - }, - { - label: this.i18n('Set Email as Verified'), - handler: ({ user }) => this.setEmailAsVerified(user), - isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false - } - ]) - } - - // Actions on accounts/servers - if (this.account) { - // User actions - this.userActions.push([ - { - label: this.i18n('Mute this account'), - description: this.i18n('Hide any content from that user for you.'), - isDisplayed: ({ account }) => account.mutedByUser === false, - handler: ({ account }) => this.blockAccountByUser(account) - }, - { - label: this.i18n('Unmute this account'), - description: this.i18n('Show back content from that user for you.'), - isDisplayed: ({ account }) => account.mutedByUser === true, - handler: ({ account }) => this.unblockAccountByUser(account) - }, - { - label: this.i18n('Mute the instance'), - description: this.i18n('Hide any content from that instance for you.'), - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, - handler: ({ account }) => this.blockServerByUser(account.host) - }, - { - label: this.i18n('Unmute the instance'), - description: this.i18n('Show back content from that instance for you.'), - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, - handler: ({ account }) => this.unblockServerByUser(account.host) - }, - { - label: this.i18n('Remove comments from your videos'), - description: this.i18n('Remove comments of this account from your videos.'), - handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'my-videos' }) - } - ]) - - let instanceActions: DropdownAction<{ user: User, account: Account }>[] = [] - - // Instance actions on account blocklists - if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) { - instanceActions = instanceActions.concat([ - { - label: this.i18n('Mute this account by your instance'), - description: this.i18n('Hide any content from that user for you, your instance and its users.'), - isDisplayed: ({ account }) => account.mutedByInstance === false, - handler: ({ account }) => this.blockAccountByInstance(account) - }, - { - label: this.i18n('Unmute this account by your instance'), - description: this.i18n('Show back content from that user for you, your instance and its users.'), - isDisplayed: ({ account }) => account.mutedByInstance === true, - handler: ({ account }) => this.unblockAccountByInstance(account) - } - ]) - } - - // Instance actions on server blocklists - if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) { - instanceActions = instanceActions.concat([ - { - label: this.i18n('Mute the instance by your instance'), - description: this.i18n('Hide any content from that instance for you, your instance and its users.'), - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, - handler: ({ account }) => this.blockServerByInstance(account.host) - }, - { - label: this.i18n('Unmute the instance by your instance'), - description: this.i18n('Show back content from that instance for you, your instance and its users.'), - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, - handler: ({ account }) => this.unblockServerByInstance(account.host) - } - ]) - } - - if (authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) { - instanceActions = instanceActions.concat([ - { - label: this.i18n('Remove comments from your instance'), - description: this.i18n('Remove comments of this account from your instance.'), - handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'instance' }) - } - ]) - } - - if (instanceActions.length !== 0) { - this.userActions.push(instanceActions) - } - } - } - } -} diff --git a/client/src/app/shared/overview/index.ts b/client/src/app/shared/overview/index.ts deleted file mode 100644 index 2f7e41298..000000000 --- a/client/src/app/shared/overview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './overview.service' diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts deleted file mode 100644 index 6d8af8052..000000000 --- a/client/src/app/shared/overview/overview.service.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { catchError, map, switchMap, tap } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { forkJoin, Observable, of } from 'rxjs' -import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' -import { environment } from '../../../environments/environment' -import { RestExtractor } from '../rest/rest-extractor.service' -import { VideosOverview } from '@app/shared/overview/videos-overview.model' -import { VideoService } from '@app/shared/video/video.service' -import { ServerService } from '@app/core' -import { immutableAssign } from '@app/shared/misc/utils' - -@Injectable() -export class OverviewService { - static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private videosService: VideoService, - private serverService: ServerService - ) {} - - getVideosOverview (page: number): Observable { - let params = new HttpParams() - params = params.append('page', page + '') - - return this.authHttp - .get(OverviewService.BASE_OVERVIEW_URL + 'videos', { params }) - .pipe( - switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable { - const observables: Observable[] = [] - const videosOverviewResult: VideosOverview = { - tags: [], - categories: [], - channels: [] - } - - // Build videos objects - for (const key of Object.keys(serverVideosOverview)) { - for (const object of serverVideosOverview[ key ]) { - observables.push( - of(object.videos) - .pipe( - switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })), - map(result => result.data), - tap(videos => { - videosOverviewResult[key].push(immutableAssign(object, { videos })) - }) - ) - ) - } - } - - if (observables.length === 0) return of(videosOverviewResult) - - return forkJoin(observables) - .pipe( - // Translate categories - switchMap(() => { - return this.serverService.getServerLocale() - .pipe( - tap(translations => { - for (const c of videosOverviewResult.categories) { - c.category.label = peertubeTranslate(c.category.label, translations) - } - }) - ) - }), - map(() => videosOverviewResult) - ) - } - -} diff --git a/client/src/app/shared/overview/videos-overview.model.ts b/client/src/app/shared/overview/videos-overview.model.ts deleted file mode 100644 index 21abe1697..000000000 --- a/client/src/app/shared/overview/videos-overview.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' -import { Video } from '@app/shared/video/video.model' - -export class VideosOverview implements VideosOverviewServer { - channels: { - channel: VideoChannelSummary - videos: Video[] - }[] - - categories: { - category: VideoConstant - videos: Video[] - }[] - - tags: { - tag: string - videos: Video[] - }[] - [key: string]: any -} diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts deleted file mode 100644 index 1ddd8fe2f..000000000 --- a/client/src/app/shared/renderer/html-renderer.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@angular/core' -import { LinkifierService } from '@app/shared/renderer/linkifier.service' - -@Injectable() -export class HtmlRendererService { - - constructor (private linkifier: LinkifierService) { - - } - - async toSafeHtml (text: string) { - // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function - const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default - - // Convert possible markdown to html - const html = this.linkifier.linkify(text) - - return sanitizeHtml(html, { - allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], - allowedSchemes: [ 'http', 'https' ], - allowedAttributes: { - 'a': [ 'href', 'class', 'target', 'rel' ] - }, - transformTags: { - a: (tagName, attribs) => { - let rel = 'noopener noreferrer' - if (attribs.rel === 'me') rel += ' me' - - return { - tagName, - attribs: Object.assign(attribs, { - target: '_blank', - rel - }) - } - } - } - }) - } -} diff --git a/client/src/app/shared/renderer/index.ts b/client/src/app/shared/renderer/index.ts deleted file mode 100644 index 39202b385..000000000 --- a/client/src/app/shared/renderer/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './html-renderer.service' -export * from './linkifier.service' -export * from './markdown.service' diff --git a/client/src/app/shared/renderer/linkifier.service.ts b/client/src/app/shared/renderer/linkifier.service.ts deleted file mode 100644 index 95d5f17cc..000000000 --- a/client/src/app/shared/renderer/linkifier.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Injectable } from '@angular/core' -import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' -import * as linkify from 'linkifyjs' -import linkifyHtml from 'linkifyjs/html' - -@Injectable() -export class LinkifierService { - - static CLASSNAME = 'linkified' - - private linkifyOptions = { - className: { - mention: LinkifierService.CLASSNAME + '-mention', - url: LinkifierService.CLASSNAME + '-url' - } - } - - constructor () { - // Apply plugin - this.mentionWithDomainPlugin(linkify) - } - - linkify (text: string) { - return linkifyHtml(text, this.linkifyOptions) - } - - private mentionWithDomainPlugin (linkify: any) { - const TT = linkify.scanner.TOKENS // Text tokens - const { TOKENS: MT, State } = linkify.parser // Multi tokens, state - const MultiToken = MT.Base - const S_START = linkify.parser.start - - const TT_AT = TT.AT - const TT_DOMAIN = TT.DOMAIN - const TT_LOCALHOST = TT.LOCALHOST - const TT_NUM = TT.NUM - const TT_COLON = TT.COLON - const TT_SLASH = TT.SLASH - const TT_TLD = TT.TLD - const TT_UNDERSCORE = TT.UNDERSCORE - const TT_DOT = TT.DOT - - function MENTION (this: any, value: any) { - this.v = value - } - - linkify.inherits(MultiToken, MENTION, { - type: 'mentionWithDomain', - isLink: true, - toHref () { - return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1) - } - }) - - const S_AT = S_START.jump(TT_AT) // @ - const S_AT_SYMS = new State() - const S_MENTION = new State(MENTION) - const S_MENTION_DIVIDER = new State() - const S_MENTION_DIVIDER_SYMS = new State() - - // @_, - S_AT.on(TT_UNDERSCORE, S_AT_SYMS) - - // @_* - S_AT_SYMS - .on(TT_UNDERSCORE, S_AT_SYMS) - .on(TT_DOT, S_AT_SYMS) - - // Valid mention (not made up entirely of symbols) - S_AT - .on(TT_DOMAIN, S_MENTION) - .on(TT_LOCALHOST, S_MENTION) - .on(TT_TLD, S_MENTION) - .on(TT_NUM, S_MENTION) - - S_AT_SYMS - .on(TT_DOMAIN, S_MENTION) - .on(TT_LOCALHOST, S_MENTION) - .on(TT_TLD, S_MENTION) - .on(TT_NUM, S_MENTION) - - // More valid mentions - S_MENTION - .on(TT_DOMAIN, S_MENTION) - .on(TT_LOCALHOST, S_MENTION) - .on(TT_TLD, S_MENTION) - .on(TT_COLON, S_MENTION) - .on(TT_NUM, S_MENTION) - .on(TT_UNDERSCORE, S_MENTION) - - // Mention with a divider - S_MENTION - .on(TT_AT, S_MENTION_DIVIDER) - .on(TT_SLASH, S_MENTION_DIVIDER) - .on(TT_DOT, S_MENTION_DIVIDER) - - // Mention _ trailing stash plus syms - S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS) - S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS) - - // Once we get a word token, mentions can start up again - S_MENTION_DIVIDER - .on(TT_DOMAIN, S_MENTION) - .on(TT_LOCALHOST, S_MENTION) - .on(TT_TLD, S_MENTION) - .on(TT_NUM, S_MENTION) - - S_MENTION_DIVIDER_SYMS - .on(TT_DOMAIN, S_MENTION) - .on(TT_LOCALHOST, S_MENTION) - .on(TT_TLD, S_MENTION) - .on(TT_NUM, S_MENTION) - } -} diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts deleted file mode 100644 index f0c87326f..000000000 --- a/client/src/app/shared/renderer/markdown.service.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Injectable } from '@angular/core' -import { buildVideoLink } from '../../../assets/player/utils' -import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' -import * as MarkdownIt from 'markdown-it' - -type MarkdownParsers = { - textMarkdownIt: MarkdownIt - textWithHTMLMarkdownIt: MarkdownIt - - enhancedMarkdownIt: MarkdownIt - enhancedWithHTMLMarkdownIt: MarkdownIt - - completeMarkdownIt: MarkdownIt -} - -type MarkdownConfig = { - rules: string[] - html: boolean - escape?: boolean -} - -type MarkdownParserConfigs = { - [id in keyof MarkdownParsers]: MarkdownConfig -} - -@Injectable() -export class MarkdownService { - static TEXT_RULES = [ - 'linkify', - 'autolink', - 'emphasis', - 'link', - 'newline', - 'list' - ] - static TEXT_WITH_HTML_RULES = MarkdownService.TEXT_RULES.concat([ 'html_inline', 'html_block' ]) - - static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ]) - static ENHANCED_WITH_HTML_RULES = MarkdownService.TEXT_WITH_HTML_RULES.concat([ 'image' ]) - - static COMPLETE_RULES = MarkdownService.ENHANCED_WITH_HTML_RULES.concat([ 'block', 'inline', 'heading', 'paragraph' ]) - - private markdownParsers: MarkdownParsers = { - textMarkdownIt: null, - textWithHTMLMarkdownIt: null, - enhancedMarkdownIt: null, - enhancedWithHTMLMarkdownIt: null, - completeMarkdownIt: null - } - private parsersConfig: MarkdownParserConfigs = { - textMarkdownIt: { rules: MarkdownService.TEXT_RULES, html: false }, - textWithHTMLMarkdownIt: { rules: MarkdownService.TEXT_WITH_HTML_RULES, html: true, escape: true }, - - enhancedMarkdownIt: { rules: MarkdownService.ENHANCED_RULES, html: false }, - enhancedWithHTMLMarkdownIt: { rules: MarkdownService.ENHANCED_WITH_HTML_RULES, html: true, escape: true }, - - completeMarkdownIt: { rules: MarkdownService.COMPLETE_RULES, html: true } - } - - constructor (private htmlRenderer: HtmlRendererService) {} - - textMarkdownToHTML (markdown: string, withHtml = false) { - if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown) - - return this.render('textMarkdownIt', markdown) - } - - enhancedMarkdownToHTML (markdown: string, withHtml = false) { - if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown) - - return this.render('enhancedMarkdownIt', markdown) - } - - completeMarkdownToHTML (markdown: string) { - return this.render('completeMarkdownIt', markdown) - } - - async processVideoTimestamps (html: string) { - return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { - const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) - const url = buildVideoLink({ startTime: t }) - return `${str}` - }) - } - - private async render (name: keyof MarkdownParsers, markdown: string) { - if (!markdown) return '' - - const config = this.parsersConfig[ name ] - if (!this.markdownParsers[ name ]) { - this.markdownParsers[ name ] = await this.createMarkdownIt(config) - } - - let html = this.markdownParsers[ name ].render(markdown) - html = this.avoidTruncatedTags(html) - - if (config.escape) return this.htmlRenderer.toSafeHtml(html) - - return html - } - - private async createMarkdownIt (config: MarkdownConfig) { - // FIXME: import('...') returns a struct module, containing a "default" field - const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default - - const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) - - for (const rule of config.rules) { - markdownIt.enable(rule) - } - - this.setTargetToLinks(markdownIt) - - return markdownIt - } - - private setTargetToLinks (markdownIt: MarkdownIt) { - // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer - const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) { - return self.renderToken(tokens, idx, options) - } - - markdownIt.renderer.rules.link_open = function (tokens, index, options, env, self) { - const token = tokens[index] - - const targetIndex = token.attrIndex('target') - if (targetIndex < 0) token.attrPush([ 'target', '_blank' ]) - else token.attrs[targetIndex][1] = '_blank' - - const relIndex = token.attrIndex('rel') - if (relIndex < 0) token.attrPush([ 'rel', 'noopener noreferrer' ]) - else token.attrs[relIndex][1] = 'noopener noreferrer' - - // pass token to default renderer. - return defaultRender(tokens, index, options, env, self) - } - } - - private avoidTruncatedTags (html: string) { - return html.replace(/\*\*?([^*]+)$/, '$1') - .replace(/]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...') - .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1') - .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...

    ') - } -} diff --git a/client/src/app/shared/rest/component-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts deleted file mode 100644 index bcb73ed0f..000000000 --- a/client/src/app/shared/rest/component-pagination.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface ComponentPagination { - currentPage: number - itemsPerPage: number - totalItems: number -} - -export type ComponentPaginationLight = Omit - -export function hasMoreItems (componentPagination: ComponentPagination) { - // No results - if (componentPagination.totalItems === 0) return false - - // Not loaded yet - if (!componentPagination.totalItems) return true - - const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage - return maxPage > componentPagination.currentPage -} diff --git a/client/src/app/shared/rest/index.ts b/client/src/app/shared/rest/index.ts deleted file mode 100644 index f00cda2b8..000000000 --- a/client/src/app/shared/rest/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './rest-extractor.service' -export * from './rest-pagination' -export * from './rest.service' -export * from './rest-table' diff --git a/client/src/app/shared/rest/rest-extractor.service.ts b/client/src/app/shared/rest/rest-extractor.service.ts deleted file mode 100644 index e6518dd1d..000000000 --- a/client/src/app/shared/rest/rest-extractor.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { throwError as observableThrowError } from 'rxjs' -import { Injectable } from '@angular/core' -import { dateToHuman } from '@app/shared/misc/utils' -import { ResultList } from '../../../../../shared' -import { Router } from '@angular/router' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Injectable() -export class RestExtractor { - - constructor ( - private router: Router, - private i18n: I18n - ) { } - - extractDataBool () { - return true - } - - applyToResultListData (result: ResultList, fun: Function, additionalArgs?: any[]): ResultList { - const data: T[] = result.data - const newData: T[] = [] - - data.forEach(d => newData.push(fun.apply(this, [ d ].concat(additionalArgs)))) - - return { - total: result.total, - data: newData - } - } - - convertResultListDateToHuman (result: ResultList, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList { - return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ]) - } - - convertDateToHuman (target: { [ id: string ]: string }, fieldsToConvert: string[]) { - fieldsToConvert.forEach(field => target[field] = dateToHuman(target[field])) - - return target - } - - handleError (err: any) { - let errorMessage - - if (err.error instanceof Error) { - // A client-side or network error occurred. Handle it accordingly. - errorMessage = err.error.message - console.error('An error occurred:', errorMessage) - } else if (typeof err.error === 'string') { - errorMessage = err.error - } else if (err.status !== undefined) { - // A server-side error occurred. - if (err.error && err.error.errors) { - const errors = err.error.errors - const errorsArray: string[] = [] - - Object.keys(errors).forEach(key => { - errorsArray.push(errors[key].msg) - }) - - errorMessage = errorsArray.join('. ') - } else if (err.error && err.error.error) { - errorMessage = err.error.error - } else if (err.status === 413) { - errorMessage = this.i18n( - 'Request is too large for the server. Please contact you administrator if you want to increase the limit size.' - ) - } else if (err.status === 429) { - const secondsLeft = err.headers.get('retry-after') - if (secondsLeft) { - const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60) - errorMessage = this.i18n('Too many attempts, please try again after {{minutesLeft}} minutes.', { minutesLeft }) - } else { - errorMessage = this.i18n('Too many attempts, please try again later.') - } - } else if (err.status === 500) { - errorMessage = this.i18n('Server error. Please retry later.') - } - - errorMessage = errorMessage ? errorMessage : 'Unknown error.' - console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) - } else { - console.error(err) - errorMessage = err - } - - const errorObj: { message: string, status: string, body: string } = { - message: errorMessage, - status: undefined, - body: undefined - } - - if (err.status) { - errorObj.status = err.status - errorObj.body = err.error - } - - return observableThrowError(errorObj) - } - - redirectTo404IfNotFound (obj: { status: number }, status = [ 404 ]) { - if (obj && obj.status && status.indexOf(obj.status) !== -1) { - // Do not use redirectService to avoid circular dependencies - this.router.navigate([ '/404' ], { skipLocationChange: true }) - } - - return observableThrowError(obj) - } -} diff --git a/client/src/app/shared/rest/rest-pagination.ts b/client/src/app/shared/rest/rest-pagination.ts deleted file mode 100644 index 0faa59303..000000000 --- a/client/src/app/shared/rest/rest-pagination.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RestPagination { - start: number - count: number -} diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts deleted file mode 100644 index d4e6cf5f2..000000000 --- a/client/src/app/shared/rest/rest-table.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' -import { LazyLoadEvent, SortMeta } from 'primeng/api' -import { RestPagination } from './rest-pagination' -import { Subject } from 'rxjs' -import { debounceTime, distinctUntilChanged } from 'rxjs/operators' - -export abstract class RestTable { - - abstract totalRecords: number - abstract sort: SortMeta - abstract pagination: RestPagination - - search: string - rowsPerPageOptions = [ 10, 20, 50, 100 ] - rowsPerPage = this.rowsPerPageOptions[0] - expandedRows = {} - - private searchStream: Subject - - abstract getIdentifier (): string - - initialize () { - this.loadSort() - this.initSearch() - } - - loadSort () { - const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey()) - - if (result) { - try { - this.sort = JSON.parse(result) - } catch (err) { - console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err) - } - } - } - - loadLazy (event: LazyLoadEvent) { - this.sort = { - order: event.sortOrder, - field: event.sortField - } - - this.pagination = { - start: event.first, - count: this.rowsPerPage - } - - this.loadData() - this.saveSort() - } - - saveSort () { - peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort)) - } - - initSearch () { - this.searchStream = new Subject() - - this.searchStream - .pipe( - debounceTime(400), - distinctUntilChanged() - ) - .subscribe(search => { - this.search = search - this.loadData() - }) - } - - onSearch (event: Event) { - const target = event.target as HTMLInputElement - this.searchStream.next(target.value) - } - - onPage (event: { first: number, rows: number }) { - if (this.rowsPerPage !== event.rows) { - this.rowsPerPage = event.rows - this.pagination = { - start: event.first, - count: this.rowsPerPage - } - this.loadData() - } - this.expandedRows = {} - } - - setTableFilter (filter: string) { - // FIXME: cannot use ViewChild, so create a component for the filter input - const filterInput = document.getElementById('table-filter') as HTMLInputElement - if (filterInput) filterInput.value = filter - } - - resetSearch () { - this.searchStream.next('') - this.setTableFilter('') - } - - protected abstract loadData (): void - - private getSortLocalStorageKey () { - return 'rest-table-sort-' + this.getIdentifier() - } -} diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts deleted file mode 100644 index 78558851a..000000000 --- a/client/src/app/shared/rest/rest.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { SortMeta } from 'primeng/api' -import { HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { ComponentPaginationLight } from './component-pagination.model' -import { RestPagination } from './rest-pagination' - -interface QueryStringFilterPrefixes { - [key: string]: { - prefix: string - handler?: (v: string) => string | number - multiple?: boolean - } -} - -type ParseQueryStringFilterResult = { - [key: string]: string | number | (string | number)[] -} - -@Injectable() -export class RestService { - - addRestGetParams (params: HttpParams, pagination?: RestPagination, sort?: SortMeta | string) { - let newParams = params - - if (pagination !== undefined) { - newParams = newParams.set('start', pagination.start.toString()) - .set('count', pagination.count.toString()) - } - - if (sort !== undefined) { - let sortString = '' - - if (typeof sort === 'string') { - sortString = sort - } else { - const sortPrefix = sort.order === 1 ? '' : '-' - sortString = sortPrefix + sort.field - } - - newParams = newParams.set('sort', sortString) - } - - return newParams - } - - addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { - for (const name of Object.keys(object)) { - const value = object[name] - if (value === undefined || value === null) continue - - if (Array.isArray(value) && value.length !== 0) { - for (const v of value) params = params.append(name, v) - } else { - params = params.append(name, value) - } - } - - return params - } - - componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination { - const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage - const count: number = componentPagination.itemsPerPage - - return { start, count } - } - - parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult { - if (!q) return {} - - // Tokenize the strings using spaces - const tokens = q.split(' ').filter(token => !!token) - - // Build prefix array - const prefixeStrings = Object.values(prefixes) - .map(p => p.prefix) - - // Search is the querystring minus defined filters - const searchTokens = tokens.filter(t => { - return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false) - }) - - const additionalFilters: ParseQueryStringFilterResult = {} - - for (const prefixKey of Object.keys(prefixes)) { - const prefixObj = prefixes[prefixKey] - const prefix = prefixObj.prefix - - const matchedTokens = tokens.filter(t => t.startsWith(prefix)) - .map(t => t.slice(prefix.length)) // Keep the value filter - .map(t => { - if (prefixObj.handler) return prefixObj.handler(t) - - return t - }) - .filter(t => !!t || t === 0) - - if (matchedTokens.length === 0) continue - - additionalFilters[prefixKey] = prefixObj.multiple === true - ? matchedTokens - : matchedTokens[0] - } - - return { - search: searchTokens.join(' ') || undefined, - - ...additionalFilters - } - } -} diff --git a/client/src/app/shared/rxjs/zone.ts b/client/src/app/shared/rxjs/zone.ts deleted file mode 100644 index 74eed7032..000000000 --- a/client/src/app/shared/rxjs/zone.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SchedulerLike, Subscription } from 'rxjs' -import { NgZone } from '@angular/core' - -class LeaveZoneScheduler implements SchedulerLike { - constructor (private zone: NgZone, private scheduler: SchedulerLike) { - } - - schedule (...args: any[]): Subscription { - return this.zone.runOutsideAngular(() => - this.scheduler.schedule.apply(this.scheduler, args) - ) - } - - now (): number { - return this.scheduler.now() - } -} - -class EnterZoneScheduler implements SchedulerLike { - constructor (private zone: NgZone, private scheduler: SchedulerLike) { - } - - schedule (...args: any[]): Subscription { - return this.zone.run(() => - this.scheduler.schedule.apply(this.scheduler, args) - ) - } - - now (): number { - return this.scheduler.now() - } -} - -export function leaveZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike { - return new LeaveZoneScheduler(zone, scheduler) -} - -export function enterZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike { - return new EnterZoneScheduler(zone, scheduler) -} diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts new file mode 100644 index 000000000..caa31d831 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -0,0 +1,69 @@ +import { FormGroup } from '@angular/forms' +import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from './form-validators' + +export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } +export type FormReactiveValidationMessages = { + [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages +} + +export abstract class FormReactive { + protected abstract formValidatorService: FormValidatorService + protected formChanged = false + + form: FormGroup + formErrors: any // To avoid casting in template because of string | FormReactiveErrors + validationMessages: FormReactiveValidationMessages + + buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + + this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)) + } + + protected forceCheck () { + return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true) + } + + protected check () { + return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false) + } + + private onValueChanged ( + form: FormGroup, + formErrors: FormReactiveErrors, + validationMessages: FormReactiveValidationMessages, + forceCheck = false + ) { + for (const field of Object.keys(formErrors)) { + if (formErrors[field] && typeof formErrors[field] === 'object') { + this.onValueChanged( + form.controls[field] as FormGroup, + formErrors[field] as FormReactiveErrors, + validationMessages[field] as FormReactiveValidationMessages, + forceCheck + ) + continue + } + + // clear previous error message (if any) + formErrors[ field ] = '' + const control = form.get(field) + + if (control.dirty) this.formChanged = true + + // Don't care if dirty on force check + const isDirty = control.dirty || forceCheck === true + if (control && isDirty && control.enabled && !control.valid) { + const messages = validationMessages[ field ] + for (const key of Object.keys(control.errors)) { + formErrors[ field ] += messages[ key ] + ' ' + } + } + } + } + +} diff --git a/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts new file mode 100644 index 000000000..f270b602b --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core' +import { ValidatorFn, Validators } from '@angular/forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { BuildFormValidator } from './form-validator.service' +import { validateHost } from './host' + +@Injectable() +export class BatchDomainsValidatorsService { + readonly DOMAINS: BuildFormValidator + + constructor (private i18n: I18n) { + this.DOMAINS = { + VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ], + MESSAGES: { + 'required': this.i18n('Domain is required.'), + 'validDomains': this.i18n('Domains entered are invalid.'), + 'uniqueDomains': this.i18n('Domains entered contain duplicates.') + } + } + } + + getNotEmptyHosts (hosts: string) { + return hosts + .split('\n') + .filter((host: string) => host && host.length !== 0) // Eject empty hosts + } + + private validDomains: ValidatorFn = (control) => { + if (!control.value) return null + + const newHostsErrors = [] + const hosts = this.getNotEmptyHosts(control.value) + + for (const host of hosts) { + if (validateHost(host) === false) { + newHostsErrors.push(this.i18n('{{host}} is not valid', { host })) + } + } + + /* Is not valid. */ + if (newHostsErrors.length !== 0) { + return { + 'validDomains': { + reason: 'invalid', + value: newHostsErrors.join('. ') + '.' + } + } + } + + /* Is valid. */ + return null + } + + private isHostsUnique: ValidatorFn = (control) => { + if (!control.value) return null + + const hosts = this.getNotEmptyHosts(control.value) + + if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { + return null + } else { + return { + 'uniqueDomains': { + reason: 'invalid' + } + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts new file mode 100644 index 000000000..c77aba6a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts @@ -0,0 +1,98 @@ +import { Validators } from '@angular/forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class CustomConfigValidatorsService { + readonly INSTANCE_NAME: BuildFormValidator + readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator + readonly SERVICES_TWITTER_USERNAME: BuildFormValidator + readonly CACHE_PREVIEWS_SIZE: BuildFormValidator + readonly CACHE_CAPTIONS_SIZE: BuildFormValidator + readonly SIGNUP_LIMIT: BuildFormValidator + readonly ADMIN_EMAIL: BuildFormValidator + readonly TRANSCODING_THREADS: BuildFormValidator + readonly INDEX_URL: BuildFormValidator + readonly SEARCH_INDEX_URL: BuildFormValidator + + constructor (private i18n: I18n) { + this.INSTANCE_NAME = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Instance name is required.') + } + } + + this.INSTANCE_SHORT_DESCRIPTION = { + VALIDATORS: [ Validators.max(250) ], + MESSAGES: { + 'max': this.i18n('Short description should not be longer than 250 characters.') + } + } + + this.SERVICES_TWITTER_USERNAME = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Twitter username is required.') + } + } + + this.CACHE_PREVIEWS_SIZE = { + VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Previews cache size is required.'), + 'min': this.i18n('Previews cache size must be greater than 1.'), + 'pattern': this.i18n('Previews cache size must be a number.') + } + } + + this.CACHE_CAPTIONS_SIZE = { + VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Captions cache size is required.'), + 'min': this.i18n('Captions cache size must be greater than 1.'), + 'pattern': this.i18n('Captions cache size must be a number.') + } + } + + this.SIGNUP_LIMIT = { + VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Signup limit is required.'), + 'min': this.i18n('Signup limit must be greater than 1.'), + 'pattern': this.i18n('Signup limit must be a number.') + } + } + + this.ADMIN_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Admin email is required.'), + 'email': this.i18n('Admin email must be valid.') + } + } + + this.TRANSCODING_THREADS = { + VALIDATORS: [ Validators.required, Validators.min(0) ], + MESSAGES: { + 'required': this.i18n('Transcoding threads is required.'), + 'min': this.i18n('Transcoding threads must be greater or equal to 0.') + } + } + + this.INDEX_URL = { + VALIDATORS: [ Validators.pattern(/^https:\/\//) ], + MESSAGES: { + 'pattern': this.i18n('Index URL should be a URL') + } + } + + this.SEARCH_INDEX_URL = { + VALIDATORS: [ Validators.pattern(/^https?:\/\//) ], + MESSAGES: { + 'pattern': this.i18n('Search index URL should be a URL') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts new file mode 100644 index 000000000..dec7d8d9a --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts @@ -0,0 +1,87 @@ +import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' +import { Injectable } from '@angular/core' +import { FormReactiveErrors, FormReactiveValidationMessages } from '../form-reactive' + +export type BuildFormValidator = { + VALIDATORS: ValidatorFn[], + MESSAGES: { [ name: string ]: string } +} +export type BuildFormArgument = { + [ id: string ]: BuildFormValidator | BuildFormArgument +} +export type BuildFormDefaultValues = { + [ name: string ]: string | string[] | BuildFormDefaultValues +} + +@Injectable() +export class FormValidatorService { + + constructor ( + private formBuilder: FormBuilder + ) {} + + buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + const formErrors: FormReactiveErrors = {} + const validationMessages: FormReactiveValidationMessages = {} + const group: { [key: string]: any } = {} + + for (const name of Object.keys(obj)) { + formErrors[name] = '' + + const field = obj[name] + if (this.isRecursiveField(field)) { + const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues) + group[name] = result.form + formErrors[name] = result.formErrors + validationMessages[name] = result.validationMessages + + continue + } + + if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } + + const defaultValue = defaultValues[name] || '' + + if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] + else group[name] = [ defaultValue ] + } + + const form = this.formBuilder.group(group) + return { form, formErrors, validationMessages } + } + + updateForm ( + form: FormGroup, + formErrors: FormReactiveErrors, + validationMessages: FormReactiveValidationMessages, + obj: BuildFormArgument, + defaultValues: BuildFormDefaultValues = {} + ) { + for (const name of Object.keys(obj)) { + formErrors[name] = '' + + const field = obj[name] + if (this.isRecursiveField(field)) { + this.updateForm( + form[name], + formErrors[name] as FormReactiveErrors, + validationMessages[name] as FormReactiveValidationMessages, + obj[name] as BuildFormArgument, + defaultValues[name] as BuildFormDefaultValues + ) + continue + } + + if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } + + const defaultValue = defaultValues[name] || '' + + if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[])) + else form.addControl(name, new FormControl(defaultValue)) + } + } + + private isRecursiveField (field: any) { + return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/host.ts b/client/src/app/shared/shared-forms/form-validators/host.ts new file mode 100644 index 000000000..c18a35f9b --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/host.ts @@ -0,0 +1,8 @@ +export function validateHost (value: string) { + // Thanks to http://stackoverflow.com/a/106223 + const HOST_REGEXP = new RegExp( + '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' + ) + + return HOST_REGEXP.test(value) +} diff --git a/client/src/app/shared/shared-forms/form-validators/index.ts b/client/src/app/shared/shared-forms/form-validators/index.ts new file mode 100644 index 000000000..8b71841a9 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/index.ts @@ -0,0 +1,17 @@ +export * from './batch-domains-validators.service' +export * from './custom-config-validators.service' +export * from './form-validator.service' +export * from './host' +export * from './instance-validators.service' +export * from './login-validators.service' +export * from './reset-password-validators.service' +export * from './user-validators.service' +export * from './video-abuse-validators.service' +export * from './video-accept-ownership-validators.service' +export * from './video-block-validators.service' +export * from './video-captions-validators.service' +export * from './video-change-ownership-validators.service' +export * from './video-channel-validators.service' +export * from './video-comment-validators.service' +export * from './video-playlist-validators.service' +export * from './video-validators.service' diff --git a/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts new file mode 100644 index 000000000..96a35a48f --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts @@ -0,0 +1,62 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class InstanceValidatorsService { + readonly FROM_EMAIL: BuildFormValidator + readonly FROM_NAME: BuildFormValidator + readonly SUBJECT: BuildFormValidator + readonly BODY: BuildFormValidator + + constructor (private i18n: I18n) { + + this.FROM_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Email is required.'), + 'email': this.i18n('Email must be valid.') + } + } + + this.FROM_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Your name is required.'), + 'minlength': this.i18n('Your name must be at least 1 character long.'), + 'maxlength': this.i18n('Your name cannot be more than 120 characters long.') + } + } + + this.SUBJECT = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('A subject is required.'), + 'minlength': this.i18n('The subject must be at least 1 character long.'), + 'maxlength': this.i18n('The subject cannot be more than 120 characters long.') + } + } + + this.BODY = { + VALIDATORS: [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(5000) + ], + MESSAGES: { + 'required': this.i18n('A message is required.'), + 'minlength': this.i18n('The message must be at least 3 characters long.'), + 'maxlength': this.i18n('The message cannot be more than 5000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts new file mode 100644 index 000000000..a5837357e --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts @@ -0,0 +1,30 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class LoginValidatorsService { + readonly LOGIN_USERNAME: BuildFormValidator + readonly LOGIN_PASSWORD: BuildFormValidator + + constructor (private i18n: I18n) { + this.LOGIN_USERNAME = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Username is required.') + } + } + + this.LOGIN_PASSWORD = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Password is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts new file mode 100644 index 000000000..d2085a309 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts @@ -0,0 +1,20 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class ResetPasswordValidatorsService { + readonly RESET_PASSWORD_CONFIRM: BuildFormValidator + + constructor (private i18n: I18n) { + this.RESET_PASSWORD_CONFIRM = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Confirmation of the password is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts new file mode 100644 index 000000000..bd3030a54 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts @@ -0,0 +1,151 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class UserValidatorsService { + readonly USER_USERNAME: BuildFormValidator + readonly USER_EMAIL: BuildFormValidator + readonly USER_PASSWORD: BuildFormValidator + readonly USER_PASSWORD_OPTIONAL: BuildFormValidator + readonly USER_CONFIRM_PASSWORD: BuildFormValidator + readonly USER_VIDEO_QUOTA: BuildFormValidator + readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator + readonly USER_ROLE: BuildFormValidator + readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator + readonly USER_DESCRIPTION: BuildFormValidator + readonly USER_TERMS: BuildFormValidator + + readonly USER_BAN_REASON: BuildFormValidator + + constructor (private i18n: I18n) { + + this.USER_USERNAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) + ], + MESSAGES: { + 'required': this.i18n('Username is required.'), + 'minlength': this.i18n('Username must be at least 1 character long.'), + 'maxlength': this.i18n('Username cannot be more than 50 characters long.'), + 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.') + } + } + + this.USER_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Email is required.'), + 'email': this.i18n('Email must be valid.') + } + } + + this.USER_PASSWORD = { + VALIDATORS: [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(255) + ], + MESSAGES: { + 'required': this.i18n('Password is required.'), + 'minlength': this.i18n('Password must be at least 6 characters long.'), + 'maxlength': this.i18n('Password cannot be more than 255 characters long.') + } + } + + this.USER_PASSWORD_OPTIONAL = { + VALIDATORS: [ + Validators.minLength(6), + Validators.maxLength(255) + ], + MESSAGES: { + 'minlength': this.i18n('Password must be at least 6 characters long.'), + 'maxlength': this.i18n('Password cannot be more than 255 characters long.') + } + } + + this.USER_CONFIRM_PASSWORD = { + VALIDATORS: [], + MESSAGES: { + 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.') + } + } + + this.USER_VIDEO_QUOTA = { + VALIDATORS: [ Validators.required, Validators.min(-1) ], + MESSAGES: { + 'required': this.i18n('Video quota is required.'), + 'min': this.i18n('Quota must be greater than -1.') + } + } + this.USER_VIDEO_QUOTA_DAILY = { + VALIDATORS: [ Validators.required, Validators.min(-1) ], + MESSAGES: { + 'required': this.i18n('Daily upload limit is required.'), + 'min': this.i18n('Daily upload limit must be greater than -1.') + } + } + + this.USER_ROLE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('User role is required.') + } + } + + this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true) + + this.USER_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': this.i18n('Description must be at least 3 characters long.'), + 'maxlength': this.i18n('Description cannot be more than 1000 characters long.') + } + } + + this.USER_TERMS = { + VALIDATORS: [ + Validators.requiredTrue + ], + MESSAGES: { + 'required': this.i18n('You must agree with the instance terms in order to register on it.') + } + } + + this.USER_BAN_REASON = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(250) + ], + MESSAGES: { + 'minlength': this.i18n('Ban reason must be at least 3 characters long.'), + 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.') + } + } + } + + private getDisplayName (required: boolean) { + const control = { + VALIDATORS: [ + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Display name is required.'), + 'minlength': this.i18n('Display name must be at least 1 character long.'), + 'maxlength': this.i18n('Display name cannot be more than 50 characters long.') + } + } + + if (required) control.VALIDATORS.push(Validators.required) + + return control + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts new file mode 100644 index 000000000..aae56d607 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts @@ -0,0 +1,30 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoAbuseValidatorsService { + readonly VIDEO_ABUSE_REASON: BuildFormValidator + readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_ABUSE_REASON = { + VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Report reason is required.'), + 'minlength': this.i18n('Report reason must be at least 2 characters long.'), + 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.') + } + } + + this.VIDEO_ABUSE_MODERATION_COMMENT = { + VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Moderation comment is required.'), + 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'), + 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts new file mode 100644 index 000000000..998d616ec --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts @@ -0,0 +1,18 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoAcceptOwnershipValidatorsService { + readonly CHANNEL: BuildFormValidator + + constructor (private i18n: I18n) { + this.CHANNEL = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('The channel is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts new file mode 100644 index 000000000..ddf0ab5eb --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts @@ -0,0 +1,19 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoBlockValidatorsService { + readonly VIDEO_BLOCK_REASON: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_BLOCK_REASON = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ], + MESSAGES: { + 'minlength': this.i18n('Block reason must be at least 2 characters long.'), + 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts new file mode 100644 index 000000000..280d28414 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts @@ -0,0 +1,27 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoCaptionsValidatorsService { + readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator + readonly VIDEO_CAPTION_FILE: BuildFormValidator + + constructor (private i18n: I18n) { + + this.VIDEO_CAPTION_LANGUAGE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video caption language is required.') + } + } + + this.VIDEO_CAPTION_FILE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video caption file is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts new file mode 100644 index 000000000..59659defd --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts @@ -0,0 +1,27 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoChangeOwnershipValidatorsService { + readonly USERNAME: BuildFormValidator + + constructor (private i18n: I18n) { + this.USERNAME = { + VALIDATORS: [ Validators.required, this.localAccountValidator ], + MESSAGES: { + 'required': this.i18n('The username is required.'), + 'localAccountOnly': this.i18n('You can only transfer ownership to a local account') + } + } + } + + localAccountValidator (control: AbstractControl): ValidationErrors { + if (control.value.includes('@')) { + return { 'localAccountOnly': true } + } + + return null + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts new file mode 100644 index 000000000..bb650b149 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts @@ -0,0 +1,64 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoChannelValidatorsService { + readonly VIDEO_CHANNEL_NAME: BuildFormValidator + readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator + readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator + readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_CHANNEL_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) + ], + MESSAGES: { + 'required': this.i18n('Name is required.'), + 'minlength': this.i18n('Name must be at least 1 character long.'), + 'maxlength': this.i18n('Name cannot be more than 50 characters long.'), + 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.') + } + } + + this.VIDEO_CHANNEL_DISPLAY_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50) + ], + MESSAGES: { + 'required': i18n('Display name is required.'), + 'minlength': i18n('Display name must be at least 1 character long.'), + 'maxlength': i18n('Display name cannot be more than 50 characters long.') + } + } + + this.VIDEO_CHANNEL_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Description must be at least 3 characters long.'), + 'maxlength': i18n('Description cannot be more than 1000 characters long.') + } + } + + this.VIDEO_CHANNEL_SUPPORT = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Support text must be at least 3 characters long.'), + 'maxlength': i18n('Support text cannot be more than 1000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts new file mode 100644 index 000000000..97c8e967e --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts @@ -0,0 +1,20 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoCommentValidatorsService { + readonly VIDEO_COMMENT_TEXT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_COMMENT_TEXT = { + VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Comment is required.'), + 'minlength': this.i18n('Comment must be at least 2 characters long.'), + 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts new file mode 100644 index 000000000..ab9c43625 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts @@ -0,0 +1,66 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AbstractControl, FormControl, Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' +import { VideoPlaylistPrivacy } from '@shared/models' + +@Injectable() +export class VideoPlaylistValidatorsService { + readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator + readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator + readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator + readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_PLAYLIST_DISPLAY_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Display name is required.'), + 'minlength': this.i18n('Display name must be at least 1 character long.'), + 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') + } + } + + this.VIDEO_PLAYLIST_PRIVACY = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Privacy is required.') + } + } + + this.VIDEO_PLAYLIST_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Description must be at least 3 characters long.'), + 'maxlength': i18n('Description cannot be more than 1000 characters long.') + } + } + + this.VIDEO_PLAYLIST_CHANNEL_ID = { + VALIDATORS: [ ], + MESSAGES: { + 'required': this.i18n('The channel is required when the playlist is public.') + } + } + } + + setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) { + if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) { + channelControl.setValidators([ Validators.required ]) + } else { + channelControl.setValidators(null) + } + + channelControl.markAsDirty() + channelControl.updateValueAndValidity() + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts new file mode 100644 index 000000000..9b24e4f62 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts @@ -0,0 +1,102 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoValidatorsService { + readonly VIDEO_NAME: BuildFormValidator + readonly VIDEO_PRIVACY: BuildFormValidator + readonly VIDEO_CATEGORY: BuildFormValidator + readonly VIDEO_LICENCE: BuildFormValidator + readonly VIDEO_LANGUAGE: BuildFormValidator + readonly VIDEO_IMAGE: BuildFormValidator + readonly VIDEO_CHANNEL: BuildFormValidator + readonly VIDEO_DESCRIPTION: BuildFormValidator + readonly VIDEO_TAGS: BuildFormValidator + readonly VIDEO_SUPPORT: BuildFormValidator + readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator + readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator + + constructor (private i18n: I18n) { + + this.VIDEO_NAME = { + VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ], + MESSAGES: { + 'required': this.i18n('Video name is required.'), + 'minlength': this.i18n('Video name must be at least 3 characters long.'), + 'maxlength': this.i18n('Video name cannot be more than 120 characters long.') + } + } + + this.VIDEO_PRIVACY = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video privacy is required.') + } + } + + this.VIDEO_CATEGORY = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_LICENCE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_LANGUAGE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_IMAGE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_CHANNEL = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video channel is required.') + } + } + + this.VIDEO_DESCRIPTION = { + VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ], + MESSAGES: { + 'minlength': this.i18n('Video description must be at least 3 characters long.'), + 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.') + } + } + + this.VIDEO_TAGS = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], + MESSAGES: { + 'minlength': this.i18n('A tag should be more than 2 characters long.'), + 'maxlength': this.i18n('A tag should be less than 30 characters long.') + } + } + + this.VIDEO_SUPPORT = { + VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], + MESSAGES: { + 'minlength': this.i18n('Video support must be at least 3 characters long.'), + 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.') + } + } + + this.VIDEO_SCHEDULE_PUBLICATION_AT = { + VALIDATORS: [ ], + MESSAGES: { + 'required': this.i18n('A date is required to schedule video update.') + } + } + + this.VIDEO_ORIGINALLY_PUBLISHED_AT = { + VALIDATORS: [ ], + MESSAGES: {} + } + } +} diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts new file mode 100644 index 000000000..aa0ee015a --- /dev/null +++ b/client/src/app/shared/shared-forms/index.ts @@ -0,0 +1,10 @@ +export * from './form-validators' +export * from './form-reactive' +export * from './input-readonly-copy.component' +export * from './markdown-textarea.component' +export * from './peertube-checkbox.component' +export * from './preview-upload.component' +export * from './reactive-file.component' +export * from './textarea-autoresize.directive' +export * from './timestamp-input.component' +export * from './shared-form.module' diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.html b/client/src/app/shared/shared-forms/input-readonly-copy.component.html new file mode 100644 index 000000000..9566e9741 --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.html @@ -0,0 +1,9 @@ +
    + + +
    + +
    +
    diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.scss b/client/src/app/shared/shared-forms/input-readonly-copy.component.scss new file mode 100644 index 000000000..8dc4f113c --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.scss @@ -0,0 +1,3 @@ +input.readonly { + font-size: 15px; +} diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.ts b/client/src/app/shared/shared-forms/input-readonly-copy.component.ts new file mode 100644 index 000000000..7528fb7a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core' +import { Notifier } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-input-readonly-copy', + templateUrl: './input-readonly-copy.component.html', + styleUrls: [ './input-readonly-copy.component.scss' ] +}) +export class InputReadonlyCopyComponent { + @Input() value = '' + + constructor ( + private notifier: Notifier, + private i18n: I18n + ) { } + + activateCopiedMessage () { + this.notifier.success(this.i18n('Copied')) + } +} diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html new file mode 100644 index 000000000..a519f3e0a --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html @@ -0,0 +1,36 @@ +
    + + + + +
    +
    diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.scss b/client/src/app/shared/shared-forms/markdown-textarea.component.scss new file mode 100644 index 000000000..f2c76f7a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.scss @@ -0,0 +1,251 @@ +@import '_variables'; +@import '_mixins'; + +$nav-preview-tab-height: 30px; +$base-padding: 15px; +$input-border-color: #C6C6C6; +$input-border-radius: 3px; + +@mixin in-small-view { + .root { + display: flex; + flex-direction: column; + + textarea { + @include peertube-textarea(100%, 150px); + + background-color: pvar(--markdownTextareaBackgroundColor); + + font-family: monospace; + font-size: 13px; + border-bottom: none; + border-bottom-left-radius: unset; + border-bottom-right-radius: unset; + } + + .nav-preview { + display: block; + text-align: right; + padding-top: 10px; + padding-bottom: 10px; + padding-left: 10px; + padding-right: 10px; + border-top: 1px dashed $input-border-color; + border-left: 1px solid $input-border-color; + border-right: 1px solid $input-border-color; + border-bottom: 1px solid $input-border-color; + border-bottom-right-radius: $input-border-radius; + + border-bottom-left-radius: $input-border-radius; + ::ng-deep { + .nav-link { + display: none !important; + } + + .grey-button { + padding: 0 12px 0 12px; + } + } + } + + ::ng-deep { + .tab-content { + display: none; + } + } + } +} + +@mixin nav-preview-medium { + display: flex; + flex-grow: 1; + border-bottom-left-radius: unset; + border-bottom-right-radius: unset; + border-bottom: 2px solid pvar(--mainColor); + + :first-child { + margin-left: auto; + } + + ::ng-deep { + .nav-link { + display: flex !important; + align-items: center; + height: $nav-preview-tab-height !important; + padding: 0 15px !important; + font-size: 85% !important; + opacity: .7; + } + + .grey-button { + margin-left: 5px; + } + } +} + +@mixin content-preview-base { + display: block; + min-height: 75px; + padding: $base-padding; + overflow-y: auto; + font-size: 15px; + word-wrap: break-word; +} + +@mixin maximized-base { + flex-direction: row; + z-index: #{z(header) - 1}; + position: fixed; + top: $header-height; + left: $menu-width; + max-height: none !important; + max-width: none !important; + width: calc(100% - #{$menu-width}); + height: calc(100vh - #{$header-height}) !important; + + $nav-preview-vertical-padding: 40px; + + .nav-preview { + @include nav-preview-medium(); + padding-top: #{$nav-preview-vertical-padding / 2}; + padding-bottom: #{$nav-preview-vertical-padding / 2}; + padding-left: 0px; + padding-right: 0px; + position: absolute; + background-color: pvar(--mainBackgroundColor); + width: 100% !important; + border-top: none; + border-left: none; + border-right: none; + + :last-child { + margin-right: $not-expanded-horizontal-margins; + } + } + + ::ng-deep .tab-content { + @include content-preview-base(); + background-color: pvar(--mainBackgroundColor); + scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor); + } + + textarea, + ::ng-deep .tab-content { + max-height: none !important; + max-width: none !important; + margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important; + height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important; + width: 50% !important; + border: none !important; + border-radius: unset !important; + } + + :host-context(.expanded) { + .root.maximized { + left: 0; + width: 100%; + } + } +} + +@mixin maximized-in-small-view { + .root.maximized { + @include maximized-base(); + + textarea { + display: none; + } + + ::ng-deep .tab-content { + width: 100% !important; + } + } +} + +@mixin maximized-tabs-in-mobile-view { + // Ellipsis on tabs for mobile view + .root.maximized { + .nav-preview { + ::ng-deep .nav-link { + @include ellipsis(); + + display: block !important; + max-width: 45% !important; + padding: 5px 0 !important; + margin-right: 10px !important; + text-align: center; + + &:not(.active) { + max-width: 15% !important; + } + + &.active { + padding: 5px 15px !important; + } + } + } + } +} + +@mixin in-medium-view { + .root { + .nav-preview { + @include nav-preview-medium(); + } + + ::ng-deep .tab-content { + @include content-preview-base(); + max-height: 210px; + border-bottom: 1px solid $input-border-color; + border-left: 1px solid $input-border-color; + border-right: 1px solid $input-border-color; + border-bottom-left-radius: $input-border-radius; + border-bottom-right-radius: $input-border-radius; + } + } +} + +@mixin maximized-in-medium-view { + .root.maximized { + @include maximized-base(); + + textarea { + display: block; + padding: $base-padding; + border-right: 1px dashed $input-border-color !important; + resize: none; + scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor); + + &:focus { + box-shadow: none; + } + } + } +} + +@include in-small-view(); +@include maximized-in-small-view(); + +@media only screen and (max-width: $mobile-view) { + @include maximized-tabs-in-mobile-view(); +} + +@media only screen and (max-width: #{$mobile-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include maximized-tabs-in-mobile-view(); + } +} + +@media only screen and (min-width: $small-view) { + :host-context(.expanded) { + @include in-medium-view(); + } + + @include maximized-in-medium-view(); +} + +@media only screen and (min-width: #{$small-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include in-medium-view(); + } +} diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts new file mode 100644 index 000000000..8dad5314c --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts @@ -0,0 +1,110 @@ +import truncate from 'lodash-es/truncate' +import { Subject } from 'rxjs' +import { debounceTime, distinctUntilChanged } from 'rxjs/operators' +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { MarkdownService } from '@app/core' + +@Component({ + selector: 'my-markdown-textarea', + templateUrl: './markdown-textarea.component.html', + styleUrls: [ './markdown-textarea.component.scss' ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkdownTextareaComponent), + multi: true + } + ] +}) + +export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { + @Input() content = '' + @Input() classes: string[] | { [klass: string]: any[] | any } = [] + @Input() textareaMaxWidth = '100%' + @Input() textareaHeight = '150px' + @Input() truncate: number + @Input() markdownType: 'text' | 'enhanced' = 'text' + @Input() markdownVideo = false + @Input() name = 'description' + + @ViewChild('textarea') textareaElement: ElementRef + + truncatedPreviewHTML = '' + previewHTML = '' + isMaximized = false + + private contentChanged = new Subject() + + constructor (private markdownService: MarkdownService) {} + + ngOnInit () { + this.contentChanged + .pipe( + debounceTime(150), + distinctUntilChanged() + ) + .subscribe(() => this.updatePreviews()) + + this.contentChanged.next(this.content) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (description: string) { + this.content = description + + this.contentChanged.next(this.content) + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.content) + + this.contentChanged.next(this.content) + } + + onMaximizeClick () { + this.isMaximized = !this.isMaximized + + // Make sure textarea have the focus + this.textareaElement.nativeElement.focus() + + // Make sure the window has no scrollbars + if (!this.isMaximized) { + this.unlockBodyScroll() + } else { + this.lockBodyScroll() + } + } + + private lockBodyScroll () { + document.getElementById('content').classList.add('lock-scroll') + } + + private unlockBodyScroll () { + document.getElementById('content').classList.remove('lock-scroll') + } + + private async updatePreviews () { + if (this.content === null || this.content === undefined) return + + this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate })) + this.previewHTML = await this.markdownRender(this.content) + } + + private async markdownRender (text: string) { + const html = this.markdownType === 'text' ? + await this.markdownService.textMarkdownToHTML(text) : + await this.markdownService.enhancedMarkdownToHTML(text) + + return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html + } +} diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.html b/client/src/app/shared/shared-forms/peertube-checkbox.component.html new file mode 100644 index 000000000..704f3e696 --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.html @@ -0,0 +1,45 @@ +
    +
    + + + + + + + + + +
    + +
    + + + + + + + +
    +
    diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.scss b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss new file mode 100644 index 000000000..cf8540dc3 --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss @@ -0,0 +1,52 @@ +@import '_variables'; +@import '_mixins'; + +.root { + display: flex; + + .form-group-checkbox { + display: flex; + align-items: center; + + .label-text { + font-weight: $font-regular; + margin: 0; + } + + input { + @include peertube-checkbox(1px); + } + } + + label { + margin-bottom: 0; + } + + my-help { + position: relative; + top: 2px; + } + + small { + font-size: 90%; + } + + .wrapper:empty { + display: none; + } + + .recommended { + margin-left: .5rem; + align-self: baseline; + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: pvar(--inputPlaceholderColor); + background-color: rgba(217,225,232,.1); + border: 1px solid rgba(217,225,232,.5); + } +} \ No newline at end of file diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.ts b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts new file mode 100644 index 000000000..76ef77e5a --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts @@ -0,0 +1,73 @@ +import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { PeerTubeTemplateDirective } from '@app/shared/shared-main' + +@Component({ + selector: 'my-peertube-checkbox', + styleUrls: [ './peertube-checkbox.component.scss' ], + templateUrl: './peertube-checkbox.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PeertubeCheckboxComponent), + multi: true + } + ] +}) +export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit { + @Input() checked = false + @Input() inputName: string + @Input() labelText: string + @Input() labelInnerHTML: string + @Input() helpPlacement = 'top auto' + @Input() disabled = false + @Input() recommended = false + + @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> + + // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836 + @Input() onPushWorkaround = false + + labelTemplate: TemplateRef + helpTemplate: TemplateRef + + constructor (private cdr: ChangeDetectorRef) { } + + ngAfterContentInit () { + { + const t = this.templates.find(t => t.name === 'label') + if (t) this.labelTemplate = t.template + } + + { + const t = this.templates.find(t => t.name === 'help') + if (t) this.helpTemplate = t.template + } + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (checked: boolean) { + this.checked = checked + + if (this.onPushWorkaround) { + this.cdr.markForCheck() + } + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.checked) + } + + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } +} diff --git a/client/src/app/shared/shared-forms/preview-upload.component.html b/client/src/app/shared/shared-forms/preview-upload.component.html new file mode 100644 index 000000000..7c3a2b588 --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.html @@ -0,0 +1,11 @@ +
    +
    + + + +
    +
    +
    diff --git a/client/src/app/shared/shared-forms/preview-upload.component.scss b/client/src/app/shared/shared-forms/preview-upload.component.scss new file mode 100644 index 000000000..88eccd5f7 --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.scss @@ -0,0 +1,29 @@ +@import '_variables'; +@import '_mixins'; + +.root { + height: auto; + display: flex; + flex-direction: column; + + .preview-container { + position: relative; + + my-reactive-file { + position: absolute; + bottom: 10px; + left: 10px; + } + + .preview { + object-fit: cover; + border-radius: 4px; + max-width: 100%; + + &.no-image { + border: 2px solid grey; + background-color: pvar(--mainBackgroundColor); + } + } + } +} diff --git a/client/src/app/shared/shared-forms/preview-upload.component.ts b/client/src/app/shared/shared-forms/preview-upload.component.ts new file mode 100644 index 000000000..7519734ba --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.ts @@ -0,0 +1,92 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' +import { ServerService } from '@app/core' +import { ServerConfig } from '@shared/models' +import { BytesPipe } from 'ngx-pipes' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-preview-upload', + styleUrls: [ './preview-upload.component.scss' ], + templateUrl: './preview-upload.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PreviewUploadComponent), + multi: true + } + ] +}) +export class PreviewUploadComponent implements OnInit, ControlValueAccessor { + @Input() inputLabel: string + @Input() inputName: string + @Input() previewWidth: string + @Input() previewHeight: string + + imageSrc: SafeResourceUrl + allowedExtensionsMessage = '' + maxSizeText: string + + private serverConfig: ServerConfig + private bytesPipe: BytesPipe + private file: Blob + + constructor ( + private sanitizer: DomSanitizer, + private serverService: ServerService, + private i18n: I18n + ) { + this.bytesPipe = new BytesPipe() + this.maxSizeText = this.i18n('max size') + } + + get videoImageExtensions () { + return this.serverConfig.video.image.extensions + } + + get maxVideoImageSize () { + return this.serverConfig.video.image.size.max + } + + get maxVideoImageSizeInBytes () { + return this.bytesPipe.transform(this.maxVideoImageSize) + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') + } + + onFileChanged (file: Blob) { + this.file = file + + this.propagateChange(this.file) + this.updatePreview() + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (file: any) { + this.file = file + this.updatePreview() + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + private updatePreview () { + if (this.file) { + const url = URL.createObjectURL(this.file) + this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url) + } + } +} diff --git a/client/src/app/shared/shared-forms/reactive-file.component.html b/client/src/app/shared/shared-forms/reactive-file.component.html new file mode 100644 index 000000000..f6bf5f9ae --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.html @@ -0,0 +1,15 @@ +
    +
    + + + {{ inputLabel }} + + +
    + +
    {{ filename }}
    +
    diff --git a/client/src/app/shared/shared-forms/reactive-file.component.scss b/client/src/app/shared/shared-forms/reactive-file.component.scss new file mode 100644 index 000000000..84c23c1d6 --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.scss @@ -0,0 +1,22 @@ +@import '_variables'; +@import '_mixins'; + +.root { + height: auto; + display: flex; + align-items: center; + + .button-file { + @include peertube-button-file(auto); + @include grey-button; + + &.with-icon { + @include button-with-icon; + } + } + + .filename { + font-weight: $font-semibold; + margin-left: 5px; + } +} diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts new file mode 100644 index 000000000..9ebf487ce --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.ts @@ -0,0 +1,91 @@ +import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { Notifier } from '@app/core' +import { GlobalIconName } from '@app/shared/shared-icons' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-reactive-file', + styleUrls: [ './reactive-file.component.scss' ], + templateUrl: './reactive-file.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ReactiveFileComponent), + multi: true + } + ] +}) +export class ReactiveFileComponent implements OnInit, ControlValueAccessor { + @Input() inputLabel: string + @Input() inputName: string + @Input() extensions: string[] = [] + @Input() maxFileSize: number + @Input() displayFilename = false + @Input() icon: GlobalIconName + + @Output() fileChanged = new EventEmitter() + + allowedExtensionsMessage = '' + fileInputValue: any + + private file: File + + constructor ( + private notifier: Notifier, + private i18n: I18n + ) {} + + get filename () { + if (!this.file) return '' + + return this.file.name + } + + ngOnInit () { + this.allowedExtensionsMessage = this.extensions.join(', ') + } + + fileChange (event: any) { + if (event.target.files && event.target.files.length) { + const [ file ] = event.target.files + + if (file.size > this.maxFileSize) { + this.notifier.error(this.i18n('This file is too large.')) + return + } + + const extension = '.' + file.name.split('.').pop() + if (this.extensions.includes(extension) === false) { + const message = this.i18n( + 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.', + { extensions: this.allowedExtensionsMessage } + ) + this.notifier.error(message) + + return + } + + this.file = file + + this.propagateChange(this.file) + this.fileChanged.emit(this.file) + } + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (file: any) { + this.file = file + + if (!this.file) this.fileInputValue = null + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } +} diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts new file mode 100644 index 000000000..e82fa97d4 --- /dev/null +++ b/client/src/app/shared/shared-forms/shared-form.module.ts @@ -0,0 +1,84 @@ + +import { NgModule } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { BatchDomainsValidatorsService } from '@app/shared/shared-forms/form-validators/batch-domains-validators.service' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { + CustomConfigValidatorsService, + FormValidatorService, + InstanceValidatorsService, + LoginValidatorsService, + ResetPasswordValidatorsService, + UserValidatorsService, + VideoAbuseValidatorsService, + VideoAcceptOwnershipValidatorsService, + VideoBlockValidatorsService, + VideoCaptionsValidatorsService, + VideoChangeOwnershipValidatorsService, + VideoChannelValidatorsService, + VideoCommentValidatorsService, + VideoPlaylistValidatorsService, + VideoValidatorsService +} from './form-validators' +import { InputReadonlyCopyComponent } from './input-readonly-copy.component' +import { MarkdownTextareaComponent } from './markdown-textarea.component' +import { PeertubeCheckboxComponent } from './peertube-checkbox.component' +import { PreviewUploadComponent } from './preview-upload.component' +import { ReactiveFileComponent } from './reactive-file.component' +import { TextareaAutoResizeDirective } from './textarea-autoresize.directive' +import { TimestampInputComponent } from './timestamp-input.component' + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + + SharedMainModule, + SharedGlobalIconModule + ], + + declarations: [ + InputReadonlyCopyComponent, + MarkdownTextareaComponent, + PeertubeCheckboxComponent, + PreviewUploadComponent, + ReactiveFileComponent, + TextareaAutoResizeDirective, + TimestampInputComponent + ], + + exports: [ + FormsModule, + ReactiveFormsModule, + + InputReadonlyCopyComponent, + MarkdownTextareaComponent, + PeertubeCheckboxComponent, + PreviewUploadComponent, + ReactiveFileComponent, + TextareaAutoResizeDirective, + TimestampInputComponent + ], + + providers: [ + CustomConfigValidatorsService, + FormValidatorService, + LoginValidatorsService, + InstanceValidatorsService, + LoginValidatorsService, + ResetPasswordValidatorsService, + UserValidatorsService, + VideoAbuseValidatorsService, + VideoAcceptOwnershipValidatorsService, + VideoBlockValidatorsService, + VideoCaptionsValidatorsService, + VideoChangeOwnershipValidatorsService, + VideoChannelValidatorsService, + VideoCommentValidatorsService, + VideoPlaylistValidatorsService, + VideoValidatorsService, + BatchDomainsValidatorsService + ] +}) +export class SharedFormModule { } diff --git a/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts b/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts new file mode 100644 index 000000000..f8c855c16 --- /dev/null +++ b/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts @@ -0,0 +1,25 @@ +// Thanks: https://github.com/evseevdev/ngx-textarea-autosize +import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core' + +@Directive({ + selector: 'textarea[myAutoResize]' +}) +export class TextareaAutoResizeDirective implements AfterViewInit { + @HostBinding('attr.rows') rows = '1' + @HostBinding('style.overflow') overflow = 'hidden' + + constructor (private elem: ElementRef) { } + + public ngAfterViewInit () { + this.resize() + } + + @HostListener('input') + resize () { + const textarea = this.elem.nativeElement as HTMLTextAreaElement + // Reset textarea height to auto that correctly calculate the new height + textarea.style.height = 'auto' + // Set new height + textarea.style.height = `${textarea.scrollHeight}px` + } +} diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html new file mode 100644 index 000000000..c57a4b32c --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.html @@ -0,0 +1,4 @@ + diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss new file mode 100644 index 000000000..8092b095b --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss @@ -0,0 +1,15 @@ +@import 'variables'; + +p-inputmask { + ::ng-deep input { + width: 80px; + font-size: 15px; + + border: none; + + &:focus-within, + &:focus { + box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest); + } + } +} diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts new file mode 100644 index 000000000..8d67a96ac --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { secondsToTime, timeToInt } from '../../../assets/player/utils' + +@Component({ + selector: 'my-timestamp-input', + styleUrls: [ './timestamp-input.component.scss' ], + templateUrl: './timestamp-input.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimestampInputComponent), + multi: true + } + ] +}) +export class TimestampInputComponent implements ControlValueAccessor, OnInit { + @Input() maxTimestamp: number + @Input() timestamp: number + @Input() disabled = false + + timestampString: string + + constructor (private changeDetector: ChangeDetectorRef) {} + + ngOnInit () { + this.writeValue(this.timestamp || 0) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (timestamp: number) { + this.timestamp = timestamp + + this.timestampString = secondsToTime(this.timestamp, true, ':') + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.timestamp = timeToInt(this.timestampString) + + this.propagateChange(this.timestamp) + } + + onBlur () { + if (this.maxTimestamp && this.timestamp > this.maxTimestamp) { + this.writeValue(this.maxTimestamp) + + this.changeDetector.detectChanges() + + this.propagateChange(this.timestamp) + } + } +} diff --git a/client/src/app/shared/shared-icons/global-icon.component.scss b/client/src/app/shared/shared-icons/global-icon.component.scss new file mode 100644 index 000000000..6795d6628 --- /dev/null +++ b/client/src/app/shared/shared-icons/global-icon.component.scss @@ -0,0 +1,6 @@ +::ng-deep { + svg { + width: inherit; + height: inherit; + } +} diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts new file mode 100644 index 000000000..169882685 --- /dev/null +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -0,0 +1,93 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' +import { HooksService } from '@app/core/plugins/hooks.service' + +const icons = { + 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default, + 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default, + 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default, + 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default, + 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default, + 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default, + 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default, + 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default, + 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default, + 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default, + 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default, + 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default, + 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default, + 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default, + 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default, + 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default, + 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default, + 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default, + 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default, + 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default, + 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default, + 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default, + 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default, + 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default, + 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default, + 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default, + 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default, + 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default, + 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default, + 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default, + 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default, + 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default, + 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default, + 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default, + 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default, + 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default, + 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default, + 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default, + 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default, + 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default, + 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default, + 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default, + 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default, + 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default, + 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default, + 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default, + 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default, + 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default, + 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default, + 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default, + 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default, + 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default, + 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default, + 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default, + 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default, + 'robot': require('!!raw-loader?!../../../assets/images/global/robot.svg').default +} + +export type GlobalIconName = keyof typeof icons + +@Component({ + selector: 'my-global-icon', + template: '', + styleUrls: [ './global-icon.component.scss' ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class GlobalIconComponent implements OnInit { + @Input() iconName: GlobalIconName + + constructor ( + private el: ElementRef, + private hooks: HooksService + ) { } + + async ngOnInit () { + const nativeElement = this.el.nativeElement as HTMLElement + nativeElement.innerHTML = await this.hooks.wrapFun( + this.getSVGContent.bind(this), + { name: this.iconName }, + 'common', + 'filter:internal.common.svg-icons.get-content.params', + 'filter:internal.common.svg-icons.get-content.result' + ) + } + + private getSVGContent (options: { name: string }) { + return icons[options.name] + } +} diff --git a/client/src/app/shared/shared-icons/index.ts b/client/src/app/shared/shared-icons/index.ts new file mode 100644 index 000000000..478e5c97d --- /dev/null +++ b/client/src/app/shared/shared-icons/index.ts @@ -0,0 +1,3 @@ +export * from './global-icon.component' + +export * from './shared-global-icon.module' diff --git a/client/src/app/shared/shared-icons/shared-global-icon.module.ts b/client/src/app/shared/shared-icons/shared-global-icon.module.ts new file mode 100644 index 000000000..b3020c78d --- /dev/null +++ b/client/src/app/shared/shared-icons/shared-global-icon.module.ts @@ -0,0 +1,21 @@ + +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { GlobalIconComponent } from './global-icon.component' + +@NgModule({ + imports: [ + CommonModule + ], + + declarations: [ + GlobalIconComponent + ], + + exports: [ + GlobalIconComponent + ], + + providers: [ ] +}) +export class SharedGlobalIconModule { } diff --git a/client/src/app/shared/shared-instance/feature-boolean.component.html b/client/src/app/shared/shared-instance/feature-boolean.component.html new file mode 100644 index 000000000..ccb8a30cc --- /dev/null +++ b/client/src/app/shared/shared-instance/feature-boolean.component.html @@ -0,0 +1,3 @@ + + + diff --git a/client/src/app/shared/shared-instance/feature-boolean.component.scss b/client/src/app/shared/shared-instance/feature-boolean.component.scss new file mode 100644 index 000000000..56d08af06 --- /dev/null +++ b/client/src/app/shared/shared-instance/feature-boolean.component.scss @@ -0,0 +1,10 @@ +@import '_variables'; +@import '_mixins'; + +.glyphicon-ok { + color: $green; +} + +.glyphicon-remove { + color: $red; +} diff --git a/client/src/app/shared/shared-instance/feature-boolean.component.ts b/client/src/app/shared/shared-instance/feature-boolean.component.ts new file mode 100644 index 000000000..d02d513d6 --- /dev/null +++ b/client/src/app/shared/shared-instance/feature-boolean.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'my-feature-boolean', + templateUrl: './feature-boolean.component.html', + styleUrls: [ './feature-boolean.component.scss' ] +}) +export class FeatureBooleanComponent { + @Input() value: boolean +} diff --git a/client/src/app/shared/shared-instance/index.ts b/client/src/app/shared/shared-instance/index.ts new file mode 100644 index 000000000..1aeed357e --- /dev/null +++ b/client/src/app/shared/shared-instance/index.ts @@ -0,0 +1,6 @@ +export * from './feature-boolean.component' +export * from './instance-features-table.component' +export * from './instance-follow.service' +export * from './instance-statistics.component' +export * from './instance.service' +export * from './shared-instance.module' diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.html b/client/src/app/shared/shared-instance/instance-features-table.component.html new file mode 100644 index 000000000..f6a3b7f0b --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-features-table.component.html @@ -0,0 +1,107 @@ +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Features found on this instance
    PeerTube version{{ getServerVersionAndCommit() }}
    +
    Default NSFW/sensitive videos policy
    +
    can be redefined by the users
    +
    {{ buildNSFWLabel() }}
    User registration allowed + +
    Video uploads
    Transcoding in multiple resolutions + +
    Video uploads + Requires manual validation by moderators + Automatically published +
    Video quota + + {{ initialUserVideoQuota | bytes: 0 }} ({{ dailyUserVideoQuota | bytes: 0 }} per day) + + + +
    +
    +
    +
    + + + Unlimited ({{ dailyUserVideoQuota | bytes: 0 }} per day) + +
    Import
    HTTP import (YouTube, Vimeo, direct URL...) + +
    Torrent import + +
    Player
    P2P enabled + +
    Search
    Users can resolve distant content + +
    +
    diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.scss b/client/src/app/shared/shared-instance/instance-features-table.component.scss new file mode 100644 index 000000000..a51574741 --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-features-table.component.scss @@ -0,0 +1,40 @@ +@import '_variables'; +@import '_mixins'; + +table { + font-size: 14px; + color: pvar(--mainForegroundColor); + + .label, + .sub-label { + min-width: 330px; + + &.label { + font-weight: $font-semibold; + } + + &.sub-label { + font-weight: $font-regular; + padding-left: 30px; + } + + .more-info { + font-style: italic; + font-weight: initial; + font-size: 14px + } + } + + td { + vertical-align: middle; + } + + caption { + caption-side: top; + font-size: 15px; + font-weight: $font-semibold; + color: pvar(--mainForegroundColor); + } +} + + diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts new file mode 100644 index 000000000..8fd15ebad --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit } from '@angular/core' +import { ServerService } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig } from '@shared/models' + +@Component({ + selector: 'my-instance-features-table', + templateUrl: './instance-features-table.component.html', + styleUrls: [ './instance-features-table.component.scss' ] +}) +export class InstanceFeaturesTableComponent implements OnInit { + quotaHelpIndication = '' + serverConfig: ServerConfig + + constructor ( + private i18n: I18n, + private serverService: ServerService + ) { + } + + get initialUserVideoQuota () { + return this.serverConfig.user.videoQuota + } + + get dailyUserVideoQuota () { + return Math.min(this.initialUserVideoQuota, this.serverConfig.user.videoQuotaDaily) + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => { + this.serverConfig = config + this.buildQuotaHelpIndication() + }) + } + + buildNSFWLabel () { + const policy = this.serverConfig.instance.defaultNSFWPolicy + + if (policy === 'do_not_list') return this.i18n('Hidden') + if (policy === 'blur') return this.i18n('Blurred with confirmation request') + if (policy === 'display') return this.i18n('Displayed') + } + + getServerVersionAndCommit () { + return this.serverService.getServerVersionAndCommit() + } + + private getApproximateTime (seconds: number) { + const hours = Math.floor(seconds / 3600) + let pluralSuffix = '' + if (hours > 1) pluralSuffix = 's' + if (hours > 0) return `~ ${hours} hour${pluralSuffix}` + + const minutes = Math.floor(seconds % 3600 / 60) + + return this.i18n('~ {{minutes}} {minutes, plural, =1 {minute} other {minutes}}', { minutes }) + } + + private buildQuotaHelpIndication () { + if (this.initialUserVideoQuota === -1) return + + const initialUserVideoQuotaBit = this.initialUserVideoQuota * 8 + + // 1080p: ~ 6Mbps + // 720p: ~ 4Mbps + // 360p: ~ 1.5Mbps + const fullHdSeconds = initialUserVideoQuotaBit / (6 * 1000 * 1000) + const hdSeconds = initialUserVideoQuotaBit / (4 * 1000 * 1000) + const normalSeconds = initialUserVideoQuotaBit / (1.5 * 1000 * 1000) + + const lines = [ + this.i18n('{{seconds}} of full HD videos', { seconds: this.getApproximateTime(fullHdSeconds) }), + this.i18n('{{seconds}} of HD videos', { seconds: this.getApproximateTime(hdSeconds) }), + this.i18n('{{seconds}} of average quality videos', { seconds: this.getApproximateTime(normalSeconds) }) + ] + + this.quotaHelpIndication = lines.join('
    ') + } +} diff --git a/client/src/app/shared/shared-instance/instance-follow.service.ts b/client/src/app/shared/shared-instance/instance-follow.service.ts new file mode 100644 index 000000000..3c9ccc40f --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-follow.service.ts @@ -0,0 +1,116 @@ +import { SortMeta } from 'primeng/api' +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/index' +import { environment } from '../../../environments/environment' + +@Injectable() +export class InstanceFollowService { + private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/server' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) { + } + + getFollowing (options: { + pagination: RestPagination, + sort: SortMeta, + search?: string, + actorType?: ActivityPubActorType, + state?: FollowState + }): Observable> { + const { pagination, sort, search, state, actorType } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + if (state) params = params.append('state', state) + if (actorType) params = params.append('actorType', actorType) + + return this.authHttp.get>(InstanceFollowService.BASE_APPLICATION_URL + '/following', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + getFollowers (options: { + pagination: RestPagination, + sort: SortMeta, + search?: string, + actorType?: ActivityPubActorType, + state?: FollowState + }): Observable> { + const { pagination, sort, search, state, actorType } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + if (state) params = params.append('state', state) + if (actorType) params = params.append('actorType', actorType) + + return this.authHttp.get>(InstanceFollowService.BASE_APPLICATION_URL + '/followers', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + follow (notEmptyHosts: string[]) { + const body = { + hosts: notEmptyHosts + } + + return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + unfollow (follow: ActorFollow) { + return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + acceptFollower (follow: ActorFollow) { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {}) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + rejectFollower (follow: ActorFollow) { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {}) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + removeFollower (follow: ActorFollow) { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.delete(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}`) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } +} diff --git a/client/src/app/shared/shared-instance/instance-statistics.component.html b/client/src/app/shared/shared-instance/instance-statistics.component.html new file mode 100644 index 000000000..399cf10fe --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-statistics.component.html @@ -0,0 +1,101 @@ +

    Loading instance statistics...

    + +
    +

    Local

    + +
    +
    +
    +
    +

    {{ serverStats.totalUsers }}

    +

    users

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalLocalVideos }}

    +

    videos

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalLocalVideoViews }}

    +

    video views

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalLocalVideoComments }}

    +

    video comments

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalLocalVideoFilesSize | bytes:1 }}

    +

    of hosted video

    +
    + +
    +
    +
    + +

    Federation

    + +
    +
    +
    +
    +

    {{ serverStats.totalVideos }}

    +

    videos

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalVideoComments }}

    +

    video comments

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalInstanceFollowers }}

    +

    followers

    +
    + +
    +
    + +
    +
    +
    +

    {{ serverStats.totalInstanceFollowing }}

    +

    following

    +
    + +
    +
    +
    +
    diff --git a/client/src/app/shared/shared-instance/instance-statistics.component.scss b/client/src/app/shared/shared-instance/instance-statistics.component.scss new file mode 100644 index 000000000..5286ab03a --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-statistics.component.scss @@ -0,0 +1,40 @@ + +h3 { + font-size: 1.25rem; +} + +.stat { + text-align: center; + margin-bottom: 1em; + overflow: hidden; + + .stat-value { + font-size: 2.25em; + line-height: 1em; + margin: 0; + } + + .stat-label { + font-size: 1.15em; + margin: 0; + } + + .glyphicon { + opacity: 0.12; + position: absolute; + left: 16px; + top: -24px; + + &.icon-bottom { + top: 4px; + } + + &::before { + font-size: 8em; + } + } + + .card-body { + z-index: 2; + } +} diff --git a/client/src/app/shared/shared-instance/instance-statistics.component.ts b/client/src/app/shared/shared-instance/instance-statistics.component.ts new file mode 100644 index 000000000..40aa8a4c0 --- /dev/null +++ b/client/src/app/shared/shared-instance/instance-statistics.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core' +import { ServerStats } from '@shared/models/server' +import { ServerService } from '@app/core' + +@Component({ + selector: 'my-instance-statistics', + templateUrl: './instance-statistics.component.html', + styleUrls: [ './instance-statistics.component.scss' ] +}) +export class InstanceStatisticsComponent implements OnInit { + serverStats: ServerStats = null + + constructor ( + private serverService: ServerService + ) { + } + + ngOnInit () { + this.serverService.getServerStats() + .subscribe(res => this.serverStats = res) + } +} diff --git a/client/src/app/shared/shared-instance/instance.service.ts b/client/src/app/shared/shared-instance/instance.service.ts new file mode 100644 index 000000000..ba9797bb5 --- /dev/null +++ b/client/src/app/shared/shared-instance/instance.service.ts @@ -0,0 +1,88 @@ +import { forkJoin } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { MarkdownService, RestExtractor, ServerService } from '@app/core' +import { About, peertubeTranslate } from '@shared/models' +import { environment } from '../../../environments/environment' + +@Injectable() +export class InstanceService { + private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' + private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private markdownService: MarkdownService, + private serverService: ServerService + ) { + } + + getAbout () { + return this.authHttp.get(InstanceService.BASE_CONFIG_URL + '/about') + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) { + const body = { + fromEmail, + fromName, + subject, + body: message + } + + return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body) + .pipe(catchError(res => this.restExtractor.handleError(res))) + + } + + async buildHtml (about: About) { + const html = { + description: '', + terms: '', + codeOfConduct: '', + moderationInformation: '', + administrator: '', + hardwareInformation: '' + } + + for (const key of Object.keys(html)) { + html[ key ] = await this.markdownService.textMarkdownToHTML(about.instance[ key ]) + } + + return html + } + + buildTranslatedLanguages (about: About) { + return forkJoin([ + this.serverService.getVideoLanguages(), + this.serverService.getServerLocale() + ]).pipe( + map(([ languagesArray, translations ]) => { + return about.instance.languages + .map(l => { + const languageObj = languagesArray.find(la => la.id === l) + + return peertubeTranslate(languageObj.label, translations) + }) + }) + ) + } + + buildTranslatedCategories (about: About) { + return forkJoin([ + this.serverService.getVideoCategories(), + this.serverService.getServerLocale() + ]).pipe( + map(([ categoriesArray, translations ]) => { + return about.instance.categories + .map(c => { + const categoryObj = categoriesArray.find(ca => ca.id === c) + + return peertubeTranslate(categoryObj.label, translations) + }) + }) + ) + } +} diff --git a/client/src/app/shared/shared-instance/shared-instance.module.ts b/client/src/app/shared/shared-instance/shared-instance.module.ts new file mode 100644 index 000000000..b75ad1a12 --- /dev/null +++ b/client/src/app/shared/shared-instance/shared-instance.module.ts @@ -0,0 +1,32 @@ + +import { NgModule } from '@angular/core' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { FeatureBooleanComponent } from './feature-boolean.component' +import { InstanceFeaturesTableComponent } from './instance-features-table.component' +import { InstanceFollowService } from './instance-follow.service' +import { InstanceStatisticsComponent } from './instance-statistics.component' +import { InstanceService } from './instance.service' + +@NgModule({ + imports: [ + SharedMainModule + ], + + declarations: [ + FeatureBooleanComponent, + InstanceFeaturesTableComponent, + InstanceStatisticsComponent + ], + + exports: [ + FeatureBooleanComponent, + InstanceFeaturesTableComponent, + InstanceStatisticsComponent + ], + + providers: [ + InstanceFollowService, + InstanceService + ] +}) +export class SharedInstanceModule { } diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts new file mode 100644 index 000000000..6df2e9d10 --- /dev/null +++ b/client/src/app/shared/shared-main/account/account.model.ts @@ -0,0 +1,30 @@ +import { Account as ServerAccount } from '@shared/models/actors/account.model' +import { Actor } from './actor.model' + +export class Account extends Actor implements ServerAccount { + displayName: string + description: string + nameWithHost: string + nameWithHostForced: string + mutedByUser: boolean + mutedByInstance: boolean + mutedServerByUser: boolean + mutedServerByInstance: boolean + + userId?: number + + constructor (hash: ServerAccount) { + super(hash) + + this.displayName = hash.displayName + this.description = hash.description + this.userId = hash.userId + this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) + this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) + + this.mutedByUser = false + this.mutedByInstance = false + this.mutedServerByUser = false + this.mutedServerByInstance = false + } +} diff --git a/client/src/app/shared/shared-main/account/account.service.ts b/client/src/app/shared/shared-main/account/account.service.ts new file mode 100644 index 000000000..8f4abf070 --- /dev/null +++ b/client/src/app/shared/shared-main/account/account.service.ts @@ -0,0 +1,29 @@ +import { Observable, ReplaySubject } from 'rxjs' +import { catchError, map, tap } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { Account as ServerAccount } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Account } from './account.model' + +@Injectable() +export class AccountService { + static BASE_ACCOUNT_URL = environment.apiUrl + '/api/v1/accounts/' + + accountLoaded = new ReplaySubject(1) + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) {} + + getAccount (id: number | string): Observable { + return this.authHttp.get(AccountService.BASE_ACCOUNT_URL + id) + .pipe( + map(accountHash => new Account(accountHash)), + tap(account => this.accountLoaded.next(account)), + catchError(res => this.restExtractor.handleError(res)) + ) + } +} diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.html b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html new file mode 100644 index 000000000..d01b9ac7f --- /dev/null +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html @@ -0,0 +1,24 @@ + +
    +
    + Avatar + +
    +
    + + + +
    +
    +
    + + +
    +
    +
    {{ actor.displayName }}
    +
    {{ actor.name }}
    +
    +
    {{ actor.followersCount }} subscribers
    +
    +
    +
    \ No newline at end of file diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss new file mode 100644 index 000000000..5a66ecfd2 --- /dev/null +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss @@ -0,0 +1,71 @@ +@import '_variables'; +@import '_mixins'; + +.actor { + display: flex; + + img { + @include avatar(100px); + + margin-right: 15px; + } + + .actor-img-edit-container { + position: relative; + width: 0; + + .actor-img-edit-button { + @include peertube-button-file(21px); + @include button-with-icon(19px); + + margin-top: 10px; + margin-bottom: 5px; + border-radius: 50%; + top: 55px; + right: 45px; + cursor: pointer; + + input { + width: 30px; + height: 30px; + } + + my-global-icon { + right: 7px; + } + } + } + + .actor-info { + justify-content: center; + display: inline-flex; + flex-direction: column; + + .actor-info-names { + display: flex; + align-items: center; + + .actor-info-display-name { + font-size: 20px; + font-weight: $font-bold; + + @media screen and (max-width: $small-view) { + font-size: 16px; + } + } + + .actor-info-username { + margin-left: 7px; + position: relative; + top: 2px; + font-size: 14px; + color: $grey-actor-name; + } + } + + .actor-info-followers { + font-size: 15px; + padding-bottom: .5rem; + } + } +} diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts new file mode 100644 index 000000000..0c04ae4a6 --- /dev/null +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts @@ -0,0 +1,64 @@ +import { BytesPipe } from 'ngx-pipes' +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { Notifier, ServerService } from '@app/core' +import { Account, VideoChannel } from '@app/shared/shared-main' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig } from '@shared/models' + +@Component({ + selector: 'my-actor-avatar-info', + templateUrl: './actor-avatar-info.component.html', + styleUrls: [ './actor-avatar-info.component.scss' ] +}) +export class ActorAvatarInfoComponent implements OnInit { + @ViewChild('avatarfileInput') avatarfileInput: ElementRef + + @Input() actor: VideoChannel | Account + + @Output() avatarChange = new EventEmitter() + + maxSizeText: string + + private serverConfig: ServerConfig + private bytesPipe: BytesPipe + + constructor ( + private serverService: ServerService, + private notifier: Notifier, + private i18n: I18n + ) { + this.bytesPipe = new BytesPipe() + this.maxSizeText = this.i18n('max size') + } + + ngOnInit (): void { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + } + + onAvatarChange () { + const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ] + if (avatarfile.size > this.maxAvatarSize) { + this.notifier.error('Error', 'This image is too large.') + return + } + + const formData = new FormData() + formData.append('avatarfile', avatarfile) + + this.avatarChange.emit(formData) + } + + get maxAvatarSize () { + return this.serverConfig.avatar.file.size.max + } + + get maxAvatarSizeInBytes () { + return this.bytesPipe.transform(this.maxAvatarSize) + } + + get avatarExtensions () { + return this.serverConfig.avatar.file.extensions.join(', ') + } +} diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts new file mode 100644 index 000000000..5fc7989dd --- /dev/null +++ b/client/src/app/shared/shared-main/account/actor.model.ts @@ -0,0 +1,65 @@ +import { Actor as ActorServer, Avatar } from '@shared/models' +import { getAbsoluteAPIUrl } from '@app/helpers' + +export abstract class Actor implements ActorServer { + id: number + url: string + name: string + host: string + followingCount: number + followersCount: number + createdAt: Date | string + updatedAt: Date | string + avatar: Avatar + + avatarUrl: string + + static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) { + if (actor?.avatar?.url) return actor.avatar.url + + if (actor && actor.avatar) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + + return absoluteAPIUrl + actor.avatar.path + } + + return this.GET_DEFAULT_AVATAR_URL() + } + + static GET_DEFAULT_AVATAR_URL () { + return window.location.origin + '/client/assets/images/default-avatar.png' + } + + static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + + if (host.trim() === thisHost && !forceHostname) return accountName + + return accountName + '@' + host + } + + protected constructor (hash: ActorServer) { + this.id = hash.id + this.url = hash.url + this.name = hash.name + this.host = hash.host + this.followingCount = hash.followingCount + this.followersCount = hash.followersCount + this.createdAt = new Date(hash.createdAt.toString()) + this.updatedAt = new Date(hash.updatedAt.toString()) + this.avatar = hash.avatar + + this.updateComputedAttributes() + } + + updateAvatar (newAvatar: Avatar) { + this.avatar = newAvatar + + this.updateComputedAttributes() + } + + private updateComputedAttributes () { + this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this) + } +} diff --git a/client/src/app/shared/shared-main/account/avatar.component.html b/client/src/app/shared/shared-main/account/avatar.component.html new file mode 100644 index 000000000..09871fca4 --- /dev/null +++ b/client/src/app/shared/shared-main/account/avatar.component.html @@ -0,0 +1,8 @@ + diff --git a/client/src/app/shared/shared-main/account/avatar.component.scss b/client/src/app/shared/shared-main/account/avatar.component.scss new file mode 100644 index 000000000..37709fce6 --- /dev/null +++ b/client/src/app/shared/shared-main/account/avatar.component.scss @@ -0,0 +1,40 @@ +@import '_mixins'; + +.wrapper { + $avatar-size: 35px; + + width: $avatar-size; + height: $avatar-size; + position: relative; + margin-right: 5px; + margin-bottom: 5px; + + &.avatar-sm { + width: 28px; + height: 28px; + margin-bottom: 3px; + } + + a { + @include disable-outline; + } + + a img { + height: 100%; + object-fit: cover; + position: absolute; + top:50%; + left:50%; + border-radius: 50%; + transform: translate(-50%,-50%) + } + + a:nth-of-type(2) img { + height: 60%; + width: 60%; + border: 2px solid pvar(--mainBackgroundColor); + transform: translateX(15%); + position: relative; + background-color: pvar(--mainBackgroundColor); + } +} diff --git a/client/src/app/shared/shared-main/account/avatar.component.ts b/client/src/app/shared/shared-main/account/avatar.component.ts new file mode 100644 index 000000000..31f39c200 --- /dev/null +++ b/client/src/app/shared/shared-main/account/avatar.component.ts @@ -0,0 +1,31 @@ +import { Component, Input, OnInit } from '@angular/core' +import { Video } from '../video/video.model' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'avatar-channel', + templateUrl: './avatar.component.html', + styleUrls: [ './avatar.component.scss' ] +}) +export class AvatarComponent implements OnInit { + @Input() video: Video + @Input() size: 'md' | 'sm' = 'md' + + channelLinkTitle = '' + accountLinkTitle = '' + + constructor ( + private i18n: I18n + ) {} + + ngOnInit () { + this.channelLinkTitle = this.i18n( + '{{name}} (channel page)', + { name: this.video.channel.name, handle: this.video.byVideoChannel } + ) + this.accountLinkTitle = this.i18n( + '{{name}} (account page)', + { name: this.video.account.name, handle: this.video.byAccount } + ) + } +} diff --git a/client/src/app/shared/shared-main/account/index.ts b/client/src/app/shared/shared-main/account/index.ts new file mode 100644 index 000000000..f5b9f3634 --- /dev/null +++ b/client/src/app/shared/shared-main/account/index.ts @@ -0,0 +1,5 @@ +export * from './account.model' +export * from './account.service' +export * from './actor-avatar-info.component' +export * from './actor.model' +export * from './avatar.component' diff --git a/client/src/app/shared/shared-main/angular/from-now.pipe.ts b/client/src/app/shared/shared-main/angular/from-now.pipe.ts new file mode 100644 index 000000000..9851468ee --- /dev/null +++ b/client/src/app/shared/shared-main/angular/from-now.pipe.ts @@ -0,0 +1,39 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { I18n } from '@ngx-translate/i18n-polyfill' + +// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site +@Pipe({ name: 'myFromNow' }) +export class FromNowPipe implements PipeTransform { + + constructor (private i18n: I18n) { } + + transform (arg: number | Date | string) { + const argDate = new Date(arg) + const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) + + let interval = Math.floor(seconds / 31536000) + if (interval > 1) return this.i18n('{{interval}} years ago', { interval }) + if (interval === 1) return this.i18n('{{interval}} year ago', { interval }) + + interval = Math.floor(seconds / 2592000) + if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) + if (interval === 1) return this.i18n('{{interval}} month ago', { interval }) + + interval = Math.floor(seconds / 604800) + if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval }) + if (interval === 1) return this.i18n('{{interval}} week ago', { interval }) + + interval = Math.floor(seconds / 86400) + if (interval > 1) return this.i18n('{{interval}} days ago', { interval }) + if (interval === 1) return this.i18n('{{interval}} day ago', { interval }) + + interval = Math.floor(seconds / 3600) + if (interval > 1) return this.i18n('{{interval}} hours ago', { interval }) + if (interval === 1) return this.i18n('{{interval}} hour ago', { interval }) + + interval = Math.floor(seconds / 60) + if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) + + return this.i18n('just now') + } +} diff --git a/client/src/app/shared/shared-main/angular/index.ts b/client/src/app/shared/shared-main/angular/index.ts new file mode 100644 index 000000000..3b072fb84 --- /dev/null +++ b/client/src/app/shared/shared-main/angular/index.ts @@ -0,0 +1,4 @@ +export * from './from-now.pipe' +export * from './infinite-scroller.directive' +export * from './number-formatter.pipe' +export * from './peertube-template.directive' diff --git a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts new file mode 100644 index 000000000..f09c3d1fc --- /dev/null +++ b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts @@ -0,0 +1,96 @@ +import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' +import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' +import { fromEvent, Observable, Subscription } from 'rxjs' + +@Directive({ + selector: '[myInfiniteScroller]' +}) +export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked { + @Input() percentLimit = 70 + @Input() autoInit = false + @Input() onItself = false + @Input() dataObservable: Observable + + @Output() nearOfBottom = new EventEmitter() + + private decimalLimit = 0 + private lastCurrentBottom = -1 + private scrollDownSub: Subscription + private container: HTMLElement + + private checkScroll = false + + constructor (private el: ElementRef) { + this.decimalLimit = this.percentLimit / 100 + } + + ngAfterContentChecked () { + if (this.checkScroll) { + this.checkScroll = false + + console.log('Checking if the initial state has a scroll.') + + if (this.hasScroll() === false) this.nearOfBottom.emit() + } + } + + ngOnInit () { + if (this.autoInit === true) return this.initialize() + } + + ngOnDestroy () { + if (this.scrollDownSub) this.scrollDownSub.unsubscribe() + } + + initialize () { + this.container = this.onItself + ? this.el.nativeElement + : document.documentElement + + // Emit the last value + const throttleOptions = { leading: true, trailing: true } + + const scrollableElement = this.onItself ? this.container : window + const scrollObservable = fromEvent(scrollableElement, 'scroll') + .pipe( + startWith(true), + throttleTime(200, undefined, throttleOptions), + map(() => this.getScrollInfo()), + distinctUntilChanged((o1, o2) => o1.current === o2.current), + share() + ) + + // Scroll Down + this.scrollDownSub = scrollObservable + .pipe( + filter(({ current }) => this.isScrollingDown(current)), + filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) + ) + .subscribe(() => this.nearOfBottom.emit()) + + if (this.dataObservable) { + this.dataObservable + .pipe(filter(d => d.length !== 0)) + .subscribe(() => this.checkScroll = true) + } + } + + private getScrollInfo () { + return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() } + } + + private getMaximumScroll () { + return this.container.scrollHeight - window.innerHeight + } + + private hasScroll () { + return this.getMaximumScroll() > 0 + } + + private isScrollingDown (current: number) { + const result = this.lastCurrentBottom < current + + this.lastCurrentBottom = current + return result + } +} diff --git a/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts new file mode 100644 index 000000000..8a0756a36 --- /dev/null +++ b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core' + +// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts + +@Pipe({ name: 'myNumberFormatter' }) +export class NumberFormatterPipe implements PipeTransform { + private dictionary: Array<{max: number, type: string}> = [ + { max: 1000, type: '' }, + { max: 1000000, type: 'K' }, + { max: 1000000000, type: 'M' } + ] + + transform (value: number) { + const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1] + const calc = Math.floor(value / (format.max / 1000)) + + return `${calc}${format.type}` + } +} diff --git a/client/src/app/shared/shared-main/angular/peertube-template.directive.ts b/client/src/app/shared/shared-main/angular/peertube-template.directive.ts new file mode 100644 index 000000000..e04c25d9a --- /dev/null +++ b/client/src/app/shared/shared-main/angular/peertube-template.directive.ts @@ -0,0 +1,12 @@ +import { Directive, Input, TemplateRef } from '@angular/core' + +@Directive({ + selector: '[ptTemplate]' +}) +export class PeerTubeTemplateDirective { + @Input('ptTemplate') name: T + + constructor (public template: TemplateRef) { + // empty + } +} diff --git a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts new file mode 100644 index 000000000..68a4acdb5 --- /dev/null +++ b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts @@ -0,0 +1,60 @@ +import { Observable, throwError as observableThrowError } from 'rxjs' +import { catchError, switchMap } from 'rxjs/operators' +import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http' +import { Injectable, Injector } from '@angular/core' +import { AuthService } from '@app/core/auth/auth.service' + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private authService: AuthService + + // https://github.com/angular/angular/issues/18224#issuecomment-316957213 + constructor (private injector: Injector) {} + + intercept (req: HttpRequest, next: HttpHandler): Observable> { + if (this.authService === undefined) { + this.authService = this.injector.get(AuthService) + } + + const authReq = this.cloneRequestWithAuth(req) + + // Pass on the cloned request instead of the original request + // Catch 401 errors (refresh token expired) + return next.handle(authReq) + .pipe( + catchError(err => { + if (err.status === 401 && err.error && err.error.code === 'invalid_token') { + return this.handleTokenExpired(req, next) + } + + return observableThrowError(err) + }) + ) + } + + private handleTokenExpired (req: HttpRequest, next: HttpHandler): Observable> { + return this.authService.refreshAccessToken() + .pipe( + switchMap(() => { + const authReq = this.cloneRequestWithAuth(req) + + return next.handle(authReq) + }) + ) + } + + private cloneRequestWithAuth (req: HttpRequest) { + const authHeaderValue = this.authService.getRequestHeaderValue() + + if (authHeaderValue === null) return req + + // Clone the request to add the new header + return req.clone({ headers: req.headers.set('Authorization', authHeaderValue) }) + } +} + +export const AUTH_INTERCEPTOR_PROVIDER = { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true +} diff --git a/client/src/app/shared/shared-main/auth/index.ts b/client/src/app/shared/shared-main/auth/index.ts new file mode 100644 index 000000000..84a07196f --- /dev/null +++ b/client/src/app/shared/shared-main/auth/index.ts @@ -0,0 +1 @@ +export * from './auth-interceptor.service' diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.html b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html new file mode 100644 index 000000000..12933d4ca --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html @@ -0,0 +1,55 @@ + diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss new file mode 100644 index 000000000..724a04efc --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss @@ -0,0 +1,72 @@ +@import '_variables'; +@import '_mixins'; + +.dropdown-divider:last-child { + display: none; +} + +.action-button { + @include peertube-button; + + &.button-styled { + + &.grey { + @include grey-button; + } + + &.orange { + @include orange-button; + } + + &:hover, &:active, &:focus { + background-color: $grey-background-color; + } + } + + display: inline-block; + padding: 0 10px; + + &::after { + display: none; + } + + .more-icon { + width: 21px; + + ::ng-deep { + @include apply-svg-color(pvar(--actionButtonColor)); + } + } + + &.small { + font-size: 14px; + height: 20px; + line-height: 20px; + } +} + +.dropdown-toggle::after { + position: relative; + top: 1px; +} + +.dropdown-menu { + .dropdown-header { + padding: 0.2rem 1rem; + } + + .dropdown-item { + display: flex; + cursor: pointer; + color: #000 !important; + + &.with-icon { + @include dropdown-with-icon-item; + } + + a, span { + display: block; + width: 100%; + } + } +} diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts b/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts new file mode 100644 index 000000000..36d7d6229 --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts @@ -0,0 +1,52 @@ +import { Component, Input } from '@angular/core' +import { GlobalIconName } from '@app/shared/shared-icons' + +export type DropdownAction = { + label?: string + iconName?: GlobalIconName + description?: string + title?: string + handler?: (a: T) => any + linkBuilder?: (a: T) => (string | number)[] + isDisplayed?: (a: T) => boolean + isHeader?: boolean +} + +export type DropdownButtonSize = 'normal' | 'small' +export type DropdownTheme = 'orange' | 'grey' +export type DropdownDirection = 'horizontal' | 'vertical' + +@Component({ + selector: 'my-action-dropdown', + styleUrls: [ './action-dropdown.component.scss' ], + templateUrl: './action-dropdown.component.html' +}) + +export class ActionDropdownComponent { + @Input() actions: DropdownAction[] | DropdownAction[][] = [] + @Input() entry: T + + @Input() placement = 'bottom-left auto' + @Input() container: null | 'body' + + @Input() buttonSize: DropdownButtonSize = 'normal' + @Input() buttonDirection: DropdownDirection = 'horizontal' + @Input() buttonStyled = true + + @Input() label: string + @Input() theme: DropdownTheme = 'grey' + + getActions (): DropdownAction[][] { + if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction[][] + + return [ this.actions as DropdownAction[] ] + } + + areActionsDisplayed (actions: Array | DropdownAction[]>, entry: T): boolean { + return actions.some(a => { + if (Array.isArray(a)) return this.areActionsDisplayed(a, entry) + + return a.isDisplayed === undefined || a.isDisplayed(entry) + }) + } +} diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html new file mode 100644 index 000000000..d2b0eb81a --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/button.component.html @@ -0,0 +1,6 @@ + + + + + {{ label }} + diff --git a/client/src/app/shared/shared-main/buttons/button.component.scss b/client/src/app/shared/shared-main/buttons/button.component.scss new file mode 100644 index 000000000..3ccfefd7e --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/button.component.scss @@ -0,0 +1,46 @@ +@import '_variables'; +@import '_mixins'; + +my-small-loader ::ng-deep .root { + display: inline-block; + margin: 0 3px 0 0; + width: 20px; +} + +.action-button { + @include peertube-button-link; + @include button-with-icon(21px, 0, -2px); +} + +.orange-button { + @include peertube-button; + @include orange-button; +} + +.orange-button-link { + @include peertube-button-link; + @include orange-button; +} + +.grey-button { + @include peertube-button; + @include grey-button; +} + +.grey-button-link { + @include peertube-button-link; + @include grey-button; +} + +// In a table, try to minimize the space taken by this button +@media screen and (max-width: 1400px) { + :host-context(td) { + .action-button { + padding: 0 13px; + } + + .button-label { + display: none; + } + } +} diff --git a/client/src/app/shared/shared-main/buttons/button.component.ts b/client/src/app/shared/shared-main/buttons/button.component.ts new file mode 100644 index 000000000..e23b90945 --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/button.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core' +import { GlobalIconName } from '@app/shared/shared-icons' + +@Component({ + selector: 'my-button', + styleUrls: ['./button.component.scss'], + templateUrl: './button.component.html' +}) + +export class ButtonComponent { + @Input() label = '' + @Input() className = 'grey-button' + @Input() icon: GlobalIconName = undefined + @Input() title: string = undefined + @Input() loading = false + + getTitle () { + return this.title || this.label + } +} diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.html b/client/src/app/shared/shared-main/buttons/delete-button.component.html new file mode 100644 index 000000000..398b6db1e --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/delete-button.component.html @@ -0,0 +1,6 @@ + + + + {{ label }} + Delete + diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts new file mode 100644 index 000000000..39e31900f --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts @@ -0,0 +1,20 @@ +import { Component, Input, OnInit } from '@angular/core' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-delete-button', + styleUrls: [ './button.component.scss' ], + templateUrl: './delete-button.component.html' +}) + +export class DeleteButtonComponent implements OnInit { + @Input() label: string + + title: string + + constructor (private i18n: I18n) { } + + ngOnInit () { + this.title = this.label || this.i18n('Delete') + } +} diff --git a/client/src/app/shared/shared-main/buttons/edit-button.component.html b/client/src/app/shared/shared-main/buttons/edit-button.component.html new file mode 100644 index 000000000..b852bb38a --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/edit-button.component.html @@ -0,0 +1,6 @@ + + + + {{ label }} + Edit + diff --git a/client/src/app/shared/shared-main/buttons/edit-button.component.ts b/client/src/app/shared/shared-main/buttons/edit-button.component.ts new file mode 100644 index 000000000..9cfe1a3bb --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/edit-button.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'my-edit-button', + styleUrls: [ './button.component.scss' ], + templateUrl: './edit-button.component.html' +}) + +export class EditButtonComponent { + @Input() label: string + @Input() routerLink: string[] | string = [] +} diff --git a/client/src/app/shared/shared-main/buttons/index.ts b/client/src/app/shared/shared-main/buttons/index.ts new file mode 100644 index 000000000..775a47a39 --- /dev/null +++ b/client/src/app/shared/shared-main/buttons/index.ts @@ -0,0 +1,4 @@ +export * from './action-dropdown.component' +export * from './button.component' +export * from './delete-button.component' +export * from './edit-button.component' diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.html b/client/src/app/shared/shared-main/date/date-toggle.component.html new file mode 100644 index 000000000..ebd4ce442 --- /dev/null +++ b/client/src/app/shared/shared-main/date/date-toggle.component.html @@ -0,0 +1,6 @@ + diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.scss b/client/src/app/shared/shared-main/date/date-toggle.component.scss new file mode 100644 index 000000000..86700d1d4 --- /dev/null +++ b/client/src/app/shared/shared-main/date/date-toggle.component.scss @@ -0,0 +1,5 @@ +.date-toggle { + &:hover { + cursor: default + } +} diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.ts b/client/src/app/shared/shared-main/date/date-toggle.component.ts new file mode 100644 index 000000000..bedf0ba4e --- /dev/null +++ b/client/src/app/shared/shared-main/date/date-toggle.component.ts @@ -0,0 +1,46 @@ +import { DatePipe } from '@angular/common' +import { Component, Input, OnChanges, OnInit } from '@angular/core' +import { FromNowPipe } from '../angular/from-now.pipe' + +@Component({ + selector: 'my-date-toggle', + templateUrl: './date-toggle.component.html', + styleUrls: [ './date-toggle.component.scss' ] +}) +export class DateToggleComponent implements OnInit, OnChanges { + @Input() date: Date + @Input() toggled = false + + dateRelative: string + dateAbsolute: string + + constructor ( + private datePipe: DatePipe, + private fromNowPipe: FromNowPipe + ) { } + + ngOnInit () { + this.updateDates() + } + + ngOnChanges () { + this.updateDates() + } + + toggle () { + this.toggled = !this.toggled + } + + getTitle () { + return this.toggled ? this.dateRelative : this.dateAbsolute + } + + getContent () { + return this.toggled ? this.dateAbsolute : this.dateRelative + } + + private updateDates () { + this.dateRelative = this.fromNowPipe.transform(this.date) + this.dateAbsolute = this.datePipe.transform(this.date, 'long') + } +} diff --git a/client/src/app/shared/shared-main/date/index.ts b/client/src/app/shared/shared-main/date/index.ts new file mode 100644 index 000000000..db00aef52 --- /dev/null +++ b/client/src/app/shared/shared-main/date/index.ts @@ -0,0 +1 @@ +export * from './date-toggle.component' diff --git a/client/src/app/shared/shared-main/feeds/feed.component.html b/client/src/app/shared/shared-main/feeds/feed.component.html new file mode 100644 index 000000000..ac0b1f454 --- /dev/null +++ b/client/src/app/shared/shared-main/feeds/feed.component.html @@ -0,0 +1,15 @@ +
    + + + + + {{ item.label }} + +
    diff --git a/client/src/app/shared/shared-main/feeds/feed.component.scss b/client/src/app/shared/shared-main/feeds/feed.component.scss new file mode 100644 index 000000000..34dd0e937 --- /dev/null +++ b/client/src/app/shared/shared-main/feeds/feed.component.scss @@ -0,0 +1,20 @@ +@import '_variables'; +@import '_mixins'; + +.video-feed { + width: min-content; + + a { + color: black; + display: block; + } + + my-global-icon { + cursor: pointer; + width: 12px; + position: relative; + top: -2px; + + @include apply-svg-color(pvar(--mainForegroundColor)) + } +} diff --git a/client/src/app/shared/shared-main/feeds/feed.component.ts b/client/src/app/shared/shared-main/feeds/feed.component.ts new file mode 100644 index 000000000..ee3731c1d --- /dev/null +++ b/client/src/app/shared/shared-main/feeds/feed.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core' +import { Syndication } from './syndication.model' + +@Component({ + selector: 'my-feed', + styleUrls: [ './feed.component.scss' ], + templateUrl: './feed.component.html' +}) +export class FeedComponent { + @Input() syndicationItems: Syndication[] +} diff --git a/client/src/app/shared/shared-main/feeds/index.ts b/client/src/app/shared/shared-main/feeds/index.ts new file mode 100644 index 000000000..6bc396699 --- /dev/null +++ b/client/src/app/shared/shared-main/feeds/index.ts @@ -0,0 +1,2 @@ +export * from './feed.component' +export * from './syndication.model' diff --git a/client/src/app/shared/shared-main/feeds/syndication.model.ts b/client/src/app/shared/shared-main/feeds/syndication.model.ts new file mode 100644 index 000000000..2466ae7c6 --- /dev/null +++ b/client/src/app/shared/shared-main/feeds/syndication.model.ts @@ -0,0 +1,7 @@ +import { FeedFormat } from '@shared/models' + +export interface Syndication { + format: FeedFormat, + label: string, + url: string +} diff --git a/client/src/app/shared/shared-main/index.ts b/client/src/app/shared/shared-main/index.ts new file mode 100644 index 000000000..a4d813c06 --- /dev/null +++ b/client/src/app/shared/shared-main/index.ts @@ -0,0 +1,12 @@ +export * from './account' +export * from './angular' +export * from './buttons' +export * from './date' +export * from './feeds' +export * from './loaders' +export * from './misc' +export * from './users' +export * from './video' +export * from './video-caption' +export * from './video-channel' +export * from './shared-main.module' diff --git a/client/src/app/shared/shared-main/loaders/index.ts b/client/src/app/shared/shared-main/loaders/index.ts new file mode 100644 index 000000000..a061914d5 --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/index.ts @@ -0,0 +1,2 @@ +export * from './loader.component' +export * from './small-loader.component' diff --git a/client/src/app/shared/shared-main/loaders/loader.component.html b/client/src/app/shared/shared-main/loaders/loader.component.html new file mode 100644 index 000000000..ca8ed063e --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/loader.component.html @@ -0,0 +1,8 @@ +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/client/src/app/shared/shared-main/loaders/loader.component.scss b/client/src/app/shared/shared-main/loaders/loader.component.scss new file mode 100644 index 000000000..ffac9c707 --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/loader.component.scss @@ -0,0 +1,45 @@ +@import '_variables'; +@import '_mixins'; + +// Thanks to https://loading.io/css/ (CC0 License) + +.loader { + display: inline-block; + position: relative; + width: 50px; + height: 50px; +} + +.loader div { + box-sizing: border-box; + display: block; + position: absolute; + width: 44px; + height: 44px; + margin: 6px; + border: 4px solid; + border-radius: 50%; + animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: #999999 transparent transparent transparent; +} + +.loader div:nth-child(1) { + animation-delay: -0.45s; +} + +.loader div:nth-child(2) { + animation-delay: -0.3s; +} + +.loader div:nth-child(3) { + animation-delay: -0.15s; +} + +@keyframes loader { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/client/src/app/shared/shared-main/loaders/loader.component.ts b/client/src/app/shared/shared-main/loaders/loader.component.ts new file mode 100644 index 000000000..e3b1eea3a --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/loader.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'my-loader', + styleUrls: [ './loader.component.scss' ], + templateUrl: './loader.component.html' +}) +export class LoaderComponent { + @Input() loading: boolean +} diff --git a/client/src/app/shared/shared-main/loaders/small-loader.component.html b/client/src/app/shared/shared-main/loaders/small-loader.component.html new file mode 100644 index 000000000..7886f8918 --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/small-loader.component.html @@ -0,0 +1,3 @@ +
    +
    +
    diff --git a/client/src/app/shared/shared-main/loaders/small-loader.component.ts b/client/src/app/shared/shared-main/loaders/small-loader.component.ts new file mode 100644 index 000000000..191877f14 --- /dev/null +++ b/client/src/app/shared/shared-main/loaders/small-loader.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'my-small-loader', + styleUrls: [ ], + templateUrl: './small-loader.component.html' +}) + +export class SmallLoaderComponent { + @Input() loading: boolean +} diff --git a/client/src/app/shared/shared-main/misc/help.component.html b/client/src/app/shared/shared-main/misc/help.component.html new file mode 100644 index 000000000..9a6d3e48e --- /dev/null +++ b/client/src/app/shared/shared-main/misc/help.component.html @@ -0,0 +1,40 @@ + +

    + +

    + + +

    +
    + +

    + +

    + +

    + + +

    +
    + +

    + +

    +
    + + + + diff --git a/client/src/app/shared/shared-main/misc/help.component.scss b/client/src/app/shared/shared-main/misc/help.component.scss new file mode 100644 index 000000000..43f33a53a --- /dev/null +++ b/client/src/app/shared/shared-main/misc/help.component.scss @@ -0,0 +1,42 @@ +@import '_variables'; +@import '_mixins'; + +.help-tooltip-button { + cursor: pointer; + border: none; + + my-global-icon { + width: 17px; + position: relative; + top: -2px; + margin: 5px; + + @include apply-svg-color(pvar(--mainForegroundColor)) + } +} + +::ng-deep { + .help-popover { + z-index: z(help-popover) !important; + max-width: 300px; + + .popover-body { + font-family: $main-fonts; + text-align: left; + padding: 10px; + font-size: 13px; + background-color: pvar(--mainBackgroundColor); + color: pvar(--mainForegroundColor); + border-radius: 3px; + + p { + margin-bottom: 0; + } + + ul { + padding-left: 20px; + margin-bottom: 0; + } + } + } +} diff --git a/client/src/app/shared/shared-main/misc/help.component.ts b/client/src/app/shared/shared-main/misc/help.component.ts new file mode 100644 index 000000000..0825b96de --- /dev/null +++ b/client/src/app/shared/shared-main/misc/help.component.ts @@ -0,0 +1,94 @@ +import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core' +import { MarkdownService } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { PeerTubeTemplateDirective } from '../angular' + +@Component({ + selector: 'my-help', + styleUrls: [ './help.component.scss' ], + templateUrl: './help.component.html' +}) + +export class HelpComponent implements OnInit, OnChanges, AfterContentInit { + @Input() helpType: 'custom' | 'markdownText' | 'markdownEnhanced' = 'custom' + @Input() tooltipPlacement = 'right auto' + + @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> + + isPopoverOpened = false + mainHtml = '' + + preHtmlTemplate: TemplateRef + customHtmlTemplate: TemplateRef + postHtmlTemplate: TemplateRef + + constructor (private i18n: I18n) { } + + ngOnInit () { + this.init() + } + + ngAfterContentInit () { + { + const t = this.templates.find(t => t.name === 'preHtml') + if (t) this.preHtmlTemplate = t.template + } + + { + const t = this.templates.find(t => t.name === 'customHtml') + if (t) this.customHtmlTemplate = t.template + } + + { + const t = this.templates.find(t => t.name === 'postHtml') + if (t) this.postHtmlTemplate = t.template + } + } + + ngOnChanges () { + this.init() + } + + onPopoverHidden () { + this.isPopoverOpened = false + } + + onPopoverShown () { + this.isPopoverOpened = true + } + + private init () { + if (this.helpType === 'markdownText') { + this.mainHtml = this.formatMarkdownSupport(MarkdownService.TEXT_RULES) + return + } + + if (this.helpType === 'markdownEnhanced') { + this.mainHtml = this.formatMarkdownSupport(MarkdownService.ENHANCED_RULES) + return + } + } + + private formatMarkdownSupport (rules: string[]) { + // tslint:disable:max-line-length + return this.i18n('Markdown compatible that supports:') + + this.createMarkdownList(rules) + } + + private createMarkdownList (rules: string[]) { + const rulesToText = { + 'emphasis': this.i18n('Emphasis'), + 'link': this.i18n('Links'), + 'newline': this.i18n('New lines'), + 'list': this.i18n('Lists'), + 'image': this.i18n('Images') + } + + const bullets = rules.map(r => rulesToText[r]) + .filter(text => text) + .map(text => '
  • ' + text + '
  • ') + .join('') + + return '
      ' + bullets + '
    ' + } +} diff --git a/client/src/app/shared/shared-main/misc/index.ts b/client/src/app/shared/shared-main/misc/index.ts new file mode 100644 index 000000000..d3e7e4be7 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/index.ts @@ -0,0 +1,2 @@ +export * from './help.component' +export * from './list-overflow.component' diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.html b/client/src/app/shared/shared-main/misc/list-overflow.component.html new file mode 100644 index 000000000..986572801 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/list-overflow.component.html @@ -0,0 +1,35 @@ +
    + + + + + + + +
    + + + +
    +
    +
    + + + + diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.scss b/client/src/app/shared/shared-main/misc/list-overflow.component.scss new file mode 100644 index 000000000..1ec044489 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/list-overflow.component.scss @@ -0,0 +1,61 @@ +@import '_mixins'; + +:host { + width: 100%; +} + +.list-overflow-parent { + overflow: hidden; +} + +.list-overflow-menu { + position: absolute; + right: 25px; +} + +button { + width: 30px; + border: none; + + &::after { + display: none; + } + + &.routeActive { + &::after { + display: inherit; + border: 2px solid pvar(--mainColor); + position: relative; + right: 95%; + top: 50%; + } + } +} + +::ng-deep .dropdown-menu { + margin-top: 0 !important; + position: static; + right: auto; + bottom: auto +} + +.modal-body { + a { + @include disable-default-a-behaviour; + + color: currentColor; + box-sizing: border-box; + display: block; + font-size: 1.2rem; + padding: 9px 12px; + text-align: initial; + text-transform: unset; + width: 100%; + + &.active { + color: pvar(--mainBackgroundColor) !important; + background-color: pvar(--mainHoverColor); + opacity: .9; + } + } +} diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.ts b/client/src/app/shared/shared-main/misc/list-overflow.component.ts new file mode 100644 index 000000000..144e0f156 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/list-overflow.component.ts @@ -0,0 +1,120 @@ +import { lowerFirst, uniqueId } from 'lodash-es' +import { take } from 'rxjs/operators' +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostListener, + Input, + QueryList, + TemplateRef, + ViewChild, + ViewChildren +} from '@angular/core' +import { ScreenService } from '@app/core' +import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' + +export interface ListOverflowItem { + label: string + routerLink: string | any[] +} + +@Component({ + selector: 'list-overflow', + templateUrl: './list-overflow.component.html', + styleUrls: [ './list-overflow.component.scss' ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListOverflowComponent implements AfterViewInit { + @Input() items: T[] + @Input() itemTemplate: TemplateRef<{item: T}> + + @ViewChild('modal', { static: true }) modal: ElementRef + @ViewChild('itemsParent', { static: true }) parent: ElementRef + @ViewChildren('itemsRendered') itemsRendered: QueryList + + showItemsUntilIndexExcluded: number + active = false + isInTouchScreen = false + isInMobileView = false + + private openedOnHover = false + + constructor ( + private cdr: ChangeDetectorRef, + private modalService: NgbModal, + private screenService: ScreenService + ) {} + + ngAfterViewInit () { + setTimeout(() => this.onWindowResize(), 0) + } + + isMenuDisplayed () { + return !!this.showItemsUntilIndexExcluded + } + + @HostListener('window:resize') + onWindowResize () { + this.isInTouchScreen = !!this.screenService.isInTouchScreen() + this.isInMobileView = !!this.screenService.isInMobileView() + + const parentWidth = this.parent.nativeElement.getBoundingClientRect().width + let showItemsUntilIndexExcluded: number + let accWidth = 0 + + for (const [index, el] of this.itemsRendered.toArray().entries()) { + accWidth += el.nativeElement.getBoundingClientRect().width + if (showItemsUntilIndexExcluded === undefined) { + showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined + } + + const e = document.getElementById(this.getId(index)) + const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true + e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden' + } + + this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded + this.cdr.markForCheck() + } + + openDropdownOnHover (dropdown: NgbDropdown) { + this.openedOnHover = true + dropdown.open() + + // Menu was closed + dropdown.openChange + .pipe(take(1)) + .subscribe(() => this.openedOnHover = false) + } + + dropdownAnchorClicked (dropdown: NgbDropdown) { + if (this.openedOnHover) { + this.openedOnHover = false + return + } + + return dropdown.toggle() + } + + closeDropdownIfHovered (dropdown: NgbDropdown) { + if (this.openedOnHover === false) return + + dropdown.close() + this.openedOnHover = false + } + + toggleModal () { + this.modalService.open(this.modal, { centered: true }) + } + + dismissOtherModals () { + this.modalService.dismissAll() + } + + getId (id: number | string = uniqueId()): string { + return lowerFirst(this.constructor.name) + '_' + id + } +} diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts new file mode 100644 index 000000000..fd96a42a0 --- /dev/null +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -0,0 +1,164 @@ +import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' +import { SharedModule as PrimeSharedModule } from 'primeng/api' +import { InputMaskModule } from 'primeng/inputmask' +import { InputSwitchModule } from 'primeng/inputswitch' +import { MultiSelectModule } from 'primeng/multiselect' +import { ClipboardModule } from '@angular/cdk/clipboard' +import { CommonModule, DatePipe } from '@angular/common' +import { HttpClientModule } from '@angular/common/http' +import { NgModule } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { RouterModule } from '@angular/router' +import { + NgbCollapseModule, + NgbDropdownModule, + NgbModalModule, + NgbNavModule, + NgbPopoverModule, + NgbTooltipModule +} from '@ng-bootstrap/ng-bootstrap' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { SharedGlobalIconModule } from '../shared-icons' +import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' +import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective } from './angular' +import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' +import { DateToggleComponent } from './date' +import { FeedComponent } from './feeds' +import { LoaderComponent, SmallLoaderComponent } from './loaders' +import { HelpComponent, ListOverflowComponent } from './misc' +import { UserHistoryService, UserNotificationsComponent, UserNotificationService } from './users' +import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' +import { VideoCaptionService } from './video-caption' +import { VideoChannelService } from './video-channel' +import { AUTH_INTERCEPTOR_PROVIDER } from './auth' + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + HttpClientModule, + + NgbDropdownModule, + NgbModalModule, + NgbPopoverModule, + NgbNavModule, + NgbTooltipModule, + NgbCollapseModule, + + ClipboardModule, + + PrimeSharedModule, + InputMaskModule, + NgPipesModule, + MultiSelectModule, + InputSwitchModule, + + SharedGlobalIconModule + ], + + declarations: [ + AvatarComponent, + ActorAvatarInfoComponent, + + FromNowPipe, + InfiniteScrollerDirective, + NumberFormatterPipe, + PeerTubeTemplateDirective, + + ActionDropdownComponent, + ButtonComponent, + DeleteButtonComponent, + EditButtonComponent, + + DateToggleComponent, + + FeedComponent, + + LoaderComponent, + SmallLoaderComponent, + + HelpComponent, + ListOverflowComponent, + + UserNotificationsComponent, + + FeedComponent + ], + + exports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + RouterModule, + HttpClientModule, + + NgbDropdownModule, + NgbModalModule, + NgbPopoverModule, + NgbNavModule, + NgbTooltipModule, + NgbCollapseModule, + + ClipboardModule, + + PrimeSharedModule, + InputMaskModule, + BytesPipe, + KeysPipe, + MultiSelectModule, + + AvatarComponent, + ActorAvatarInfoComponent, + + FromNowPipe, + InfiniteScrollerDirective, + NumberFormatterPipe, + PeerTubeTemplateDirective, + + ActionDropdownComponent, + ButtonComponent, + DeleteButtonComponent, + EditButtonComponent, + + DateToggleComponent, + + FeedComponent, + + LoaderComponent, + SmallLoaderComponent, + + HelpComponent, + ListOverflowComponent, + + UserNotificationsComponent, + + FeedComponent + ], + + providers: [ + I18n, + + DatePipe, + + FromNowPipe, + + AUTH_INTERCEPTOR_PROVIDER, + + AccountService, + + UserHistoryService, + UserNotificationService, + + RedundancyService, + VideoImportService, + VideoOwnershipService, + VideoService, + + VideoCaptionService, + + VideoChannelService + ] +}) +export class SharedMainModule { } diff --git a/client/src/app/shared/shared-main/users/index.ts b/client/src/app/shared/shared-main/users/index.ts new file mode 100644 index 000000000..83401ab52 --- /dev/null +++ b/client/src/app/shared/shared-main/users/index.ts @@ -0,0 +1,4 @@ +export * from './user-history.service' +export * from './user-notification.model' +export * from './user-notification.service' +export * from './user-notifications.component' diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts new file mode 100644 index 000000000..43970dc5b --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-history.service.ts @@ -0,0 +1,43 @@ +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { ResultList } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Video } from '../video/video.model' +import { VideoService } from '../video/video.service' + +@Injectable() +export class UserHistoryService { + static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private videoService: VideoService + ) {} + + getUserVideosHistory (historyPagination: ComponentPaginationLight) { + const pagination = this.restService.componentPaginationToRestPagination(historyPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp + .get>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) + .pipe( + switchMap(res => this.videoService.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + deleteUserVideosHistory () { + return this.authHttp + .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {}) + .pipe( + map(() => this.restExtractor.extractDataBool()), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts new file mode 100644 index 000000000..de25d3ab9 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notification.model.ts @@ -0,0 +1,184 @@ +import { Actor } from '../account/actor.model' +import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models' + +export class UserNotification implements UserNotificationServer { + id: number + type: UserNotificationType + read: boolean + + video?: VideoInfo & { + channel: ActorInfo & { avatarUrl?: string } + } + + videoImport?: { + id: number + video?: VideoInfo + torrentName?: string + magnetUri?: string + targetUrl?: string + } + + comment?: { + id: number + threadId: number + account: ActorInfo & { avatarUrl?: string } + video: VideoInfo + } + + videoAbuse?: { + id: number + video: VideoInfo + } + + videoBlacklist?: { + id: number + video: VideoInfo + } + + account?: ActorInfo & { avatarUrl?: string } + + actorFollow?: { + id: number + state: FollowState + follower: ActorInfo & { avatarUrl?: string } + following: { + type: 'account' | 'channel' | 'instance' + name: string + displayName: string + host: string + } + } + + createdAt: string + updatedAt: string + + // Additional fields + videoUrl?: string + commentUrl?: any[] + videoAbuseUrl?: string + videoAutoBlacklistUrl?: string + accountUrl?: string + videoImportIdentifier?: string + videoImportUrl?: string + instanceFollowUrl?: string + + constructor (hash: UserNotificationServer) { + this.id = hash.id + this.type = hash.type + this.read = hash.read + + // We assume that some fields exist + // To prevent a notification popup crash in case of bug, wrap it inside a try/catch + try { + this.video = hash.video + if (this.video) this.setAvatarUrl(this.video.channel) + + this.videoImport = hash.videoImport + + this.comment = hash.comment + if (this.comment) this.setAvatarUrl(this.comment.account) + + this.videoAbuse = hash.videoAbuse + + this.videoBlacklist = hash.videoBlacklist + + this.account = hash.account + if (this.account) this.setAvatarUrl(this.account) + + this.actorFollow = hash.actorFollow + if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower) + + this.createdAt = hash.createdAt + this.updatedAt = hash.updatedAt + + switch (this.type) { + case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO: + case UserNotificationType.COMMENT_MENTION: + if (!this.comment) break + this.accountUrl = this.buildAccountUrl(this.comment.account) + this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] + break + + case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: + this.videoAbuseUrl = '/admin/moderation/video-abuses/list' + this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) + break + + case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: + this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list' + // Backward compatibility where we did not assign videoBlacklist to this type of notification before + if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video } + + this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) + break + + case UserNotificationType.BLACKLIST_ON_MY_VIDEO: + this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) + break + + case UserNotificationType.MY_VIDEO_PUBLISHED: + this.videoUrl = this.buildVideoUrl(this.video) + break + + case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: + this.videoImportUrl = this.buildVideoImportUrl() + this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) + + if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video) + break + + case UserNotificationType.MY_VIDEO_IMPORT_ERROR: + this.videoImportUrl = this.buildVideoImportUrl() + this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) + break + + case UserNotificationType.NEW_USER_REGISTRATION: + this.accountUrl = this.buildAccountUrl(this.account) + break + + case UserNotificationType.NEW_FOLLOW: + this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) + break + + case UserNotificationType.NEW_INSTANCE_FOLLOWER: + this.instanceFollowUrl = '/admin/follows/followers-list' + break + + case UserNotificationType.AUTO_INSTANCE_FOLLOWING: + this.instanceFollowUrl = '/admin/follows/following-list' + break + } + } catch (err) { + this.type = null + console.error(err) + } + } + + private buildVideoUrl (video: { uuid: string }) { + return '/videos/watch/' + video.uuid + } + + private buildAccountUrl (account: { name: string, host: string }) { + return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host) + } + + private buildVideoImportUrl () { + return '/my-account/video-imports' + } + + private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) { + return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName + } + + private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { + actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts new file mode 100644 index 000000000..8dd9472fe --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notification.service.ts @@ -0,0 +1,81 @@ +import { catchError, map, tap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core' +import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { UserNotification } from './user-notification.model' + +@Injectable() +export class UserNotificationService { + static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications' + static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private userNotificationSocket: UserNotificationSocket + ) {} + + listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) { + let params = new HttpParams() + params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination)) + + if (unread) params = params.append('unread', `${unread}`) + + const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined + + return this.authHttp.get>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + countUnreadNotifications () { + return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true) + .pipe(map(n => n.total)) + } + + markAsRead (notification: UserNotification) { + const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read' + + const body = { ids: [ notification.id ] } + const headers = { ignoreLoadingBar: '' } + + return this.authHttp.post(url, body, { headers }) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => this.userNotificationSocket.dispatch('read')), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + markAllAsRead () { + const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all' + const headers = { ignoreLoadingBar: '' } + + return this.authHttp.post(url, {}, { headers }) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => this.userNotificationSocket.dispatch('read-all')), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + updateNotificationSettings (user: User, settings: UserNotificationSetting) { + const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS + + return this.authHttp.put(url, settings) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + private formatNotification (notification: UserNotificationServer) { + return new UserNotification(notification) + } +} diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html new file mode 100644 index 000000000..08771110d --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html @@ -0,0 +1,166 @@ +
    You don't have notifications.
    + +
    +
    + + + + + + + + + + +
    + {{ notification.video.channel.displayName }} published a new video: {{ notification.video.name }} +
    +
    + + + + +
    + The notification concerns a video now unavailable +
    +
    +
    + + + + +
    + Your video {{ notification.video.name }} has been unblocked +
    +
    + + + + +
    + Your video {{ notification.videoBlacklist.video.name }} has been blocked +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + The notification concerns a comment now unavailable +
    +
    +
    + + + + +
    + Your video {{ notification.video.name }} has been published +
    +
    + + + + +
    + Your video import {{ notification.videoImportIdentifier }} succeeded +
    +
    + + + + +
    + Your video import {{ notification.videoImportIdentifier }} failed +
    +
    + + + + +
    + User {{ notification.account.name }} registered on your instance +
    +
    + + + + + + +
    + {{ notification.actorFollow.follower.displayName }} is following + + your channel {{ notification.actorFollow.following.displayName }} + your account +
    +
    + + + + + + + + + + + + +
    + Your instance has a new follower ({{ notification.actorFollow?.follower.host }}) + awaiting your approval +
    +
    + + + + +
    + Your instance automatically followed {{ notification.actorFollow.following.host }} +
    +
    + + + + +
    + The notification points to a content now unavailable +
    +
    +
    + +
    {{ notification.createdAt | myFromNow }}
    +
    +
    diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.scss b/client/src/app/shared/shared-main/users/user-notifications.component.scss new file mode 100644 index 000000000..5166bd559 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.scss @@ -0,0 +1,53 @@ +@import '_variables'; +@import '_mixins'; + +.no-notification { + display: flex; + justify-content: center; + align-items: center; + padding: 20px 0; +} + +.notification { + display: flex; + align-items: center; + font-size: inherit; + padding: 15px 5px 15px 10px; + border-bottom: 1px solid $separator-border-color; + word-break: break-word; + + &.unread { + background-color: rgba(0, 0, 0, 0.05); + } + + my-global-icon { + width: 24px; + margin-right: 11px; + margin-left: 3px; + + @include apply-svg-color(#333); + } + + .avatar { + @include avatar(30px); + + margin-right: 10px; + } + + .message { + flex-grow: 1; + + a { + font-weight: $font-semibold; + } + } + + .from-date { + font-size: 0.85em; + color: pvar(--greyForegroundColor); + padding-left: 5px; + min-width: 70px; + text-align: right; + margin-left: auto; + } +} diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.ts b/client/src/app/shared/shared-main/users/user-notifications.component.ts new file mode 100644 index 000000000..6abd8b7d8 --- /dev/null +++ b/client/src/app/shared/shared-main/users/user-notifications.component.ts @@ -0,0 +1,100 @@ +import { Subject } from 'rxjs' +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { ComponentPagination, hasMoreItems, Notifier } from '@app/core' +import { UserNotificationType } from '@shared/models' +import { UserNotification } from './user-notification.model' +import { UserNotificationService } from './user-notification.service' + +@Component({ + selector: 'my-user-notifications', + templateUrl: 'user-notifications.component.html', + styleUrls: [ 'user-notifications.component.scss' ] +}) +export class UserNotificationsComponent implements OnInit { + @Input() ignoreLoadingBar = false + @Input() infiniteScroll = true + @Input() itemsPerPage = 20 + @Input() markAllAsReadSubject: Subject + + @Output() notificationsLoaded = new EventEmitter() + + notifications: UserNotification[] = [] + + // So we can access it in the template + UserNotificationType = UserNotificationType + + componentPagination: ComponentPagination + + onDataSubject = new Subject() + + constructor ( + private userNotificationService: UserNotificationService, + private notifier: Notifier + ) { } + + ngOnInit () { + this.componentPagination = { + currentPage: 1, + itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable + totalItems: null + } + + this.loadMoreNotifications() + + if (this.markAllAsReadSubject) { + this.markAllAsReadSubject.subscribe(() => this.markAllAsRead()) + } + } + + loadMoreNotifications () { + this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar) + .subscribe( + result => { + this.notifications = this.notifications.concat(result.data) + this.componentPagination.totalItems = result.total + + this.notificationsLoaded.emit() + + this.onDataSubject.next(result.data) + }, + + err => this.notifier.error(err.message) + ) + } + + onNearOfBottom () { + if (this.infiniteScroll === false) return + + this.componentPagination.currentPage++ + + if (hasMoreItems(this.componentPagination)) { + this.loadMoreNotifications() + } + } + + markAsRead (notification: UserNotification) { + if (notification.read) return + + this.userNotificationService.markAsRead(notification) + .subscribe( + () => { + notification.read = true + }, + + err => this.notifier.error(err.message) + ) + } + + markAllAsRead () { + this.userNotificationService.markAllAsRead() + .subscribe( + () => { + for (const notification of this.notifications) { + notification.read = true + } + }, + + err => this.notifier.error(err.message) + ) + } +} diff --git a/client/src/app/shared/shared-main/video-caption/index.ts b/client/src/app/shared/shared-main/video-caption/index.ts new file mode 100644 index 000000000..308200f27 --- /dev/null +++ b/client/src/app/shared/shared-main/video-caption/index.ts @@ -0,0 +1,2 @@ +export * from './video-caption-edit.model' +export * from './video-caption.service' diff --git a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts new file mode 100644 index 000000000..732f20158 --- /dev/null +++ b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts @@ -0,0 +1,9 @@ +export interface VideoCaptionEdit { + language: { + id: string + label?: string + } + + action?: 'CREATE' | 'REMOVE' + captionfile?: any +} diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts new file mode 100644 index 000000000..d45fb837a --- /dev/null +++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts @@ -0,0 +1,74 @@ +import { Observable, of } from 'rxjs' +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, ServerService } from '@app/core' +import { objectToFormData, sortBy } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main/video' +import { peertubeTranslate, ResultList, VideoCaption } from '@shared/models' +import { VideoCaptionEdit } from './video-caption-edit.model' + +@Injectable() +export class VideoCaptionService { + constructor ( + private authHttp: HttpClient, + private serverService: ServerService, + private restExtractor: RestExtractor + ) {} + + listCaptions (videoId: number | string): Observable> { + return this.authHttp.get>(VideoService.BASE_VIDEO_URL + videoId + '/captions') + .pipe( + switchMap(captionsResult => { + return this.serverService.getServerLocale() + .pipe(map(translations => ({ captionsResult, translations }))) + }), + map(({ captionsResult, translations }) => { + for (const c of captionsResult.data) { + c.language.label = peertubeTranslate(c.language.label, translations) + } + + return captionsResult + }), + map(captionsResult => { + sortBy(captionsResult.data, 'language', 'label') + + return captionsResult + }) + ) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + removeCaption (videoId: number | string, language: string) { + return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + addCaption (videoId: number | string, language: string, captionfile: File) { + const body = { captionfile } + const data = objectToFormData(body) + + return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) { + let obs = of(true) + + for (const videoCaption of videoCaptions) { + if (videoCaption.action === 'CREATE') { + obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile))) + } else if (videoCaption.action === 'REMOVE') { + obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id))) + } + } + + return obs + } +} diff --git a/client/src/app/shared/shared-main/video-channel/index.ts b/client/src/app/shared/shared-main/video-channel/index.ts new file mode 100644 index 000000000..1fcf6d3be --- /dev/null +++ b/client/src/app/shared/shared-main/video-channel/index.ts @@ -0,0 +1,2 @@ +export * from './video-channel.model' +export * from './video-channel.service' diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts new file mode 100644 index 000000000..123389afb --- /dev/null +++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts @@ -0,0 +1,42 @@ +import { VideoChannel as ServerVideoChannel, ViewsPerDate, Account } from '@shared/models' +import { Actor } from '../account/actor.model' + +export class VideoChannel extends Actor implements ServerVideoChannel { + displayName: string + description: string + support: string + isLocal: boolean + nameWithHost: string + nameWithHostForced: string + + ownerAccount?: Account + ownerBy?: string + ownerAvatarUrl?: string + + videosCount?: number + + viewsPerDay?: ViewsPerDate[] + + constructor (hash: ServerVideoChannel) { + super(hash) + + this.displayName = hash.displayName + this.description = hash.description + this.support = hash.support + this.isLocal = hash.isLocal + this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) + this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) + + this.videosCount = hash.videosCount + + if (hash.viewsPerDay) { + this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) + } + + if (hash.ownerAccount) { + this.ownerAccount = hash.ownerAccount + this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) + this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) + } + } +} diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts new file mode 100644 index 000000000..5483e305f --- /dev/null +++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts @@ -0,0 +1,94 @@ +import { Observable, ReplaySubject } from 'rxjs' +import { catchError, map, tap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { Avatar, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Account } from '../account' +import { AccountService } from '../account/account.service' +import { VideoChannel } from './video-channel.model' + +@Injectable() +export class VideoChannelService { + static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/' + + videoChannelLoaded = new ReplaySubject(1) + + static extractVideoChannels (result: ResultList) { + const videoChannels: VideoChannel[] = [] + + for (const videoChannelJSON of result.data) { + videoChannels.push(new VideoChannel(videoChannelJSON)) + } + + return { data: videoChannels, total: result.total } + } + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) { } + + getVideoChannel (videoChannelName: string) { + return this.authHttp.get(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName) + .pipe( + map(videoChannelHash => new VideoChannel(videoChannelHash)), + tap(videoChannel => this.videoChannelLoaded.next(videoChannel)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listAccountVideoChannels ( + account: Account, + componentPagination?: ComponentPaginationLight, + withStats = false + ): Observable> { + const pagination = componentPagination + ? this.restService.componentPaginationToRestPagination(componentPagination) + : { start: 0, count: 20 } + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + params = params.set('withStats', withStats + '') + + const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' + return this.authHttp.get>(url, { params }) + .pipe( + map(res => VideoChannelService.extractVideoChannels(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + createVideoChannel (videoChannel: VideoChannelCreate) { + return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) { + return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) { + const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick' + + return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + removeVideoChannel (videoChannel: VideoChannel) { + return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts new file mode 100644 index 000000000..3053df4ef --- /dev/null +++ b/client/src/app/shared/shared-main/video/index.ts @@ -0,0 +1,7 @@ +export * from './redundancy.service' +export * from './video-details.model' +export * from './video-edit.model' +export * from './video-import.service' +export * from './video-ownership.service' +export * from './video.model' +export * from './video.service' diff --git a/client/src/app/shared/shared-main/video/redundancy.service.ts b/client/src/app/shared/shared-main/video/redundancy.service.ts new file mode 100644 index 000000000..6e839e655 --- /dev/null +++ b/client/src/app/shared/shared-main/video/redundancy.service.ts @@ -0,0 +1,73 @@ +import { SortMeta } from 'primeng/api' +import { concat, Observable } from 'rxjs' +import { catchError, map, toArray } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' +import { environment } from '../../../../environments/environment' + +@Injectable() +export class RedundancyService { + static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) { } + + updateRedundancy (host: string, redundancyAllowed: boolean) { + const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host + + const body = { redundancyAllowed } + + return this.authHttp.put(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listVideoRedundancies (options: { + pagination: RestPagination, + sort: SortMeta, + target?: VideoRedundanciesTarget + }): Observable> { + const { pagination, sort, target } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (target) params = params.append('target', target) + + return this.authHttp.get>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params }) + .pipe( + catchError(res => this.restExtractor.handleError(res)) + ) + } + + addVideoRedundancy (video: Video) { + return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id }) + .pipe( + catchError(res => this.restExtractor.handleError(res)) + ) + } + + removeVideoRedundancies (redundancy: VideoRedundancy) { + const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id) + .concat(redundancy.redundancies.files.map(r => r.id)) + .map(id => this.removeRedundancy(id)) + + return concat(...observables) + .pipe(toArray()) + } + + private removeRedundancy (redundancyId: number) { + return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } +} diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts new file mode 100644 index 000000000..a1cb051e9 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-details.model.ts @@ -0,0 +1,69 @@ +import { Account } from '@app/shared/shared-main/account/account.model' +import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model' +import { + VideoConstant, + VideoDetails as VideoDetailsServerModel, + VideoFile, + VideoState, + VideoStreamingPlaylist, + VideoStreamingPlaylistType +} from '@shared/models' +import { Video } from './video.model' + +export class VideoDetails extends Video implements VideoDetailsServerModel { + descriptionPath: string + support: string + channel: VideoChannel + tags: string[] + files: VideoFile[] + account: Account + commentsEnabled: boolean + downloadEnabled: boolean + + waitTranscoding: boolean + state: VideoConstant + + likesPercent: number + dislikesPercent: number + + trackerUrls: string[] + + streamingPlaylists: VideoStreamingPlaylist[] + + constructor (hash: VideoDetailsServerModel, translations = {}) { + super(hash, translations) + + this.descriptionPath = hash.descriptionPath + this.files = hash.files + this.channel = new VideoChannel(hash.channel) + this.account = new Account(hash.account) + this.tags = hash.tags + this.support = hash.support + this.commentsEnabled = hash.commentsEnabled + this.downloadEnabled = hash.downloadEnabled + + this.trackerUrls = hash.trackerUrls + this.streamingPlaylists = hash.streamingPlaylists + + this.buildLikeAndDislikePercents() + } + + buildLikeAndDislikePercents () { + this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 + this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 + } + + getHlsPlaylist () { + return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) + } + + hasHlsPlaylist () { + return !!this.getHlsPlaylist() + } + + getFiles () { + if (this.files.length === 0) return this.getHlsPlaylist().files + + return this.files + } +} diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts new file mode 100644 index 000000000..6a529e052 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-edit.model.ts @@ -0,0 +1,120 @@ +import { Video, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' + +export class VideoEdit implements VideoUpdate { + static readonly SPECIAL_SCHEDULED_PRIVACY = -1 + + category: number + licence: number + language: string + description: string + name: string + tags: string[] + nsfw: boolean + commentsEnabled: boolean + downloadEnabled: boolean + waitTranscoding: boolean + channelId: number + privacy: VideoPrivacy + support: string + thumbnailfile?: any + previewfile?: any + thumbnailUrl: string + previewUrl: string + uuid?: string + id?: number + scheduleUpdate?: VideoScheduleUpdate + originallyPublishedAt?: Date | string + + constructor ( + video?: Video & { + tags: string[], + commentsEnabled: boolean, + downloadEnabled: boolean, + support: string, + thumbnailUrl: string, + previewUrl: string + }) { + if (video) { + this.id = video.id + this.uuid = video.uuid + this.category = video.category.id + this.licence = video.licence.id + this.language = video.language.id + this.description = video.description + this.name = video.name + this.tags = video.tags + this.nsfw = video.nsfw + this.commentsEnabled = video.commentsEnabled + this.downloadEnabled = video.downloadEnabled + this.waitTranscoding = video.waitTranscoding + this.channelId = video.channel.id + this.privacy = video.privacy.id + this.support = video.support + this.thumbnailUrl = video.thumbnailUrl + this.previewUrl = video.previewUrl + + this.scheduleUpdate = video.scheduledUpdate + this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null + } + } + + patch (values: { [ id: string ]: string }) { + Object.keys(values).forEach((key) => { + this[ key ] = values[ key ] + }) + + // If schedule publication, the video is private and will be changed to public privacy + if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) { + const updateAt = new Date(values['schedulePublicationAt']) + updateAt.setSeconds(0) + + this.privacy = VideoPrivacy.PRIVATE + this.scheduleUpdate = { + updateAt: updateAt.toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } else { + this.scheduleUpdate = null + } + + // Convert originallyPublishedAt to string so that function objectToFormData() works correctly + if (this.originallyPublishedAt) { + const originallyPublishedAt = new Date(values['originallyPublishedAt']) + this.originallyPublishedAt = originallyPublishedAt.toISOString() + } + + // Use the same file than the preview for the thumbnail + if (this.previewfile) { + this.thumbnailfile = this.previewfile + } + } + + toFormPatch () { + const json = { + category: this.category, + licence: this.licence, + language: this.language, + description: this.description, + support: this.support, + name: this.name, + tags: this.tags, + nsfw: this.nsfw, + commentsEnabled: this.commentsEnabled, + downloadEnabled: this.downloadEnabled, + waitTranscoding: this.waitTranscoding, + channelId: this.channelId, + privacy: this.privacy, + originallyPublishedAt: this.originallyPublishedAt + } + + // Special case if we scheduled an update + if (this.scheduleUpdate) { + Object.assign(json, { + privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY, + schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString()) + }) + } + + return json + } +} diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts new file mode 100644 index 000000000..a700abacb --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-import.service.ts @@ -0,0 +1,100 @@ +import { SortMeta } from 'primeng/api' +import { Observable } from 'rxjs' +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService, ServerService, UserService } from '@app/core' +import { objectToFormData } from '@app/helpers' +import { peertubeTranslate, ResultList, VideoImport, VideoImportCreate, VideoUpdate } from '@shared/models' +import { environment } from '../../../../environments/environment' + +@Injectable() +export class VideoImportService { + private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor, + private serverService: ServerService + ) {} + + importVideoUrl (targetUrl: string, video: VideoUpdate): Observable { + const url = VideoImportService.BASE_VIDEO_IMPORT_URL + + const body = this.buildImportVideoObject(video) + body.targetUrl = targetUrl + + const data = objectToFormData(body) + return this.authHttp.post(url, data) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable { + const url = VideoImportService.BASE_VIDEO_IMPORT_URL + const body: VideoImportCreate = this.buildImportVideoObject(video) + + if (typeof target === 'string') body.magnetUri = target + else body.torrentfile = target + + const data = objectToFormData(body) + return this.authHttp.post(url, data) + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable> { + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp + .get>(UserService.BASE_USERS_URL + '/me/videos/imports', { params }) + .pipe( + switchMap(res => this.extractVideoImports(res)), + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + private buildImportVideoObject (video: VideoUpdate): VideoImportCreate { + const language = video.language || null + const licence = video.licence || null + const category = video.category || null + const description = video.description || null + const support = video.support || null + const scheduleUpdate = video.scheduleUpdate || null + const originallyPublishedAt = video.originallyPublishedAt || null + + return { + name: video.name, + category, + licence, + language, + support, + description, + channelId: video.channelId, + privacy: video.privacy, + tags: video.tags, + nsfw: video.nsfw, + waitTranscoding: video.waitTranscoding, + commentsEnabled: video.commentsEnabled, + downloadEnabled: video.downloadEnabled, + thumbnailfile: video.thumbnailfile, + previewfile: video.previewfile, + scheduleUpdate, + originallyPublishedAt + } + } + + private extractVideoImports (result: ResultList): Observable> { + return this.serverService.getServerLocale() + .pipe( + map(translations => { + result.data.forEach(d => + d.state.label = peertubeTranslate(d.state.label, translations) + ) + + return result + }) + ) + } +} diff --git a/client/src/app/shared/shared-main/video/video-ownership.service.ts b/client/src/app/shared/shared-main/video/video-ownership.service.ts new file mode 100644 index 000000000..273930a6c --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-ownership.service.ts @@ -0,0 +1,64 @@ +import { SortMeta } from 'primeng/api' +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList, VideoChangeOwnership, VideoChangeOwnershipAccept, VideoChangeOwnershipCreate } from '@shared/models' +import { environment } from '../../../../environments/environment' + +@Injectable() +export class VideoOwnershipService { + private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) { + } + + changeOwnership (id: number, username: string) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership' + const body: VideoChangeOwnershipCreate = { + username + } + + return this.authHttp.post(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable> { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership' + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp.get>(url, { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + acceptOwnership (id: number, input: VideoChangeOwnershipAccept) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept' + return this.authHttp.post(url, input) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(this.restExtractor.handleError) + ) + } + + refuseOwnership (id: number) { + const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse' + return this.authHttp.post(url, {}) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(this.restExtractor.handleError) + ) + } +} diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts new file mode 100644 index 000000000..3e6d6a38d --- /dev/null +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -0,0 +1,188 @@ +import { AuthUser } from '@app/core' +import { User } from '@app/core/users/user.model' +import { durationToString, getAbsoluteAPIUrl } from '@app/helpers' +import { + Avatar, + peertubeTranslate, + ServerConfig, + UserRight, + Video as VideoServerModel, + VideoConstant, + VideoPrivacy, + VideoScheduleUpdate, + VideoState +} from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Actor } from '../account/actor.model' + +export class Video implements VideoServerModel { + byVideoChannel: string + byAccount: string + + accountAvatarUrl: string + videoChannelAvatarUrl: string + + createdAt: Date + updatedAt: Date + publishedAt: Date + originallyPublishedAt: Date | string + category: VideoConstant + licence: VideoConstant + language: VideoConstant + privacy: VideoConstant + description: string + duration: number + durationLabel: string + id: number + uuid: string + isLocal: boolean + name: string + serverHost: string + thumbnailPath: string + thumbnailUrl: string + + previewPath: string + previewUrl: string + + embedPath: string + embedUrl: string + + url?: string + + views: number + likes: number + dislikes: number + nsfw: boolean + + originInstanceUrl: string + originInstanceHost: string + + waitTranscoding?: boolean + state?: VideoConstant + scheduledUpdate?: VideoScheduleUpdate + blacklisted?: boolean + blockedReason?: string + + account: { + id: number + name: string + displayName: string + url: string + host: string + avatar?: Avatar + } + + channel: { + id: number + name: string + displayName: string + url: string + host: string + avatar?: Avatar + } + + userHistory?: { + currentTime: number + } + + static buildClientUrl (videoUUID: string) { + return '/videos/watch/' + videoUUID + } + + constructor (hash: VideoServerModel, translations = {}) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + + this.createdAt = new Date(hash.createdAt.toString()) + this.publishedAt = new Date(hash.publishedAt.toString()) + this.category = hash.category + this.licence = hash.licence + this.language = hash.language + this.privacy = hash.privacy + this.waitTranscoding = hash.waitTranscoding + this.state = hash.state + this.description = hash.description + + this.duration = hash.duration + this.durationLabel = durationToString(hash.duration) + + this.id = hash.id + this.uuid = hash.uuid + + this.isLocal = hash.isLocal + this.name = hash.name + + this.thumbnailPath = hash.thumbnailPath + this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath) + + this.previewPath = hash.previewPath + this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) + + this.embedPath = hash.embedPath + this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath) + + this.url = hash.url + + this.views = hash.views + this.likes = hash.likes + this.dislikes = hash.dislikes + + this.nsfw = hash.nsfw + + this.account = hash.account + this.channel = hash.channel + + this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host) + this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host) + this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) + this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel) + + this.category.label = peertubeTranslate(this.category.label, translations) + this.licence.label = peertubeTranslate(this.licence.label, translations) + this.language.label = peertubeTranslate(this.language.label, translations) + this.privacy.label = peertubeTranslate(this.privacy.label, translations) + + this.scheduledUpdate = hash.scheduledUpdate + this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null + + if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) + + this.blacklisted = hash.blacklisted + this.blockedReason = hash.blacklistedReason + + this.userHistory = hash.userHistory + + this.originInstanceHost = this.account.host + this.originInstanceUrl = 'https://' + this.originInstanceHost + } + + isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { + // Video is not NSFW, skip + if (this.nsfw === false) return false + + // Return user setting if logged in + if (user) return user.nsfwPolicy !== 'display' + + // Return default instance config + return serverConfig.instance.defaultNSFWPolicy !== 'display' + } + + isRemovableBy (user: AuthUser) { + return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) + } + + isBlockableBy (user: AuthUser) { + return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true + } + + isUnblockableBy (user: AuthUser) { + return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true + } + + isUpdatableBy (user: AuthUser) { + return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) + } + + canBeDuplicatedBy (user: AuthUser) { + return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) + } +} diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts new file mode 100644 index 000000000..20d13fa10 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -0,0 +1,380 @@ +import { FfprobeData } from 'fluent-ffmpeg' +import { Observable } from 'rxjs' +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' +import { objectToFormData } from '@app/helpers' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { + FeedFormat, + NSFWPolicyType, + ResultList, + UserVideoRate, + UserVideoRateType, + UserVideoRateUpdate, + Video as VideoServerModel, + VideoConstant, + VideoDetails as VideoDetailsServerModel, + VideoFilter, + VideoPrivacy, + VideoSortField, + VideoUpdate +} from '@shared/models' +import { environment } from '../../../../environments/environment' +import { Account, AccountService } from '../account' +import { VideoChannel, VideoChannelService } from '../video-channel' +import { VideoDetails } from './video-details.model' +import { VideoEdit } from './video-edit.model' +import { Video } from './video.model' + +export interface VideosProvider { + getVideos (parameters: { + videoPagination: ComponentPaginationLight, + sort: VideoSortField, + filter?: VideoFilter, + categoryOneOf?: number[], + languageOneOf?: string[] + nsfwPolicy: NSFWPolicyType + }): Observable> +} + +@Injectable() +export class VideoService implements VideosProvider { + static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private serverService: ServerService, + private i18n: I18n + ) {} + + getVideoViewUrl (uuid: string) { + return VideoService.BASE_VIDEO_URL + uuid + '/views' + } + + getUserWatchingVideoUrl (uuid: string) { + return VideoService.BASE_VIDEO_URL + uuid + '/watching' + } + + getVideo (options: { videoId: string }): Observable { + return this.serverService.getServerLocale() + .pipe( + switchMap(translations => { + return this.authHttp.get(VideoService.BASE_VIDEO_URL + options.videoId) + .pipe(map(videoHash => ({ videoHash, translations }))) + }), + map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideo (video: VideoEdit) { + const language = video.language || null + const licence = video.licence || null + const category = video.category || null + const description = video.description || null + const support = video.support || null + const scheduleUpdate = video.scheduleUpdate || null + const originallyPublishedAt = video.originallyPublishedAt || null + + const body: VideoUpdate = { + name: video.name, + category, + licence, + language, + support, + description, + channelId: video.channelId, + privacy: video.privacy, + tags: video.tags, + nsfw: video.nsfw, + waitTranscoding: video.waitTranscoding, + commentsEnabled: video.commentsEnabled, + downloadEnabled: video.downloadEnabled, + thumbnailfile: video.thumbnailfile, + previewfile: video.previewfile, + scheduleUpdate, + originallyPublishedAt + } + + const data = objectToFormData(body) + + return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + uploadVideo (video: FormData) { + const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true }) + + return this.authHttp + .request<{ video: { id: number, uuid: string } }>(req) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable> { + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + params = this.restService.addObjectParams(params, { search }) + + return this.authHttp + .get>(UserService.BASE_USERS_URL + 'me/videos', { params }) + .pipe( + switchMap(res => this.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getAccountVideos ( + account: Account, + videoPagination: ComponentPaginationLight, + sort: VideoSortField + ): Observable> { + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + return this.authHttp + .get>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) + .pipe( + switchMap(res => this.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoChannelVideos ( + videoChannel: VideoChannel, + videoPagination: ComponentPaginationLight, + sort: VideoSortField, + nsfwPolicy?: NSFWPolicyType + ): Observable> { + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (nsfwPolicy) { + params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) + } + + return this.authHttp + .get>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) + .pipe( + switchMap(res => this.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideos (parameters: { + videoPagination: ComponentPaginationLight, + sort: VideoSortField, + filter?: VideoFilter, + categoryOneOf?: number[], + languageOneOf?: string[], + skipCount?: boolean, + nsfwPolicy?: NSFWPolicyType + }): Observable> { + const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters + + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (filter) params = params.set('filter', filter) + if (skipCount) params = params.set('skipCount', skipCount + '') + + if (nsfwPolicy) { + params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) + } + + if (languageOneOf) { + for (const l of languageOneOf) { + params = params.append('languageOneOf[]', l) + } + } + + if (categoryOneOf) { + for (const c of categoryOneOf) { + params = params.append('categoryOneOf[]', c + '') + } + } + + return this.authHttp + .get>(VideoService.BASE_VIDEO_URL, { params }) + .pipe( + switchMap(res => this.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + buildBaseFeedUrls (params: HttpParams) { + const feeds = [ + { + format: FeedFormat.RSS, + label: 'rss 2.0', + url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() + }, + { + format: FeedFormat.ATOM, + label: 'atom 1.0', + url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() + }, + { + format: FeedFormat.JSON, + label: 'json 1.0', + url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() + } + ] + + if (params && params.keys().length !== 0) { + for (const feed of feeds) { + feed.url += '?' + params.toString() + } + } + + return feeds + } + + getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) { + let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort) + + if (filter) params = params.set('filter', filter) + + if (categoryOneOf) { + for (const c of categoryOneOf) { + params = params.append('categoryOneOf[]', c + '') + } + } + + return this.buildBaseFeedUrls(params) + } + + getAccountFeedUrls (accountId: number) { + let params = this.restService.addRestGetParams(new HttpParams()) + params = params.set('accountId', accountId.toString()) + + return this.buildBaseFeedUrls(params) + } + + getVideoChannelFeedUrls (videoChannelId: number) { + let params = this.restService.addRestGetParams(new HttpParams()) + params = params.set('videoChannelId', videoChannelId.toString()) + + return this.buildBaseFeedUrls(params) + } + + getVideoFileMetadata (metadataUrl: string) { + return this.authHttp + .get(metadataUrl) + .pipe( + catchError(err => this.restExtractor.handleError(err)) + ) + } + + removeVideo (id: number) { + return this.authHttp + .delete(VideoService.BASE_VIDEO_URL + id) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + loadCompleteDescription (descriptionPath: string) { + return this.authHttp + .get<{ description: string }>(environment.apiUrl + descriptionPath) + .pipe( + map(res => res.description), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + setVideoLike (id: number) { + return this.setVideoRate(id, 'like') + } + + setVideoDislike (id: number) { + return this.setVideoRate(id, 'dislike') + } + + unsetVideoLike (id: number) { + return this.setVideoRate(id, 'none') + } + + getUserVideoRating (id: number) { + const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' + + return this.authHttp.get(url) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + extractVideos (result: ResultList) { + return this.serverService.getServerLocale() + .pipe( + map(translations => { + const videosJson = result.data + const totalVideos = result.total + const videos: Video[] = [] + + for (const videoJson of videosJson) { + videos.push(new Video(videoJson, translations)) + } + + return { total: totalVideos, data: videos } + }) + ) + } + + explainedPrivacyLabels (privacies: VideoConstant[]) { + const base = [ + { + id: VideoPrivacy.PRIVATE, + label: this.i18n('Only I can see this video') + }, + { + id: VideoPrivacy.UNLISTED, + label: this.i18n('Only people with the private link can see this video') + }, + { + id: VideoPrivacy.PUBLIC, + label: this.i18n('Anyone can see this video') + }, + { + id: VideoPrivacy.INTERNAL, + label: this.i18n('Only users of this instance can see this video') + } + ] + + return base.filter(o => !!privacies.find(p => p.id === o.id)) + } + + nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) { + return nsfwPolicy === 'do_not_list' + ? 'false' + : 'both' + } + + private setVideoRate (id: number, rateType: UserVideoRateType) { + const url = VideoService.BASE_VIDEO_URL + id + '/rate' + const body: UserVideoRateUpdate = { + rating: rateType + } + + return this.authHttp + .put(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-moderation/account-block.model.ts b/client/src/app/shared/shared-moderation/account-block.model.ts new file mode 100644 index 000000000..8f76c69dc --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-block.model.ts @@ -0,0 +1,14 @@ +import { AccountBlock as AccountBlockServer } from '@shared/models' +import { Account } from '@app/shared/shared-main' + +export class AccountBlock implements AccountBlockServer { + byAccount: Account + blockedAccount: Account + createdAt: Date | string + + constructor (block: AccountBlockServer) { + this.byAccount = new Account(block.byAccount) + this.blockedAccount = new Account(block.blockedAccount) + this.createdAt = block.createdAt + } +} diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.html b/client/src/app/shared/shared-moderation/account-blocklist.component.html new file mode 100644 index 000000000..486785f35 --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-blocklist.component.html @@ -0,0 +1,64 @@ + + +
    +
    + + + Clear filters +
    +
    +
    + + + + Account + Muted at + + + + + + + + +
    + Avatar +
    + {{ accountBlock.blockedAccount.displayName }} + {{ accountBlock.blockedAccount.nameWithHost }} +
    +
    +
    + + + {{ accountBlock.createdAt | date: 'short' }} + + + + +
    + + + + +
    + No account found matching current filters. + No account found. +
    + + +
    +
    diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.scss b/client/src/app/shared/shared-moderation/account-blocklist.component.scss new file mode 100644 index 000000000..aa8363ff4 --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-blocklist.component.scss @@ -0,0 +1,16 @@ +@import '_variables'; +@import '_mixins'; + +.caption { + justify-content: flex-end; + + input { + @include peertube-input-text(250px); + flex-grow: 1; + } +} + +.unblock-button { + @include peertube-button; + @include grey-button; +} \ No newline at end of file diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.ts b/client/src/app/shared/shared-moderation/account-blocklist.component.ts new file mode 100644 index 000000000..38e0d0424 --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-blocklist.component.ts @@ -0,0 +1,78 @@ +import { SortMeta } from 'primeng/api' +import { OnInit } from '@angular/core' +import { Notifier, RestPagination, RestTable } from '@app/core' +import { Actor } from '@app/shared/shared-main' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AccountBlock } from './account-block.model' +import { BlocklistComponentType, BlocklistService } from './blocklist.service' + +export class GenericAccountBlocklistComponent extends RestTable implements OnInit { + // @ts-ignore: "Abstract methods can only appear within an abstract class" + abstract mode: BlocklistComponentType + + blockedAccounts: AccountBlock[] = [] + totalRecords = 0 + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + constructor ( + private notifier: Notifier, + private blocklistService: BlocklistService, + private i18n: I18n + ) { + super() + } + + // @ts-ignore: "Abstract methods can only appear within an abstract class" + abstract getIdentifier (): string + + ngOnInit () { + this.initialize() + } + + switchToDefaultAvatar ($event: Event) { + ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() + } + + unblockAccount (accountBlock: AccountBlock) { + const blockedAccount = accountBlock.blockedAccount + const operation = this.mode === BlocklistComponentType.Account + ? this.blocklistService.unblockAccountByUser(blockedAccount) + : this.blocklistService.unblockAccountByInstance(blockedAccount) + + operation.subscribe( + () => { + this.notifier.success( + this.mode === BlocklistComponentType.Account + ? this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: blockedAccount.nameWithHost }) + : this.i18n('Account {{nameWithHost}} unmuted by your instance.', { nameWithHost: blockedAccount.nameWithHost }) + ) + + this.loadData() + } + ) + } + + protected loadData () { + const operation = this.mode === BlocklistComponentType.Account + ? this.blocklistService.getUserAccountBlocklist({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }) + : this.blocklistService.getInstanceAccountBlocklist({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }) + + return operation.subscribe( + resultList => { + this.blockedAccounts = resultList.data + this.totalRecords = resultList.total + }, + + err => this.notifier.error(err.message) + ) + } +} diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.html b/client/src/app/shared/shared-moderation/batch-domains-modal.component.html new file mode 100644 index 000000000..1b85c8f48 --- /dev/null +++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.html @@ -0,0 +1,43 @@ + + + + + + diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss b/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss new file mode 100644 index 000000000..9621a566f --- /dev/null +++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss @@ -0,0 +1,3 @@ +textarea { + height: 200px; +} diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts new file mode 100644 index 000000000..fdd4a79a9 --- /dev/null +++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { BatchDomainsValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-batch-domains-modal', + templateUrl: './batch-domains-modal.component.html', + styleUrls: [ './batch-domains-modal.component.scss' ] +}) +export class BatchDomainsModalComponent extends FormReactive implements OnInit { + @ViewChild('modal', { static: true }) modal: NgbModal + @Input() placeholder = 'example.com' + @Input() action: string + @Output() domains = new EventEmitter() + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private batchDomainsValidatorsService: BatchDomainsValidatorsService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + if (!this.action) this.action = this.i18n('Process domains') + + this.buildForm({ + domains: this.batchDomainsValidatorsService.DOMAINS + }) + } + + openModal () { + this.openedModal = this.modalService.open(this.modal, { centered: true }) + } + + hide () { + this.openedModal.close() + } + + submit () { + this.domains.emit( + this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value) + ) + this.form.reset() + this.hide() + } +} diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts new file mode 100644 index 000000000..0caa92782 --- /dev/null +++ b/client/src/app/shared/shared-moderation/blocklist.service.ts @@ -0,0 +1,153 @@ +import { SortMeta } from 'primeng/api' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '@shared/models' +import { environment } from '../../../environments/environment' +import { Account } from '../shared-main' +import { AccountBlock } from './account-block.model' + +export enum BlocklistComponentType { Account, Instance } + +@Injectable() +export class BlocklistService { + static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist' + static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) { } + + /*********************** User -> Account blocklist ***********************/ + + getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { + const { pagination, sort, search } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + + return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + blockAccountByUser (account: Account) { + const body = { accountName: account.nameWithHost } + + return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + unblockAccountByUser (account: Account) { + const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost + + return this.authHttp.delete(path) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + /*********************** User -> Server blocklist ***********************/ + + getUserServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { + const { pagination, sort, search } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + + return this.authHttp.get>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + blockServerByUser (host: string) { + const body = { host } + + return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + unblockServerByUser (host: string) { + const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host + + return this.authHttp.delete(path) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + /*********************** Instance -> Account blocklist ***********************/ + + getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { + const { pagination, sort, search } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + + return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + blockAccountByInstance (account: Account) { + const body = { accountName: account.nameWithHost } + + return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + unblockAccountByInstance (account: Account) { + const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost + + return this.authHttp.delete(path) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + /*********************** Instance -> Server blocklist ***********************/ + + getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { + const { pagination, sort, search } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) params = params.append('search', search) + + return this.authHttp.get>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + blockServerByInstance (host: string) { + const body = { host } + + return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + unblockServerByInstance (host: string) { + const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host + + return this.authHttp.delete(path) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + private formatAccountBlock (accountBlock: AccountBlockServer) { + return new AccountBlock(accountBlock) + } +} diff --git a/client/src/app/shared/shared-moderation/bulk.service.ts b/client/src/app/shared/shared-moderation/bulk.service.ts new file mode 100644 index 000000000..f0b869421 --- /dev/null +++ b/client/src/app/shared/shared-moderation/bulk.service.ts @@ -0,0 +1,23 @@ +import { catchError } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { BulkRemoveCommentsOfBody } from '@shared/models' +import { environment } from '../../../environments/environment' + +@Injectable() +export class BulkService { + static BASE_BULK_URL = environment.apiUrl + '/api/v1/bulk' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) { } + + removeCommentsOf (body: BulkRemoveCommentsOfBody) { + const url = BulkService.BASE_BULK_URL + '/remove-comments-of' + + return this.authHttp.post(url, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts new file mode 100644 index 000000000..8e74254f6 --- /dev/null +++ b/client/src/app/shared/shared-moderation/index.ts @@ -0,0 +1,13 @@ +export * from './account-block.model' +export * from './account-blocklist.component' +export * from './batch-domains-modal.component' +export * from './blocklist.service' +export * from './bulk.service' +export * from './server-blocklist.component' +export * from './user-ban-modal.component' +export * from './user-moderation-dropdown.component' +export * from './video-abuse.service' +export * from './video-block.component' +export * from './video-block.service' +export * from './video-report.component' +export * from './shared-moderation.module' diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.html b/client/src/app/shared/shared-moderation/server-blocklist.component.html new file mode 100644 index 000000000..977e0e141 --- /dev/null +++ b/client/src/app/shared/shared-moderation/server-blocklist.component.html @@ -0,0 +1,59 @@ + + +
    +
    + + + Clear filters +
    + + + Mute domain + +
    +
    + + + + Instance + Muted at + + + + + + + + + {{ serverBlock.blockedServer.host }} + + + + {{ serverBlock.createdAt | date: 'short' }} + + + + + + + + + +
    + No server found matching current filters. + No server found. +
    + + +
    +
    + + diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss new file mode 100644 index 000000000..9ddb76850 --- /dev/null +++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss @@ -0,0 +1,34 @@ +@import '_variables'; +@import '_mixins'; + +a { + @include disable-default-a-behaviour; + display: inline-block; + + &, &:hover { + color: pvar(--mainForegroundColor); + } + + span { + font-size: 80%; + color: pvar(--inputPlaceholderColor); + } +} + +.caption { + justify-content: flex-end; + + input { + @include peertube-input-text(250px); + flex-grow: 1; + } +} + +.unblock-button { + @include peertube-button; + @include grey-button; +} + +.block-button { + @include create-button; +} diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.ts b/client/src/app/shared/shared-moderation/server-blocklist.component.ts new file mode 100644 index 000000000..d904d0605 --- /dev/null +++ b/client/src/app/shared/shared-moderation/server-blocklist.component.ts @@ -0,0 +1,100 @@ +import { SortMeta } from 'primeng/api' +import { OnInit, ViewChild } from '@angular/core' +import { BatchDomainsModalComponent } from '@app/shared/shared-moderation/batch-domains-modal.component' +import { Notifier, RestPagination, RestTable } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerBlock } from '@shared/models' +import { BlocklistComponentType, BlocklistService } from './blocklist.service' + +export class GenericServerBlocklistComponent extends RestTable implements OnInit { + @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent + + // @ts-ignore: "Abstract methods can only appear within an abstract class" + public abstract mode: BlocklistComponentType + + blockedServers: ServerBlock[] = [] + totalRecords = 0 + sort: SortMeta = { field: 'createdAt', order: -1 } + pagination: RestPagination = { count: this.rowsPerPage, start: 0 } + + constructor ( + protected notifier: Notifier, + protected blocklistService: BlocklistService, + protected i18n: I18n + ) { + super() + } + + // @ts-ignore: "Abstract methods can only appear within an abstract class" + public abstract getIdentifier (): string + + ngOnInit () { + this.initialize() + } + + unblockServer (serverBlock: ServerBlock) { + const operation = (host: string) => this.mode === BlocklistComponentType.Account + ? this.blocklistService.unblockServerByUser(host) + : this.blocklistService.unblockServerByInstance(host) + const host = serverBlock.blockedServer.host + + operation(host).subscribe( + () => { + this.notifier.success( + this.mode === BlocklistComponentType.Account + ? this.i18n('Instance {{host}} unmuted.', { host }) + : this.i18n('Instance {{host}} unmuted by your instance.', { host }) + ) + + this.loadData() + } + ) + } + + addServersToBlock () { + this.batchDomainsModal.openModal() + } + + onDomainsToBlock (domains: string[]) { + const operation = (domain: string) => this.mode === BlocklistComponentType.Account + ? this.blocklistService.blockServerByUser(domain) + : this.blocklistService.blockServerByInstance(domain) + + domains.forEach(domain => { + operation(domain).subscribe( + () => { + this.notifier.success( + this.mode === BlocklistComponentType.Account + ? this.i18n('Instance {{domain}} muted.', { domain }) + : this.i18n('Instance {{domain}} muted by your instance.', { domain }) + ) + + this.loadData() + } + ) + }) + } + + protected loadData () { + const operation = this.mode === BlocklistComponentType.Account + ? this.blocklistService.getUserServerBlocklist({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }) + : this.blocklistService.getInstanceServerBlocklist({ + pagination: this.pagination, + sort: this.sort, + search: this.search + }) + + return operation.subscribe( + resultList => { + this.blockedServers = resultList.data + this.totalRecords = resultList.total + }, + + err => this.notifier.error(err.message) + ) + } +} diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts new file mode 100644 index 000000000..f7e64dfa3 --- /dev/null +++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts @@ -0,0 +1,46 @@ + +import { NgModule } from '@angular/core' +import { SharedFormModule } from '../shared-forms/shared-form.module' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { BatchDomainsModalComponent } from './batch-domains-modal.component' +import { BlocklistService } from './blocklist.service' +import { BulkService } from './bulk.service' +import { UserBanModalComponent } from './user-ban-modal.component' +import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' +import { VideoAbuseService } from './video-abuse.service' +import { VideoBlockComponent } from './video-block.component' +import { VideoBlockService } from './video-block.service' +import { VideoReportComponent } from './video-report.component' + +@NgModule({ + imports: [ + SharedMainModule, + SharedFormModule, + SharedGlobalIconModule + ], + + declarations: [ + UserBanModalComponent, + UserModerationDropdownComponent, + VideoBlockComponent, + VideoReportComponent, + BatchDomainsModalComponent + ], + + exports: [ + UserBanModalComponent, + UserModerationDropdownComponent, + VideoBlockComponent, + VideoReportComponent, + BatchDomainsModalComponent + ], + + providers: [ + BlocklistService, + BulkService, + VideoAbuseService, + VideoBlockService + ] +}) +export class SharedModerationModule { } diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.html b/client/src/app/shared/shared-moderation/user-ban-modal.component.html new file mode 100644 index 000000000..365eb1938 --- /dev/null +++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.html @@ -0,0 +1,38 @@ + + + + + + diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.scss b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss new file mode 100644 index 000000000..84562f15c --- /dev/null +++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss @@ -0,0 +1,6 @@ +@import 'variables'; +@import 'mixins'; + +textarea { + @include peertube-textarea(100%, 60px); +} diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts new file mode 100644 index 000000000..124e58669 --- /dev/null +++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts @@ -0,0 +1,68 @@ +import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { Notifier, UserService } from '@app/core' +import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { User } from '@shared/models' + +@Component({ + selector: 'my-user-ban-modal', + templateUrl: './user-ban-modal.component.html', + styleUrls: [ './user-ban-modal.component.scss' ] +}) +export class UserBanModalComponent extends FormReactive implements OnInit { + @ViewChild('modal', { static: true }) modal: NgbModal + @Output() userBanned = new EventEmitter() + + private usersToBan: User | User[] + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private notifier: Notifier, + private userService: UserService, + private userValidatorsService: UserValidatorsService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({ + reason: this.userValidatorsService.USER_BAN_REASON + }) + } + + openModal (user: User | User[]) { + this.usersToBan = user + this.openedModal = this.modalService.open(this.modal, { centered: true }) + } + + hide () { + this.usersToBan = undefined + this.openedModal.close() + } + + async banUser () { + const reason = this.form.value['reason'] || undefined + + this.userService.banUsers(this.usersToBan, reason) + .subscribe( + () => { + const message = Array.isArray(this.usersToBan) + ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length }) + : this.i18n('User {{username}} banned.', { username: this.usersToBan.username }) + + this.notifier.success(message) + + this.userBanned.emit(this.usersToBan) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + +} diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html new file mode 100644 index 000000000..4d562387a --- /dev/null +++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html @@ -0,0 +1,9 @@ + + + + + diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts new file mode 100644 index 000000000..d3c37f082 --- /dev/null +++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts @@ -0,0 +1,379 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core' +import { AuthService, ConfirmService, Notifier, ServerService, UserService } from '@app/core' +import { Account, DropdownAction } from '@app/shared/shared-main' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { BulkRemoveCommentsOfBody, ServerConfig, User, UserRight } from '@shared/models' +import { BlocklistService } from './blocklist.service' +import { BulkService } from './bulk.service' +import { UserBanModalComponent } from './user-ban-modal.component' + +@Component({ + selector: 'my-user-moderation-dropdown', + templateUrl: './user-moderation-dropdown.component.html' +}) +export class UserModerationDropdownComponent implements OnInit, OnChanges { + @ViewChild('userBanModal') userBanModal: UserBanModalComponent + + @Input() user: User + @Input() account: Account + + @Input() buttonSize: 'normal' | 'small' = 'normal' + @Input() placement = 'left-top left-bottom auto' + @Input() label: string + @Input() container: 'body' | undefined = undefined + + @Output() userChanged = new EventEmitter() + @Output() userDeleted = new EventEmitter() + + userActions: DropdownAction<{ user: User, account: Account }>[][] = [] + + private serverConfig: ServerConfig + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private serverService: ServerService, + private userService: UserService, + private blocklistService: BlocklistService, + private bulkService: BulkService, + private i18n: I18n + ) { } + + get requiresEmailVerification () { + return this.serverConfig.signup.requiresEmailVerification + } + + ngOnInit (): void { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + } + + ngOnChanges () { + this.buildActions() + } + + openBanUserModal (user: User) { + if (user.username === 'root') { + this.notifier.error(this.i18n('You cannot ban root.')) + return + } + + this.userBanModal.openModal(user) + } + + onUserBanned () { + this.userChanged.emit() + } + + async unbanUser (user: User) { + const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username }) + const res = await this.confirmService.confirm(message, this.i18n('Unban')) + if (res === false) return + + this.userService.unbanUsers(user) + .subscribe( + () => { + this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username })) + + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + async removeUser (user: User) { + if (user.username === 'root') { + this.notifier.error(this.i18n('You cannot delete root.')) + return + } + + const message = this.i18n('If you remove this user, you will not be able to create another with the same username!') + const res = await this.confirmService.confirm(message, this.i18n('Delete')) + if (res === false) return + + this.userService.removeUser(user).subscribe( + () => { + this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username })) + this.userDeleted.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + setEmailAsVerified (user: User) { + this.userService.updateUser(user.id, { emailVerified: true }).subscribe( + () => { + this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username })) + + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + blockAccountByUser (account: Account) { + this.blocklistService.blockAccountByUser(account) + .subscribe( + () => { + this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost })) + + this.account.mutedByUser = true + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + unblockAccountByUser (account: Account) { + this.blocklistService.unblockAccountByUser(account) + .subscribe( + () => { + this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost })) + + this.account.mutedByUser = false + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + blockServerByUser (host: string) { + this.blocklistService.blockServerByUser(host) + .subscribe( + () => { + this.notifier.success(this.i18n('Instance {{host}} muted.', { host })) + + this.account.mutedServerByUser = true + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + unblockServerByUser (host: string) { + this.blocklistService.unblockServerByUser(host) + .subscribe( + () => { + this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host })) + + this.account.mutedServerByUser = false + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + blockAccountByInstance (account: Account) { + this.blocklistService.blockAccountByInstance(account) + .subscribe( + () => { + this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })) + + this.account.mutedByInstance = true + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + unblockAccountByInstance (account: Account) { + this.blocklistService.unblockAccountByInstance(account) + .subscribe( + () => { + this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost })) + + this.account.mutedByInstance = false + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + blockServerByInstance (host: string) { + this.blocklistService.blockServerByInstance(host) + .subscribe( + () => { + this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host })) + + this.account.mutedServerByInstance = true + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + unblockServerByInstance (host: string) { + this.blocklistService.unblockServerByInstance(host) + .subscribe( + () => { + this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host })) + + this.account.mutedServerByInstance = false + this.userChanged.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + async bulkRemoveCommentsOf (body: BulkRemoveCommentsOfBody) { + const message = this.i18n('Are you sure you want to remove all the comments of this account?') + const res = await this.confirmService.confirm(message, this.i18n('Delete account comments')) + if (res === false) return + + this.bulkService.removeCommentsOf(body) + .subscribe( + () => { + this.notifier.success(this.i18n('Will remove comments of this account (may take several minutes).')) + }, + + err => this.notifier.error(err.message) + ) + } + + getRouterUserEditLink (user: User) { + return [ '/admin', 'users', 'update', user.id ] + } + + private buildActions () { + this.userActions = [] + + if (this.authService.isLoggedIn()) { + const authUser = this.authService.getUser() + + if (this.user && authUser.id === this.user.id) return + + if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) { + this.userActions.push([ + { + label: this.i18n('Edit user'), + description: this.i18n('Change quota, role, and more.'), + linkBuilder: ({ user }) => this.getRouterUserEditLink(user) + }, + { + label: this.i18n('Delete user'), + description: this.i18n('Videos will be deleted, comments will be tombstoned.'), + handler: ({ user }) => this.removeUser(user) + }, + { + label: this.i18n('Ban'), + description: this.i18n('User won\'t be able to login anymore, but videos and comments will be kept as is.'), + handler: ({ user }) => this.openBanUserModal(user), + isDisplayed: ({ user }) => !user.blocked + }, + { + label: this.i18n('Unban user'), + description: this.i18n('Allow the user to login and create videos/comments again'), + handler: ({ user }) => this.unbanUser(user), + isDisplayed: ({ user }) => user.blocked + }, + { + label: this.i18n('Set Email as Verified'), + handler: ({ user }) => this.setEmailAsVerified(user), + isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false + } + ]) + } + + // Actions on accounts/servers + if (this.account) { + // User actions + this.userActions.push([ + { + label: this.i18n('Mute this account'), + description: this.i18n('Hide any content from that user for you.'), + isDisplayed: ({ account }) => account.mutedByUser === false, + handler: ({ account }) => this.blockAccountByUser(account) + }, + { + label: this.i18n('Unmute this account'), + description: this.i18n('Show back content from that user for you.'), + isDisplayed: ({ account }) => account.mutedByUser === true, + handler: ({ account }) => this.unblockAccountByUser(account) + }, + { + label: this.i18n('Mute the instance'), + description: this.i18n('Hide any content from that instance for you.'), + isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, + handler: ({ account }) => this.blockServerByUser(account.host) + }, + { + label: this.i18n('Unmute the instance'), + description: this.i18n('Show back content from that instance for you.'), + isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, + handler: ({ account }) => this.unblockServerByUser(account.host) + }, + { + label: this.i18n('Remove comments from your videos'), + description: this.i18n('Remove comments of this account from your videos.'), + handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'my-videos' }) + } + ]) + + let instanceActions: DropdownAction<{ user: User, account: Account }>[] = [] + + // Instance actions on account blocklists + if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) { + instanceActions = instanceActions.concat([ + { + label: this.i18n('Mute this account by your instance'), + description: this.i18n('Hide any content from that user for you, your instance and its users.'), + isDisplayed: ({ account }) => account.mutedByInstance === false, + handler: ({ account }) => this.blockAccountByInstance(account) + }, + { + label: this.i18n('Unmute this account by your instance'), + description: this.i18n('Show back content from that user for you, your instance and its users.'), + isDisplayed: ({ account }) => account.mutedByInstance === true, + handler: ({ account }) => this.unblockAccountByInstance(account) + } + ]) + } + + // Instance actions on server blocklists + if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) { + instanceActions = instanceActions.concat([ + { + label: this.i18n('Mute the instance by your instance'), + description: this.i18n('Hide any content from that instance for you, your instance and its users.'), + isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, + handler: ({ account }) => this.blockServerByInstance(account.host) + }, + { + label: this.i18n('Unmute the instance by your instance'), + description: this.i18n('Show back content from that instance for you, your instance and its users.'), + isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, + handler: ({ account }) => this.unblockServerByInstance(account.host) + } + ]) + } + + if (authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) { + instanceActions = instanceActions.concat([ + { + label: this.i18n('Remove comments from your instance'), + description: this.i18n('Remove comments of this account from your instance.'), + handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'instance' }) + } + ]) + } + + if (instanceActions.length !== 0) { + this.userActions.push(instanceActions) + } + } + } + } +} diff --git a/client/src/app/shared/shared-moderation/video-abuse.service.ts b/client/src/app/shared/shared-moderation/video-abuse.service.ts new file mode 100644 index 000000000..44dea44a5 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-abuse.service.ts @@ -0,0 +1,98 @@ +import { omit } from 'lodash-es' +import { SortMeta } from 'primeng/api' +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models' +import { environment } from '../../../environments/environment' + +@Injectable() +export class VideoAbuseService { + private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) {} + + getVideoAbuses (options: { + pagination: RestPagination, + sort: SortMeta, + search?: string + }): Observable> { + const { pagination, sort, search } = options + const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + id: { prefix: '#' }, + state: { + prefix: 'state:', + handler: v => { + if (v === 'accepted') return VideoAbuseState.ACCEPTED + if (v === 'pending') return VideoAbuseState.PENDING + if (v === 'rejected') return VideoAbuseState.REJECTED + + return undefined + } + }, + videoIs: { + prefix: 'videoIs:', + handler: v => { + if (v === 'deleted') return v + if (v === 'blacklisted') return v + + return undefined + } + }, + searchReporter: { prefix: 'reporter:' }, + searchReportee: { prefix: 'reportee:' }, + predefinedReason: { prefix: 'tag:' } + }) + + params = this.restService.addObjectParams(params, filters) + } + + return this.authHttp.get>(url, { params }) + .pipe( + catchError(res => this.restExtractor.handleError(res)) + ) + } + + reportVideo (parameters: { id: number } & VideoAbuseCreate) { + const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' + + const body = omit(parameters, [ 'id' ]) + + return this.authHttp.post(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) { + const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id + + return this.authHttp.put(url, abuseUpdate) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + removeVideoAbuse (videoAbuse: VideoAbuse) { + const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id + + return this.authHttp.delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + }} diff --git a/client/src/app/shared/shared-moderation/video-block.component.html b/client/src/app/shared/shared-moderation/video-block.component.html new file mode 100644 index 000000000..5e73d66c5 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-block.component.html @@ -0,0 +1,45 @@ + + + + + diff --git a/client/src/app/shared/shared-moderation/video-block.component.scss b/client/src/app/shared/shared-moderation/video-block.component.scss new file mode 100644 index 000000000..afcdb9a16 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-block.component.scss @@ -0,0 +1,6 @@ +@import 'variables'; +@import 'mixins'; + +textarea { + @include peertube-textarea(100%, 100px); +} diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts new file mode 100644 index 000000000..054651e71 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-block.component.ts @@ -0,0 +1,74 @@ +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { Notifier } from '@app/core' +import { FormReactive, FormValidatorService, VideoBlockValidatorsService } from '@app/shared/shared-forms' +import { Video } from '@app/shared/shared-main' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoBlockService } from './video-block.service' + +@Component({ + selector: 'my-video-block', + templateUrl: './video-block.component.html', + styleUrls: [ './video-block.component.scss' ] +}) +export class VideoBlockComponent extends FormReactive implements OnInit { + @Input() video: Video = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + @Output() videoBlocked = new EventEmitter() + + error: string = null + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private videoBlockValidatorsService: VideoBlockValidatorsService, + private videoBlocklistService: VideoBlockService, + private notifier: Notifier, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + const defaultValues = { unfederate: 'true' } + + this.buildForm({ + reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON, + unfederate: null + }, defaultValues) + } + + show () { + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) + } + + hide () { + this.openedModal.close() + this.openedModal = null + } + + block () { + const reason = this.form.value[ 'reason' ] || undefined + const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined + + this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate) + .subscribe( + () => { + this.notifier.success(this.i18n('Video blocked.')) + this.hide() + + this.video.blacklisted = true + this.video.blockedReason = reason + + this.videoBlocked.emit() + }, + + err => this.notifier.error(err.message) + ) + } +} diff --git a/client/src/app/shared/shared-moderation/video-block.service.ts b/client/src/app/shared/shared-moderation/video-block.service.ts new file mode 100644 index 000000000..c22ceefcc --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-block.service.ts @@ -0,0 +1,78 @@ +import { SortMeta } from 'primeng/api' +import { from as observableFrom, Observable } from 'rxjs' +import { catchError, concatMap, map, toArray } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor, RestPagination, RestService } from '@app/core' +import { ResultList, VideoBlacklist, VideoBlacklistType } from '@shared/models' +import { environment } from '../../../environments/environment' + +@Injectable() +export class VideoBlockService { + private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restService: RestService, + private restExtractor: RestExtractor + ) {} + + listBlocks (options: { + pagination: RestPagination + sort: SortMeta + search?: string + type?: VideoBlacklistType + }): Observable> { + const { pagination, sort, search, type } = options + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) { + const filters = this.restService.parseQueryStringFilter(search, { + type: { + prefix: 'type:', + handler: v => { + if (v === 'manual') return VideoBlacklistType.MANUAL + if (v === 'auto') return VideoBlacklistType.AUTO_BEFORE_PUBLISHED + + return undefined + } + } + }) + + params = this.restService.addObjectParams(params, filters) + } + if (type) params = params.append('type', type.toString()) + + return this.authHttp.get>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params }) + .pipe( + map(res => this.restExtractor.convertResultListDateToHuman(res)), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + unblockVideo (videoIdArgs: number | number[]) { + const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ] + + return observableFrom(videoIds) + .pipe( + concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + blockVideo (videoId: number, reason: string, unfederate: boolean) { + const body = { + unfederate, + reason + } + + return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(res => this.restExtractor.handleError(res)) + ) + } +} diff --git a/client/src/app/shared/shared-moderation/video-report.component.html b/client/src/app/shared/shared-moderation/video-report.component.html new file mode 100644 index 000000000..d6beb6d2a --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-report.component.html @@ -0,0 +1,97 @@ + + + + + diff --git a/client/src/app/shared/shared-moderation/video-report.component.scss b/client/src/app/shared/shared-moderation/video-report.component.scss new file mode 100644 index 000000000..b2606cbd8 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-report.component.scss @@ -0,0 +1,27 @@ +@import 'variables'; +@import 'mixins'; + +.information { + margin-bottom: 20px; +} + +textarea { + @include peertube-textarea(100%, 100px); +} + +.start-at, +.stop-at { + width: 300px; + display: flex; + align-items: center; + + my-timestamp-input { + margin-left: 10px; + } +} + +.screenratio { + @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { + left: 0; + }; +} diff --git a/client/src/app/shared/shared-moderation/video-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts new file mode 100644 index 000000000..11c805636 --- /dev/null +++ b/client/src/app/shared/shared-moderation/video-report.component.ts @@ -0,0 +1,161 @@ +import { mapValues, pickBy } from 'lodash-es' +import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' +import { Component, Input, OnInit, ViewChild } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' +import { Notifier } from '@app/core' +import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model' +import { Video } from '../shared-main' +import { VideoAbuseService } from './video-abuse.service' + +@Component({ + selector: 'my-video-report', + templateUrl: './video-report.component.html', + styleUrls: [ './video-report.component.scss' ] +}) +export class VideoReportComponent extends FormReactive implements OnInit { + @Input() video: Video = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + error: string = null + predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] + embedHtml: SafeHtml + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private videoAbuseValidatorsService: VideoAbuseValidatorsService, + private videoAbuseService: VideoAbuseService, + private notifier: Notifier, + private sanitizer: DomSanitizer, + private i18n: I18n + ) { + super() + } + + get currentHost () { + return window.location.host + } + + get originHost () { + if (this.isRemoteVideo()) { + return this.video.account.host + } + + return '' + } + + get timestamp () { + return this.form.get('timestamp').value + } + + getVideoEmbed () { + return this.sanitizer.bypassSecurityTrustHtml( + buildVideoEmbed( + buildVideoLink({ + baseUrl: this.video.embedUrl, + title: false, + warningTitle: false + }) + ) + ) + } + + ngOnInit () { + this.buildForm({ + reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, + predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), + timestamp: { + hasStart: null, + startAt: null, + hasEnd: null, + endAt: null + } + }) + + this.predefinedReasons = [ + { + id: 'violentOrRepulsive', + label: this.i18n('Violent or repulsive'), + help: this.i18n('Contains offensive, violent, or coarse language or iconography.') + }, + { + id: 'hatefulOrAbusive', + label: this.i18n('Hateful or abusive'), + help: this.i18n('Contains abusive, racist or sexist language or iconography.') + }, + { + id: 'spamOrMisleading', + label: this.i18n('Spam, ad or false news'), + help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.') + }, + { + id: 'privacy', + label: this.i18n('Privacy breach or doxxing'), + help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).') + }, + { + id: 'rights', + label: this.i18n('Intellectual property violation'), + help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.') + }, + { + id: 'serverRules', + label: this.i18n('Breaks server rules'), + description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.') + }, + { + id: 'thumbnails', + label: this.i18n('Thumbnails'), + help: this.i18n('The above can only be seen in thumbnails.') + }, + { + id: 'captions', + label: this.i18n('Captions'), + help: this.i18n('The above can only be seen in captions (please describe which).') + } + ] + + this.embedHtml = this.getVideoEmbed() + } + + show () { + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) + } + + hide () { + this.openedModal.close() + this.openedModal = null + } + + report () { + const reason = this.form.get('reason').value + const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] + const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value + + this.videoAbuseService.reportVideo({ + id: this.video.id, + reason, + predefinedReasons, + startAt: hasStart && startAt ? startAt : undefined, + endAt: hasEnd && endAt ? endAt : undefined + }).subscribe( + () => { + this.notifier.success(this.i18n('Video reported.')) + this.hide() + }, + + err => this.notifier.error(err.message) + ) + } + + isRemoteVideo () { + return !this.video.isLocal + } +} diff --git a/client/src/app/shared/shared-thumbnail/index.ts b/client/src/app/shared/shared-thumbnail/index.ts new file mode 100644 index 000000000..e09692867 --- /dev/null +++ b/client/src/app/shared/shared-thumbnail/index.ts @@ -0,0 +1,2 @@ +export * from './video-thumbnail.component' +export * from './shared-thumbnail.module' diff --git a/client/src/app/shared/shared-thumbnail/shared-thumbnail.module.ts b/client/src/app/shared/shared-thumbnail/shared-thumbnail.module.ts new file mode 100644 index 000000000..8ac557c14 --- /dev/null +++ b/client/src/app/shared/shared-thumbnail/shared-thumbnail.module.ts @@ -0,0 +1,23 @@ + +import { NgModule } from '@angular/core' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { VideoThumbnailComponent } from './video-thumbnail.component' + +@NgModule({ + imports: [ + SharedMainModule, + SharedGlobalIconModule + ], + + declarations: [ + VideoThumbnailComponent + ], + + exports: [ + VideoThumbnailComponent + ], + + providers: [ ] +}) +export class SharedThumbnailModule { } diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html new file mode 100644 index 000000000..fe5510c56 --- /dev/null +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html @@ -0,0 +1,33 @@ + + + +
    + +
    + +
    +
    + + +
    + +
    +
    +
    + +
    +
    + +
    {{ video.durationLabel }}
    + +
    +
    +
    + +
    +
    +
    +
    diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss new file mode 100644 index 000000000..feff78a87 --- /dev/null +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss @@ -0,0 +1,74 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +.video-thumbnail { + @include miniature-thumbnail; + + .progress-bar { + height: 3px; + width: 100%; + position: absolute; + bottom: 0; + background-color: rgba(0, 0, 0, 0.20); + + div { + height: 100%; + background-color: pvar(--mainColor); + } + } + + .video-thumbnail-watch-later-overlay, + .video-thumbnail-label-overlay, + .video-thumbnail-duration-overlay { + @include static-thumbnail-overlay; + + border-radius: 3px; + font-size: 12px; + font-weight: $font-semibold; + line-height: 1.2; + z-index: z(miniature); + } + + .video-thumbnail-label-overlay { + position: absolute; + padding: 0 5px; + left: 5px; + top: 5px; + font-weight: $font-bold; + + &.warning { background-color: orange; } + &.danger { background-color: red; } + } + + .video-thumbnail-duration-overlay { + position: absolute; + padding: 0 3px; + right: 5px; + bottom: 5px; + } + + .video-thumbnail-actions-overlay { + position: absolute; + display: flex; + flex-direction: column; + right: 5px; + top: 5px; + opacity: 0; + + div:not(:first-child) { + margin-top: 2px; + } + + .video-thumbnail-watch-later-overlay { + padding: 3px; + + my-global-icon { + width: 22px; + height: 22px; + + @include apply-svg-color(#fff); + } + } + } +} diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts new file mode 100644 index 000000000..3ff45d9b7 --- /dev/null +++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts @@ -0,0 +1,63 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { ScreenService } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Video } from '../shared-main' + +@Component({ + selector: 'my-video-thumbnail', + styleUrls: [ './video-thumbnail.component.scss' ], + templateUrl: './video-thumbnail.component.html' +}) +export class VideoThumbnailComponent { + @Input() video: Video + @Input() nsfw = false + @Input() routerLink: any[] + @Input() queryParams: { [ p: string ]: any } + + @Input() displayWatchLaterPlaylist: boolean + @Input() inWatchLaterPlaylist: boolean + + @Output() watchLaterClick = new EventEmitter() + + addToWatchLaterText: string + addedToWatchLaterText: string + + constructor ( + private screenService: ScreenService, + private i18n: I18n + ) { + this.addToWatchLaterText = this.i18n('Add to watch later') + this.addedToWatchLaterText = this.i18n('Remove from watch later') + } + + getImageUrl () { + if (!this.video) return '' + + if (this.screenService.isInMobileView()) { + return this.video.previewUrl + } + + return this.video.thumbnailUrl + } + + getProgressPercent () { + if (!this.video.userHistory) return 0 + + const currentTime = this.video.userHistory.currentTime + + return (currentTime / this.video.duration) * 100 + } + + getVideoRouterLink () { + if (this.routerLink) return this.routerLink + + return [ '/videos/watch', this.video.uuid ] + } + + onWatchLaterClick (event: Event) { + this.watchLaterClick.emit(this.inWatchLaterPlaylist) + + event.stopPropagation() + return false + } +} diff --git a/client/src/app/shared/shared-user-settings/index.ts b/client/src/app/shared/shared-user-settings/index.ts new file mode 100644 index 000000000..dcc08bdce --- /dev/null +++ b/client/src/app/shared/shared-user-settings/index.ts @@ -0,0 +1,4 @@ +export * from './user-interface-settings.component' +export * from './user-video-settings.component' + +export * from './shared-user-settings.module' diff --git a/client/src/app/shared/shared-user-settings/shared-user-settings.module.ts b/client/src/app/shared/shared-user-settings/shared-user-settings.module.ts new file mode 100644 index 000000000..395f2e3d0 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/shared-user-settings.module.ts @@ -0,0 +1,26 @@ + +import { NgModule } from '@angular/core' +import { SharedFormModule } from '../shared-forms' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { UserInterfaceSettingsComponent } from './user-interface-settings.component' +import { UserVideoSettingsComponent } from './user-video-settings.component' + +@NgModule({ + imports: [ + SharedMainModule, + SharedFormModule + ], + + declarations: [ + UserInterfaceSettingsComponent, + UserVideoSettingsComponent + ], + + exports: [ + UserInterfaceSettingsComponent, + UserVideoSettingsComponent + ], + + providers: [ ] +}) +export class SharedUserInterfaceSettingsModule { } diff --git a/client/src/app/shared/shared-user-settings/user-interface-settings.component.html b/client/src/app/shared/shared-user-settings/user-interface-settings.component.html new file mode 100644 index 000000000..0d0ddc0f2 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-interface-settings.component.html @@ -0,0 +1,17 @@ +
    + +
    + + +
    + +
    +
    + + +
    diff --git a/client/src/app/shared/shared-user-settings/user-interface-settings.component.scss b/client/src/app/shared/shared-user-settings/user-interface-settings.component.scss new file mode 100644 index 000000000..7818dfc02 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-interface-settings.component.scss @@ -0,0 +1,21 @@ +@import '_variables'; +@import '_mixins'; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; + + display: block; + margin-top: 15px; +} + +.peertube-select-container { + @include peertube-select-container(340px); + + margin-bottom: 30px; +} diff --git a/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts new file mode 100644 index 000000000..875ffa3f1 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts @@ -0,0 +1,86 @@ +import { Subject, Subscription } from 'rxjs' +import { Component, Input, OnDestroy, OnInit } from '@angular/core' +import { AuthService, Notifier, ServerService, UserService } from '@app/core' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig, User, UserUpdateMe } from '@shared/models' + +@Component({ + selector: 'my-user-interface-settings', + templateUrl: './user-interface-settings.component.html', + styleUrls: [ './user-interface-settings.component.scss' ] +}) +export class UserInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy { + @Input() user: User = null + @Input() reactiveUpdate = false + @Input() notifyOnUpdate = true + @Input() userInformationLoaded: Subject + + formValuesWatcher: Subscription + + private serverConfig: ServerConfig + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private userService: UserService, + private serverService: ServerService, + private i18n: I18n + ) { + super() + } + + get availableThemes () { + return this.serverConfig.theme.registered + .map(t => t.name) + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.buildForm({ + theme: null + }) + + this.userInformationLoaded + .subscribe(() => { + this.form.patchValue({ + theme: this.user.theme + }) + + if (this.reactiveUpdate) { + this.formValuesWatcher = this.form.valueChanges.subscribe(val => this.updateInterfaceSettings()) + } + }) + } + + ngOnDestroy () { + this.formValuesWatcher?.unsubscribe() + } + + updateInterfaceSettings () { + const theme = this.form.value['theme'] + + const details: UserUpdateMe = { + theme + } + + if (this.authService.isLoggedIn()) { + this.userService.updateMyProfile(details).subscribe( + () => { + this.authService.refreshUserInformation() + + if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.')) + }, + + err => this.notifier.error(err.message) + ) + } else { + this.userService.updateMyAnonymousProfile(details) + if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.')) + } + } +} diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html new file mode 100644 index 000000000..0dda33af2 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html @@ -0,0 +1,75 @@ +
    +
    + + + + + With Do not list or Blur thumbnails, a confirmation will be requested to watch the video. + + + + +
    + +
    +
    + +
    + + + + In Recently added, Trending, Local, Most liked and Search pages + + + +
    + +
    +
    + + + +
    + + + The sharing system implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load. + + +
    + +
    + + + When on a video page, directly start playing the video. + + +
    + +
    + + + When a video ends, follow up with the next suggested video. + + +
    + + +
    diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss new file mode 100644 index 000000000..430250b87 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss @@ -0,0 +1,24 @@ +@import '_variables'; +@import '_mixins'; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; + + margin-top: 15px; +} + +.peertube-select-container { + @include peertube-select-container(340px); + + margin-bottom: 30px; +} + +.form-group-select { + margin-bottom: 30px; +} diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts new file mode 100644 index 000000000..4e4539936 --- /dev/null +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts @@ -0,0 +1,139 @@ +import { pick } from 'lodash-es' +import { SelectItem } from 'primeng/api' +import { forkJoin, Subject, Subscription } from 'rxjs' +import { first } from 'rxjs/operators' +import { Component, Input, OnDestroy, OnInit } from '@angular/core' +import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { UserUpdateMe } from '@shared/models' +import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' + +@Component({ + selector: 'my-user-video-settings', + templateUrl: './user-video-settings.component.html', + styleUrls: [ './user-video-settings.component.scss' ] +}) +export class UserVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy { + @Input() user: User = null + @Input() reactiveUpdate = false + @Input() notifyOnUpdate = true + @Input() userInformationLoaded: Subject + + languageItems: SelectItem[] = [] + defaultNSFWPolicy: NSFWPolicyType + formValuesWatcher: Subscription + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private userService: UserService, + private serverService: ServerService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + let oldForm: any + + this.buildForm({ + nsfwPolicy: null, + webTorrentEnabled: null, + autoPlayVideo: null, + autoPlayNextVideo: null, + videoLanguages: null + }) + + forkJoin([ + this.serverService.getVideoLanguages(), + this.serverService.getConfig(), + this.userInformationLoaded.pipe(first()) + ]).subscribe(([ languages, config ]) => { + this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ] + this.languageItems = this.languageItems + .concat(languages.map(l => ({ label: l.label, value: l.id }))) + + const videoLanguages = this.user.videoLanguages + ? this.user.videoLanguages + : this.languageItems.map(l => l.value) + + this.defaultNSFWPolicy = config.instance.defaultNSFWPolicy + + this.form.patchValue({ + nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, + webTorrentEnabled: this.user.webTorrentEnabled, + autoPlayVideo: this.user.autoPlayVideo === true, + autoPlayNextVideo: this.user.autoPlayNextVideo, + videoLanguages + }) + + if (this.reactiveUpdate) { + oldForm = { ...this.form.value } + this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => { + const updatedKey = Object.keys(formValue).find(k => formValue[k] !== oldForm[k]) + oldForm = { ...this.form.value } + this.updateDetails([updatedKey]) + }) + } + }) + } + + ngOnDestroy () { + this.formValuesWatcher?.unsubscribe() + } + + updateDetails (onlyKeys?: string[]) { + const nsfwPolicy = this.form.value[ 'nsfwPolicy' ] + const webTorrentEnabled = this.form.value['webTorrentEnabled'] + const autoPlayVideo = this.form.value['autoPlayVideo'] + const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] + + let videoLanguages: string[] = this.form.value['videoLanguages'] + if (Array.isArray(videoLanguages)) { + if (videoLanguages.length === this.languageItems.length) { + videoLanguages = null // null means "All" + } else if (videoLanguages.length > 20) { + this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.') + return + } else if (videoLanguages.length === 0) { + this.notifier.error('You need to enabled at least 1 video language.') + return + } + } + + let details: UserUpdateMe = { + nsfwPolicy, + webTorrentEnabled, + autoPlayVideo, + autoPlayNextVideo, + videoLanguages + } + + if (onlyKeys) details = pick(details, onlyKeys) + + if (this.authService.isLoggedIn()) { + this.userService.updateMyProfile(details).subscribe( + () => { + this.authService.refreshUserInformation() + + if (this.notifyOnUpdate) this.notifier.success(this.i18n('Video settings updated.')) + }, + + err => this.notifier.error(err.message) + ) + } else { + this.userService.updateMyAnonymousProfile(details) + if (this.notifyOnUpdate) this.notifier.success(this.i18n('Display/Video settings updated.')) + } + } + + getDefaultVideoLanguageLabel () { + return this.i18n('No language') + } + + getSelectedVideoLanguageLabel () { + return this.i18n('{{\'{0} languages selected') + } +} diff --git a/client/src/app/shared/shared-user-subscription/index.ts b/client/src/app/shared/shared-user-subscription/index.ts new file mode 100644 index 000000000..fd53d14b5 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/index.ts @@ -0,0 +1,5 @@ +export * from './user-subscription.service' +export * from './subscribe-button.component' +export * from './remote-subscribe.component' + +export * from './shared-user-subscription.module' diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html new file mode 100644 index 000000000..acfec0a8e --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html @@ -0,0 +1,32 @@ +
    +
    + +
    + + + + + + + You can subscribe to the channel via any ActivityPub-capable fediverse instance.

    + For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there. +
    +
    +
    + + + + + You can interact with this via any ActivityPub-capable fediverse instance.

    + For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there. +
    +
    +
    +
    diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss new file mode 100644 index 000000000..698c5866a --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss @@ -0,0 +1,6 @@ +@import '_mixins'; + +.btn-remote-follow { + @include peertube-button; + @include orange-button; +} \ No newline at end of file diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts new file mode 100644 index 000000000..09164a5d3 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, OnInit } from '@angular/core' +import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms' + +@Component({ + selector: 'my-remote-subscribe', + templateUrl: './remote-subscribe.component.html', + styleUrls: ['./remote-subscribe.component.scss'] +}) +export class RemoteSubscribeComponent extends FormReactive implements OnInit { + @Input() uri: string + @Input() interact = false + @Input() showHelp = false + + constructor ( + protected formValidatorService: FormValidatorService, + private userValidatorsService: UserValidatorsService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + text: this.userValidatorsService.USER_EMAIL + }) + } + + onValidKey () { + this.check() + if (!this.form.valid) return + + this.formValidated() + } + + formValidated () { + const address = this.form.value['text'] + const [ username, hostname ] = address.split('@') + + // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5 + fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`) + .then(response => response.json()) + .then(data => new Promise((resolve, reject) => { + console.log(data) + + if (data && Array.isArray(data.links)) { + const link: { template: string } = data.links.find((link: any) => { + return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe' + }) + + if (link && link.template.includes('{uri}')) { + resolve(link.template.replace('{uri}', encodeURIComponent(this.uri))) + } + } + reject() + })) + .then(window.open) + .catch(err => console.error(err)) + } +} diff --git a/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts b/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts new file mode 100644 index 000000000..cddea80bf --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts @@ -0,0 +1,29 @@ + +import { NgModule } from '@angular/core' +import { SharedFormModule } from '../shared-forms' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { RemoteSubscribeComponent } from './remote-subscribe.component' +import { SubscribeButtonComponent } from './subscribe-button.component' +import { UserSubscriptionService } from './user-subscription.service' + +@NgModule({ + imports: [ + SharedMainModule, + SharedFormModule + ], + + declarations: [ + RemoteSubscribeComponent, + SubscribeButtonComponent + ], + + exports: [ + RemoteSubscribeComponent, + SubscribeButtonComponent + ], + + providers: [ + UserSubscriptionService + ] +}) +export class SharedUserSubscriptionModule { } diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.html b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html new file mode 100644 index 000000000..85b3d1fdb --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html @@ -0,0 +1,67 @@ +
    + + + + + Subscribe + + Subscribe to all channels + {{ subscribeStatus(true).length }}/{{ subscribed.size }} + channels subscribed + + + + + {{ videoChannels[0].followersCount | myNumberFormatter }} + + + + + + + + + + + + +
    + + + +
    +
    diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss b/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss new file mode 100644 index 000000000..b739c5ae2 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss @@ -0,0 +1,112 @@ +@import '_variables'; +@import '_mixins'; + +.btn-group-subscribe { + @include peertube-button; + @include disable-default-a-behaviour; + + float: right; + padding: 0; + + & > .btn, + & > .dropdown > .dropdown-toggle { + font-size: 15px; + } + + &:not(.big) { + white-space: nowrap; + } + + &.big { + height: 35px; + + & > button:first-child { + width: 175px; + } + + button .extra-text { + span:first-child { + line-height: 80%; + } + + span:not(:first-child) { + font-size: 75%; + } + } + } + + // Unlogged + & > .dropdown > .dropdown-toggle span { + padding-right: 3px; + } + + // Logged + & > .btn { + padding-right: 4px; + + & + .dropdown > button { + padding-left: 2px; + + &::after { + position: relative; + top: 1px; + } + } + } + + &.subscribe-button { + .btn { + @include orange-button; + font-weight: 600; + } + + span.followers-count { + padding-left: 5px; + } + } + &.unsubscribe-button { + .btn { + @include grey-button; + font-weight: 600; + } + } + + .dropdown-menu { + cursor: default; + + button { + cursor: pointer; + } + + .dropdown-item-neutral { + cursor: default; + + &:hover, + &:focus { + background-color: inherit; + } + } + } + + ::ng-deep form { + padding: 0.25rem 1rem; + } + + input { + @include peertube-input-text(100%); + } +} + +.extra-text { + display: flex; + flex-direction: column; + + span:first-child { + line-height: 75%; + } + + span:not(:first-child) { + font-size: 60%; + text-align: left; + } +} diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts b/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts new file mode 100644 index 000000000..72fa3f4fd --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts @@ -0,0 +1,196 @@ +import { concat, forkJoin, merge } from 'rxjs' +import { Component, Input, OnChanges, OnInit } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, Notifier } from '@app/core' +import { Account, VideoChannel, VideoService } from '@app/shared/shared-main' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { FeedFormat } from '@shared/models' +import { UserSubscriptionService } from './user-subscription.service' + +@Component({ + selector: 'my-subscribe-button', + templateUrl: './subscribe-button.component.html', + styleUrls: [ './subscribe-button.component.scss' ] +}) +export class SubscribeButtonComponent implements OnInit, OnChanges { + /** + * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel], + * or with an account and a full list of that account's videoChannels. The latter is intended + * to allow mass un/subscription from an account's page, while keeping the channel-centric + * subscription model. + */ + @Input() account: Account + @Input() videoChannels: VideoChannel[] + @Input() displayFollowers = false + @Input() size: 'small' | 'normal' = 'normal' + + subscribed = new Map() + + constructor ( + private authService: AuthService, + private router: Router, + private notifier: Notifier, + private userSubscriptionService: UserSubscriptionService, + private i18n: I18n, + private videoService: VideoService + ) { } + + get handle () { + return this.account + ? this.account.nameWithHost + : this.videoChannel.name + '@' + this.videoChannel.host + } + + get channelHandle () { + return this.getChannelHandler(this.videoChannel) + } + + get uri () { + return this.account + ? this.account.url + : this.videoChannels[0].url + } + + get rssUri () { + const rssFeed = this.account + ? this.videoService + .getAccountFeedUrls(this.account.id) + .find(i => i.format === FeedFormat.RSS) + : this.videoService + .getVideoChannelFeedUrls(this.videoChannels[0].id) + .find(i => i.format === FeedFormat.RSS) + + return rssFeed.url + } + + get videoChannel () { + return this.videoChannels[0] + } + + get isAllChannelsSubscribed () { + return this.subscribeStatus(true).length === this.videoChannels.length + } + + get isAtLeastOneChannelSubscribed () { + return this.subscribeStatus(true).length > 0 + } + + get isBigButton () { + return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed + } + + ngOnInit () { + this.loadSubscribedStatus() + } + + ngOnChanges () { + this.ngOnInit() + } + + subscribe () { + if (this.isUserLoggedIn()) { + return this.localSubscribe() + } + + return this.gotoLogin() + } + + localSubscribe () { + const subscribedStatus = this.subscribeStatus(false) + + const observableBatch = this.videoChannels + .map(videoChannel => this.getChannelHandler(videoChannel)) + .filter(handle => subscribedStatus.includes(handle)) + .map(handle => this.userSubscriptionService.addSubscription(handle)) + + forkJoin(observableBatch) + .subscribe( + () => { + this.notifier.success( + this.account + ? this.i18n( + 'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.', + { nameWithHost: this.account.displayName } + ) + : this.i18n( + 'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.', + { nameWithHost: this.videoChannels[0].displayName } + ) + , + this.i18n('Subscribed') + ) + }, + + err => this.notifier.error(err.message) + ) + } + + unsubscribe () { + if (this.isUserLoggedIn()) { + this.localUnsubscribe() + } + } + + localUnsubscribe () { + const subscribeStatus = this.subscribeStatus(true) + + const observableBatch = this.videoChannels + .map(videoChannel => this.getChannelHandler(videoChannel)) + .filter(handle => subscribeStatus.includes(handle)) + .map(handle => this.userSubscriptionService.deleteSubscription(handle)) + + concat(...observableBatch) + .subscribe({ + complete: () => { + this.notifier.success( + this.account + ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost }) + : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost }) + , + this.i18n('Unsubscribed') + ) + }, + + error: err => this.notifier.error(err.message) + }) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + gotoLogin () { + this.router.navigate([ '/login' ]) + } + + subscribeStatus (subscribed: boolean) { + const accumulator: string[] = [] + for (const [key, value] of this.subscribed.entries()) { + if (value === subscribed) accumulator.push(key) + } + + return accumulator + } + + private getChannelHandler (videoChannel: VideoChannel) { + return videoChannel.name + '@' + videoChannel.host + } + + private loadSubscribedStatus () { + if (!this.isUserLoggedIn()) return + + for (const videoChannel of this.videoChannels) { + const handle = this.getChannelHandler(videoChannel) + this.subscribed.set(handle, false) + + merge( + this.userSubscriptionService.listenToSubscriptionCacheChange(handle), + this.userSubscriptionService.doesSubscriptionExist(handle) + ).subscribe( + res => this.subscribed.set(handle, res), + + err => this.notifier.error(err.message) + ) + } + } +} diff --git a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts new file mode 100644 index 000000000..732ed6bcb --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts @@ -0,0 +1,182 @@ +import * as debug from 'debug' +import { uniq } from 'lodash-es' +import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' +import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable, NgZone } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { enterZone, leaveZone } from '@app/helpers' +import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' +import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' +import { environment } from '../../../environments/environment' + +const logger = debug('peertube:subscriptions:UserSubscriptionService') + +type SubscriptionExistResult = { [ uri: string ]: boolean } +type SubscriptionExistResultObservable = { [ uri: string ]: Observable } + +@Injectable() +export class UserSubscriptionService { + static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' + + // Use a replay subject because we "next" a value before subscribing + private existsSubject = new ReplaySubject(1) + private readonly existsObservable: Observable + + private myAccountSubscriptionCache: SubscriptionExistResult = {} + private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {} + private myAccountSubscriptionCacheSubject = new Subject() + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private videoService: VideoService, + private restService: RestService, + private ngZone: NgZone + ) { + this.existsObservable = merge( + this.existsSubject.pipe( + // We leave Angular zone so Protractor does not get stuck + bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), + filter(uris => uris.length !== 0), + map(uris => uniq(uris)), + observeOn(enterZone(this.ngZone, asyncScheduler)), + switchMap(uris => this.doSubscriptionsExist(uris)), + share() + ), + + this.myAccountSubscriptionCacheSubject + ) + } + + getUserSubscriptionVideos (parameters: { + videoPagination: ComponentPaginationLight, + sort: VideoSortField, + skipCount?: boolean + }): Observable> { + const { videoPagination, sort, skipCount } = parameters + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (skipCount) params = params.set('skipCount', skipCount + '') + + return this.authHttp + .get>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params }) + .pipe( + switchMap(res => this.videoService.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + /** + * Subscription part + */ + + deleteSubscription (nameWithHost: string) { + const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost + + return this.authHttp.delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + this.myAccountSubscriptionCache[nameWithHost] = false + + this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + addSubscription (nameWithHost: string) { + const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + + const body = { uri: nameWithHost } + return this.authHttp.post(url, body) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + this.myAccountSubscriptionCache[nameWithHost] = true + + this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listSubscriptions (componentPagination: ComponentPaginationLight): Observable> { + const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp.get>(url, { params }) + .pipe( + map(res => VideoChannelService.extractVideoChannels(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + /** + * SubscriptionExist part + */ + + listenToMyAccountSubscriptionCacheSubject () { + return this.myAccountSubscriptionCacheSubject.asObservable() + } + + listenToSubscriptionCacheChange (nameWithHost: string) { + if (nameWithHost in this.myAccountSubscriptionCacheObservable) { + return this.myAccountSubscriptionCacheObservable[ nameWithHost ] + } + + const obs = this.existsObservable + .pipe( + filter(existsResult => existsResult[ nameWithHost ] !== undefined), + map(existsResult => existsResult[ nameWithHost ]) + ) + + this.myAccountSubscriptionCacheObservable[ nameWithHost ] = obs + return obs + } + + doesSubscriptionExist (nameWithHost: string) { + logger('Running subscription check for %d.', nameWithHost) + + if (nameWithHost in this.myAccountSubscriptionCache) { + logger('Found cache for %d.', nameWithHost) + + return of(this.myAccountSubscriptionCache[ nameWithHost ]) + } + + this.existsSubject.next(nameWithHost) + + logger('Fetching from network for %d.', nameWithHost) + return this.existsObservable.pipe( + filter(existsResult => existsResult[ nameWithHost ] !== undefined), + map(existsResult => existsResult[ nameWithHost ]), + tap(result => this.myAccountSubscriptionCache[ nameWithHost ] = result) + ) + } + + private doSubscriptionsExist (uris: string[]): Observable { + const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' + let params = new HttpParams() + + params = this.restService.addObjectParams(params, { uris }) + + return this.authHttp.get(url, { params }) + .pipe( + tap(res => { + this.myAccountSubscriptionCache = { + ...this.myAccountSubscriptionCache, + ...res + } + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/abstract-video-list.html new file mode 100644 index 000000000..1e919ee72 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.html @@ -0,0 +1,49 @@ +
    +
    +

    +
    + {{ titlePage }} +
    + +

    + + + +
    + + +
    +
    + +
    No results.
    +
    + +

    + {{ getCurrentGroupedDateLabel(video) }} +

    + +
    + + +
    +
    +
    +
    diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss new file mode 100644 index 000000000..7f23098aa --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss @@ -0,0 +1,75 @@ +@import '_bootstrap-variables'; +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +.videos-header { + display: flex; + justify-content: space-between; + align-items: baseline; + + .title-page.title-page-single { + display: flex; + + my-feed { + display: inline-block; + top: 1px; + margin-left: 5px; + width: max-content; + opacity: 0; + transition: ease-in .2s opacity; + } + &:hover my-feed { + opacity: 1; + } + } + + .action-block { + a button { + @include peertube-button; + @include grey-button; + @include button-with-icon(18px, 3px, -1px); + } + } + + .moderation-block { + display: flex; + flex-grow: 1; + justify-content: flex-end; + align-items: center; + } +} + +.date-title { + font-size: 16px; + font-weight: $font-semibold; + margin-bottom: 20px; + margin-top: -10px; + + // make the element span a full grid row within .videos grid + grid-column: 1 / -1; + + &:not(:first-child) { + margin-top: .5rem; + padding-top: 20px; + border-top: 1px solid $separator-border-color; + } +} + +.margin-content { + @include fluid-videos-miniature-layout; +} + +@media screen and (max-width: $mobile-view) { + .videos-header { + flex-direction: column; + align-items: center; + height: auto; + margin-bottom: 10px; + + .title-page { + margin-bottom: 10px; + margin-right: 0px; + } + } +} diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts new file mode 100644 index 000000000..0ef842652 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts @@ -0,0 +1,310 @@ +import { fromEvent, Observable, Subject, Subscription } from 'rxjs' +import { debounceTime, switchMap, tap } from 'rxjs/operators' +import { OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { + AuthService, + ComponentPaginationLight, + LocalStorageService, + Notifier, + ScreenService, + ServerService, + User, + UserService +} from '@app/core' +import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' +import { GlobalIconName } from '@app/shared/shared-icons' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' +import { ServerConfig, VideoSortField } from '@shared/models' +import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' +import { Syndication, Video } from '../shared-main' +import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component' + +enum GroupDate { + UNKNOWN = 0, + TODAY = 1, + YESTERDAY = 2, + LAST_WEEK = 3, + LAST_MONTH = 4, + OLDER = 5 +} + +export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { + pagination: ComponentPaginationLight = { + currentPage: 1, + itemsPerPage: 25 + } + sort: VideoSortField = '-publishedAt' + + categoryOneOf?: number[] + languageOneOf?: string[] + nsfwPolicy?: NSFWPolicyType + defaultSort: VideoSortField = '-publishedAt' + + syndicationItems: Syndication[] = [] + + loadOnInit = true + useUserVideoPreferences = false + ownerDisplayType: OwnerDisplayType = 'account' + displayModerationBlock = false + titleTooltip: string + displayVideoActions = true + groupByDate = false + + videos: Video[] = [] + hasDoneFirstQuery = false + disabled = false + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: true, + privacyText: false, + state: false, + blacklistInfo: false + } + + actions: { + routerLink: string + iconName: GlobalIconName + label: string + }[] = [] + + onDataSubject = new Subject() + + userMiniature: User + + protected serverConfig: ServerConfig + + protected abstract notifier: Notifier + protected abstract authService: AuthService + protected abstract userService: UserService + protected abstract route: ActivatedRoute + protected abstract serverService: ServerService + protected abstract screenService: ScreenService + protected abstract storageService: LocalStorageService + protected abstract router: Router + protected abstract i18n: I18n + abstract titlePage: string + + private resizeSubscription: Subscription + private angularState: number + + private groupedDateLabels: { [id in GroupDate]: string } + private groupedDates: { [id: number]: GroupDate } = {} + + private lastQueryLength: number + + abstract getVideosObservable (page: number): Observable<{ data: Video[] }> + + abstract generateSyndicationList (): void + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.groupedDateLabels = { + [GroupDate.UNKNOWN]: null, + [GroupDate.TODAY]: this.i18n('Today'), + [GroupDate.YESTERDAY]: this.i18n('Yesterday'), + [GroupDate.LAST_WEEK]: this.i18n('Last week'), + [GroupDate.LAST_MONTH]: this.i18n('Last month'), + [GroupDate.OLDER]: this.i18n('Older') + } + + // Subscribe to route changes + const routeParams = this.route.snapshot.queryParams + this.loadRouteParams(routeParams) + + this.resizeSubscription = fromEvent(window, 'resize') + .pipe(debounceTime(500)) + .subscribe(() => this.calcPageSizes()) + + this.calcPageSizes() + + const loadUserObservable = this.loadUserAndSettings() + + if (this.loadOnInit === true) { + loadUserObservable.subscribe(() => this.loadMoreVideos()) + } + + this.userService.listenAnonymousUpdate() + .pipe(switchMap(() => this.loadUserAndSettings())) + .subscribe(() => { + if (this.hasDoneFirstQuery) this.reloadVideos() + }) + + // Display avatar in mobile view + if (this.screenService.isInMobileView()) { + this.displayOptions.avatar = true + } + } + + ngOnDestroy () { + if (this.resizeSubscription) this.resizeSubscription.unsubscribe() + } + + disableForReuse () { + this.disabled = true + } + + enabledForReuse () { + this.disabled = false + } + + videoById (index: number, video: Video) { + return video.id + } + + onNearOfBottom () { + if (this.disabled) return + + // No more results + if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return + + this.pagination.currentPage += 1 + + this.setScrollRouteParams() + + this.loadMoreVideos() + } + + loadMoreVideos (reset = false) { + this.getVideosObservable(this.pagination.currentPage).subscribe( + ({ data }) => { + this.hasDoneFirstQuery = true + this.lastQueryLength = data.length + + if (reset) this.videos = [] + this.videos = this.videos.concat(data) + + if (this.groupByDate) this.buildGroupedDateLabels() + + this.onMoreVideos() + + this.onDataSubject.next(data) + }, + + error => { + const message = this.i18n('Cannot load more videos. Try again later.') + + console.error(message, { error }) + this.notifier.error(message) + } + ) + } + + reloadVideos () { + this.pagination.currentPage = 1 + this.loadMoreVideos(true) + } + + toggleModerationDisplay () { + throw new Error('toggleModerationDisplay is not implemented') + } + + removeVideoFromArray (video: Video) { + this.videos = this.videos.filter(v => v.id !== video.id) + } + + buildGroupedDateLabels () { + let currentGroupedDate: GroupDate = GroupDate.UNKNOWN + + for (const video of this.videos) { + const publishedDate = video.publishedAt + + if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) { + if (currentGroupedDate === GroupDate.TODAY) continue + + currentGroupedDate = GroupDate.TODAY + this.groupedDates[ video.id ] = currentGroupedDate + continue + } + + if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) { + if (currentGroupedDate === GroupDate.YESTERDAY) continue + + currentGroupedDate = GroupDate.YESTERDAY + this.groupedDates[ video.id ] = currentGroupedDate + continue + } + + if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) { + if (currentGroupedDate === GroupDate.LAST_WEEK) continue + + currentGroupedDate = GroupDate.LAST_WEEK + this.groupedDates[ video.id ] = currentGroupedDate + continue + } + + if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) { + if (currentGroupedDate === GroupDate.LAST_MONTH) continue + + currentGroupedDate = GroupDate.LAST_MONTH + this.groupedDates[ video.id ] = currentGroupedDate + continue + } + + if (currentGroupedDate <= GroupDate.OLDER) { + if (currentGroupedDate === GroupDate.OLDER) continue + + currentGroupedDate = GroupDate.OLDER + this.groupedDates[ video.id ] = currentGroupedDate + } + } + } + + getCurrentGroupedDateLabel (video: Video) { + if (this.groupByDate === false) return undefined + + return this.groupedDateLabels[this.groupedDates[video.id]] + } + + // On videos hook for children that want to do something + protected onMoreVideos () { /* empty */ } + + protected loadRouteParams (routeParams: { [ key: string ]: any }) { + this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort + this.categoryOneOf = routeParams[ 'categoryOneOf' ] + this.angularState = routeParams[ 'a-state' ] + } + + private calcPageSizes () { + if (this.screenService.isInMobileView()) { + this.pagination.itemsPerPage = 5 + } + } + + private setScrollRouteParams () { + // Already set + if (this.angularState) return + + this.angularState = 42 + + const queryParams = { + 'a-state': this.angularState, + categoryOneOf: this.categoryOneOf + } + + let path = this.router.url + if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute + + this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) + } + + private loadUserAndSettings () { + return this.userService.getAnonymousOrLoggedUser() + .pipe(tap(user => { + this.userMiniature = user + + if (!this.useUserVideoPreferences) return + + this.languageOneOf = user.videoLanguages + this.nsfwPolicy = user.nsfwPolicy + })) + } +} diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts new file mode 100644 index 000000000..47ca6f51b --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/index.ts @@ -0,0 +1,7 @@ +export * from './abstract-video-list' +export * from './video-actions-dropdown.component' +export * from './video-download.component' +export * from './video-miniature.component' +export * from './videos-selection.component' + +export * from './shared-video-miniature.module' diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts new file mode 100644 index 000000000..666144864 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts @@ -0,0 +1,40 @@ + +import { NgModule } from '@angular/core' +import { SharedFormModule } from '../shared-forms' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { SharedModerationModule } from '../shared-moderation' +import { SharedThumbnailModule } from '../shared-thumbnail' +import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' +import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' +import { VideoDownloadComponent } from './video-download.component' +import { VideoMiniatureComponent } from './video-miniature.component' +import { VideosSelectionComponent } from './videos-selection.component' + +@NgModule({ + imports: [ + SharedMainModule, + SharedFormModule, + SharedModerationModule, + SharedVideoPlaylistModule, + SharedThumbnailModule, + SharedGlobalIconModule + ], + + declarations: [ + VideoActionsDropdownComponent, + VideoDownloadComponent, + VideoMiniatureComponent, + VideosSelectionComponent + ], + + exports: [ + VideoActionsDropdownComponent, + VideoDownloadComponent, + VideoMiniatureComponent, + VideosSelectionComponent + ], + + providers: [ ] +}) +export class SharedVideoMiniatureModule { } diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html new file mode 100644 index 000000000..3c8271b65 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html @@ -0,0 +1,21 @@ + + +
    + + +
    + +
    +
    + + + + + + +
    diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss new file mode 100644 index 000000000..67d7ee86a --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss @@ -0,0 +1,12 @@ +.playlist-dropdown { + position: absolute; + + .anchor { + display: block; + opacity: 0; + } +} + +::ng-deep .icon-playlist-add { + left: 2px; +} diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts new file mode 100644 index 000000000..db8d1c309 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts @@ -0,0 +1,269 @@ +import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' +import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' +import { VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoCaption } from '@shared/models' +import { DropdownAction, DropdownButtonSize, DropdownDirection, RedundancyService, Video, VideoDetails, VideoService } from '../shared-main' +import { VideoAddToPlaylistComponent } from '../shared-video-playlist' +import { VideoDownloadComponent } from './video-download.component' + +export type VideoActionsDisplayType = { + playlist?: boolean + download?: boolean + update?: boolean + blacklist?: boolean + delete?: boolean + report?: boolean + duplicate?: boolean +} + +@Component({ + selector: 'my-video-actions-dropdown', + templateUrl: './video-actions-dropdown.component.html', + styleUrls: [ './video-actions-dropdown.component.scss' ] +}) +export class VideoActionsDropdownComponent implements OnChanges { + @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown + @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent + + @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent + @ViewChild('videoReportModal') videoReportModal: VideoReportComponent + @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent + + @Input() video: Video | VideoDetails + @Input() videoCaptions: VideoCaption[] = [] + + @Input() displayOptions: VideoActionsDisplayType = { + playlist: false, + download: true, + update: true, + blacklist: true, + delete: true, + report: true, + duplicate: true + } + @Input() placement = 'left' + + @Input() label: string + + @Input() buttonStyled = false + @Input() buttonSize: DropdownButtonSize = 'normal' + @Input() buttonDirection: DropdownDirection = 'vertical' + + @Output() videoRemoved = new EventEmitter() + @Output() videoUnblocked = new EventEmitter() + @Output() videoBlocked = new EventEmitter() + @Output() modalOpened = new EventEmitter() + + videoActions: DropdownAction<{ video: Video }>[][] = [] + + private loaded = false + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoBlocklistService: VideoBlockService, + private screenService: ScreenService, + private videoService: VideoService, + private redundancyService: RedundancyService, + private i18n: I18n + ) { } + + get user () { + return this.authService.getUser() + } + + ngOnChanges () { + if (this.loaded) { + this.loaded = false + this.playlistAdd.reload() + } + + this.buildActions() + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + loadDropdownInformation () { + if (!this.isUserLoggedIn() || this.loaded === true) return + + this.loaded = true + + if (this.displayOptions.playlist) this.playlistAdd.load() + } + + /* Show modals */ + + showDownloadModal () { + this.modalOpened.emit() + + this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions) + } + + showReportModal () { + this.modalOpened.emit() + + this.videoReportModal.show() + } + + showBlockModal () { + this.modalOpened.emit() + + this.videoBlockModal.show() + } + + /* Actions checker */ + + isVideoUpdatable () { + return this.video.isUpdatableBy(this.user) + } + + isVideoRemovable () { + return this.video.isRemovableBy(this.user) + } + + isVideoBlockable () { + return this.video.isBlockableBy(this.user) + } + + isVideoUnblockable () { + return this.video.isUnblockableBy(this.user) + } + + isVideoDownloadable () { + return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled + } + + canVideoBeDuplicated () { + return this.video.canBeDuplicatedBy(this.user) + } + + /* Action handlers */ + + async unblockVideo () { + const confirmMessage = this.i18n( + 'Do you really want to unblock this video? It will be available again in the videos list.' + ) + + const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock')) + if (res === false) return + + this.videoBlocklistService.unblockVideo(this.video.id).subscribe( + () => { + this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name })) + + this.video.blacklisted = false + this.video.blockedReason = null + + this.videoUnblocked.emit() + }, + + err => this.notifier.error(err.message) + ) + } + + async removeVideo () { + this.modalOpened.emit() + + const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete')) + if (res === false) return + + this.videoService.removeVideo(this.video.id) + .subscribe( + () => { + this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name })) + + this.videoRemoved.emit() + }, + + error => this.notifier.error(error.message) + ) + } + + duplicateVideo () { + this.redundancyService.addVideoRedundancy(this.video) + .subscribe( + () => { + const message = this.i18n('This video will be duplicated by your instance.') + this.notifier.success(message) + }, + + err => this.notifier.error(err.message) + ) + } + + onVideoBlocked () { + this.videoBlocked.emit() + } + + getPlaylistDropdownPlacement () { + if (this.screenService.isInSmallView()) { + return 'bottom-right' + } + + return 'bottom-left bottom-right' + } + + private buildActions () { + this.videoActions = [ + [ + { + label: this.i18n('Save to playlist'), + handler: () => this.playlistDropdown.toggle(), + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist, + iconName: 'playlist-add' + } + ], + [ + { + label: this.i18n('Download'), + handler: () => this.showDownloadModal(), + isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(), + iconName: 'download' + }, + { + label: this.i18n('Update'), + linkBuilder: ({ video }) => [ '/videos/update', video.uuid ], + iconName: 'edit', + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable() + }, + { + label: this.i18n('Block'), + handler: () => this.showBlockModal(), + iconName: 'no', + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable() + }, + { + label: this.i18n('Unblock'), + handler: () => this.unblockVideo(), + iconName: 'undo', + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable() + }, + { + label: this.i18n('Mirror'), + handler: () => this.duplicateVideo(), + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(), + iconName: 'cloud-download' + }, + { + label: this.i18n('Delete'), + handler: () => this.removeVideo(), + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), + iconName: 'delete' + } + ], + [ + { + label: this.i18n('Report'), + handler: () => this.showReportModal(), + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report, + iconName: 'alert' + } + ] + ] + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.html b/client/src/app/shared/shared-video-miniature/video-download.component.html new file mode 100644 index 000000000..c65e371ee --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-download.component.html @@ -0,0 +1,108 @@ + + + + + + + diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/video-download.component.scss new file mode 100644 index 000000000..b09078bea --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-download.component.scss @@ -0,0 +1,64 @@ +@import 'variables'; +@import 'mixins'; + +.peertube-select-container { + @include peertube-select-container(100px); + + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; + + select { + height: inherit; + } +} + +#dropdownDownloadType { + cursor: pointer; +} + +.download-type { + margin-top: 30px; + + .peertube-radio-container { + @include peertube-radio-container; + + display: inline-block; + margin-right: 30px; + } +} + +.file-metadata { + padding: 1rem; +} + +.file-metadata .metadata-attribute { + font-size: 13px; + display: block; + margin-bottom: 12px; + + .metadata-attribute-label { + min-width: 142px; + padding-right: 5px; + display: inline-block; + color: pvar(--greyForegroundColor); + font-weight: $font-bold; + } + + a.metadata-attribute-value { + @include disable-default-a-behaviour; + color: pvar(--mainForegroundColor); + + &:hover { + opacity: 0.9; + } + } + + &.metadata-attribute-tags { + .metadata-attribute-value:not(:nth-child(2)) { + &::before { + content: ', ' + } + } + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts new file mode 100644 index 000000000..21df8b674 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts @@ -0,0 +1,206 @@ +import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' +import { mapValues, pick } from 'lodash-es' +import { BytesPipe } from 'ngx-pipes' +import { Component, ElementRef, ViewChild } from '@angular/core' +import { AuthService, Notifier } from '@app/core' +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' +import { NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main' + +type DownloadType = 'video' | 'subtitles' +type FileMetadata = { [key: string]: { label: string, value: string }} + +@Component({ + selector: 'my-video-download', + templateUrl: './video-download.component.html', + styleUrls: [ './video-download.component.scss' ] +}) +export class VideoDownloadComponent { + @ViewChild('modal', { static: true }) modal: ElementRef + + downloadType: 'direct' | 'torrent' = 'torrent' + resolutionId: number | string = -1 + subtitleLanguageId: string + + video: VideoDetails + videoFile: VideoFile + videoFileMetadataFormat: FileMetadata + videoFileMetadataVideoStream: FileMetadata | undefined + videoFileMetadataAudioStream: FileMetadata | undefined + videoCaptions: VideoCaption[] + activeModal: NgbActiveModal + + type: DownloadType = 'video' + + private bytesPipe: BytesPipe + private numbersPipe: NumberFormatterPipe + + constructor ( + private notifier: Notifier, + private modalService: NgbModal, + private videoService: VideoService, + private auth: AuthService, + private i18n: I18n + ) { + this.bytesPipe = new BytesPipe() + this.numbersPipe = new NumberFormatterPipe() + } + + get typeText () { + return this.type === 'video' + ? this.i18n('video') + : this.i18n('subtitles') + } + + getVideoFiles () { + if (!this.video) return [] + + return this.video.getFiles() + } + + show (video: VideoDetails, videoCaptions?: VideoCaption[]) { + this.video = video + this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined + + this.activeModal = this.modalService.open(this.modal, { centered: true }) + + this.resolutionId = this.getVideoFiles()[0].resolution.id + this.onResolutionIdChange() + if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id + } + + onClose () { + this.video = undefined + this.videoCaptions = undefined + } + + download () { + window.location.assign(this.getLink()) + this.activeModal.close() + } + + getLink () { + return this.type === 'subtitles' && this.videoCaptions + ? this.getSubtitlesLink() + : this.getVideoFileLink() + } + + async onResolutionIdChange () { + this.videoFile = this.getVideoFile() + if (this.videoFile.metadata || !this.videoFile.metadataUrl) return + + await this.hydrateMetadataFromMetadataUrl(this.videoFile) + + this.videoFileMetadataFormat = this.videoFile + ? this.getMetadataFormat(this.videoFile.metadata.format) + : undefined + this.videoFileMetadataVideoStream = this.videoFile + ? this.getMetadataStream(this.videoFile.metadata.streams, 'video') + : undefined + this.videoFileMetadataAudioStream = this.videoFile + ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio') + : undefined + } + + getVideoFile () { + // HTML select send us a string, so convert it to a number + this.resolutionId = parseInt(this.resolutionId.toString(), 10) + + const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId) + if (!file) { + console.error('Could not find file with resolution %d.', this.resolutionId) + return + } + return file + } + + getVideoFileLink () { + const file = this.videoFile + if (!file) return + + const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL + ? '?access_token=' + this.auth.getAccessToken() + : '' + + switch (this.downloadType) { + case 'direct': + return file.fileDownloadUrl + suffix + + case 'torrent': + return file.torrentDownloadUrl + suffix + } + } + + getSubtitlesLink () { + return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath + } + + activateCopiedMessage () { + this.notifier.success(this.i18n('Copied')) + } + + switchToType (type: DownloadType) { + this.type = type + } + + getMetadataFormat (format: FfprobeFormat) { + const keyToTranslateFunction = { + 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }), + 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }), + 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }), + 'bit_rate': (value: number) => ({ + label: this.i18n('Bitrate'), + value: `${this.numbersPipe.transform(value)}bps` + }) + } + + // flattening format + const sanitizedFormat = Object.assign(format, format.tags) + delete sanitizedFormat.tags + + return mapValues( + pick(sanitizedFormat, Object.keys(keyToTranslateFunction)), + (val, key) => keyToTranslateFunction[key](val) + ) + } + + getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') { + const stream = streams.find(s => s.codec_type === type) + if (!stream) return undefined + + let keyToTranslateFunction = { + 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }), + 'profile': (value: string) => ({ label: this.i18n('Profile'), value }), + 'bit_rate': (value: number) => ({ + label: this.i18n('Bitrate'), + value: `${this.numbersPipe.transform(value)}bps` + }) + } + + if (type === 'video') { + keyToTranslateFunction = Object.assign(keyToTranslateFunction, { + 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }), + 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }), + 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }), + 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value }) + }) + } else { + keyToTranslateFunction = Object.assign(keyToTranslateFunction, { + 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }), + 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value }) + }) + } + + return mapValues( + pick(stream, Object.keys(keyToTranslateFunction)), + (val, key) => keyToTranslateFunction[key](val) + ) + } + + private hydrateMetadataFromMetadataUrl (file: VideoFile) { + const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) + observable.subscribe(res => file.metadata = res) + return observable.toPromise() + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html new file mode 100644 index 000000000..82afc866f --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html @@ -0,0 +1,66 @@ +
    + + Unlisted + Private + + +
    +
    +
    + + + + +
    + {{ video.name }} + + + + + + + {video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}} + + + + + + {{ video.byVideoChannel }} + + +
    + {{ video.privacy.label }} + - + {{ getStateLabel(video) }} +
    +
    +
    + +
    + Blocked + {{ video.blockedReason }} +
    + +
    + Sensitive +
    +
    + +
    + + +
    +
    +
    diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss new file mode 100644 index 000000000..38cac5b6e --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss @@ -0,0 +1,200 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +$more-button-width: 40px; +$more-margin-right: 15px; + +.video-miniature { + display: inline-flex; + flex-direction: column; + padding-bottom: $video-miniature-margin-bottom; + vertical-align: top; + + .video-bottom { + display: flex; + + .video-miniature-information { + width: $video-miniature-width - $more-button-width - $more-margin-right; + line-height: normal; + + .avatar { + margin: 10px 10px 0 0; + + img { + @include avatar(40px); + } + } + + .video-miniature-name { + @include miniature-name; + width: calc(100% - #{$more-button-width}); + } + + .video-miniature-meta { + width: calc(100% + #{$more-button-width}); + overflow: hidden; + } + + .video-miniature-created-at-views { + display: block; + font-size: 13px; + } + + .video-miniature-account, + .video-miniature-channel { + @include disable-default-a-behaviour; + @include ellipsis; + + display: block; + font-size: 13px; + color: pvar(--greyForegroundColor); + + &:hover { + color: $grey-foreground-hover-color; + } + } + + .video-info-privacy, + .video-info-blocked .blocked-label, + .video-info-nsfw { + font-weight: $font-semibold; + } + + .video-info-blocked { + color: red; + + .blocked-reason::before { + content: ' - '; + } + } + + .video-info-nsfw { + color: red; + } + } + + .video-actions { + margin-top: 3px; + width: $more-button-width; + height: 30px; + + ::ng-deep .dropdown-root:not(.show) { + opacity: 0; + } + + ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root { + opacity: 1; + } + + ::ng-deep .more-icon { + opacity: .6; + + &:hover { + opacity: 1; + } + } + } + + @media screen and (max-width: $small-view) { + .video-miniature-information { + margin: 0 10px; + } + + .video-actions { + margin: 0; + top: -3px; + + ::ng-deep .dropdown-root { + opacity: 1 !important; + } + } + } + } + + &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay, + &:hover .video-bottom .video-actions ::ng-deep .dropdown-root { + opacity: 1; + } + + &.fit-width { + width: 100%; + + .video-bottom { + width: 100% !important; + + .video-miniature-information { + width: calc(100% - #{$more-button-width}) !important; + } + } + + my-video-thumbnail { + @include large-screen-ratio($selector: '::ng-deep .video-thumbnail'); + } + } + + &.display-as-row { + flex-direction: row; + padding-bottom: 0; + height: auto; + display: flex; + flex-grow: 1; + + my-video-thumbnail { + margin-right: 10px; + } + + .video-bottom { + .video-miniature-information { + @media screen and (min-width: $small-view) { + width: auto; + min-width: 500px; + } + + .video-miniature-name { + @include ellipsis-multiline(1.3em, 2); + + margin-top: 2px; + margin-bottom: 5px; + } + + .video-miniature-created-at-views, + .video-miniature-account, + .video-miniature-channel { + font-size: 95%; + width: fit-content; + } + + .video-miniature-created-at-views + .video-miniature-channel { + margin-top: 5px; + } + + .video-info-privacy { + margin-top: 5px; + } + + .video-info-blocked { + margin-top: 3px; + } + } + + .video-actions { + margin: 0; + top: -3px; + } + } + + @media screen and (max-width: $small-view) { + flex-direction: column; + height: auto; + + my-video-thumbnail { + margin-right: 0; + } + + .video-miniature-information { + min-width: initial; + } + } + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts new file mode 100644 index 000000000..6f32977b3 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts @@ -0,0 +1,283 @@ +import { switchMap } from 'rxjs/operators' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Inject, + Input, + LOCALE_ID, + OnInit, + Output +} from '@angular/core' +import { AuthService, ScreenService, ServerService, User } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared' +import { Video } from '../shared-main' +import { VideoPlaylistService } from '../shared-video-playlist' +import { VideoActionsDisplayType } from './video-actions-dropdown.component' + +export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' +export type MiniatureDisplayOptions = { + date?: boolean + views?: boolean + by?: boolean + avatar?: boolean + privacyLabel?: boolean + privacyText?: boolean + state?: boolean + blacklistInfo?: boolean + nsfw?: boolean +} + +@Component({ + selector: 'my-video-miniature', + styleUrls: [ './video-miniature.component.scss' ], + templateUrl: './video-miniature.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VideoMiniatureComponent implements OnInit { + @Input() user: User + @Input() video: Video + + @Input() ownerDisplayType: OwnerDisplayType = 'account' + @Input() displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: false, + privacyText: false, + state: false, + blacklistInfo: false + } + @Input() displayAsRow = false + @Input() displayVideoActions = true + @Input() fitWidth = false + + @Input() useLazyLoadUrl = false + + @Output() videoBlocked = new EventEmitter() + @Output() videoUnblocked = new EventEmitter() + @Output() videoRemoved = new EventEmitter() + + videoActionsDisplayOptions: VideoActionsDisplayType = { + playlist: true, + download: false, + update: true, + blacklist: true, + delete: true, + report: true, + duplicate: true + } + showActions = false + serverConfig: ServerConfig + + addToWatchLaterText: string + addedToWatchLaterText: string + inWatchLaterPlaylist: boolean + channelLinkTitle = '' + + watchLaterPlaylist: { + id: number + playlistElementId?: number + } + + videoLink: any[] = [] + + private ownerDisplayTypeChosen: 'account' | 'videoChannel' + + constructor ( + private screenService: ScreenService, + private serverService: ServerService, + private i18n: I18n, + private authService: AuthService, + private videoPlaylistService: VideoPlaylistService, + private cd: ChangeDetectorRef, + @Inject(LOCALE_ID) private localeId: string + ) {} + + get isVideoBlur () { + return this.video.isVideoNSFWForUser(this.user, this.serverConfig) + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => { + this.serverConfig = config + this.buildVideoLink() + }) + + this.setUpBy() + + this.channelLinkTitle = this.i18n( + '{{name}} (channel page)', + { name: this.video.channel.name, handle: this.video.byVideoChannel } + ) + + // We rely on mouseenter to lazy load actions + if (this.screenService.isInTouchScreen()) { + this.loadActions() + } + } + + buildVideoLink () { + if (this.useLazyLoadUrl && this.video.url) { + const remoteUriConfig = this.serverConfig.search.remoteUri + + // Redirect on the external instance if not allowed to fetch remote data + const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users + const fromPath = window.location.pathname + window.location.search + + this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ] + return + } + + this.videoLink = [ '/videos/watch', this.video.uuid ] + } + + displayOwnerAccount () { + return this.ownerDisplayTypeChosen === 'account' + } + + displayOwnerVideoChannel () { + return this.ownerDisplayTypeChosen === 'videoChannel' + } + + isUnlistedVideo () { + return this.video.privacy.id === VideoPrivacy.UNLISTED + } + + isPrivateVideo () { + return this.video.privacy.id === VideoPrivacy.PRIVATE + } + + getStateLabel (video: Video) { + if (!video.state) return '' + + if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) { + return this.i18n('Published') + } + + if (video.scheduledUpdate) { + const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) + return this.i18n('Publication scheduled on ') + updateAt + } + + if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) { + return this.i18n('Waiting transcoding') + } + + if (video.state.id === VideoState.TO_TRANSCODE) { + return this.i18n('To transcode') + } + + if (video.state.id === VideoState.TO_IMPORT) { + return this.i18n('To import') + } + + return '' + } + + getAvatarUrl () { + if (this.ownerDisplayTypeChosen === 'account') { + return this.video.accountAvatarUrl + } + + return this.video.videoChannelAvatarUrl + } + + loadActions () { + if (this.displayVideoActions) this.showActions = true + + this.loadWatchLater() + } + + onVideoBlocked () { + this.videoBlocked.emit() + } + + onVideoUnblocked () { + this.videoUnblocked.emit() + } + + onVideoRemoved () { + this.videoRemoved.emit() + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onWatchLaterClick (currentState: boolean) { + if (currentState === true) this.removeFromWatchLater() + else this.addToWatchLater() + + this.inWatchLaterPlaylist = !currentState + } + + addToWatchLater () { + const body = { videoId: this.video.id } + + this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe( + res => { + this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id + } + ) + } + + removeFromWatchLater () { + this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id) + .subscribe( + _ => { /* empty */ } + ) + } + + isWatchLaterPlaylistDisplayed () { + return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined + } + + private setUpBy () { + if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') { + this.ownerDisplayTypeChosen = this.ownerDisplayType + return + } + + // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12) + // -> Use the account name + if ( + this.video.channel.name === `${this.video.account.name}_channel` || + this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + ) { + this.ownerDisplayTypeChosen = 'account' + } else { + this.ownerDisplayTypeChosen = 'videoChannel' + } + } + + private loadWatchLater () { + if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return + + this.authService.userInformationLoaded + .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id))) + .subscribe(existResult => { + const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER) + const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id) + this.inWatchLaterPlaylist = false + + this.watchLaterPlaylist = { + id: watchLaterPlaylist.id + } + + if (existsInWatchLater) { + this.inWatchLaterPlaylist = true + this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId + } + + this.cd.markForCheck() + }) + + this.videoPlaylistService.runPlaylistCheck(this.video.id) + } +} diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.html b/client/src/app/shared/shared-video-miniature/videos-selection.component.html new file mode 100644 index 000000000..44aa567b9 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.html @@ -0,0 +1,30 @@ +
    No results.
    + +
    +
    + +
    + +
    + + + + +
    +
    + + Cancel + + + +
    +
    + + + + +
    +
    diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.scss b/client/src/app/shared/shared-video-miniature/videos-selection.component.scss new file mode 100644 index 000000000..d3cbabf23 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.scss @@ -0,0 +1,57 @@ +@import '_variables'; +@import '_mixins'; + +.action-selection-mode { + display: flex; + justify-content: flex-end; + flex-grow: 1; + + .action-selection-mode-child { + position: fixed; + + .action-button { + display: inline-block; + } + + .action-button-cancel-selection { + @include peertube-button; + @include grey-button; + + margin-right: 10px; + } + } +} + +.video { + @include row-blocks; + + &:first-child { + margin-top: 47px; + } + + .checkbox-container { + display: flex; + align-items: center; + margin-right: 20px; + margin-left: 12px; + } + + my-video-miniature { + flex-grow: 1; + } +} + +@media screen and (max-width: $small-view) { + .video { + flex-direction: column; + height: auto; + + .checkbox-container { + display: none; + } + + my-button { + margin-top: 10px; + } + } +} diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts new file mode 100644 index 000000000..3e0e3b983 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts @@ -0,0 +1,118 @@ +import { Observable } from 'rxjs' +import { + AfterContentInit, + Component, + ContentChildren, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + TemplateRef +} from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ResultList, VideoSortField } from '@shared/models' +import { PeerTubeTemplateDirective, Video } from '../shared-main' +import { AbstractVideoList } from './abstract-video-list' +import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component' + +export type SelectionType = { [ id: number ]: boolean } + +@Component({ + selector: 'my-videos-selection', + templateUrl: './videos-selection.component.html', + styleUrls: [ './videos-selection.component.scss' ] +}) +export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { + @Input() pagination: ComponentPagination + @Input() titlePage: string + @Input() miniatureDisplayOptions: MiniatureDisplayOptions + @Input() ownerDisplayType: OwnerDisplayType + + @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable> + + @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> + + @Output() selectionChange = new EventEmitter() + @Output() videosModelChange = new EventEmitter() + + _selection: SelectionType = {} + + rowButtonsTemplate: TemplateRef + globalButtonsTemplate: TemplateRef + + constructor ( + protected i18n: I18n, + protected router: Router, + protected route: ActivatedRoute, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + protected serverService: ServerService + ) { + super() + } + + @Input() get selection () { + return this._selection + } + + set selection (selection: SelectionType) { + this._selection = selection + this.selectionChange.emit(this._selection) + } + + @Input() get videosModel () { + return this.videos + } + + set videosModel (videos: Video[]) { + this.videos = videos + this.videosModelChange.emit(this.videos) + } + + ngOnInit () { + super.ngOnInit() + } + + ngAfterContentInit () { + { + const t = this.templates.find(t => t.name === 'rowButtons') + if (t) this.rowButtonsTemplate = t.template + } + + { + const t = this.templates.find(t => t.name === 'globalButtons') + if (t) this.globalButtonsTemplate = t.template + } + } + + ngOnDestroy () { + super.ngOnDestroy() + } + + getVideosObservable (page: number) { + return this.getVideosObservableFunction(page, this.sort) + } + + abortSelectionMode () { + this._selection = {} + } + + isInSelectionMode () { + return Object.keys(this._selection).some(k => this._selection[ k ] === true) + } + + generateSyndicationList () { + throw new Error('Method not implemented.') + } + + protected onMoreVideos () { + this.videosModel = this.videos + } +} diff --git a/client/src/app/shared/shared-video-playlist/index.ts b/client/src/app/shared/shared-video-playlist/index.ts new file mode 100644 index 000000000..63bb046c6 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/index.ts @@ -0,0 +1,8 @@ +export * from './video-add-to-playlist.component' +export * from './video-playlist-element-miniature.component' +export * from './video-playlist-element.model' +export * from './video-playlist-miniature.component' +export * from './video-playlist.model' +export * from './video-playlist.service' + +export * from './shared-video-playlist.module' diff --git a/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts b/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts new file mode 100644 index 000000000..0566b1592 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts @@ -0,0 +1,36 @@ + +import { NgModule } from '@angular/core' +import { SharedFormModule } from '../shared-forms' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { SharedThumbnailModule } from '../shared-thumbnail' +import { VideoAddToPlaylistComponent } from './video-add-to-playlist.component' +import { VideoPlaylistElementMiniatureComponent } from './video-playlist-element-miniature.component' +import { VideoPlaylistMiniatureComponent } from './video-playlist-miniature.component' +import { VideoPlaylistService } from './video-playlist.service' + +@NgModule({ + imports: [ + SharedMainModule, + SharedFormModule, + SharedThumbnailModule, + SharedGlobalIconModule + ], + + declarations: [ + VideoAddToPlaylistComponent, + VideoPlaylistElementMiniatureComponent, + VideoPlaylistMiniatureComponent + ], + + exports: [ + VideoAddToPlaylistComponent, + VideoPlaylistElementMiniatureComponent, + VideoPlaylistMiniatureComponent + ], + + providers: [ + VideoPlaylistService + ] +}) +export class SharedVideoPlaylistModule { } diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html new file mode 100644 index 000000000..a40e0699e --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html @@ -0,0 +1,82 @@ +
    +
    +
    +
    Save to
    + +
    + + + Options +
    +
    + +
    +
    + + + +
    + +
    + + + +
    +
    +
    + +
    + +
    + +
    + +
    + + + + +
    diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss new file mode 100644 index 000000000..47baa997b --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss @@ -0,0 +1,107 @@ +@import '_variables'; +@import '_mixins'; + +.header, +.dropdown-item, +.input-container { + padding: 8px 24px; +} + +.header { + min-width: 240px; + margin-bottom: 10px; + border-bottom: 1px solid $separator-border-color; + + .first-row { + display: flex; + align-items: center; + + .title { + font-size: 18px; + flex-grow: 1; + } + + .options { + display: flex; + align-items: center; + font-size: 14px; + cursor: pointer; + + my-global-icon { + @include apply-svg-color(#333); + + width: 16px; + height: 23px; + margin-right: 3px; + } + } + } + + .options-row { + margin-top: 10px; + padding-left: 10px; + + > div { + display: flex; + align-items: center; + } + } +} + +.playlists { + max-height: 180px; + overflow-y: auto; +} + +.playlist { + display: inline-flex; + cursor: pointer; + + my-peertube-checkbox { + margin-right: 10px; + align-self: center; + } + + .display-name { + display: flex; + align-items: flex-end; + + .timestamp-info { + font-size: 0.9em; + color: pvar(--greyForegroundColor); + margin-left: 5px; + } + } +} + +.new-playlist-button, +.new-playlist-block { + padding-top: 10px; + border-top: 1px solid $separator-border-color; +} + +.new-playlist-button { + cursor: pointer; + + my-global-icon { + @include apply-svg-color(#333); + + position: relative; + left: -1px; + top: -1px; + margin-right: 4px; + width: 21px; + height: 21px; + } +} + +input[type=text] { + @include peertube-input-text(200px); + + display: block; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts new file mode 100644 index 000000000..f611fc46b --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts @@ -0,0 +1,278 @@ +import * as debug from 'debug' +import { Subject, Subscription } from 'rxjs' +import { debounceTime, filter } from 'rxjs/operators' +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core' +import { AuthService, DisableForReuseHook, Notifier } from '@app/core' +import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' +import { secondsToTime } from '../../../assets/player/utils' +import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service' + +const logger = debug('peertube:playlists:VideoAddToPlaylistComponent') + +type PlaylistSummary = { + id: number + inPlaylist: boolean + displayName: string + + playlistElementId?: number + startTimestamp?: number + stopTimestamp?: number +} + +@Component({ + selector: 'my-video-add-to-playlist', + styleUrls: [ './video-add-to-playlist.component.scss' ], + templateUrl: './video-add-to-playlist.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook { + @Input() video: Video + @Input() currentVideoTimestamp: number + @Input() lazyLoad = false + + isNewPlaylistBlockOpened = false + videoPlaylistSearch: string + videoPlaylistSearchChanged = new Subject() + videoPlaylists: PlaylistSummary[] = [] + timestampOptions: { + startTimestampEnabled: boolean + startTimestamp: number + stopTimestampEnabled: boolean + stopTimestamp: number + } + displayOptions = false + + private disabled = false + + private listenToPlaylistChangeSub: Subscription + private playlistsData: CachedPlaylist[] = [] + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private i18n: I18n, + private videoPlaylistService: VideoPlaylistService, + private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, + private cd: ChangeDetectorRef + ) { + super() + } + + get user () { + return this.authService.getUser() + } + + ngOnInit () { + this.buildForm({ + displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME + }) + + this.videoPlaylistService.listenToMyAccountPlaylistsChange() + .subscribe(result => { + this.playlistsData = result.data + + this.videoPlaylistService.runPlaylistCheck(this.video.id) + }) + + this.videoPlaylistSearchChanged + .pipe(debounceTime(500)) + .subscribe(() => this.load()) + + if (this.lazyLoad === false) this.load() + } + + ngOnChanges (simpleChanges: SimpleChanges) { + if (simpleChanges['video']) { + this.reload() + } + } + + ngOnDestroy () { + this.unsubscribePlaylistChanges() + } + + disableForReuse () { + this.disabled = true + } + + enabledForReuse () { + this.disabled = false + } + + reload () { + logger('Reloading component') + + this.videoPlaylists = [] + this.videoPlaylistSearch = undefined + + this.resetOptions(true) + this.load() + + this.cd.markForCheck() + } + + load () { + logger('Loading component') + + this.listenToPlaylistChanges() + + this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch) + .subscribe(playlistsResult => { + this.playlistsData = playlistsResult.data + + this.videoPlaylistService.runPlaylistCheck(this.video.id) + }) + } + + openChange (opened: boolean) { + if (opened === false) { + this.isNewPlaylistBlockOpened = false + this.displayOptions = false + } + } + + openCreateBlock (event: Event) { + event.preventDefault() + + this.isNewPlaylistBlockOpened = true + } + + togglePlaylist (event: Event, playlist: PlaylistSummary) { + event.preventDefault() + + if (playlist.inPlaylist === true) { + this.removeVideoFromPlaylist(playlist) + } else { + this.addVideoInPlaylist(playlist) + } + + playlist.inPlaylist = !playlist.inPlaylist + this.resetOptions() + + this.cd.markForCheck() + } + + createPlaylist () { + const displayName = this.form.value[ 'displayName' ] + + const videoPlaylistCreate: VideoPlaylistCreate = { + displayName, + privacy: VideoPlaylistPrivacy.PRIVATE + } + + this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( + () => { + this.isNewPlaylistBlockOpened = false + + this.cd.markForCheck() + }, + + err => this.notifier.error(err.message) + ) + } + + resetOptions (resetTimestamp = false) { + this.displayOptions = false + + this.timestampOptions = {} as any + this.timestampOptions.startTimestampEnabled = false + this.timestampOptions.stopTimestampEnabled = false + + if (resetTimestamp) { + this.timestampOptions.startTimestamp = 0 + this.timestampOptions.stopTimestamp = this.video.duration + } + } + + formatTimestamp (playlist: PlaylistSummary) { + const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : '' + const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : '' + + return `(${start}-${stop})` + } + + onVideoPlaylistSearchChanged () { + this.videoPlaylistSearchChanged.next() + } + + private removeVideoFromPlaylist (playlist: PlaylistSummary) { + if (!playlist.playlistElementId) return + + this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id) + .subscribe( + () => { + this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName })) + }, + + err => { + this.notifier.error(err.message) + }, + + () => this.cd.markForCheck() + ) + } + + private listenToPlaylistChanges () { + this.unsubscribePlaylistChanges() + + this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id) + .pipe(filter(() => this.disabled === false)) + .subscribe(existResult => this.rebuildPlaylists(existResult)) + } + + private unsubscribePlaylistChanges () { + if (this.listenToPlaylistChangeSub) { + this.listenToPlaylistChangeSub.unsubscribe() + this.listenToPlaylistChangeSub = undefined + } + } + + private rebuildPlaylists (existResult: VideoExistInPlaylist[]) { + logger('Got existing results for %d.', this.video.id, existResult) + + this.videoPlaylists = [] + for (const playlist of this.playlistsData) { + const existingPlaylist = existResult.find(p => p.playlistId === playlist.id) + + this.videoPlaylists.push({ + id: playlist.id, + displayName: playlist.displayName, + inPlaylist: !!existingPlaylist, + playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined, + startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, + stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined + }) + } + + logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists) + + this.cd.markForCheck() + } + + private addVideoInPlaylist (playlist: PlaylistSummary) { + const body: VideoPlaylistElementCreate = { videoId: this.video.id } + + if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp + if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp + + this.videoPlaylistService.addVideoInPlaylist(playlist.id, body) + .subscribe( + () => { + const message = body.startTimestamp || body.stopTimestamp + ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) }) + : this.i18n('Video added in {{n}}', { n: playlist.displayName }) + + this.notifier.success(message) + }, + + err => { + this.notifier.error(err.message) + }, + + () => this.cd.markForCheck() + ) + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html new file mode 100644 index 000000000..e3f7ef017 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html @@ -0,0 +1,92 @@ +
    + +
    + + {{ position }} +
    + + + +
    + +
    + + {{ playlistElement.video.name }} + + + + + {{ formatTimestamp(playlistElement) }} + + + + Unavailable + Private + Deleted + +
    + + + + +
    + + +
    + + + +
    +
    + + + +
    + +
    + + + +
    + + +
    +
    + + + + Delete from {{ playlist?.displayName }} + +
    +
    +
    diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss new file mode 100644 index 000000000..afd775b25 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss @@ -0,0 +1,224 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +$thumbnail-width: 130px; +$thumbnail-height: 72px; + +my-video-thumbnail { + @include thumbnail-size-component($thumbnail-width, $thumbnail-height); +} + +.fake-thumbnail { + width: $thumbnail-width; + height: $thumbnail-height; + background-color: #ececec; +} + +my-video-thumbnail, +.fake-thumbnail { + display: flex; // Avoids an issue with line-height that adds space below the element + margin-right: 10px; +} + +.video { + display: flex; + align-items: center; + background-color: pvar(--mainBackgroundColor); + padding: 10px; + border-bottom: 1px solid $separator-border-color; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + + .more { + opacity: 1; + } + } + + @media not all and (hover: hover) and (pointer: fine) { + .more { + opacity: 1 !important; + } + } + + &.playing { + background-color: rgba(0, 0, 0, 0.02); + } + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + display: flex; + min-width: 0; + align-items: center; + cursor: pointer; + + .position { + font-weight: $font-semibold; + margin-right: 10px; + color: pvar(--greyForegroundColor); + min-width: 25px; + + my-global-icon { + @include apply-svg-color(pvar(--greyForegroundColor)); + + width: 17px; + position: relative; + left: -2px; + } + } + + .video-info { + display: flex; + flex-direction: column; + align-self: flex-start; + min-width: 0; + + a { + width: auto; + } + + .video-info-account, .video-info-timestamp { + color: pvar(--greyForegroundColor); + } + } + } + + .video-info-name { + font-size: 18px; + font-weight: $font-semibold; + display: inline-block; + + @include ellipsis; + } + + .more, my-edit-button { + justify-self: flex-end; + margin-left: auto; + cursor: pointer; + min-width: 24px; + } + + .more { + opacity: 0; + + &.show { + opacity: 1; + } + + .icon-more { + @include apply-svg-color(pvar(--greyForegroundColor)); + + display: flex; + + &::after { + border: none; + } + } + + .dropdown-item { + @include dropdown-with-icon-item; + } + + .timestamp-options { + padding-top: 0; + padding-left: 35px; + margin-bottom: 15px; + + > div { + display: flex; + align-items: center; + } + + input { + @include peertube-button; + @include orange-button; + + margin-top: 10px; + } + } + } +} + +@mixin more-dropdown-control { + .video { + my-edit-button { + display: none; + + + .more { + display: inline-flex; + } + } + } +} + +@mixin edit-button-control { + .video { + my-edit-button { + display: none; + } + + &.playing { + my-edit-button { + display: inline-flex; + height: max-content; + } + } + + my-edit-button + .more { + display: none; + } + } +} + +@mixin edit-button-in-mobile-view { + .video { + my-edit-button { + ::ng-deep .action-button-edit { + padding: 0 13px; + + .button-label { + display: none; + } + } + } + } +} + +@media screen and (min-width: $small-view) { + :host-context(.expanded) { + @include more-dropdown-control(); + } +} + +@media screen and (max-width: $small-view) { + :host-context(.expanded) { + @include edit-button-control(); + } +} + +@media screen and (max-width: $mobile-view) { + :host-context(.expanded) { + @include edit-button-in-mobile-view(); + } +} + +@media screen and (min-width: #{$small-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include more-dropdown-control(); + } +} + +@media screen and (max-width: #{$small-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include edit-button-control(); + } +} + +@media screen and (max-width: #{$mobile-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include edit-button-in-mobile-view(); + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts new file mode 100644 index 000000000..57a5fbe61 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts @@ -0,0 +1,182 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { AuthService, Notifier, ServerService } from '@app/core' +import { Video } from '@app/shared/shared-main' +import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models' +import { secondsToTime } from '../../../assets/player/utils' +import { VideoPlaylistElement } from './video-playlist-element.model' +import { VideoPlaylist } from './video-playlist.model' +import { VideoPlaylistService } from './video-playlist.service' + +@Component({ + selector: 'my-video-playlist-element-miniature', + styleUrls: [ './video-playlist-element-miniature.component.scss' ], + templateUrl: './video-playlist-element-miniature.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VideoPlaylistElementMiniatureComponent implements OnInit { + @ViewChild('moreDropdown') moreDropdown: NgbDropdown + + @Input() playlist: VideoPlaylist + @Input() playlistElement: VideoPlaylistElement + @Input() owned = false + @Input() playing = false + @Input() rowLink = false + @Input() accountLink = true + @Input() position: number // Keep this property because we're in the OnPush change detection strategy + @Input() touchScreenEditButton = false + + @Output() elementRemoved = new EventEmitter() + + displayTimestampOptions = false + + timestampOptions: { + startTimestampEnabled: boolean + startTimestamp: number + stopTimestampEnabled: boolean + stopTimestamp: number + } = {} as any + + private serverConfig: ServerConfig + + constructor ( + private authService: AuthService, + private serverService: ServerService, + private notifier: Notifier, + private i18n: I18n, + private videoPlaylistService: VideoPlaylistService, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit (): void { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => { + this.serverConfig = config + this.cdr.detectChanges() + }) + } + + isUnavailable (e: VideoPlaylistElement) { + return e.type === VideoPlaylistElementType.UNAVAILABLE + } + + isPrivate (e: VideoPlaylistElement) { + return e.type === VideoPlaylistElementType.PRIVATE + } + + isDeleted (e: VideoPlaylistElement) { + return e.type === VideoPlaylistElementType.DELETED + } + + buildRouterLink () { + if (!this.playlist) return null + + return [ '/videos/watch/playlist', this.playlist.uuid ] + } + + buildRouterQuery () { + if (!this.playlistElement || !this.playlistElement.video) return {} + + return { + videoId: this.playlistElement.video.uuid, + start: this.playlistElement.startTimestamp, + stop: this.playlistElement.stopTimestamp, + resume: true + } + } + + isVideoBlur (video: Video) { + return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig) + } + + removeFromPlaylist (playlistElement: VideoPlaylistElement) { + const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined + + this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId) + .subscribe( + () => { + this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName })) + + this.elementRemoved.emit(playlistElement) + }, + + err => this.notifier.error(err.message) + ) + + this.moreDropdown.close() + } + + updateTimestamps (playlistElement: VideoPlaylistElement) { + const body: VideoPlaylistElementUpdate = {} + + body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null + body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null + + this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id) + .subscribe( + () => { + this.notifier.success(this.i18n('Timestamps updated')) + + playlistElement.startTimestamp = body.startTimestamp + playlistElement.stopTimestamp = body.stopTimestamp + + this.cdr.detectChanges() + }, + + err => this.notifier.error(err.message) + ) + + this.moreDropdown.close() + } + + formatTimestamp (playlistElement: VideoPlaylistElement) { + const start = playlistElement.startTimestamp + const stop = playlistElement.stopTimestamp + + const startFormatted = secondsToTime(start, true, ':') + const stopFormatted = secondsToTime(stop, true, ':') + + if (start === null && stop === null) return '' + + if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted + if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted + + return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted + } + + onDropdownOpenChange () { + this.displayTimestampOptions = false + } + + toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) { + event.preventDefault() + + this.displayTimestampOptions = !this.displayTimestampOptions + + if (this.displayTimestampOptions === true) { + this.timestampOptions = { + startTimestampEnabled: false, + stopTimestampEnabled: false, + startTimestamp: 0, + stopTimestamp: playlistElement.video.duration + } + + if (playlistElement.startTimestamp) { + this.timestampOptions.startTimestampEnabled = true + this.timestampOptions.startTimestamp = playlistElement.startTimestamp + } + + if (playlistElement.stopTimestamp) { + this.timestampOptions.stopTimestampEnabled = true + this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp + } + } + + // FIXME: why do we have to use setTimeout here? + setTimeout(() => { + this.cdr.detectChanges() + }) + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts new file mode 100644 index 000000000..27a79d1fd --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts @@ -0,0 +1,24 @@ +import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos' +import { Video } from '@app/shared/shared-main' + +export class VideoPlaylistElement implements ServerVideoPlaylistElement { + id: number + position: number + startTimestamp: number + stopTimestamp: number + + type: VideoPlaylistElementType + + video?: Video + + constructor (hash: ServerVideoPlaylistElement, translations: {}) { + this.id = hash.id + this.position = hash.position + this.startTimestamp = hash.startTimestamp + this.stopTimestamp = hash.stopTimestamp + + this.type = hash.type + + if (hash.video) this.video = new Video(hash.video, translations) + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html new file mode 100644 index 000000000..86f6664cb --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html @@ -0,0 +1,34 @@ +
    + + + +
    + {playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}} +
    + +
    +
    +
    +
    + +
    + + {{ playlist.displayName }} + + + + {{ playlist.videoChannelBy }} + + +
    + {{ playlist.privacy.label }} + + Updated {{ playlist.updatedAt | myFromNow }} +
    + +
    {{ playlist.description }}
    +
    +
    diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss new file mode 100644 index 000000000..1b16dbb01 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss @@ -0,0 +1,78 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +.miniature { + display: inline-block; + + &.no-videos:not(.to-manage){ + a { + cursor: default !important; + } + } + + &.to-manage, + &.no-videos { + .play-overlay { + display: none; + } + } + + .miniature-thumbnail { + @include miniature-thumbnail; + + .miniature-playlist-info-overlay { + @include static-thumbnail-overlay; + + position: absolute; + right: 0; + bottom: 0; + height: $video-thumbnail-height; + padding: 0 10px; + display: flex; + align-items: center; + font-size: 14px; + font-weight: $font-semibold; + } + } + + .miniature-info { + width: 200px; + margin-top: 2px; + line-height: normal; + + .miniature-name { + @include miniature-name; + + @include ellipsis-multiline(1.3em, 2); + + margin: 0; + } + + .by { + @include disable-default-a-behaviour; + + display: block; + color: pvar(--greyForegroundColor); + } + + .privacy-date { + margin-top: 5px; + + .video-info-privacy { + font-size: 14px; + font-weight: $font-semibold; + + &::after { + content: '-'; + margin: 0 3px; + } + } + } + + .video-info-description { + margin-top: 10px; + color: pvar(--greyForegroundColor); + } + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts new file mode 100644 index 000000000..4b0669a32 --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core' +import { VideoPlaylist } from './video-playlist.model' + +@Component({ + selector: 'my-video-playlist-miniature', + styleUrls: [ './video-playlist-miniature.component.scss' ], + templateUrl: './video-playlist-miniature.component.html' +}) +export class VideoPlaylistMiniatureComponent { + @Input() playlist: VideoPlaylist + @Input() toManage = false + @Input() displayChannel = false + @Input() displayDescription = false + @Input() displayPrivacy = false + + getPlaylistUrl () { + if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ] + if (this.playlist.videosLength === 0) return null + + return [ '/videos/watch/playlist', this.playlist.uuid ] + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts new file mode 100644 index 000000000..8f63d2abd --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts @@ -0,0 +1,98 @@ +import { getAbsoluteAPIUrl } from '@app/helpers' +import { Actor } from '@app/shared/shared-main' +import { + AccountSummary, + peertubeTranslate, + VideoChannelSummary, + VideoConstant, + VideoPlaylist as ServerVideoPlaylist, + VideoPlaylistPrivacy, + VideoPlaylistType +} from '@shared/models' + +export class VideoPlaylist implements ServerVideoPlaylist { + id: number + uuid: string + isLocal: boolean + + displayName: string + description: string + privacy: VideoConstant + + thumbnailPath: string + + videosLength: number + + type: VideoConstant + + createdAt: Date | string + updatedAt: Date | string + + ownerAccount: AccountSummary + videoChannel?: VideoChannelSummary + + thumbnailUrl: string + + ownerBy: string + ownerAvatarUrl: string + + videoChannelBy?: string + videoChannelAvatarUrl?: string + + private thumbnailVersion: number + private originThumbnailUrl: string + + constructor (hash: ServerVideoPlaylist, translations: {}) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + + this.id = hash.id + this.uuid = hash.uuid + this.isLocal = hash.isLocal + + this.displayName = hash.displayName + + this.description = hash.description + this.privacy = hash.privacy + + this.thumbnailPath = hash.thumbnailPath + + if (this.thumbnailPath) { + this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath + this.originThumbnailUrl = this.thumbnailUrl + } else { + this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg' + } + + this.videosLength = hash.videosLength + + this.type = hash.type + + this.createdAt = new Date(hash.createdAt) + this.updatedAt = new Date(hash.updatedAt) + + this.ownerAccount = hash.ownerAccount + this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) + this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) + + if (hash.videoChannel) { + this.videoChannel = hash.videoChannel + this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host) + this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel) + } + + this.privacy.label = peertubeTranslate(this.privacy.label, translations) + + if (this.type.id === VideoPlaylistType.WATCH_LATER) { + this.displayName = peertubeTranslate(this.displayName, translations) + } + } + + refreshThumbnail () { + if (!this.originThumbnailUrl) return + + if (!this.thumbnailVersion) this.thumbnailVersion = 0 + this.thumbnailVersion++ + + this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion + } +} diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts new file mode 100644 index 000000000..cc3d04b9e --- /dev/null +++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts @@ -0,0 +1,355 @@ +import * as debug from 'debug' +import { uniq } from 'lodash-es' +import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' +import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable, NgZone } from '@angular/core' +import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core' +import { enterZone, leaveZone, objectToFormData } from '@app/helpers' +import { Account, AccountService, VideoChannel, VideoChannelService } from '@app/shared/shared-main' +import { + ResultList, + VideoExistInPlaylist, + VideoPlaylist as VideoPlaylistServerModel, + VideoPlaylistCreate, + VideoPlaylistElement as ServerVideoPlaylistElement, + VideoPlaylistElementCreate, + VideoPlaylistElementUpdate, + VideoPlaylistReorder, + VideoPlaylistUpdate, + VideosExistInPlaylists +} from '@shared/models' +import { environment } from '../../../environments/environment' +import { VideoPlaylistElement } from './video-playlist-element.model' +import { VideoPlaylist } from './video-playlist.model' + +const logger = debug('peertube:playlists:VideoPlaylistService') + +export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string } + +@Injectable() +export class VideoPlaylistService { + static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' + static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/' + + // Use a replay subject because we "next" a value before subscribing + private videoExistsInPlaylistNotifier = new ReplaySubject(1) + private videoExistsInPlaylistCacheSubject = new Subject() + private readonly videoExistsInPlaylistObservable: Observable + + private videoExistsObservableCache: { [ id: number ]: Observable } = {} + private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {} + + private myAccountPlaylistCache: ResultList = undefined + private myAccountPlaylistCacheRunning: Observable> + private myAccountPlaylistCacheSubject = new Subject>() + + constructor ( + private authHttp: HttpClient, + private serverService: ServerService, + private restExtractor: RestExtractor, + private restService: RestService, + private ngZone: NgZone + ) { + this.videoExistsInPlaylistObservable = merge( + this.videoExistsInPlaylistNotifier.pipe( + // We leave Angular zone so Protractor does not get stuck + bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), + filter(videoIds => videoIds.length !== 0), + map(videoIds => uniq(videoIds)), + observeOn(enterZone(this.ngZone, asyncScheduler)), + switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)), + share() + ), + + this.videoExistsInPlaylistCacheSubject + ) + } + + listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable> { + const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp.get>(url, { params }) + .pipe( + switchMap(res => this.extractPlaylists(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listMyPlaylistWithCache (user: AuthUser, search?: string) { + if (!search) { + if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning + if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache) + } + + const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search) + .pipe( + tap(result => { + if (!search) { + this.myAccountPlaylistCacheRunning = undefined + this.myAccountPlaylistCache = result + } + }), + share() + ) + + if (!search) this.myAccountPlaylistCacheRunning = obs + return obs + } + + listAccountPlaylists ( + account: Account, + componentPagination: ComponentPaginationLight, + sort: string, + search?: string + ): Observable> { + const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' + const pagination = componentPagination + ? this.restService.componentPaginationToRestPagination(componentPagination) + : undefined + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + if (search) params = this.restService.addObjectParams(params, { search }) + + return this.authHttp.get>(url, { params }) + .pipe( + switchMap(res => this.extractPlaylists(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoPlaylist (id: string | number) { + const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id + + return this.authHttp.get(url) + .pipe( + switchMap(res => this.extractPlaylist(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + createVideoPlaylist (body: VideoPlaylistCreate) { + const data = objectToFormData(body) + + return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) + .pipe( + tap(res => { + if (!this.myAccountPlaylistCache) return + + this.myAccountPlaylistCache.total++ + + this.myAccountPlaylistCache.data.push({ + id: res.videoPlaylist.id, + displayName: body.displayName + }) + + this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) { + const data = objectToFormData(body) + + return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + if (!this.myAccountPlaylistCache) return + + const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id) + playlist.displayName = body.displayName + + this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + removeVideoPlaylist (videoPlaylist: VideoPlaylist) { + return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + if (!this.myAccountPlaylistCache) return + + this.myAccountPlaylistCache.total-- + this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data + .filter(p => p.id !== videoPlaylist.id) + + this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) { + const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos' + + return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body) + .pipe( + tap(res => { + const existsResult = this.videoExistsCache[body.videoId] + existsResult.push({ + playlistId, + playlistElementId: res.videoPlaylistElement.id, + startTimestamp: body.startTimestamp, + stopTimestamp: body.stopTimestamp + }) + + this.runPlaylistCheck(body.videoId) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) { + return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + const existsResult = this.videoExistsCache[videoId] + const elem = existsResult.find(e => e.playlistElementId === playlistElementId) + + elem.startTimestamp = body.startTimestamp + elem.stopTimestamp = body.stopTimestamp + + this.runPlaylistCheck(videoId) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) { + return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId) + .pipe( + map(this.restExtractor.extractDataBool), + tap(() => { + if (!videoId) return + + this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId) + this.runPlaylistCheck(videoId) + }), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) { + const body: VideoPlaylistReorder = { + startPosition: oldPosition, + insertAfterPosition: newPosition + } + + return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getPlaylistVideos ( + videoPlaylistId: number | string, + componentPagination: ComponentPaginationLight + ): Observable> { + const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos' + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp + .get>(path, { params }) + .pipe( + switchMap(res => this.extractVideoPlaylistElements(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listenToMyAccountPlaylistsChange () { + return this.myAccountPlaylistCacheSubject.asObservable() + } + + listenToVideoPlaylistChange (videoId: number) { + if (this.videoExistsObservableCache[ videoId ]) { + return this.videoExistsObservableCache[ videoId ] + } + + const obs = this.videoExistsInPlaylistObservable + .pipe( + map(existsResult => existsResult[ videoId ]), + filter(r => !!r), + tap(result => this.videoExistsCache[ videoId ] = result) + ) + + this.videoExistsObservableCache[ videoId ] = obs + return obs + } + + runPlaylistCheck (videoId: number) { + logger('Running playlist check.') + + if (this.videoExistsCache[videoId]) { + logger('Found cache for %d.', videoId) + + return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] }) + } + + logger('Fetching from network for %d.', videoId) + return this.videoExistsInPlaylistNotifier.next(videoId) + } + + extractPlaylists (result: ResultList) { + return this.serverService.getServerLocale() + .pipe( + map(translations => { + const playlistsJSON = result.data + const total = result.total + const playlists: VideoPlaylist[] = [] + + for (const playlistJSON of playlistsJSON) { + playlists.push(new VideoPlaylist(playlistJSON, translations)) + } + + return { data: playlists, total } + }) + ) + } + + extractPlaylist (playlist: VideoPlaylistServerModel) { + return this.serverService.getServerLocale() + .pipe(map(translations => new VideoPlaylist(playlist, translations))) + } + + extractVideoPlaylistElements (result: ResultList) { + return this.serverService.getServerLocale() + .pipe( + map(translations => { + const elementsJson = result.data + const total = result.total + const elements: VideoPlaylistElement[] = [] + + for (const elementJson of elementsJson) { + elements.push(new VideoPlaylistElement(elementJson, translations)) + } + + return { total, data: elements } + }) + ) + } + + private doVideosExistInPlaylist (videoIds: number[]): Observable { + const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' + + let params = new HttpParams() + params = this.restService.addObjectParams(params, { videoIds }) + + return this.authHttp.get(url, { params, headers: { ignoreLoadingBar: '' } }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts deleted file mode 100644 index 98fab9e16..000000000 --- a/client/src/app/shared/shared.module.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' -import { SharedModule as PrimeSharedModule } from 'primeng/api' -import { InputMaskModule } from 'primeng/inputmask' -import { InputSwitchModule } from 'primeng/inputswitch' -import { MultiSelectModule } from 'primeng/multiselect' -import { ClipboardModule } from '@angular/cdk/clipboard' -import { CommonModule } from '@angular/common' -import { HttpClientModule } from '@angular/common/http' -import { NgModule } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { RouterModule } from '@angular/router' -import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service' -import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component' -import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface' -import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings' -import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component' -import { AccountService } from '@app/shared/account/account.service' -import { FromNowPipe } from '@app/shared/angular/from-now.pipe' -import { HighlightPipe } from '@app/shared/angular/highlight.pipe' -import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' -import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' -import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' -import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' -import { BlocklistService } from '@app/shared/blocklist' -import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component' -import { AvatarComponent } from '@app/shared/channel/avatar.component' -import { ConfirmComponent } from '@app/shared/confirm/confirm.component' -import { DateToggleComponent } from '@app/shared/date/date-toggle.component' -import { - CustomConfigValidatorsService, - InstanceValidatorsService, - LoginValidatorsService, - ReactiveFileComponent, - ResetPasswordValidatorsService, - TextareaAutoResizeDirective, - UserValidatorsService, - VideoAbuseValidatorsService, - VideoAcceptOwnershipValidatorsService, - VideoBlockValidatorsService, - VideoChangeOwnershipValidatorsService, - VideoChannelValidatorsService, - VideoCommentValidatorsService, - VideoPlaylistValidatorsService, - VideoValidatorsService -} from '@app/shared/forms' -import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' -import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' -import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' -import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' -import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' -import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' -import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' -import { GlobalIconComponent } from '@app/shared/images/global-icon.component' -import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component' -import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' -import { FollowService } from '@app/shared/instance/follow.service' -import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' -import { InstanceStatisticsComponent } from '@app/shared/instance/instance-statistics.component' -import { InstanceService } from '@app/shared/instance/instance.service' -import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component' -import { HelpComponent } from '@app/shared/misc/help.component' -import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component' -import { ScreenService } from '@app/shared/misc/screen.service' -import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' -import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service' -import { UserBanModalComponent } from '@app/shared/moderation' -import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component' -import { OverviewService } from '@app/shared/overview' -import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' -import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' -import { UserHistoryService } from '@app/shared/users/user-history.service' -import { UserNotificationService } from '@app/shared/users/user-notification.service' -import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component' -import { VideoCaptionService } from '@app/shared/video-caption' -import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' -import { VideoImportService } from '@app/shared/video-import/video-import.service' -import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' -import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component' -import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' -import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' -import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' -import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component' -import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' -import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' -import { RedundancyService } from '@app/shared/video/redundancy.service' -import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' -import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component' -import { - NgbCollapseModule, - NgbDropdownModule, - NgbModalModule, - NgbNavModule, - NgbPopoverModule, - NgbTooltipModule -} from '@ng-bootstrap/ng-bootstrap' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { AUTH_INTERCEPTOR_PROVIDER } from './auth' -import { BulkService } from './bulk/bulk.service' -import { ButtonComponent } from './buttons/button.component' -import { DeleteButtonComponent } from './buttons/delete-button.component' -import { EditButtonComponent } from './buttons/edit-button.component' -import { LoaderComponent } from './misc/loader.component' -import { RestExtractor, RestService } from './rest' -import { UserService } from './users' -import { VideoAbuseService } from './video-abuse' -import { VideoBlockService } from './video-block' -import { VideoOwnershipService } from './video-ownership' -import { FeedComponent } from './video/feed.component' -import { VideoMiniatureComponent } from './video/video-miniature.component' -import { VideoThumbnailComponent } from './video/video-thumbnail.component' -import { VideoService } from './video/video.service' - -@NgModule({ - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - RouterModule, - HttpClientModule, - - NgbDropdownModule, - NgbModalModule, - NgbPopoverModule, - NgbNavModule, - NgbTooltipModule, - NgbCollapseModule, - - ClipboardModule, - - PrimeSharedModule, - InputMaskModule, - NgPipesModule, - MultiSelectModule, - InputSwitchModule - ], - - declarations: [ - LoaderComponent, - SmallLoaderComponent, - - VideoThumbnailComponent, - VideoMiniatureComponent, - VideoPlaylistMiniatureComponent, - VideoAddToPlaylistComponent, - VideoPlaylistElementMiniatureComponent, - VideosSelectionComponent, - VideoActionsDropdownComponent, - - VideoDownloadComponent, - VideoReportComponent, - VideoBlockComponent, - - FeedComponent, - - ButtonComponent, - DeleteButtonComponent, - EditButtonComponent, - - NumberFormatterPipe, - ObjectLengthPipe, - FromNowPipe, - HighlightPipe, - PeerTubeTemplateDirective, - VideoDurationPipe, - - ActionDropdownComponent, - MarkdownTextareaComponent, - InfiniteScrollerDirective, - TextareaAutoResizeDirective, - HelpComponent, - ListOverflowComponent, - - ReactiveFileComponent, - PeertubeCheckboxComponent, - TimestampInputComponent, - InputReadonlyCopyComponent, - - AvatarComponent, - SubscribeButtonComponent, - RemoteSubscribeComponent, - InstanceFeaturesTableComponent, - InstanceStatisticsComponent, - FeatureBooleanComponent, - UserBanModalComponent, - UserModerationDropdownComponent, - TopMenuDropdownComponent, - UserNotificationsComponent, - ConfirmComponent, - DateToggleComponent, - - GlobalIconComponent, - PreviewUploadComponent, - - MyAccountVideoSettingsComponent, - MyAccountInterfaceSettingsComponent, - ActorAvatarInfoComponent, - BatchDomainsModalComponent - ], - - exports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - RouterModule, - HttpClientModule, - - NgbDropdownModule, - NgbModalModule, - NgbPopoverModule, - NgbNavModule, - NgbTooltipModule, - NgbCollapseModule, - - ClipboardModule, - - PrimeSharedModule, - InputMaskModule, - BytesPipe, - KeysPipe, - MultiSelectModule, - - LoaderComponent, - SmallLoaderComponent, - - VideoThumbnailComponent, - VideoMiniatureComponent, - VideoPlaylistMiniatureComponent, - VideoAddToPlaylistComponent, - VideoPlaylistElementMiniatureComponent, - VideosSelectionComponent, - VideoActionsDropdownComponent, - - VideoDownloadComponent, - VideoReportComponent, - VideoBlockComponent, - - FeedComponent, - - ButtonComponent, - DeleteButtonComponent, - EditButtonComponent, - - ActionDropdownComponent, - MarkdownTextareaComponent, - InfiniteScrollerDirective, - TextareaAutoResizeDirective, - HelpComponent, - ListOverflowComponent, - InputReadonlyCopyComponent, - - ReactiveFileComponent, - PeertubeCheckboxComponent, - TimestampInputComponent, - - AvatarComponent, - SubscribeButtonComponent, - RemoteSubscribeComponent, - InstanceFeaturesTableComponent, - InstanceStatisticsComponent, - UserBanModalComponent, - UserModerationDropdownComponent, - TopMenuDropdownComponent, - UserNotificationsComponent, - ConfirmComponent, - DateToggleComponent, - - GlobalIconComponent, - PreviewUploadComponent, - - NumberFormatterPipe, - ObjectLengthPipe, - FromNowPipe, - HighlightPipe, - PeerTubeTemplateDirective, - VideoDurationPipe, - - MyAccountVideoSettingsComponent, - MyAccountInterfaceSettingsComponent, - ActorAvatarInfoComponent, - BatchDomainsModalComponent - ], - - providers: [ - AUTH_INTERCEPTOR_PROVIDER, - RestExtractor, - RestService, - VideoAbuseService, - VideoBlockService, - VideoOwnershipService, - UserService, - VideoService, - AccountService, - VideoChannelService, - VideoPlaylistService, - VideoCaptionService, - VideoImportService, - UserSubscriptionService, - - FormValidatorService, - CustomConfigValidatorsService, - LoginValidatorsService, - ResetPasswordValidatorsService, - UserValidatorsService, - BatchDomainsValidatorsService, - VideoPlaylistValidatorsService, - VideoAbuseValidatorsService, - VideoChannelValidatorsService, - VideoCommentValidatorsService, - VideoValidatorsService, - VideoCaptionsValidatorsService, - VideoBlockValidatorsService, - OverviewService, - VideoChangeOwnershipValidatorsService, - VideoAcceptOwnershipValidatorsService, - InstanceValidatorsService, - BlocklistService, - UserHistoryService, - InstanceService, - BulkService, - - MarkdownService, - LinkifierService, - HtmlRendererService, - - I18nPrimengCalendarService, - ScreenService, - LocalStorageService, SessionStorageService, - - UserNotificationService, - - FollowService, - RedundancyService, - - I18n - ] -}) -export class SharedModule { } diff --git a/client/src/app/shared/user-subscription/index.ts b/client/src/app/shared/user-subscription/index.ts deleted file mode 100644 index e76940f7b..000000000 --- a/client/src/app/shared/user-subscription/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './user-subscription.service' -export * from './subscribe-button.component' -export * from './remote-subscribe.component' diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.html b/client/src/app/shared/user-subscription/remote-subscribe.component.html deleted file mode 100644 index acfec0a8e..000000000 --- a/client/src/app/shared/user-subscription/remote-subscribe.component.html +++ /dev/null @@ -1,32 +0,0 @@ -
    -
    - -
    - - - - - - - You can subscribe to the channel via any ActivityPub-capable fediverse instance.

    - For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there. -
    -
    -
    - - - - - You can interact with this via any ActivityPub-capable fediverse instance.

    - For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there. -
    -
    -
    -
    diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.scss b/client/src/app/shared/user-subscription/remote-subscribe.component.scss deleted file mode 100644 index 698c5866a..000000000 --- a/client/src/app/shared/user-subscription/remote-subscribe.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import '_mixins'; - -.btn-remote-follow { - @include peertube-button; - @include orange-button; -} \ No newline at end of file diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.ts b/client/src/app/shared/user-subscription/remote-subscribe.component.ts deleted file mode 100644 index befdb7157..000000000 --- a/client/src/app/shared/user-subscription/remote-subscribe.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core' -import { FormReactive } from '@app/shared/forms/form-reactive' -import { - FormValidatorService, - UserValidatorsService -} from '@app/shared/forms/form-validators' - -@Component({ - selector: 'my-remote-subscribe', - templateUrl: './remote-subscribe.component.html', - styleUrls: ['./remote-subscribe.component.scss'] -}) -export class RemoteSubscribeComponent extends FormReactive implements OnInit { - @Input() uri: string - @Input() interact = false - @Input() showHelp = false - - constructor ( - protected formValidatorService: FormValidatorService, - private userValidatorsService: UserValidatorsService - ) { - super() - } - - ngOnInit () { - this.buildForm({ - text: this.userValidatorsService.USER_EMAIL - }) - } - - onValidKey () { - this.check() - if (!this.form.valid) return - - this.formValidated() - } - - formValidated () { - const address = this.form.value['text'] - const [ username, hostname ] = address.split('@') - - // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5 - fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`) - .then(response => response.json()) - .then(data => new Promise((resolve, reject) => { - console.log(data) - - if (data && Array.isArray(data.links)) { - const link: { template: string } = data.links.find((link: any) => { - return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe' - }) - - if (link && link.template.includes('{uri}')) { - resolve(link.template.replace('{uri}', encodeURIComponent(this.uri))) - } - } - reject() - })) - .then(window.open) - .catch(err => console.error(err)) - } -} diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html deleted file mode 100644 index 85b3d1fdb..000000000 --- a/client/src/app/shared/user-subscription/subscribe-button.component.html +++ /dev/null @@ -1,67 +0,0 @@ -
    - - - - - Subscribe - - Subscribe to all channels - {{ subscribeStatus(true).length }}/{{ subscribed.size }} - channels subscribed - - - - - {{ videoChannels[0].followersCount | myNumberFormatter }} - - - - - - - - - - - - -
    - - - -
    -
    diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss deleted file mode 100644 index b739c5ae2..000000000 --- a/client/src/app/shared/user-subscription/subscribe-button.component.scss +++ /dev/null @@ -1,112 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.btn-group-subscribe { - @include peertube-button; - @include disable-default-a-behaviour; - - float: right; - padding: 0; - - & > .btn, - & > .dropdown > .dropdown-toggle { - font-size: 15px; - } - - &:not(.big) { - white-space: nowrap; - } - - &.big { - height: 35px; - - & > button:first-child { - width: 175px; - } - - button .extra-text { - span:first-child { - line-height: 80%; - } - - span:not(:first-child) { - font-size: 75%; - } - } - } - - // Unlogged - & > .dropdown > .dropdown-toggle span { - padding-right: 3px; - } - - // Logged - & > .btn { - padding-right: 4px; - - & + .dropdown > button { - padding-left: 2px; - - &::after { - position: relative; - top: 1px; - } - } - } - - &.subscribe-button { - .btn { - @include orange-button; - font-weight: 600; - } - - span.followers-count { - padding-left: 5px; - } - } - &.unsubscribe-button { - .btn { - @include grey-button; - font-weight: 600; - } - } - - .dropdown-menu { - cursor: default; - - button { - cursor: pointer; - } - - .dropdown-item-neutral { - cursor: default; - - &:hover, - &:focus { - background-color: inherit; - } - } - } - - ::ng-deep form { - padding: 0.25rem 1rem; - } - - input { - @include peertube-input-text(100%); - } -} - -.extra-text { - display: flex; - flex-direction: column; - - span:first-child { - line-height: 75%; - } - - span:not(:first-child) { - font-size: 60%; - text-align: left; - } -} diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts deleted file mode 100644 index 947f34c85..000000000 --- a/client/src/app/shared/user-subscription/subscribe-button.component.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Component, Input, OnInit, OnChanges } from '@angular/core' -import { Router } from '@angular/router' -import { AuthService, Notifier } from '@app/core' -import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { VideoService } from '@app/shared/video/video.service' -import { FeedFormat } from '../../../../../shared/models/feeds' -import { Account } from '@app/shared/account/account.model' -import { concat, forkJoin, merge } from 'rxjs' - -@Component({ - selector: 'my-subscribe-button', - templateUrl: './subscribe-button.component.html', - styleUrls: [ './subscribe-button.component.scss' ] -}) -export class SubscribeButtonComponent implements OnInit, OnChanges { - /** - * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel], - * or with an account and a full list of that account's videoChannels. The latter is intended - * to allow mass un/subscription from an account's page, while keeping the channel-centric - * subscription model. - */ - @Input() account: Account - @Input() videoChannels: VideoChannel[] - @Input() displayFollowers = false - @Input() size: 'small' | 'normal' = 'normal' - - subscribed = new Map() - - constructor ( - private authService: AuthService, - private router: Router, - private notifier: Notifier, - private userSubscriptionService: UserSubscriptionService, - private i18n: I18n, - private videoService: VideoService - ) { } - - get handle () { - return this.account - ? this.account.nameWithHost - : this.videoChannel.name + '@' + this.videoChannel.host - } - - get channelHandle () { - return this.getChannelHandler(this.videoChannel) - } - - get uri () { - return this.account - ? this.account.url - : this.videoChannels[0].url - } - - get rssUri () { - const rssFeed = this.account - ? this.videoService - .getAccountFeedUrls(this.account.id) - .find(i => i.format === FeedFormat.RSS) - : this.videoService - .getVideoChannelFeedUrls(this.videoChannels[0].id) - .find(i => i.format === FeedFormat.RSS) - - return rssFeed.url - } - - get videoChannel () { - return this.videoChannels[0] - } - - get isAllChannelsSubscribed () { - return this.subscribeStatus(true).length === this.videoChannels.length - } - - get isAtLeastOneChannelSubscribed () { - return this.subscribeStatus(true).length > 0 - } - - get isBigButton () { - return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed - } - - ngOnInit () { - this.loadSubscribedStatus() - } - - ngOnChanges () { - this.ngOnInit() - } - - subscribe () { - if (this.isUserLoggedIn()) { - return this.localSubscribe() - } - - return this.gotoLogin() - } - - localSubscribe () { - const subscribedStatus = this.subscribeStatus(false) - - const observableBatch = this.videoChannels - .map(videoChannel => this.getChannelHandler(videoChannel)) - .filter(handle => subscribedStatus.includes(handle)) - .map(handle => this.userSubscriptionService.addSubscription(handle)) - - forkJoin(observableBatch) - .subscribe( - () => { - this.notifier.success( - this.account - ? this.i18n( - 'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.', - { nameWithHost: this.account.displayName } - ) - : this.i18n( - 'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.', - { nameWithHost: this.videoChannels[0].displayName } - ) - , - this.i18n('Subscribed') - ) - }, - - err => this.notifier.error(err.message) - ) - } - - unsubscribe () { - if (this.isUserLoggedIn()) { - this.localUnsubscribe() - } - } - - localUnsubscribe () { - const subscribeStatus = this.subscribeStatus(true) - - const observableBatch = this.videoChannels - .map(videoChannel => this.getChannelHandler(videoChannel)) - .filter(handle => subscribeStatus.includes(handle)) - .map(handle => this.userSubscriptionService.deleteSubscription(handle)) - - concat(...observableBatch) - .subscribe({ - complete: () => { - this.notifier.success( - this.account - ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost }) - : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost }) - , - this.i18n('Unsubscribed') - ) - }, - - error: err => this.notifier.error(err.message) - }) - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - gotoLogin () { - this.router.navigate([ '/login' ]) - } - - subscribeStatus (subscribed: boolean) { - const accumulator: string[] = [] - for (const [key, value] of this.subscribed.entries()) { - if (value === subscribed) accumulator.push(key) - } - - return accumulator - } - - private getChannelHandler (videoChannel: VideoChannel) { - return videoChannel.name + '@' + videoChannel.host - } - - private loadSubscribedStatus () { - if (!this.isUserLoggedIn()) return - - for (const videoChannel of this.videoChannels) { - const handle = this.getChannelHandler(videoChannel) - this.subscribed.set(handle, false) - - merge( - this.userSubscriptionService.listenToSubscriptionCacheChange(handle), - this.userSubscriptionService.doesSubscriptionExist(handle) - ).subscribe( - res => this.subscribed.set(handle, res), - - err => this.notifier.error(err.message) - ) - } - } -} diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts deleted file mode 100644 index 9af9ba23e..000000000 --- a/client/src/app/shared/user-subscription/user-subscription.service.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators' -import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable, NgZone } from '@angular/core' -import { ResultList } from '../../../../../shared' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestService } from '../rest' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' -import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos' -import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' -import { uniq } from 'lodash-es' -import * as debug from 'debug' -import { enterZone, leaveZone } from '@app/shared/rxjs/zone' - -const logger = debug('peertube:subscriptions:UserSubscriptionService') - -type SubscriptionExistResult = { [ uri: string ]: boolean } -type SubscriptionExistResultObservable = { [ uri: string ]: Observable } - -@Injectable() -export class UserSubscriptionService { - static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' - - // Use a replay subject because we "next" a value before subscribing - private existsSubject = new ReplaySubject(1) - private readonly existsObservable: Observable - - private myAccountSubscriptionCache: SubscriptionExistResult = {} - private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {} - private myAccountSubscriptionCacheSubject = new Subject() - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService, - private ngZone: NgZone - ) { - this.existsObservable = merge( - this.existsSubject.pipe( - // We leave Angular zone so Protractor does not get stuck - bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), - filter(uris => uris.length !== 0), - map(uris => uniq(uris)), - observeOn(enterZone(this.ngZone, asyncScheduler)), - switchMap(uris => this.doSubscriptionsExist(uris)), - share() - ), - - this.myAccountSubscriptionCacheSubject - ) - } - - /** - * Subscription part - */ - - deleteSubscription (nameWithHost: string) { - const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost - - return this.authHttp.delete(url) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - this.myAccountSubscriptionCache[nameWithHost] = false - - this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - addSubscription (nameWithHost: string) { - const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL - - const body = { uri: nameWithHost } - return this.authHttp.post(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - this.myAccountSubscriptionCache[nameWithHost] = true - - this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - listSubscriptions (componentPagination: ComponentPaginationLight): Observable> { - const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL - - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - - return this.authHttp.get>(url, { params }) - .pipe( - map(res => VideoChannelService.extractVideoChannels(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - /** - * SubscriptionExist part - */ - - listenToMyAccountSubscriptionCacheSubject () { - return this.myAccountSubscriptionCacheSubject.asObservable() - } - - listenToSubscriptionCacheChange (nameWithHost: string) { - if (nameWithHost in this.myAccountSubscriptionCacheObservable) { - return this.myAccountSubscriptionCacheObservable[ nameWithHost ] - } - - const obs = this.existsObservable - .pipe( - filter(existsResult => existsResult[ nameWithHost ] !== undefined), - map(existsResult => existsResult[ nameWithHost ]) - ) - - this.myAccountSubscriptionCacheObservable[ nameWithHost ] = obs - return obs - } - - doesSubscriptionExist (nameWithHost: string) { - logger('Running subscription check for %d.', nameWithHost) - - if (nameWithHost in this.myAccountSubscriptionCache) { - logger('Found cache for %d.', nameWithHost) - - return of(this.myAccountSubscriptionCache[ nameWithHost ]) - } - - this.existsSubject.next(nameWithHost) - - logger('Fetching from network for %d.', nameWithHost) - return this.existsObservable.pipe( - filter(existsResult => existsResult[ nameWithHost ] !== undefined), - map(existsResult => existsResult[ nameWithHost ]), - tap(result => this.myAccountSubscriptionCache[ nameWithHost ] = result) - ) - } - - private doSubscriptionsExist (uris: string[]): Observable { - const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' - let params = new HttpParams() - - params = this.restService.addObjectParams(params, { uris }) - - return this.authHttp.get(url, { params }) - .pipe( - tap(res => { - this.myAccountSubscriptionCache = { - ...this.myAccountSubscriptionCache, - ...res - } - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } -} diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts deleted file mode 100644 index ebd715fb1..000000000 --- a/client/src/app/shared/users/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './user.model' -export * from './user.service' -export * from './user-notifications.component' diff --git a/client/src/app/shared/users/user-history.service.ts b/client/src/app/shared/users/user-history.service.ts deleted file mode 100644 index b358cdf20..000000000 --- a/client/src/app/shared/users/user-history.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { RestExtractor } from '../rest/rest-extractor.service' -import { RestService } from '../rest/rest.service' -import { Video } from '../video/video.model' -import { catchError, map, switchMap } from 'rxjs/operators' -import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' -import { VideoService } from '@app/shared/video/video.service' -import { ResultList } from '../../../../../shared' - -@Injectable() -export class UserHistoryService { - static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService, - private videoService: VideoService - ) {} - - getUserVideosHistory (historyPagination: ComponentPaginationLight) { - const pagination = this.restService.componentPaginationToRestPagination(historyPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - - return this.authHttp - .get>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) - .pipe( - switchMap(res => this.videoService.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - deleteUserVideosHistory () { - return this.authHttp - .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {}) - .pipe( - map(() => this.restExtractor.extractDataBool()), - catchError(err => this.restExtractor.handleError(err)) - ) - } -} diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts deleted file mode 100644 index 7b8368d87..000000000 --- a/client/src/app/shared/users/user-notification.model.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared' -import { Actor } from '@app/shared/actor/actor.model' - -export class UserNotification implements UserNotificationServer { - id: number - type: UserNotificationType - read: boolean - - video?: VideoInfo & { - channel: ActorInfo & { avatarUrl?: string } - } - - videoImport?: { - id: number - video?: VideoInfo - torrentName?: string - magnetUri?: string - targetUrl?: string - } - - comment?: { - id: number - threadId: number - account: ActorInfo & { avatarUrl?: string } - video: VideoInfo - } - - videoAbuse?: { - id: number - video: VideoInfo - } - - videoBlacklist?: { - id: number - video: VideoInfo - } - - account?: ActorInfo & { avatarUrl?: string } - - actorFollow?: { - id: number - state: FollowState - follower: ActorInfo & { avatarUrl?: string } - following: { - type: 'account' | 'channel' | 'instance' - name: string - displayName: string - host: string - } - } - - createdAt: string - updatedAt: string - - // Additional fields - videoUrl?: string - commentUrl?: any[] - videoAbuseUrl?: string - videoAutoBlacklistUrl?: string - accountUrl?: string - videoImportIdentifier?: string - videoImportUrl?: string - instanceFollowUrl?: string - - constructor (hash: UserNotificationServer) { - this.id = hash.id - this.type = hash.type - this.read = hash.read - - // We assume that some fields exist - // To prevent a notification popup crash in case of bug, wrap it inside a try/catch - try { - this.video = hash.video - if (this.video) this.setAvatarUrl(this.video.channel) - - this.videoImport = hash.videoImport - - this.comment = hash.comment - if (this.comment) this.setAvatarUrl(this.comment.account) - - this.videoAbuse = hash.videoAbuse - - this.videoBlacklist = hash.videoBlacklist - - this.account = hash.account - if (this.account) this.setAvatarUrl(this.account) - - this.actorFollow = hash.actorFollow - if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower) - - this.createdAt = hash.createdAt - this.updatedAt = hash.updatedAt - - switch (this.type) { - case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION: - this.videoUrl = this.buildVideoUrl(this.video) - break - - case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO: - this.videoUrl = this.buildVideoUrl(this.video) - break - - case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO: - case UserNotificationType.COMMENT_MENTION: - if (!this.comment) break - this.accountUrl = this.buildAccountUrl(this.comment.account) - this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] - break - - case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: - this.videoAbuseUrl = '/admin/moderation/video-abuses/list' - this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) - break - - case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: - this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list' - // Backward compatibility where we did not assign videoBlacklist to this type of notification before - if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video } - - this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) - break - - case UserNotificationType.BLACKLIST_ON_MY_VIDEO: - this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) - break - - case UserNotificationType.MY_VIDEO_PUBLISHED: - this.videoUrl = this.buildVideoUrl(this.video) - break - - case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: - this.videoImportUrl = this.buildVideoImportUrl() - this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) - - if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video) - break - - case UserNotificationType.MY_VIDEO_IMPORT_ERROR: - this.videoImportUrl = this.buildVideoImportUrl() - this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) - break - - case UserNotificationType.NEW_USER_REGISTRATION: - this.accountUrl = this.buildAccountUrl(this.account) - break - - case UserNotificationType.NEW_FOLLOW: - this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) - break - - case UserNotificationType.NEW_INSTANCE_FOLLOWER: - this.instanceFollowUrl = '/admin/follows/followers-list' - break - - case UserNotificationType.AUTO_INSTANCE_FOLLOWING: - this.instanceFollowUrl = '/admin/follows/following-list' - break - } - } catch (err) { - this.type = null - console.error(err) - } - } - - private buildVideoUrl (video: { uuid: string }) { - return '/videos/watch/' + video.uuid - } - - private buildAccountUrl (account: { name: string, host: string }) { - return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host) - } - - private buildVideoImportUrl () { - return '/my-account/video-imports' - } - - private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) { - return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName - } - - private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { - actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) - } -} diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts deleted file mode 100644 index e525a1d58..000000000 --- a/client/src/app/shared/users/user-notification.service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Injectable } from '@angular/core' -import { HttpClient, HttpParams } from '@angular/common/http' -import { RestExtractor, RestService } from '../rest' -import { catchError, map, tap } from 'rxjs/operators' -import { environment } from '../../../environments/environment' -import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared' -import { UserNotification } from './user-notification.model' -import { AuthService } from '../../core' -import { ComponentPaginationLight } from '../rest/component-pagination.model' -import { User } from '../users/user.model' -import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' - -@Injectable() -export class UserNotificationService { - static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications' - static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings' - - constructor ( - private auth: AuthService, - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService, - private userNotificationSocket: UserNotificationSocket - ) {} - - listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) { - let params = new HttpParams() - params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination)) - - if (unread) params = params.append('unread', `${unread}`) - - const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined - - return this.authHttp.get>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - countUnreadNotifications () { - return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true) - .pipe(map(n => n.total)) - } - - markAsRead (notification: UserNotification) { - const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read' - - const body = { ids: [ notification.id ] } - const headers = { ignoreLoadingBar: '' } - - return this.authHttp.post(url, body, { headers }) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => this.userNotificationSocket.dispatch('read')), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - markAllAsRead () { - const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all' - const headers = { ignoreLoadingBar: '' } - - return this.authHttp.post(url, {}, { headers }) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => this.userNotificationSocket.dispatch('read-all')), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - updateNotificationSettings (user: User, settings: UserNotificationSetting) { - const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS - - return this.authHttp.put(url, settings) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - private formatNotification (notification: UserNotificationServer) { - return new UserNotification(notification) - } -} diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html deleted file mode 100644 index 08771110d..000000000 --- a/client/src/app/shared/users/user-notifications.component.html +++ /dev/null @@ -1,166 +0,0 @@ -
    You don't have notifications.
    - -
    -
    - - - - - - - - - - -
    - {{ notification.video.channel.displayName }} published a new video: {{ notification.video.name }} -
    -
    - - - - -
    - The notification concerns a video now unavailable -
    -
    -
    - - - - -
    - Your video {{ notification.video.name }} has been unblocked -
    -
    - - - - -
    - Your video {{ notification.videoBlacklist.video.name }} has been blocked -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - The notification concerns a comment now unavailable -
    -
    -
    - - - - -
    - Your video {{ notification.video.name }} has been published -
    -
    - - - - -
    - Your video import {{ notification.videoImportIdentifier }} succeeded -
    -
    - - - - -
    - Your video import {{ notification.videoImportIdentifier }} failed -
    -
    - - - - -
    - User {{ notification.account.name }} registered on your instance -
    -
    - - - - - - -
    - {{ notification.actorFollow.follower.displayName }} is following - - your channel {{ notification.actorFollow.following.displayName }} - your account -
    -
    - - - - - - - - - - - - -
    - Your instance has a new follower ({{ notification.actorFollow?.follower.host }}) - awaiting your approval -
    -
    - - - - -
    - Your instance automatically followed {{ notification.actorFollow.following.host }} -
    -
    - - - - -
    - The notification points to a content now unavailable -
    -
    -
    - -
    {{ notification.createdAt | myFromNow }}
    -
    -
    diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss deleted file mode 100644 index 5166bd559..000000000 --- a/client/src/app/shared/users/user-notifications.component.scss +++ /dev/null @@ -1,53 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.no-notification { - display: flex; - justify-content: center; - align-items: center; - padding: 20px 0; -} - -.notification { - display: flex; - align-items: center; - font-size: inherit; - padding: 15px 5px 15px 10px; - border-bottom: 1px solid $separator-border-color; - word-break: break-word; - - &.unread { - background-color: rgba(0, 0, 0, 0.05); - } - - my-global-icon { - width: 24px; - margin-right: 11px; - margin-left: 3px; - - @include apply-svg-color(#333); - } - - .avatar { - @include avatar(30px); - - margin-right: 10px; - } - - .message { - flex-grow: 1; - - a { - font-weight: $font-semibold; - } - } - - .from-date { - font-size: 0.85em; - color: pvar(--greyForegroundColor); - padding-left: 5px; - min-width: 70px; - text-align: right; - margin-left: auto; - } -} diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts deleted file mode 100644 index 977dd8925..000000000 --- a/client/src/app/shared/users/user-notifications.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' -import { UserNotificationService } from '@app/shared/users/user-notification.service' -import { UserNotificationType } from '../../../../../shared' -import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' -import { Notifier } from '@app/core' -import { UserNotification } from '@app/shared/users/user-notification.model' -import { Subject } from 'rxjs' - -@Component({ - selector: 'my-user-notifications', - templateUrl: 'user-notifications.component.html', - styleUrls: [ 'user-notifications.component.scss' ] -}) -export class UserNotificationsComponent implements OnInit { - @Input() ignoreLoadingBar = false - @Input() infiniteScroll = true - @Input() itemsPerPage = 20 - @Input() markAllAsReadSubject: Subject - - @Output() notificationsLoaded = new EventEmitter() - - notifications: UserNotification[] = [] - - // So we can access it in the template - UserNotificationType = UserNotificationType - - componentPagination: ComponentPagination - - onDataSubject = new Subject() - - constructor ( - private userNotificationService: UserNotificationService, - private notifier: Notifier - ) { } - - ngOnInit () { - this.componentPagination = { - currentPage: 1, - itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable - totalItems: null - } - - this.loadMoreNotifications() - - if (this.markAllAsReadSubject) { - this.markAllAsReadSubject.subscribe(() => this.markAllAsRead()) - } - } - - loadMoreNotifications () { - this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar) - .subscribe( - result => { - this.notifications = this.notifications.concat(result.data) - this.componentPagination.totalItems = result.total - - this.notificationsLoaded.emit() - - this.onDataSubject.next(result.data) - }, - - err => this.notifier.error(err.message) - ) - } - - onNearOfBottom () { - if (this.infiniteScroll === false) return - - this.componentPagination.currentPage++ - - if (hasMoreItems(this.componentPagination)) { - this.loadMoreNotifications() - } - } - - markAsRead (notification: UserNotification) { - if (notification.read) return - - this.userNotificationService.markAsRead(notification) - .subscribe( - () => { - notification.read = true - }, - - err => this.notifier.error(err.message) - ) - } - - markAllAsRead () { - this.userNotificationService.markAllAsRead() - .subscribe( - () => { - for (const notification of this.notifications) { - notification.read = true - } - }, - - err => this.notifier.error(err.message) - ) - } -} diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts deleted file mode 100644 index 3348fe75f..000000000 --- a/client/src/app/shared/users/user.model.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - hasUserRight, - User as UserServerModel, - UserNotificationSetting, - UserRight, - UserRole -} from '../../../../../shared/models/users' -import { VideoChannel } from '../../../../../shared/models/videos' -import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' -import { Account } from '@app/shared/account/account.model' -import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { UserAdminFlag } from '@shared/models/users/user-flag.model' - -export class User implements UserServerModel { - static KEYS = { - ID: 'id', - ROLE: 'role', - EMAIL: 'email', - VIDEOS_HISTORY_ENABLED: 'videos-history-enabled', - USERNAME: 'username', - NSFW_POLICY: 'nsfw_policy', - WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled', - AUTO_PLAY_VIDEO: 'auto_play_video', - SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video', - AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist', - THEME: 'last_active_theme', - VIDEO_LANGUAGES: 'video_languages' - } - - id: number - username: string - email: string - pendingEmail: string | null - - emailVerified: boolean - nsfwPolicy: NSFWPolicyType - - adminFlags?: UserAdminFlag - - autoPlayVideo: boolean - autoPlayNextVideo: boolean - autoPlayNextVideoPlaylist: boolean - webTorrentEnabled: boolean - videosHistoryEnabled: boolean - videoLanguages: string[] - - role: UserRole - roleLabel: string - - videoQuota: number - videoQuotaDaily: number - videoQuotaUsed?: number - videoQuotaUsedDaily?: number - videosCount?: number - videoAbusesCount?: number - videoAbusesAcceptedCount?: number - videoAbusesCreatedCount?: number - videoCommentsCount?: number - - theme: string - - account: Account - notificationSettings?: UserNotificationSetting - videoChannels?: VideoChannel[] - - blocked: boolean - blockedReason?: string - - noInstanceConfigWarningModal: boolean - noWelcomeModal: boolean - - pluginAuth: string | null - - lastLoginDate: Date | null - - createdAt: Date - - constructor (hash: Partial) { - this.id = hash.id - this.username = hash.username - this.email = hash.email - - this.role = hash.role - - this.videoChannels = hash.videoChannels - - this.videoQuota = hash.videoQuota - this.videoQuotaDaily = hash.videoQuotaDaily - this.videoQuotaUsed = hash.videoQuotaUsed - this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily - this.videosCount = hash.videosCount - this.videoAbusesCount = hash.videoAbusesCount - this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount - this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount - this.videoCommentsCount = hash.videoCommentsCount - - this.nsfwPolicy = hash.nsfwPolicy - this.webTorrentEnabled = hash.webTorrentEnabled - this.autoPlayVideo = hash.autoPlayVideo - this.autoPlayNextVideo = hash.autoPlayNextVideo - this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist - this.videosHistoryEnabled = hash.videosHistoryEnabled - this.videoLanguages = hash.videoLanguages - - this.theme = hash.theme - - this.adminFlags = hash.adminFlags - - this.blocked = hash.blocked - this.blockedReason = hash.blockedReason - - this.noInstanceConfigWarningModal = hash.noInstanceConfigWarningModal - this.noWelcomeModal = hash.noWelcomeModal - - this.notificationSettings = hash.notificationSettings - - this.createdAt = hash.createdAt - - this.pluginAuth = hash.pluginAuth - this.lastLoginDate = hash.lastLoginDate - - if (hash.account !== undefined) { - this.account = new Account(hash.account) - } - } - - get accountAvatarUrl () { - if (!this.account) return '' - - return this.account.avatarUrl - } - - hasRight (right: UserRight) { - return hasUserRight(this.role, right) - } - - patch (obj: UserServerModel) { - for (const key of Object.keys(obj)) { - this[key] = obj[key] - } - - if (obj.account !== undefined) { - this.account = new Account(obj.account) - } - } - - updateAccountAvatar (newAccountAvatar: Avatar) { - this.account.updateAvatar(newAccountAvatar) - } -} diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts deleted file mode 100644 index de1c8ec94..000000000 --- a/client/src/app/shared/users/user.service.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { has } from 'lodash-es' -import { BytesPipe } from 'ngx-pipes' -import { SortMeta } from 'primeng/api' -import { from, Observable, of } from 'rxjs' -import { catchError, concatMap, first, map, shareReplay, toArray, throttleTime, filter } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { AuthService } from '@app/core/auth' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { UserRegister } from '@shared/models/users/user-register.model' -import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' -import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' -import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { environment } from '../../../environments/environment' -import { LocalStorageService, SessionStorageService } from '../misc/storage.service' -import { RestExtractor, RestPagination, RestService } from '../rest' -import { User } from './user.model' - -@Injectable() -export class UserService { - static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' - - private bytesPipe = new BytesPipe() - - private userCache: { [ id: number ]: Observable } = {} - - constructor ( - private authHttp: HttpClient, - private authService: AuthService, - private restExtractor: RestExtractor, - private restService: RestService, - private localStorageService: LocalStorageService, - private sessionStorageService: SessionStorageService, - private i18n: I18n - ) { } - - changePassword (currentPassword: string, newPassword: string) { - const url = UserService.BASE_USERS_URL + 'me' - const body: UserUpdateMe = { - currentPassword, - password: newPassword - } - - return this.authHttp.put(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - changeEmail (password: string, newEmail: string) { - const url = UserService.BASE_USERS_URL + 'me' - const body: UserUpdateMe = { - currentPassword: password, - email: newEmail - } - - return this.authHttp.put(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateMyProfile (profile: UserUpdateMe) { - const url = UserService.BASE_USERS_URL + 'me' - - return this.authHttp.put(url, profile) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateMyAnonymousProfile (profile: UserUpdateMe) { - const supportedKeys = { - // local storage keys - nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val), - webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)), - autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)), - autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)), - theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val), - videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)), - - // session storage keys - autoPlayNextVideo: (val: boolean) => - this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val)) - } - - for (const key of Object.keys(profile)) { - try { - if (has(supportedKeys, key)) supportedKeys[key](profile[key]) - } catch (err) { - console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err) - } - } - } - - listenAnonymousUpdate () { - return this.localStorageService.watch([ - User.KEYS.NSFW_POLICY, - User.KEYS.WEBTORRENT_ENABLED, - User.KEYS.AUTO_PLAY_VIDEO, - User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, - User.KEYS.THEME, - User.KEYS.VIDEO_LANGUAGES - ]).pipe( - throttleTime(200), - filter(() => this.authService.isLoggedIn() !== true), - map(() => this.getAnonymousUser()) - ) - } - - deleteMe () { - const url = UserService.BASE_USERS_URL + 'me' - - return this.authHttp.delete(url) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - changeAvatar (avatarForm: FormData) { - const url = UserService.BASE_USERS_URL + 'me/avatar/pick' - - return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - signup (userCreate: UserRegister) { - return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getMyVideoQuotaUsed () { - const url = UserService.BASE_USERS_URL + 'me/video-quota-used' - - return this.authHttp.get(url) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - askResetPassword (email: string) { - const url = UserService.BASE_USERS_URL + '/ask-reset-password' - - return this.authHttp.post(url, { email }) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - resetPassword (userId: number, verificationString: string, password: string) { - const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password` - const body = { - verificationString, - password - } - - return this.authHttp.post(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) { - const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email` - const body = { - verificationString, - isPendingEmail - } - - return this.authHttp.post(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - askSendVerifyEmail (email: string) { - const url = UserService.BASE_USERS_URL + '/ask-send-verify-email' - - return this.authHttp.post(url, { email }) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - autocomplete (search: string): Observable { - const url = UserService.BASE_USERS_URL + 'autocomplete' - const params = new HttpParams().append('search', search) - - return this.authHttp - .get(url, { params }) - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) { - // Don't update display name, the user seems to have changed it - if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername - - return this.displayNameToUsername(newDisplayName) - } - - displayNameToUsername (displayName: string) { - if (!displayName) return '' - - return displayName - .toLowerCase() - .replace(/\s/g, '_') - .replace(/[^a-z0-9_.]/g, '') - } - - /* ###### Admin methods ###### */ - - addUser (userCreate: UserCreate) { - return this.authHttp.post(UserService.BASE_USERS_URL, userCreate) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateUser (userId: number, userUpdate: UserUpdate) { - return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateUsers (users: UserServerModel[], userUpdate: UserUpdate) { - return from(users) - .pipe( - concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), - toArray(), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getUserWithCache (userId: number) { - if (!this.userCache[userId]) { - this.userCache[ userId ] = this.getUser(userId).pipe(shareReplay()) - } - - return this.userCache[userId] - } - - getUser (userId: number, withStats = false) { - const params = new HttpParams().append('withStats', withStats + '') - return this.authHttp.get(UserService.BASE_USERS_URL + userId, { params }) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - getAnonymousUser () { - let videoLanguages: string[] - - try { - videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES)) - } catch (err) { - videoLanguages = null - console.error('Cannot parse desired video languages from localStorage.', err) - } - - return new User({ - // local storage keys - nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType, - webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false', - theme: this.localStorageService.getItem(User.KEYS.THEME) || 'instance-default', - videoLanguages, - - autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false', - autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true', - - // session storage keys - autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' - }) - } - - getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable> { - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) params = params.append('search', search) - - return this.authHttp.get>(UserService.BASE_USERS_URL, { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - removeUser (usersArg: UserServerModel | UserServerModel[]) { - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] - - return from(users) - .pipe( - concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)), - toArray(), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) { - const body = reason ? { reason } : {} - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] - - return from(users) - .pipe( - concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)), - toArray(), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - unbanUsers (usersArg: UserServerModel | UserServerModel[]) { - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] - - return from(users) - .pipe( - concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})), - toArray(), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getAnonymousOrLoggedUser () { - if (!this.authService.isLoggedIn()) { - return of(this.getAnonymousUser()) - } - - return this.authService.userInformationLoaded - .pipe( - first(), - map(() => this.authService.getUser()) - ) - } - - private formatUser (user: UserServerModel) { - let videoQuota - if (user.videoQuota === -1) { - videoQuota = this.i18n('Unlimited') - } else { - videoQuota = this.bytesPipe.transform(user.videoQuota, 0) - } - - const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0) - - const roleLabels: { [ id in UserRole ]: string } = { - [UserRole.USER]: this.i18n('User'), - [UserRole.ADMINISTRATOR]: this.i18n('Administrator'), - [UserRole.MODERATOR]: this.i18n('Moderator') - } - - return Object.assign(user, { - roleLabel: roleLabels[user.role], - videoQuota, - videoQuotaUsed - }) - } -} diff --git a/client/src/app/shared/video-abuse/index.ts b/client/src/app/shared/video-abuse/index.ts deleted file mode 100644 index 92cbfb5f9..000000000 --- a/client/src/app/shared/video-abuse/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './video-abuse.service' diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts deleted file mode 100644 index 43f4674b1..000000000 --- a/client/src/app/shared/video-abuse/video-abuse.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { catchError, map } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { SortMeta } from 'primeng/api' -import { Observable } from 'rxjs' -import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestPagination, RestService } from '../rest' -import { omit } from 'lodash-es' - -@Injectable() -export class VideoAbuseService { - private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) {} - - getVideoAbuses (options: { - pagination: RestPagination, - sort: SortMeta, - search?: string - }): Observable> { - const { pagination, sort, search } = options - const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) { - const filters = this.restService.parseQueryStringFilter(search, { - id: { prefix: '#' }, - state: { - prefix: 'state:', - handler: v => { - if (v === 'accepted') return VideoAbuseState.ACCEPTED - if (v === 'pending') return VideoAbuseState.PENDING - if (v === 'rejected') return VideoAbuseState.REJECTED - - return undefined - } - }, - videoIs: { - prefix: 'videoIs:', - handler: v => { - if (v === 'deleted') return v - if (v === 'blacklisted') return v - - return undefined - } - }, - searchReporter: { prefix: 'reporter:' }, - searchReportee: { prefix: 'reportee:' }, - predefinedReason: { prefix: 'tag:' } - }) - - params = this.restService.addObjectParams(params, filters) - } - - return this.authHttp.get>(url, { params }) - .pipe( - catchError(res => this.restExtractor.handleError(res)) - ) - } - - reportVideo (parameters: { id: number } & VideoAbuseCreate) { - const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' - - const body = omit(parameters, [ 'id' ]) - - return this.authHttp.post(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) { - const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id - - return this.authHttp.put(url, abuseUpdate) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - removeVideoAbuse (videoAbuse: VideoAbuse) { - const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id - - return this.authHttp.delete(url) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - }} diff --git a/client/src/app/shared/video-block/index.ts b/client/src/app/shared/video-block/index.ts deleted file mode 100644 index a99551a38..000000000 --- a/client/src/app/shared/video-block/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './video-block.service' diff --git a/client/src/app/shared/video-block/video-block.service.ts b/client/src/app/shared/video-block/video-block.service.ts deleted file mode 100644 index d0673ddba..000000000 --- a/client/src/app/shared/video-block/video-block.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { catchError, map, concatMap, toArray } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { SortMeta } from 'primeng/api' -import { from as observableFrom, Observable } from 'rxjs' -import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestPagination, RestService } from '../rest' - -@Injectable() -export class VideoBlockService { - private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) {} - - listBlocks (options: { - pagination: RestPagination - sort: SortMeta - search?: string - type?: VideoBlacklistType - }): Observable> { - const { pagination, sort, search, type } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (search) { - const filters = this.restService.parseQueryStringFilter(search, { - type: { - prefix: 'type:', - handler: v => { - if (v === 'manual') return VideoBlacklistType.MANUAL - if (v === 'auto') return VideoBlacklistType.AUTO_BEFORE_PUBLISHED - - return undefined - } - } - }) - - params = this.restService.addObjectParams(params, filters) - } - if (type) params = params.append('type', type.toString()) - - return this.authHttp.get>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - unblockVideo (videoIdArgs: number | number[]) { - const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ] - - return observableFrom(videoIds) - .pipe( - concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')), - toArray(), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - blockVideo (videoId: number, reason: string, unfederate: boolean) { - const body = { - unfederate, - reason - } - - return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } -} diff --git a/client/src/app/shared/video-caption/index.ts b/client/src/app/shared/video-caption/index.ts deleted file mode 100644 index c48a70558..000000000 --- a/client/src/app/shared/video-caption/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './video-caption.service' diff --git a/client/src/app/shared/video-caption/video-caption-edit.model.ts b/client/src/app/shared/video-caption/video-caption-edit.model.ts deleted file mode 100644 index 732f20158..000000000 --- a/client/src/app/shared/video-caption/video-caption-edit.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface VideoCaptionEdit { - language: { - id: string - label?: string - } - - action?: 'CREATE' | 'REMOVE' - captionfile?: any -} diff --git a/client/src/app/shared/video-caption/video-caption.service.ts b/client/src/app/shared/video-caption/video-caption.service.ts deleted file mode 100644 index 6bfe67435..000000000 --- a/client/src/app/shared/video-caption/video-caption.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { catchError, map, switchMap } from 'rxjs/operators' -import { HttpClient } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { Observable, of } from 'rxjs' -import { peertubeTranslate, ResultList } from '../../../../../shared' -import { RestExtractor } from '../rest' -import { VideoService } from '@app/shared/video/video.service' -import { objectToFormData, sortBy } from '@app/shared/misc/utils' -import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model' -import { VideoCaption } from '../../../../../shared/models/videos/caption/video-caption.model' -import { ServerService } from '@app/core' - -@Injectable() -export class VideoCaptionService { - constructor ( - private authHttp: HttpClient, - private serverService: ServerService, - private restExtractor: RestExtractor - ) {} - - listCaptions (videoId: number | string): Observable> { - return this.authHttp.get>(VideoService.BASE_VIDEO_URL + videoId + '/captions') - .pipe( - switchMap(captionsResult => { - return this.serverService.getServerLocale() - .pipe(map(translations => ({ captionsResult, translations }))) - }), - map(({ captionsResult, translations }) => { - for (const c of captionsResult.data) { - c.language.label = peertubeTranslate(c.language.label, translations) - } - - return captionsResult - }), - map(captionsResult => { - sortBy(captionsResult.data, 'language', 'label') - - return captionsResult - }) - ) - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - removeCaption (videoId: number | string, language: string) { - return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - addCaption (videoId: number | string, language: string, captionfile: File) { - const body = { captionfile } - const data = objectToFormData(body) - - return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) { - let obs = of(true) - - for (const videoCaption of videoCaptions) { - if (videoCaption.action === 'CREATE') { - obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile))) - } else if (videoCaption.action === 'REMOVE') { - obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id))) - } - } - - return obs - } -} diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts deleted file mode 100644 index 2f4597343..000000000 --- a/client/src/app/shared/video-channel/video-channel.model.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos' -import { Actor } from '../actor/actor.model' -import { Account } from '../../../../../shared/models/actors' - -export class VideoChannel extends Actor implements ServerVideoChannel { - displayName: string - description: string - support: string - isLocal: boolean - nameWithHost: string - nameWithHostForced: string - - ownerAccount?: Account - ownerBy?: string - ownerAvatarUrl?: string - - videosCount?: number - - viewsPerDay?: ViewsPerDate[] - - constructor (hash: ServerVideoChannel) { - super(hash) - - this.displayName = hash.displayName - this.description = hash.description - this.support = hash.support - this.isLocal = hash.isLocal - this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) - this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) - - this.videosCount = hash.videosCount - - if (hash.viewsPerDay) { - this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) - } - - if (hash.ownerAccount) { - this.ownerAccount = hash.ownerAccount - this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) - this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) - } - } -} diff --git a/client/src/app/shared/video-channel/video-channel.service.ts b/client/src/app/shared/video-channel/video-channel.service.ts deleted file mode 100644 index 0e036bda7..000000000 --- a/client/src/app/shared/video-channel/video-channel.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { catchError, map, tap } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { Observable, ReplaySubject } from 'rxjs' -import { RestExtractor } from '../rest/rest-extractor.service' -import { HttpClient, HttpParams } from '@angular/common/http' -import { VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '../../../../../shared/models/videos' -import { AccountService } from '../account/account.service' -import { ResultList } from '../../../../../shared' -import { VideoChannel } from './video-channel.model' -import { environment } from '../../../environments/environment' -import { Account } from '@app/shared/account/account.model' -import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' -import { RestService } from '@app/shared/rest' - -@Injectable() -export class VideoChannelService { - static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/' - - videoChannelLoaded = new ReplaySubject(1) - - static extractVideoChannels (result: ResultList) { - const videoChannels: VideoChannel[] = [] - - for (const videoChannelJSON of result.data) { - videoChannels.push(new VideoChannel(videoChannelJSON)) - } - - return { data: videoChannels, total: result.total } - } - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) { } - - getVideoChannel (videoChannelName: string) { - return this.authHttp.get(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName) - .pipe( - map(videoChannelHash => new VideoChannel(videoChannelHash)), - tap(videoChannel => this.videoChannelLoaded.next(videoChannel)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - listAccountVideoChannels ( - account: Account, - componentPagination?: ComponentPaginationLight, - withStats = false - ): Observable> { - const pagination = componentPagination - ? this.restService.componentPaginationToRestPagination(componentPagination) - : { start: 0, count: 20 } - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - params = params.set('withStats', withStats + '') - - const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' - return this.authHttp.get>(url, { params }) - .pipe( - map(res => VideoChannelService.extractVideoChannels(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - createVideoChannel (videoChannel: VideoChannelCreate) { - return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) { - return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) { - const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick' - - return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - removeVideoChannel (videoChannel: VideoChannel) { - return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } -} diff --git a/client/src/app/shared/video-import/index.ts b/client/src/app/shared/video-import/index.ts deleted file mode 100644 index 9bb73ec2c..000000000 --- a/client/src/app/shared/video-import/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './video-import.service' diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts deleted file mode 100644 index afd9e3fb5..000000000 --- a/client/src/app/shared/video-import/video-import.service.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { catchError, map, switchMap } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { Observable } from 'rxjs' -import { peertubeTranslate, VideoImport } from '../../../../../shared' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestService } from '../rest' -import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/videos' -import { objectToFormData } from '@app/shared/misc/utils' -import { ResultList } from '../../../../../shared/models/result-list.model' -import { UserService } from '@app/shared/users/user.service' -import { SortMeta } from 'primeng/api' -import { RestPagination } from '@app/shared/rest' -import { ServerService } from '@app/core' - -@Injectable() -export class VideoImportService { - private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor, - private serverService: ServerService - ) {} - - importVideoUrl (targetUrl: string, video: VideoUpdate): Observable { - const url = VideoImportService.BASE_VIDEO_IMPORT_URL - - const body = this.buildImportVideoObject(video) - body.targetUrl = targetUrl - - const data = objectToFormData(body) - return this.authHttp.post(url, data) - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable { - const url = VideoImportService.BASE_VIDEO_IMPORT_URL - const body: VideoImportCreate = this.buildImportVideoObject(video) - - if (typeof target === 'string') body.magnetUri = target - else body.torrentfile = target - - const data = objectToFormData(body) - return this.authHttp.post(url, data) - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable> { - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - return this.authHttp - .get>(UserService.BASE_USERS_URL + '/me/videos/imports', { params }) - .pipe( - switchMap(res => this.extractVideoImports(res)), - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - private buildImportVideoObject (video: VideoUpdate): VideoImportCreate { - const language = video.language || null - const licence = video.licence || null - const category = video.category || null - const description = video.description || null - const support = video.support || null - const scheduleUpdate = video.scheduleUpdate || null - const originallyPublishedAt = video.originallyPublishedAt || null - - return { - name: video.name, - category, - licence, - language, - support, - description, - channelId: video.channelId, - privacy: video.privacy, - tags: video.tags, - nsfw: video.nsfw, - waitTranscoding: video.waitTranscoding, - commentsEnabled: video.commentsEnabled, - downloadEnabled: video.downloadEnabled, - thumbnailfile: video.thumbnailfile, - previewfile: video.previewfile, - scheduleUpdate, - originallyPublishedAt - } - } - - private extractVideoImports (result: ResultList): Observable> { - return this.serverService.getServerLocale() - .pipe( - map(translations => { - result.data.forEach(d => - d.state.label = peertubeTranslate(d.state.label, translations) - ) - - return result - }) - ) - } -} diff --git a/client/src/app/shared/video-ownership/index.ts b/client/src/app/shared/video-ownership/index.ts deleted file mode 100644 index fe8902ee2..000000000 --- a/client/src/app/shared/video-ownership/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './video-ownership.service' diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts deleted file mode 100644 index b95d5b792..000000000 --- a/client/src/app/shared/video-ownership/video-ownership.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { catchError, map } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { environment } from '../../../environments/environment' -import { RestExtractor, RestService } from '../rest' -import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' -import { Observable } from 'rxjs/index' -import { SortMeta } from 'primeng/api' -import { ResultList, VideoChangeOwnership } from '../../../../../shared' -import { RestPagination } from '@app/shared/rest' -import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' - -@Injectable() -export class VideoOwnershipService { - private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) { - } - - changeOwnership (id: number, username: string) { - const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership' - const body: VideoChangeOwnershipCreate = { - username - } - - return this.authHttp.post(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable> { - const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership' - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - return this.authHttp.get>(url, { params }) - .pipe( - map(res => this.restExtractor.convertResultListDateToHuman(res)), - catchError(res => this.restExtractor.handleError(res)) - ) - } - - acceptOwnership (id: number, input: VideoChangeOwnershipAccept) { - const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept' - return this.authHttp.post(url, input) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(this.restExtractor.handleError) - ) - } - - refuseOwnership (id: number) { - const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse' - return this.authHttp.post(url, {}) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(this.restExtractor.handleError) - ) - } -} diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html deleted file mode 100644 index a40e0699e..000000000 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html +++ /dev/null @@ -1,82 +0,0 @@ -
    -
    -
    -
    Save to
    - -
    - - - Options -
    -
    - -
    -
    - - - -
    - -
    - - - -
    -
    -
    - -
    - -
    - -
    - -
    - - - - -
    diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss deleted file mode 100644 index 47baa997b..000000000 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss +++ /dev/null @@ -1,107 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.header, -.dropdown-item, -.input-container { - padding: 8px 24px; -} - -.header { - min-width: 240px; - margin-bottom: 10px; - border-bottom: 1px solid $separator-border-color; - - .first-row { - display: flex; - align-items: center; - - .title { - font-size: 18px; - flex-grow: 1; - } - - .options { - display: flex; - align-items: center; - font-size: 14px; - cursor: pointer; - - my-global-icon { - @include apply-svg-color(#333); - - width: 16px; - height: 23px; - margin-right: 3px; - } - } - } - - .options-row { - margin-top: 10px; - padding-left: 10px; - - > div { - display: flex; - align-items: center; - } - } -} - -.playlists { - max-height: 180px; - overflow-y: auto; -} - -.playlist { - display: inline-flex; - cursor: pointer; - - my-peertube-checkbox { - margin-right: 10px; - align-self: center; - } - - .display-name { - display: flex; - align-items: flex-end; - - .timestamp-info { - font-size: 0.9em; - color: pvar(--greyForegroundColor); - margin-left: 5px; - } - } -} - -.new-playlist-button, -.new-playlist-block { - padding-top: 10px; - border-top: 1px solid $separator-border-color; -} - -.new-playlist-button { - cursor: pointer; - - my-global-icon { - @include apply-svg-color(#333); - - position: relative; - left: -1px; - top: -1px; - margin-right: 4px; - width: 21px; - height: 21px; - } -} - -input[type=text] { - @include peertube-input-text(200px); - - display: block; -} - -input[type=submit] { - @include peertube-button; - @include orange-button; -} diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts deleted file mode 100644 index 0c593a79a..000000000 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core' -import { CachedPlaylist, VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' -import { AuthService, Notifier } from '@app/core' -import { Subject, Subscription } from 'rxjs' -import { debounceTime, filter } from 'rxjs/operators' -import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' -import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { secondsToTime } from '../../../assets/player/utils' -import * as debug from 'debug' -import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' -import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' - -const logger = debug('peertube:playlists:VideoAddToPlaylistComponent') - -type PlaylistSummary = { - id: number - inPlaylist: boolean - displayName: string - - playlistElementId?: number - startTimestamp?: number - stopTimestamp?: number -} - -@Component({ - selector: 'my-video-add-to-playlist', - styleUrls: [ './video-add-to-playlist.component.scss' ], - templateUrl: './video-add-to-playlist.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook { - @Input() video: Video - @Input() currentVideoTimestamp: number - @Input() lazyLoad = false - - isNewPlaylistBlockOpened = false - videoPlaylistSearch: string - videoPlaylistSearchChanged = new Subject() - videoPlaylists: PlaylistSummary[] = [] - timestampOptions: { - startTimestampEnabled: boolean - startTimestamp: number - stopTimestampEnabled: boolean - stopTimestamp: number - } - displayOptions = false - - private disabled = false - - private listenToPlaylistChangeSub: Subscription - private playlistsData: CachedPlaylist[] = [] - - constructor ( - protected formValidatorService: FormValidatorService, - private authService: AuthService, - private notifier: Notifier, - private i18n: I18n, - private videoPlaylistService: VideoPlaylistService, - private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, - private cd: ChangeDetectorRef - ) { - super() - } - - get user () { - return this.authService.getUser() - } - - ngOnInit () { - this.buildForm({ - displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME - }) - - this.videoPlaylistService.listenToMyAccountPlaylistsChange() - .subscribe(result => { - this.playlistsData = result.data - - this.videoPlaylistService.runPlaylistCheck(this.video.id) - }) - - this.videoPlaylistSearchChanged - .pipe(debounceTime(500)) - .subscribe(() => this.load()) - - if (this.lazyLoad === false) this.load() - } - - ngOnChanges (simpleChanges: SimpleChanges) { - if (simpleChanges['video']) { - this.reload() - } - } - - ngOnDestroy () { - this.unsubscribePlaylistChanges() - } - - disableForReuse () { - this.disabled = true - } - - enabledForReuse () { - this.disabled = false - } - - reload () { - logger('Reloading component') - - this.videoPlaylists = [] - this.videoPlaylistSearch = undefined - - this.resetOptions(true) - this.load() - - this.cd.markForCheck() - } - - load () { - logger('Loading component') - - this.listenToPlaylistChanges() - - this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch) - .subscribe(playlistsResult => { - this.playlistsData = playlistsResult.data - - this.videoPlaylistService.runPlaylistCheck(this.video.id) - }) - } - - openChange (opened: boolean) { - if (opened === false) { - this.isNewPlaylistBlockOpened = false - this.displayOptions = false - } - } - - openCreateBlock (event: Event) { - event.preventDefault() - - this.isNewPlaylistBlockOpened = true - } - - togglePlaylist (event: Event, playlist: PlaylistSummary) { - event.preventDefault() - - if (playlist.inPlaylist === true) { - this.removeVideoFromPlaylist(playlist) - } else { - this.addVideoInPlaylist(playlist) - } - - playlist.inPlaylist = !playlist.inPlaylist - this.resetOptions() - - this.cd.markForCheck() - } - - createPlaylist () { - const displayName = this.form.value[ 'displayName' ] - - const videoPlaylistCreate: VideoPlaylistCreate = { - displayName, - privacy: VideoPlaylistPrivacy.PRIVATE - } - - this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( - () => { - this.isNewPlaylistBlockOpened = false - - this.cd.markForCheck() - }, - - err => this.notifier.error(err.message) - ) - } - - resetOptions (resetTimestamp = false) { - this.displayOptions = false - - this.timestampOptions = {} as any - this.timestampOptions.startTimestampEnabled = false - this.timestampOptions.stopTimestampEnabled = false - - if (resetTimestamp) { - this.timestampOptions.startTimestamp = 0 - this.timestampOptions.stopTimestamp = this.video.duration - } - } - - formatTimestamp (playlist: PlaylistSummary) { - const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : '' - const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : '' - - return `(${start}-${stop})` - } - - onVideoPlaylistSearchChanged () { - this.videoPlaylistSearchChanged.next() - } - - private removeVideoFromPlaylist (playlist: PlaylistSummary) { - if (!playlist.playlistElementId) return - - this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id) - .subscribe( - () => { - this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName })) - }, - - err => { - this.notifier.error(err.message) - }, - - () => this.cd.markForCheck() - ) - } - - private listenToPlaylistChanges () { - this.unsubscribePlaylistChanges() - - this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id) - .pipe(filter(() => this.disabled === false)) - .subscribe(existResult => this.rebuildPlaylists(existResult)) - } - - private unsubscribePlaylistChanges () { - if (this.listenToPlaylistChangeSub) { - this.listenToPlaylistChangeSub.unsubscribe() - this.listenToPlaylistChangeSub = undefined - } - } - - private rebuildPlaylists (existResult: VideoExistInPlaylist[]) { - logger('Got existing results for %d.', this.video.id, existResult) - - this.videoPlaylists = [] - for (const playlist of this.playlistsData) { - const existingPlaylist = existResult.find(p => p.playlistId === playlist.id) - - this.videoPlaylists.push({ - id: playlist.id, - displayName: playlist.displayName, - inPlaylist: !!existingPlaylist, - playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined, - startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, - stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined - }) - } - - logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists) - - this.cd.markForCheck() - } - - private addVideoInPlaylist (playlist: PlaylistSummary) { - const body: VideoPlaylistElementCreate = { videoId: this.video.id } - - if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp - if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp - - this.videoPlaylistService.addVideoInPlaylist(playlist.id, body) - .subscribe( - () => { - const message = body.startTimestamp || body.stopTimestamp - ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) }) - : this.i18n('Video added in {{n}}', { n: playlist.displayName }) - - this.notifier.success(message) - }, - - err => { - this.notifier.error(err.message) - }, - - () => this.cd.markForCheck() - ) - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html deleted file mode 100644 index e3f7ef017..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html +++ /dev/null @@ -1,92 +0,0 @@ -
    - -
    - - {{ position }} -
    - - - -
    - -
    - - {{ playlistElement.video.name }} - - - - - {{ formatTimestamp(playlistElement) }} - - - - Unavailable - Private - Deleted - -
    - - - - -
    - - -
    - - - -
    -
    - - - -
    - -
    - - - -
    - - -
    -
    - - - - Delete from {{ playlist?.displayName }} - -
    -
    -
    diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss deleted file mode 100644 index afd775b25..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss +++ /dev/null @@ -1,224 +0,0 @@ -@import '_variables'; -@import '_mixins'; -@import '_miniature'; - -$thumbnail-width: 130px; -$thumbnail-height: 72px; - -my-video-thumbnail { - @include thumbnail-size-component($thumbnail-width, $thumbnail-height); -} - -.fake-thumbnail { - width: $thumbnail-width; - height: $thumbnail-height; - background-color: #ececec; -} - -my-video-thumbnail, -.fake-thumbnail { - display: flex; // Avoids an issue with line-height that adds space below the element - margin-right: 10px; -} - -.video { - display: flex; - align-items: center; - background-color: pvar(--mainBackgroundColor); - padding: 10px; - border-bottom: 1px solid $separator-border-color; - - &:hover { - background-color: rgba(0, 0, 0, 0.05); - - .more { - opacity: 1; - } - } - - @media not all and (hover: hover) and (pointer: fine) { - .more { - opacity: 1 !important; - } - } - - &.playing { - background-color: rgba(0, 0, 0, 0.02); - } - - a { - @include disable-default-a-behaviour; - - color: pvar(--mainForegroundColor); - display: flex; - min-width: 0; - align-items: center; - cursor: pointer; - - .position { - font-weight: $font-semibold; - margin-right: 10px; - color: pvar(--greyForegroundColor); - min-width: 25px; - - my-global-icon { - @include apply-svg-color(pvar(--greyForegroundColor)); - - width: 17px; - position: relative; - left: -2px; - } - } - - .video-info { - display: flex; - flex-direction: column; - align-self: flex-start; - min-width: 0; - - a { - width: auto; - } - - .video-info-account, .video-info-timestamp { - color: pvar(--greyForegroundColor); - } - } - } - - .video-info-name { - font-size: 18px; - font-weight: $font-semibold; - display: inline-block; - - @include ellipsis; - } - - .more, my-edit-button { - justify-self: flex-end; - margin-left: auto; - cursor: pointer; - min-width: 24px; - } - - .more { - opacity: 0; - - &.show { - opacity: 1; - } - - .icon-more { - @include apply-svg-color(pvar(--greyForegroundColor)); - - display: flex; - - &::after { - border: none; - } - } - - .dropdown-item { - @include dropdown-with-icon-item; - } - - .timestamp-options { - padding-top: 0; - padding-left: 35px; - margin-bottom: 15px; - - > div { - display: flex; - align-items: center; - } - - input { - @include peertube-button; - @include orange-button; - - margin-top: 10px; - } - } - } -} - -@mixin more-dropdown-control { - .video { - my-edit-button { - display: none; - - + .more { - display: inline-flex; - } - } - } -} - -@mixin edit-button-control { - .video { - my-edit-button { - display: none; - } - - &.playing { - my-edit-button { - display: inline-flex; - height: max-content; - } - } - - my-edit-button + .more { - display: none; - } - } -} - -@mixin edit-button-in-mobile-view { - .video { - my-edit-button { - ::ng-deep .action-button-edit { - padding: 0 13px; - - .button-label { - display: none; - } - } - } - } -} - -@media screen and (min-width: $small-view) { - :host-context(.expanded) { - @include more-dropdown-control(); - } -} - -@media screen and (max-width: $small-view) { - :host-context(.expanded) { - @include edit-button-control(); - } -} - -@media screen and (max-width: $mobile-view) { - :host-context(.expanded) { - @include edit-button-in-mobile-view(); - } -} - -@media screen and (min-width: #{$small-view + $menu-width}) { - :host-context(.main-col:not(.expanded)) { - @include more-dropdown-control(); - } -} - -@media screen and (max-width: #{$small-view + $menu-width}) { - :host-context(.main-col:not(.expanded)) { - @include edit-button-control(); - } -} - -@media screen and (max-width: #{$mobile-view + $menu-width}) { - :host-context(.main-col:not(.expanded)) { - @include edit-button-in-mobile-view(); - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts deleted file mode 100644 index fad03e045..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' -import { Video } from '@app/shared/video/video.model' -import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models' -import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' -import { ActivatedRoute } from '@angular/router' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { VideoService } from '@app/shared/video/video.service' -import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' -import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' -import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' -import { secondsToTime } from '../../../assets/player/utils' -import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' - -@Component({ - selector: 'my-video-playlist-element-miniature', - styleUrls: [ './video-playlist-element-miniature.component.scss' ], - templateUrl: './video-playlist-element-miniature.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class VideoPlaylistElementMiniatureComponent implements OnInit { - @ViewChild('moreDropdown') moreDropdown: NgbDropdown - - @Input() playlist: VideoPlaylist - @Input() playlistElement: VideoPlaylistElement - @Input() owned = false - @Input() playing = false - @Input() rowLink = false - @Input() accountLink = true - @Input() position: number // Keep this property because we're in the OnPush change detection strategy - @Input() touchScreenEditButton = false - - @Output() elementRemoved = new EventEmitter() - - displayTimestampOptions = false - - timestampOptions: { - startTimestampEnabled: boolean - startTimestamp: number - stopTimestampEnabled: boolean - stopTimestamp: number - } = {} as any - - private serverConfig: ServerConfig - - constructor ( - private authService: AuthService, - private serverService: ServerService, - private notifier: Notifier, - private confirmService: ConfirmService, - private route: ActivatedRoute, - private i18n: I18n, - private videoService: VideoService, - private videoPlaylistService: VideoPlaylistService, - private cdr: ChangeDetectorRef - ) {} - - ngOnInit (): void { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => { - this.serverConfig = config - this.cdr.detectChanges() - }) - } - - isUnavailable (e: VideoPlaylistElement) { - return e.type === VideoPlaylistElementType.UNAVAILABLE - } - - isPrivate (e: VideoPlaylistElement) { - return e.type === VideoPlaylistElementType.PRIVATE - } - - isDeleted (e: VideoPlaylistElement) { - return e.type === VideoPlaylistElementType.DELETED - } - - buildRouterLink () { - if (!this.playlist) return null - - return [ '/videos/watch/playlist', this.playlist.uuid ] - } - - buildRouterQuery () { - if (!this.playlistElement || !this.playlistElement.video) return {} - - return { - videoId: this.playlistElement.video.uuid, - start: this.playlistElement.startTimestamp, - stop: this.playlistElement.stopTimestamp, - resume: true - } - } - - isVideoBlur (video: Video) { - return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig) - } - - removeFromPlaylist (playlistElement: VideoPlaylistElement) { - const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined - - this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId) - .subscribe( - () => { - this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName })) - - this.elementRemoved.emit(playlistElement) - }, - - err => this.notifier.error(err.message) - ) - - this.moreDropdown.close() - } - - updateTimestamps (playlistElement: VideoPlaylistElement) { - const body: VideoPlaylistElementUpdate = {} - - body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null - body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null - - this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id) - .subscribe( - () => { - this.notifier.success(this.i18n('Timestamps updated')) - - playlistElement.startTimestamp = body.startTimestamp - playlistElement.stopTimestamp = body.stopTimestamp - - this.cdr.detectChanges() - }, - - err => this.notifier.error(err.message) - ) - - this.moreDropdown.close() - } - - formatTimestamp (playlistElement: VideoPlaylistElement) { - const start = playlistElement.startTimestamp - const stop = playlistElement.stopTimestamp - - const startFormatted = secondsToTime(start, true, ':') - const stopFormatted = secondsToTime(stop, true, ':') - - if (start === null && stop === null) return '' - - if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted - if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted - - return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted - } - - onDropdownOpenChange () { - this.displayTimestampOptions = false - } - - toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) { - event.preventDefault() - - this.displayTimestampOptions = !this.displayTimestampOptions - - if (this.displayTimestampOptions === true) { - this.timestampOptions = { - startTimestampEnabled: false, - stopTimestampEnabled: false, - startTimestamp: 0, - stopTimestamp: playlistElement.video.duration - } - - if (playlistElement.startTimestamp) { - this.timestampOptions.startTimestampEnabled = true - this.timestampOptions.startTimestamp = playlistElement.startTimestamp - } - - if (playlistElement.stopTimestamp) { - this.timestampOptions.stopTimestampEnabled = true - this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp - } - } - - // FIXME: why do we have to use setTimeout here? - setTimeout(() => { - this.cdr.detectChanges() - }) - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist-element.model.ts b/client/src/app/shared/video-playlist/video-playlist-element.model.ts deleted file mode 100644 index f1c46d1eb..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-element.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos' -import { Video } from '@app/shared/video/video.model' - -export class VideoPlaylistElement implements ServerVideoPlaylistElement { - id: number - position: number - startTimestamp: number - stopTimestamp: number - - type: VideoPlaylistElementType - - video?: Video - - constructor (hash: ServerVideoPlaylistElement, translations: {}) { - this.id = hash.id - this.position = hash.position - this.startTimestamp = hash.startTimestamp - this.stopTimestamp = hash.stopTimestamp - - this.type = hash.type - - if (hash.video) this.video = new Video(hash.video, translations) - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html deleted file mode 100644 index 86f6664cb..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html +++ /dev/null @@ -1,34 +0,0 @@ -
    - - - -
    - {playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}} -
    - -
    -
    -
    -
    - -
    - - {{ playlist.displayName }} - - - - {{ playlist.videoChannelBy }} - - -
    - {{ playlist.privacy.label }} - - Updated {{ playlist.updatedAt | myFromNow }} -
    - -
    {{ playlist.description }}
    -
    -
    diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss deleted file mode 100644 index 1b16dbb01..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss +++ /dev/null @@ -1,78 +0,0 @@ -@import '_variables'; -@import '_mixins'; -@import '_miniature'; - -.miniature { - display: inline-block; - - &.no-videos:not(.to-manage){ - a { - cursor: default !important; - } - } - - &.to-manage, - &.no-videos { - .play-overlay { - display: none; - } - } - - .miniature-thumbnail { - @include miniature-thumbnail; - - .miniature-playlist-info-overlay { - @include static-thumbnail-overlay; - - position: absolute; - right: 0; - bottom: 0; - height: $video-thumbnail-height; - padding: 0 10px; - display: flex; - align-items: center; - font-size: 14px; - font-weight: $font-semibold; - } - } - - .miniature-info { - width: 200px; - margin-top: 2px; - line-height: normal; - - .miniature-name { - @include miniature-name; - - @include ellipsis-multiline(1.3em, 2); - - margin: 0; - } - - .by { - @include disable-default-a-behaviour; - - display: block; - color: pvar(--greyForegroundColor); - } - - .privacy-date { - margin-top: 5px; - - .video-info-privacy { - font-size: 14px; - font-weight: $font-semibold; - - &::after { - content: '-'; - margin: 0 3px; - } - } - } - - .video-info-description { - margin-top: 10px; - color: pvar(--greyForegroundColor); - } - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts deleted file mode 100644 index 523e96f2a..000000000 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, Input } from '@angular/core' -import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' - -@Component({ - selector: 'my-video-playlist-miniature', - styleUrls: [ './video-playlist-miniature.component.scss' ], - templateUrl: './video-playlist-miniature.component.html' -}) -export class VideoPlaylistMiniatureComponent { - @Input() playlist: VideoPlaylist - @Input() toManage = false - @Input() displayChannel = false - @Input() displayDescription = false - @Input() displayPrivacy = false - - getPlaylistUrl () { - if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ] - if (this.playlist.videosLength === 0) return null - - return [ '/videos/watch/playlist', this.playlist.uuid ] - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts deleted file mode 100644 index 6f27e7475..000000000 --- a/client/src/app/shared/video-playlist/video-playlist.model.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - VideoChannelSummary, - VideoConstant, - VideoPlaylist as ServerVideoPlaylist, - VideoPlaylistPrivacy, - VideoPlaylistType -} from '../../../../../shared/models/videos' -import { AccountSummary, peertubeTranslate } from '@shared/models' -import { Actor } from '@app/shared/actor/actor.model' -import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' - -export class VideoPlaylist implements ServerVideoPlaylist { - id: number - uuid: string - isLocal: boolean - - displayName: string - description: string - privacy: VideoConstant - - thumbnailPath: string - - videosLength: number - - type: VideoConstant - - createdAt: Date | string - updatedAt: Date | string - - ownerAccount: AccountSummary - videoChannel?: VideoChannelSummary - - thumbnailUrl: string - - ownerBy: string - ownerAvatarUrl: string - - videoChannelBy?: string - videoChannelAvatarUrl?: string - - private thumbnailVersion: number - private originThumbnailUrl: string - - constructor (hash: ServerVideoPlaylist, translations: {}) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - - this.id = hash.id - this.uuid = hash.uuid - this.isLocal = hash.isLocal - - this.displayName = hash.displayName - - this.description = hash.description - this.privacy = hash.privacy - - this.thumbnailPath = hash.thumbnailPath - - if (this.thumbnailPath) { - this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath - this.originThumbnailUrl = this.thumbnailUrl - } else { - this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg' - } - - this.videosLength = hash.videosLength - - this.type = hash.type - - this.createdAt = new Date(hash.createdAt) - this.updatedAt = new Date(hash.updatedAt) - - this.ownerAccount = hash.ownerAccount - this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) - this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) - - if (hash.videoChannel) { - this.videoChannel = hash.videoChannel - this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host) - this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel) - } - - this.privacy.label = peertubeTranslate(this.privacy.label, translations) - - if (this.type.id === VideoPlaylistType.WATCH_LATER) { - this.displayName = peertubeTranslate(this.displayName, translations) - } - } - - refreshThumbnail () { - if (!this.originThumbnailUrl) return - - if (!this.thumbnailVersion) this.thumbnailVersion = 0 - this.thumbnailVersion++ - - this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion - } -} diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts deleted file mode 100644 index 38d915c6b..000000000 --- a/client/src/app/shared/video-playlist/video-playlist.service.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators' -import { Injectable, NgZone } from '@angular/core' -import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' -import { RestExtractor } from '../rest/rest-extractor.service' -import { HttpClient, HttpParams } from '@angular/common/http' -import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared' -import { environment } from '../../../environments/environment' -import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' -import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' -import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' -import { objectToFormData } from '@app/shared/misc/utils' -import { AuthUser, ServerService } from '@app/core' -import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' -import { AccountService } from '@app/shared/account/account.service' -import { Account } from '@app/shared/account/account.model' -import { RestService } from '@app/shared/rest' -import { VideoExistInPlaylist, VideosExistInPlaylists } from '@shared/models/videos/playlist/video-exist-in-playlist.model' -import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model' -import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model' -import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model' -import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' -import { uniq } from 'lodash-es' -import * as debug from 'debug' -import { enterZone, leaveZone } from '@app/shared/rxjs/zone' - -const logger = debug('peertube:playlists:VideoPlaylistService') - -export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string } - -@Injectable() -export class VideoPlaylistService { - static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' - static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/' - - // Use a replay subject because we "next" a value before subscribing - private videoExistsInPlaylistNotifier = new ReplaySubject(1) - private videoExistsInPlaylistCacheSubject = new Subject() - private readonly videoExistsInPlaylistObservable: Observable - - private videoExistsObservableCache: { [ id: number ]: Observable } = {} - private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {} - - private myAccountPlaylistCache: ResultList = undefined - private myAccountPlaylistCacheRunning: Observable> - private myAccountPlaylistCacheSubject = new Subject>() - - constructor ( - private authHttp: HttpClient, - private serverService: ServerService, - private restExtractor: RestExtractor, - private restService: RestService, - private ngZone: NgZone - ) { - this.videoExistsInPlaylistObservable = merge( - this.videoExistsInPlaylistNotifier.pipe( - // We leave Angular zone so Protractor does not get stuck - bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), - filter(videoIds => videoIds.length !== 0), - map(videoIds => uniq(videoIds)), - observeOn(enterZone(this.ngZone, asyncScheduler)), - switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)), - share() - ), - - this.videoExistsInPlaylistCacheSubject - ) - } - - listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable> { - const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - - return this.authHttp.get>(url, { params }) - .pipe( - switchMap(res => this.extractPlaylists(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - listMyPlaylistWithCache (user: AuthUser, search?: string) { - if (!search) { - if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning - if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache) - } - - const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search) - .pipe( - tap(result => { - if (!search) { - this.myAccountPlaylistCacheRunning = undefined - this.myAccountPlaylistCache = result - } - }), - share() - ) - - if (!search) this.myAccountPlaylistCacheRunning = obs - return obs - } - - listAccountPlaylists ( - account: Account, - componentPagination: ComponentPaginationLight, - sort: string, - search?: string - ): Observable> { - const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' - const pagination = componentPagination - ? this.restService.componentPaginationToRestPagination(componentPagination) - : undefined - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - if (search) params = this.restService.addObjectParams(params, { search }) - - return this.authHttp.get>(url, { params }) - .pipe( - switchMap(res => this.extractPlaylists(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideoPlaylist (id: string | number) { - const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id - - return this.authHttp.get(url) - .pipe( - switchMap(res => this.extractPlaylist(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - createVideoPlaylist (body: VideoPlaylistCreate) { - const data = objectToFormData(body) - - return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) - .pipe( - tap(res => { - if (!this.myAccountPlaylistCache) return - - this.myAccountPlaylistCache.total++ - - this.myAccountPlaylistCache.data.push({ - id: res.videoPlaylist.id, - displayName: body.displayName - }) - - this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) { - const data = objectToFormData(body) - - return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - if (!this.myAccountPlaylistCache) return - - const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id) - playlist.displayName = body.displayName - - this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - removeVideoPlaylist (videoPlaylist: VideoPlaylist) { - return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - if (!this.myAccountPlaylistCache) return - - this.myAccountPlaylistCache.total-- - this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data - .filter(p => p.id !== videoPlaylist.id) - - this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) { - const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos' - - return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body) - .pipe( - tap(res => { - const existsResult = this.videoExistsCache[body.videoId] - existsResult.push({ - playlistId, - playlistElementId: res.videoPlaylistElement.id, - startTimestamp: body.startTimestamp, - stopTimestamp: body.stopTimestamp - }) - - this.runPlaylistCheck(body.videoId) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) { - return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - const existsResult = this.videoExistsCache[videoId] - const elem = existsResult.find(e => e.playlistElementId === playlistElementId) - - elem.startTimestamp = body.startTimestamp - elem.stopTimestamp = body.stopTimestamp - - this.runPlaylistCheck(videoId) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) { - return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId) - .pipe( - map(this.restExtractor.extractDataBool), - tap(() => { - if (!videoId) return - - this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId) - this.runPlaylistCheck(videoId) - }), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) { - const body: VideoPlaylistReorder = { - startPosition: oldPosition, - insertAfterPosition: newPosition - } - - return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getPlaylistVideos ( - videoPlaylistId: number | string, - componentPagination: ComponentPaginationLight - ): Observable> { - const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos' - const pagination = this.restService.componentPaginationToRestPagination(componentPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - - return this.authHttp - .get>(path, { params }) - .pipe( - switchMap(res => this.extractVideoPlaylistElements(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - listenToMyAccountPlaylistsChange () { - return this.myAccountPlaylistCacheSubject.asObservable() - } - - listenToVideoPlaylistChange (videoId: number) { - if (this.videoExistsObservableCache[ videoId ]) { - return this.videoExistsObservableCache[ videoId ] - } - - const obs = this.videoExistsInPlaylistObservable - .pipe( - map(existsResult => existsResult[ videoId ]), - filter(r => !!r), - tap(result => this.videoExistsCache[ videoId ] = result) - ) - - this.videoExistsObservableCache[ videoId ] = obs - return obs - } - - runPlaylistCheck (videoId: number) { - logger('Running playlist check.') - - if (this.videoExistsCache[videoId]) { - logger('Found cache for %d.', videoId) - - return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] }) - } - - logger('Fetching from network for %d.', videoId) - return this.videoExistsInPlaylistNotifier.next(videoId) - } - - extractPlaylists (result: ResultList) { - return this.serverService.getServerLocale() - .pipe( - map(translations => { - const playlistsJSON = result.data - const total = result.total - const playlists: VideoPlaylist[] = [] - - for (const playlistJSON of playlistsJSON) { - playlists.push(new VideoPlaylist(playlistJSON, translations)) - } - - return { data: playlists, total } - }) - ) - } - - extractPlaylist (playlist: VideoPlaylistServerModel) { - return this.serverService.getServerLocale() - .pipe(map(translations => new VideoPlaylist(playlist, translations))) - } - - extractVideoPlaylistElements (result: ResultList) { - return this.serverService.getServerLocale() - .pipe( - map(translations => { - const elementsJson = result.data - const total = result.total - const elements: VideoPlaylistElement[] = [] - - for (const elementJson of elementsJson) { - elements.push(new VideoPlaylistElement(elementJson, translations)) - } - - return { total, data: elements } - }) - ) - } - - private doVideosExistInPlaylist (videoIds: number[]): Observable { - const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' - - let params = new HttpParams() - params = this.restService.addObjectParams(params, { videoIds }) - - return this.authHttp.get(url, { params, headers: { ignoreLoadingBar: '' } }) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } -} diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html deleted file mode 100644 index 1e919ee72..000000000 --- a/client/src/app/shared/video/abstract-video-list.html +++ /dev/null @@ -1,49 +0,0 @@ -
    -
    -

    -
    - {{ titlePage }} -
    - -

    - - - -
    - - -
    -
    - -
    No results.
    -
    - -

    - {{ getCurrentGroupedDateLabel(video) }} -

    - -
    - - -
    -
    -
    -
    diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss deleted file mode 100644 index 7f23098aa..000000000 --- a/client/src/app/shared/video/abstract-video-list.scss +++ /dev/null @@ -1,75 +0,0 @@ -@import '_bootstrap-variables'; -@import '_variables'; -@import '_mixins'; -@import '_miniature'; - -.videos-header { - display: flex; - justify-content: space-between; - align-items: baseline; - - .title-page.title-page-single { - display: flex; - - my-feed { - display: inline-block; - top: 1px; - margin-left: 5px; - width: max-content; - opacity: 0; - transition: ease-in .2s opacity; - } - &:hover my-feed { - opacity: 1; - } - } - - .action-block { - a button { - @include peertube-button; - @include grey-button; - @include button-with-icon(18px, 3px, -1px); - } - } - - .moderation-block { - display: flex; - flex-grow: 1; - justify-content: flex-end; - align-items: center; - } -} - -.date-title { - font-size: 16px; - font-weight: $font-semibold; - margin-bottom: 20px; - margin-top: -10px; - - // make the element span a full grid row within .videos grid - grid-column: 1 / -1; - - &:not(:first-child) { - margin-top: .5rem; - padding-top: 20px; - border-top: 1px solid $separator-border-color; - } -} - -.margin-content { - @include fluid-videos-miniature-layout; -} - -@media screen and (max-width: $mobile-view) { - .videos-header { - flex-direction: column; - align-items: center; - height: auto; - margin-bottom: 10px; - - .title-page { - margin-bottom: 10px; - margin-right: 0px; - } - } -} diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts deleted file mode 100644 index 0bc339ff6..000000000 --- a/client/src/app/shared/video/abstract-video-list.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' -import { debounceTime, tap, throttleTime, switchMap } from 'rxjs/operators' -import { OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { Notifier, ServerService } from '@app/core' -import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' -import { GlobalIconName } from '@app/shared/images/global-icon.component' -import { ScreenService } from '@app/shared/misc/screen.service' -import { Syndication } from '@app/shared/video/syndication.model' -import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' -import { ServerConfig } from '@shared/models' -import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' -import { AuthService } from '../../core/auth' -import { LocalStorageService } from '../misc/storage.service' -import { ComponentPaginationLight } from '../rest/component-pagination.model' -import { User, UserService } from '../users' -import { VideoSortField } from './sort-field.type' -import { Video } from './video.model' - -enum GroupDate { - UNKNOWN = 0, - TODAY = 1, - YESTERDAY = 2, - LAST_WEEK = 3, - LAST_MONTH = 4, - OLDER = 5 -} - -export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { - pagination: ComponentPaginationLight = { - currentPage: 1, - itemsPerPage: 25 - } - sort: VideoSortField = '-publishedAt' - - categoryOneOf?: number[] - languageOneOf?: string[] - nsfwPolicy?: NSFWPolicyType - defaultSort: VideoSortField = '-publishedAt' - - syndicationItems: Syndication[] = [] - - loadOnInit = true - useUserVideoPreferences = false - ownerDisplayType: OwnerDisplayType = 'account' - displayModerationBlock = false - titleTooltip: string - displayVideoActions = true - groupByDate = false - - videos: Video[] = [] - hasDoneFirstQuery = false - disabled = false - - displayOptions: MiniatureDisplayOptions = { - date: true, - views: true, - by: true, - avatar: false, - privacyLabel: true, - privacyText: false, - state: false, - blacklistInfo: false - } - - actions: { - routerLink: string - iconName: GlobalIconName - label: string - }[] = [] - - onDataSubject = new Subject() - - userMiniature: User - - protected serverConfig: ServerConfig - - protected abstract notifier: Notifier - protected abstract authService: AuthService - protected abstract userService: UserService - protected abstract route: ActivatedRoute - protected abstract serverService: ServerService - protected abstract screenService: ScreenService - protected abstract storageService: LocalStorageService - protected abstract router: Router - protected abstract i18n: I18n - abstract titlePage: string - - private resizeSubscription: Subscription - private angularState: number - - private groupedDateLabels: { [id in GroupDate]: string } - private groupedDates: { [id: number]: GroupDate } = {} - - private lastQueryLength: number - - abstract getVideosObservable (page: number): Observable<{ data: Video[] }> - - abstract generateSyndicationList (): void - - ngOnInit () { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) - - this.groupedDateLabels = { - [GroupDate.UNKNOWN]: null, - [GroupDate.TODAY]: this.i18n('Today'), - [GroupDate.YESTERDAY]: this.i18n('Yesterday'), - [GroupDate.LAST_WEEK]: this.i18n('Last week'), - [GroupDate.LAST_MONTH]: this.i18n('Last month'), - [GroupDate.OLDER]: this.i18n('Older') - } - - // Subscribe to route changes - const routeParams = this.route.snapshot.queryParams - this.loadRouteParams(routeParams) - - this.resizeSubscription = fromEvent(window, 'resize') - .pipe(debounceTime(500)) - .subscribe(() => this.calcPageSizes()) - - this.calcPageSizes() - - const loadUserObservable = this.loadUserAndSettings() - - if (this.loadOnInit === true) { - loadUserObservable.subscribe(() => this.loadMoreVideos()) - } - - this.userService.listenAnonymousUpdate() - .pipe(switchMap(() => this.loadUserAndSettings())) - .subscribe(() => { - if (this.hasDoneFirstQuery) this.reloadVideos() - }) - - // Display avatar in mobile view - if (this.screenService.isInMobileView()) { - this.displayOptions.avatar = true - } - } - - ngOnDestroy () { - if (this.resizeSubscription) this.resizeSubscription.unsubscribe() - } - - disableForReuse () { - this.disabled = true - } - - enabledForReuse () { - this.disabled = false - } - - videoById (index: number, video: Video) { - return video.id - } - - onNearOfBottom () { - if (this.disabled) return - - // No more results - if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return - - this.pagination.currentPage += 1 - - this.setScrollRouteParams() - - this.loadMoreVideos() - } - - loadMoreVideos (reset = false) { - this.getVideosObservable(this.pagination.currentPage).subscribe( - ({ data }) => { - this.hasDoneFirstQuery = true - this.lastQueryLength = data.length - - if (reset) this.videos = [] - this.videos = this.videos.concat(data) - - if (this.groupByDate) this.buildGroupedDateLabels() - - this.onMoreVideos() - - this.onDataSubject.next(data) - }, - - error => { - const message = this.i18n('Cannot load more videos. Try again later.') - - console.error(message, { error }) - this.notifier.error(message) - } - ) - } - - reloadVideos () { - this.pagination.currentPage = 1 - this.loadMoreVideos(true) - } - - toggleModerationDisplay () { - throw new Error('toggleModerationDisplay is not implemented') - } - - removeVideoFromArray (video: Video) { - this.videos = this.videos.filter(v => v.id !== video.id) - } - - buildGroupedDateLabels () { - let currentGroupedDate: GroupDate = GroupDate.UNKNOWN - - for (const video of this.videos) { - const publishedDate = video.publishedAt - - if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) { - if (currentGroupedDate === GroupDate.TODAY) continue - - currentGroupedDate = GroupDate.TODAY - this.groupedDates[ video.id ] = currentGroupedDate - continue - } - - if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) { - if (currentGroupedDate === GroupDate.YESTERDAY) continue - - currentGroupedDate = GroupDate.YESTERDAY - this.groupedDates[ video.id ] = currentGroupedDate - continue - } - - if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) { - if (currentGroupedDate === GroupDate.LAST_WEEK) continue - - currentGroupedDate = GroupDate.LAST_WEEK - this.groupedDates[ video.id ] = currentGroupedDate - continue - } - - if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) { - if (currentGroupedDate === GroupDate.LAST_MONTH) continue - - currentGroupedDate = GroupDate.LAST_MONTH - this.groupedDates[ video.id ] = currentGroupedDate - continue - } - - if (currentGroupedDate <= GroupDate.OLDER) { - if (currentGroupedDate === GroupDate.OLDER) continue - - currentGroupedDate = GroupDate.OLDER - this.groupedDates[ video.id ] = currentGroupedDate - } - } - } - - getCurrentGroupedDateLabel (video: Video) { - if (this.groupByDate === false) return undefined - - return this.groupedDateLabels[this.groupedDates[video.id]] - } - - // On videos hook for children that want to do something - protected onMoreVideos () { /* empty */ } - - protected loadRouteParams (routeParams: { [ key: string ]: any }) { - this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort - this.categoryOneOf = routeParams[ 'categoryOneOf' ] - this.angularState = routeParams[ 'a-state' ] - } - - private calcPageSizes () { - if (this.screenService.isInMobileView()) { - this.pagination.itemsPerPage = 5 - } - } - - private setScrollRouteParams () { - // Already set - if (this.angularState) return - - this.angularState = 42 - - const queryParams = { - 'a-state': this.angularState, - categoryOneOf: this.categoryOneOf - } - - let path = this.router.url - if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute - - this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) - } - - private loadUserAndSettings () { - return this.userService.getAnonymousOrLoggedUser() - .pipe(tap(user => { - this.userMiniature = user - - if (!this.useUserVideoPreferences) return - - this.languageOneOf = user.videoLanguages - this.nsfwPolicy = user.nsfwPolicy - })) - } -} diff --git a/client/src/app/shared/video/feed.component.html b/client/src/app/shared/video/feed.component.html deleted file mode 100644 index ac0b1f454..000000000 --- a/client/src/app/shared/video/feed.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
    - - - - - {{ item.label }} - -
    diff --git a/client/src/app/shared/video/feed.component.scss b/client/src/app/shared/video/feed.component.scss deleted file mode 100644 index 34dd0e937..000000000 --- a/client/src/app/shared/video/feed.component.scss +++ /dev/null @@ -1,20 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.video-feed { - width: min-content; - - a { - color: black; - display: block; - } - - my-global-icon { - cursor: pointer; - width: 12px; - position: relative; - top: -2px; - - @include apply-svg-color(pvar(--mainForegroundColor)) - } -} diff --git a/client/src/app/shared/video/feed.component.ts b/client/src/app/shared/video/feed.component.ts deleted file mode 100644 index 12507458f..000000000 --- a/client/src/app/shared/video/feed.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component, Input } from '@angular/core' -import { Syndication } from '@app/shared/video/syndication.model' - -@Component({ - selector: 'my-feed', - styleUrls: [ './feed.component.scss' ], - templateUrl: './feed.component.html' -}) -export class FeedComponent { - @Input() syndicationItems: Syndication[] -} diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts deleted file mode 100644 index f09c3d1fc..000000000 --- a/client/src/app/shared/video/infinite-scroller.directive.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' -import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' -import { fromEvent, Observable, Subscription } from 'rxjs' - -@Directive({ - selector: '[myInfiniteScroller]' -}) -export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked { - @Input() percentLimit = 70 - @Input() autoInit = false - @Input() onItself = false - @Input() dataObservable: Observable - - @Output() nearOfBottom = new EventEmitter() - - private decimalLimit = 0 - private lastCurrentBottom = -1 - private scrollDownSub: Subscription - private container: HTMLElement - - private checkScroll = false - - constructor (private el: ElementRef) { - this.decimalLimit = this.percentLimit / 100 - } - - ngAfterContentChecked () { - if (this.checkScroll) { - this.checkScroll = false - - console.log('Checking if the initial state has a scroll.') - - if (this.hasScroll() === false) this.nearOfBottom.emit() - } - } - - ngOnInit () { - if (this.autoInit === true) return this.initialize() - } - - ngOnDestroy () { - if (this.scrollDownSub) this.scrollDownSub.unsubscribe() - } - - initialize () { - this.container = this.onItself - ? this.el.nativeElement - : document.documentElement - - // Emit the last value - const throttleOptions = { leading: true, trailing: true } - - const scrollableElement = this.onItself ? this.container : window - const scrollObservable = fromEvent(scrollableElement, 'scroll') - .pipe( - startWith(true), - throttleTime(200, undefined, throttleOptions), - map(() => this.getScrollInfo()), - distinctUntilChanged((o1, o2) => o1.current === o2.current), - share() - ) - - // Scroll Down - this.scrollDownSub = scrollObservable - .pipe( - filter(({ current }) => this.isScrollingDown(current)), - filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) - ) - .subscribe(() => this.nearOfBottom.emit()) - - if (this.dataObservable) { - this.dataObservable - .pipe(filter(d => d.length !== 0)) - .subscribe(() => this.checkScroll = true) - } - } - - private getScrollInfo () { - return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() } - } - - private getMaximumScroll () { - return this.container.scrollHeight - window.innerHeight - } - - private hasScroll () { - return this.getMaximumScroll() > 0 - } - - private isScrollingDown (current: number) { - const result = this.lastCurrentBottom < current - - this.lastCurrentBottom = current - return result - } -} diff --git a/client/src/app/shared/video/modals/video-block.component.html b/client/src/app/shared/video/modals/video-block.component.html deleted file mode 100644 index 5e73d66c5..000000000 --- a/client/src/app/shared/video/modals/video-block.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/client/src/app/shared/video/modals/video-block.component.scss b/client/src/app/shared/video/modals/video-block.component.scss deleted file mode 100644 index afcdb9a16..000000000 --- a/client/src/app/shared/video/modals/video-block.component.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -textarea { - @include peertube-textarea(100%, 100px); -} diff --git a/client/src/app/shared/video/modals/video-block.component.ts b/client/src/app/shared/video/modals/video-block.component.ts deleted file mode 100644 index 1a25e0578..000000000 --- a/client/src/app/shared/video/modals/video-block.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' -import { Notifier, RedirectService } from '@app/core' -import { VideoBlockService } from '../../video-block' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { FormReactive, VideoBlockValidatorsService } from '@app/shared/forms' -import { Video } from '@app/shared/video/video.model' - -@Component({ - selector: 'my-video-block', - templateUrl: './video-block.component.html', - styleUrls: [ './video-block.component.scss' ] -}) -export class VideoBlockComponent extends FormReactive implements OnInit { - @Input() video: Video = null - - @ViewChild('modal', { static: true }) modal: NgbModal - - @Output() videoBlocked = new EventEmitter() - - error: string = null - - private openedModal: NgbModalRef - - constructor ( - protected formValidatorService: FormValidatorService, - private modalService: NgbModal, - private videoBlockValidatorsService: VideoBlockValidatorsService, - private videoBlocklistService: VideoBlockService, - private notifier: Notifier, - private i18n: I18n - ) { - super() - } - - ngOnInit () { - const defaultValues = { unfederate: 'true' } - - this.buildForm({ - reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON, - unfederate: null - }, defaultValues) - } - - show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) - } - - hide () { - this.openedModal.close() - this.openedModal = null - } - - block () { - const reason = this.form.value[ 'reason' ] || undefined - const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined - - this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate) - .subscribe( - () => { - this.notifier.success(this.i18n('Video blocked.')) - this.hide() - - this.video.blacklisted = true - this.video.blockedReason = reason - - this.videoBlocked.emit() - }, - - err => this.notifier.error(err.message) - ) - } -} diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html deleted file mode 100644 index c65e371ee..000000000 --- a/client/src/app/shared/video/modals/video-download.component.html +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss deleted file mode 100644 index b09078bea..000000000 --- a/client/src/app/shared/video/modals/video-download.component.scss +++ /dev/null @@ -1,64 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -.peertube-select-container { - @include peertube-select-container(100px); - - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: none; - - select { - height: inherit; - } -} - -#dropdownDownloadType { - cursor: pointer; -} - -.download-type { - margin-top: 30px; - - .peertube-radio-container { - @include peertube-radio-container; - - display: inline-block; - margin-right: 30px; - } -} - -.file-metadata { - padding: 1rem; -} - -.file-metadata .metadata-attribute { - font-size: 13px; - display: block; - margin-bottom: 12px; - - .metadata-attribute-label { - min-width: 142px; - padding-right: 5px; - display: inline-block; - color: pvar(--greyForegroundColor); - font-weight: $font-bold; - } - - a.metadata-attribute-value { - @include disable-default-a-behaviour; - color: pvar(--mainForegroundColor); - - &:hover { - opacity: 0.9; - } - } - - &.metadata-attribute-tags { - .metadata-attribute-value:not(:nth-child(2)) { - &::before { - content: ', ' - } - } - } -} diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts deleted file mode 100644 index d77187821..000000000 --- a/client/src/app/shared/video/modals/video-download.component.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Component, ElementRef, ViewChild } from '@angular/core' -import { VideoDetails } from '../../../shared/video/video-details.model' -import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { AuthService, Notifier } from '@app/core' -import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' -import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' -import { mapValues, pick } from 'lodash-es' -import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' -import { BytesPipe } from 'ngx-pipes' -import { VideoService } from '../video.service' - -type DownloadType = 'video' | 'subtitles' -type FileMetadata = { [key: string]: { label: string, value: string }} - -@Component({ - selector: 'my-video-download', - templateUrl: './video-download.component.html', - styleUrls: [ './video-download.component.scss' ] -}) -export class VideoDownloadComponent { - @ViewChild('modal', { static: true }) modal: ElementRef - - downloadType: 'direct' | 'torrent' = 'torrent' - resolutionId: number | string = -1 - subtitleLanguageId: string - - video: VideoDetails - videoFile: VideoFile - videoFileMetadataFormat: FileMetadata - videoFileMetadataVideoStream: FileMetadata | undefined - videoFileMetadataAudioStream: FileMetadata | undefined - videoCaptions: VideoCaption[] - activeModal: NgbActiveModal - - type: DownloadType = 'video' - - private bytesPipe: BytesPipe - private numbersPipe: NumberFormatterPipe - - constructor ( - private notifier: Notifier, - private modalService: NgbModal, - private videoService: VideoService, - private auth: AuthService, - private i18n: I18n - ) { - this.bytesPipe = new BytesPipe() - this.numbersPipe = new NumberFormatterPipe() - } - - get typeText () { - return this.type === 'video' - ? this.i18n('video') - : this.i18n('subtitles') - } - - getVideoFiles () { - if (!this.video) return [] - - return this.video.getFiles() - } - - show (video: VideoDetails, videoCaptions?: VideoCaption[]) { - this.video = video - this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined - - this.activeModal = this.modalService.open(this.modal, { centered: true }) - - this.resolutionId = this.getVideoFiles()[0].resolution.id - this.onResolutionIdChange() - if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id - } - - onClose () { - this.video = undefined - this.videoCaptions = undefined - } - - download () { - window.location.assign(this.getLink()) - this.activeModal.close() - } - - getLink () { - return this.type === 'subtitles' && this.videoCaptions - ? this.getSubtitlesLink() - : this.getVideoFileLink() - } - - async onResolutionIdChange () { - this.videoFile = this.getVideoFile() - if (this.videoFile.metadata || !this.videoFile.metadataUrl) return - - await this.hydrateMetadataFromMetadataUrl(this.videoFile) - - this.videoFileMetadataFormat = this.videoFile - ? this.getMetadataFormat(this.videoFile.metadata.format) - : undefined - this.videoFileMetadataVideoStream = this.videoFile - ? this.getMetadataStream(this.videoFile.metadata.streams, 'video') - : undefined - this.videoFileMetadataAudioStream = this.videoFile - ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio') - : undefined - } - - getVideoFile () { - // HTML select send us a string, so convert it to a number - this.resolutionId = parseInt(this.resolutionId.toString(), 10) - - const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId) - if (!file) { - console.error('Could not find file with resolution %d.', this.resolutionId) - return - } - return file - } - - getVideoFileLink () { - const file = this.videoFile - if (!file) return - - const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL - ? '?access_token=' + this.auth.getAccessToken() - : '' - - switch (this.downloadType) { - case 'direct': - return file.fileDownloadUrl + suffix - - case 'torrent': - return file.torrentDownloadUrl + suffix - } - } - - getSubtitlesLink () { - return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath - } - - activateCopiedMessage () { - this.notifier.success(this.i18n('Copied')) - } - - switchToType (type: DownloadType) { - this.type = type - } - - getMetadataFormat (format: FfprobeFormat) { - const keyToTranslateFunction = { - 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }), - 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }), - 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }), - 'bit_rate': (value: number) => ({ - label: this.i18n('Bitrate'), - value: `${this.numbersPipe.transform(value)}bps` - }) - } - - // flattening format - const sanitizedFormat = Object.assign(format, format.tags) - delete sanitizedFormat.tags - - return mapValues( - pick(sanitizedFormat, Object.keys(keyToTranslateFunction)), - (val, key) => keyToTranslateFunction[key](val) - ) - } - - getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') { - const stream = streams.find(s => s.codec_type === type) - if (!stream) return undefined - - let keyToTranslateFunction = { - 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }), - 'profile': (value: string) => ({ label: this.i18n('Profile'), value }), - 'bit_rate': (value: number) => ({ - label: this.i18n('Bitrate'), - value: `${this.numbersPipe.transform(value)}bps` - }) - } - - if (type === 'video') { - keyToTranslateFunction = Object.assign(keyToTranslateFunction, { - 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }), - 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }), - 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }), - 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value }) - }) - } else { - keyToTranslateFunction = Object.assign(keyToTranslateFunction, { - 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }), - 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value }) - }) - } - - return mapValues( - pick(stream, Object.keys(keyToTranslateFunction)), - (val, key) => keyToTranslateFunction[key](val) - ) - } - - private hydrateMetadataFromMetadataUrl (file: VideoFile) { - const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) - observable.subscribe(res => file.metadata = res) - return observable.toPromise() - } -} diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html deleted file mode 100644 index d6beb6d2a..000000000 --- a/client/src/app/shared/video/modals/video-report.component.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss deleted file mode 100644 index b2606cbd8..000000000 --- a/client/src/app/shared/video/modals/video-report.component.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import 'variables'; -@import 'mixins'; - -.information { - margin-bottom: 20px; -} - -textarea { - @include peertube-textarea(100%, 100px); -} - -.start-at, -.stop-at { - width: 300px; - display: flex; - align-items: center; - - my-timestamp-input { - margin-left: 10px; - } -} - -.screenratio { - @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { - left: 0; - }; -} diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts deleted file mode 100644 index c2d441bba..000000000 --- a/client/src/app/shared/video/modals/video-report.component.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Component, Input, OnInit, ViewChild } from '@angular/core' -import { Notifier } from '@app/core' -import { FormReactive } from '../../../shared/forms' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' -import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { VideoAbuseService } from '@app/shared/video-abuse' -import { Video } from '@app/shared/video/video.model' -import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' -import { DomSanitizer, SafeHtml } from '@angular/platform-browser' -import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' -import { mapValues, pickBy } from 'lodash-es' - -@Component({ - selector: 'my-video-report', - templateUrl: './video-report.component.html', - styleUrls: [ './video-report.component.scss' ] -}) -export class VideoReportComponent extends FormReactive implements OnInit { - @Input() video: Video = null - - @ViewChild('modal', { static: true }) modal: NgbModal - - error: string = null - predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] - embedHtml: SafeHtml - - private openedModal: NgbModalRef - - constructor ( - protected formValidatorService: FormValidatorService, - private modalService: NgbModal, - private videoAbuseValidatorsService: VideoAbuseValidatorsService, - private videoAbuseService: VideoAbuseService, - private notifier: Notifier, - private sanitizer: DomSanitizer, - private i18n: I18n - ) { - super() - } - - get currentHost () { - return window.location.host - } - - get originHost () { - if (this.isRemoteVideo()) { - return this.video.account.host - } - - return '' - } - - get timestamp () { - return this.form.get('timestamp').value - } - - getVideoEmbed () { - return this.sanitizer.bypassSecurityTrustHtml( - buildVideoEmbed( - buildVideoLink({ - baseUrl: this.video.embedUrl, - title: false, - warningTitle: false - }) - ) - ) - } - - ngOnInit () { - this.buildForm({ - reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, - predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), - timestamp: { - hasStart: null, - startAt: null, - hasEnd: null, - endAt: null - } - }) - - this.predefinedReasons = [ - { - id: 'violentOrRepulsive', - label: this.i18n('Violent or repulsive'), - help: this.i18n('Contains offensive, violent, or coarse language or iconography.') - }, - { - id: 'hatefulOrAbusive', - label: this.i18n('Hateful or abusive'), - help: this.i18n('Contains abusive, racist or sexist language or iconography.') - }, - { - id: 'spamOrMisleading', - label: this.i18n('Spam, ad or false news'), - help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.') - }, - { - id: 'privacy', - label: this.i18n('Privacy breach or doxxing'), - help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).') - }, - { - id: 'rights', - label: this.i18n('Intellectual property violation'), - help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.') - }, - { - id: 'serverRules', - label: this.i18n('Breaks server rules'), - description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.') - }, - { - id: 'thumbnails', - label: this.i18n('Thumbnails'), - help: this.i18n('The above can only be seen in thumbnails.') - }, - { - id: 'captions', - label: this.i18n('Captions'), - help: this.i18n('The above can only be seen in captions (please describe which).') - } - ] - - this.embedHtml = this.getVideoEmbed() - } - - show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) - } - - hide () { - this.openedModal.close() - this.openedModal = null - } - - report () { - const reason = this.form.get('reason').value - const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] - const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value - - this.videoAbuseService.reportVideo({ - id: this.video.id, - reason, - predefinedReasons, - startAt: hasStart && startAt ? startAt : undefined, - endAt: hasEnd && endAt ? endAt : undefined - }).subscribe( - () => { - this.notifier.success(this.i18n('Video reported.')) - this.hide() - }, - - err => this.notifier.error(err.message) - ) - } - - isRemoteVideo () { - return !this.video.isLocal - } -} diff --git a/client/src/app/shared/video/recommendation-info.model.ts b/client/src/app/shared/video/recommendation-info.model.ts deleted file mode 100644 index 0233563bb..000000000 --- a/client/src/app/shared/video/recommendation-info.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RecommendationInfo { - uuid: string - tags?: string[] -} diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts deleted file mode 100644 index fb918d73b..000000000 --- a/client/src/app/shared/video/redundancy.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { catchError, map, toArray } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { RestExtractor, RestPagination, RestService } from '@app/shared/rest' -import { SortMeta } from 'primeng/api' -import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' -import { concat, Observable } from 'rxjs' -import { environment } from '../../../environments/environment' - -@Injectable() -export class RedundancyService { - static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy' - - constructor ( - private authHttp: HttpClient, - private restService: RestService, - private restExtractor: RestExtractor - ) { } - - updateRedundancy (host: string, redundancyAllowed: boolean) { - const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host - - const body = { redundancyAllowed } - - return this.authHttp.put(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - listVideoRedundancies (options: { - pagination: RestPagination, - sort: SortMeta, - target?: VideoRedundanciesTarget - }): Observable> { - const { pagination, sort, target } = options - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (target) params = params.append('target', target) - - return this.authHttp.get>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params }) - .pipe( - catchError(res => this.restExtractor.handleError(res)) - ) - } - - addVideoRedundancy (video: Video) { - return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id }) - .pipe( - catchError(res => this.restExtractor.handleError(res)) - ) - } - - removeVideoRedundancies (redundancy: VideoRedundancy) { - const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id) - .concat(redundancy.redundancies.files.map(r => r.id)) - .map(id => this.removeRedundancy(id)) - - return concat(...observables) - .pipe(toArray()) - } - - private removeRedundancy (redundancyId: number) { - return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(res => this.restExtractor.handleError(res)) - ) - } -} diff --git a/client/src/app/shared/video/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts deleted file mode 100644 index 65b24d946..000000000 --- a/client/src/app/shared/video/sort-field.type.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type VideoSortField = 'name' | '-name' - | 'duration' | '-duration' - | 'publishedAt' | '-publishedAt' - | 'createdAt' | '-createdAt' - | 'views' | '-views' - | 'likes' | '-likes' - | 'trending' | '-trending' - -export type CommentSortField = 'createdAt' | '-createdAt' - | 'totalReplies' | '-totalReplies' diff --git a/client/src/app/shared/video/syndication.model.ts b/client/src/app/shared/video/syndication.model.ts deleted file mode 100644 index c59ab01e8..000000000 --- a/client/src/app/shared/video/syndication.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' - -export interface Syndication { - format: FeedFormat, - label: string, - url: string -} diff --git a/client/src/app/shared/video/video-actions-dropdown.component.html b/client/src/app/shared/video/video-actions-dropdown.component.html deleted file mode 100644 index 3c8271b65..000000000 --- a/client/src/app/shared/video/video-actions-dropdown.component.html +++ /dev/null @@ -1,21 +0,0 @@ - - -
    - - -
    - -
    -
    - - - - - - -
    diff --git a/client/src/app/shared/video/video-actions-dropdown.component.scss b/client/src/app/shared/video/video-actions-dropdown.component.scss deleted file mode 100644 index 67d7ee86a..000000000 --- a/client/src/app/shared/video/video-actions-dropdown.component.scss +++ /dev/null @@ -1,12 +0,0 @@ -.playlist-dropdown { - position: absolute; - - .anchor { - display: block; - opacity: 0; - } -} - -::ng-deep .icon-playlist-add { - left: 2px; -} diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts deleted file mode 100644 index 1f5763610..000000000 --- a/client/src/app/shared/video/video-actions-dropdown.component.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' -import { AuthService, ConfirmService, Notifier } from '@app/core' -import { Video } from '@app/shared/video/video.model' -import { VideoService } from '@app/shared/video/video.service' -import { VideoDetails } from '@app/shared/video/video-details.model' -import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' -import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' -import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' -import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' -import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component' -import { VideoBlockService } from '@app/shared/video-block' -import { ScreenService } from '@app/shared/misc/screen.service' -import { VideoCaption } from '@shared/models' -import { RedundancyService } from '@app/shared/video/redundancy.service' - -export type VideoActionsDisplayType = { - playlist?: boolean - download?: boolean - update?: boolean - blacklist?: boolean - delete?: boolean - report?: boolean - duplicate?: boolean -} - -@Component({ - selector: 'my-video-actions-dropdown', - templateUrl: './video-actions-dropdown.component.html', - styleUrls: [ './video-actions-dropdown.component.scss' ] -}) -export class VideoActionsDropdownComponent implements OnChanges { - @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown - @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent - - @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent - @ViewChild('videoReportModal') videoReportModal: VideoReportComponent - @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent - - @Input() video: Video | VideoDetails - @Input() videoCaptions: VideoCaption[] = [] - - @Input() displayOptions: VideoActionsDisplayType = { - playlist: false, - download: true, - update: true, - blacklist: true, - delete: true, - report: true, - duplicate: true - } - @Input() placement = 'left' - - @Input() label: string - - @Input() buttonStyled = false - @Input() buttonSize: DropdownButtonSize = 'normal' - @Input() buttonDirection: DropdownDirection = 'vertical' - - @Output() videoRemoved = new EventEmitter() - @Output() videoUnblocked = new EventEmitter() - @Output() videoBlocked = new EventEmitter() - @Output() modalOpened = new EventEmitter() - - videoActions: DropdownAction<{ video: Video }>[][] = [] - - private loaded = false - - constructor ( - private authService: AuthService, - private notifier: Notifier, - private confirmService: ConfirmService, - private videoBlocklistService: VideoBlockService, - private screenService: ScreenService, - private videoService: VideoService, - private redundancyService: RedundancyService, - private i18n: I18n - ) { } - - get user () { - return this.authService.getUser() - } - - ngOnChanges () { - if (this.loaded) { - this.loaded = false - this.playlistAdd.reload() - } - - this.buildActions() - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - loadDropdownInformation () { - if (!this.isUserLoggedIn() || this.loaded === true) return - - this.loaded = true - - if (this.displayOptions.playlist) this.playlistAdd.load() - } - - /* Show modals */ - - showDownloadModal () { - this.modalOpened.emit() - - this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions) - } - - showReportModal () { - this.modalOpened.emit() - - this.videoReportModal.show() - } - - showBlockModal () { - this.modalOpened.emit() - - this.videoBlockModal.show() - } - - /* Actions checker */ - - isVideoUpdatable () { - return this.video.isUpdatableBy(this.user) - } - - isVideoRemovable () { - return this.video.isRemovableBy(this.user) - } - - isVideoBlockable () { - return this.video.isBlockableBy(this.user) - } - - isVideoUnblockable () { - return this.video.isUnblockableBy(this.user) - } - - isVideoDownloadable () { - return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled - } - - canVideoBeDuplicated () { - return this.video.canBeDuplicatedBy(this.user) - } - - /* Action handlers */ - - async unblockVideo () { - const confirmMessage = this.i18n( - 'Do you really want to unblock this video? It will be available again in the videos list.' - ) - - const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock')) - if (res === false) return - - this.videoBlocklistService.unblockVideo(this.video.id).subscribe( - () => { - this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name })) - - this.video.blacklisted = false - this.video.blockedReason = null - - this.videoUnblocked.emit() - }, - - err => this.notifier.error(err.message) - ) - } - - async removeVideo () { - this.modalOpened.emit() - - const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete')) - if (res === false) return - - this.videoService.removeVideo(this.video.id) - .subscribe( - () => { - this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name })) - - this.videoRemoved.emit() - }, - - error => this.notifier.error(error.message) - ) - } - - duplicateVideo () { - this.redundancyService.addVideoRedundancy(this.video) - .subscribe( - () => { - const message = this.i18n('This video will be duplicated by your instance.') - this.notifier.success(message) - }, - - err => this.notifier.error(err.message) - ) - } - - onVideoBlocked () { - this.videoBlocked.emit() - } - - getPlaylistDropdownPlacement () { - if (this.screenService.isInSmallView()) { - return 'bottom-right' - } - - return 'bottom-left bottom-right' - } - - private buildActions () { - this.videoActions = [ - [ - { - label: this.i18n('Save to playlist'), - handler: () => this.playlistDropdown.toggle(), - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist, - iconName: 'playlist-add' - } - ], - [ - { - label: this.i18n('Download'), - handler: () => this.showDownloadModal(), - isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(), - iconName: 'download' - }, - { - label: this.i18n('Update'), - linkBuilder: ({ video }) => [ '/videos/update', video.uuid ], - iconName: 'edit', - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable() - }, - { - label: this.i18n('Block'), - handler: () => this.showBlockModal(), - iconName: 'no', - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable() - }, - { - label: this.i18n('Unblock'), - handler: () => this.unblockVideo(), - iconName: 'undo', - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable() - }, - { - label: this.i18n('Mirror'), - handler: () => this.duplicateVideo(), - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(), - iconName: 'cloud-download' - }, - { - label: this.i18n('Delete'), - handler: () => this.removeVideo(), - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), - iconName: 'delete' - } - ], - [ - { - label: this.i18n('Report'), - handler: () => this.showReportModal(), - isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report, - iconName: 'alert' - } - ] - ] - } -} diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts deleted file mode 100644 index 14347a109..000000000 --- a/client/src/app/shared/video/video-details.model.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared' -import { Video } from '../../shared/video/video.model' -import { Account } from '@app/shared/account/account.model' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model' -import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type' - -export class VideoDetails extends Video implements VideoDetailsServerModel { - descriptionPath: string - support: string - channel: VideoChannel - tags: string[] - files: VideoFile[] - account: Account - commentsEnabled: boolean - downloadEnabled: boolean - - waitTranscoding: boolean - state: VideoConstant - - likesPercent: number - dislikesPercent: number - - trackerUrls: string[] - - streamingPlaylists: VideoStreamingPlaylist[] - - constructor (hash: VideoDetailsServerModel, translations = {}) { - super(hash, translations) - - this.descriptionPath = hash.descriptionPath - this.files = hash.files - this.channel = new VideoChannel(hash.channel) - this.account = new Account(hash.account) - this.tags = hash.tags - this.support = hash.support - this.commentsEnabled = hash.commentsEnabled - this.downloadEnabled = hash.downloadEnabled - - this.trackerUrls = hash.trackerUrls - this.streamingPlaylists = hash.streamingPlaylists - - this.buildLikeAndDislikePercents() - } - - buildLikeAndDislikePercents () { - this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 - this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 - } - - getHlsPlaylist () { - return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) - } - - hasHlsPlaylist () { - return !!this.getHlsPlaylist() - } - - getFiles () { - if (this.files.length === 0) return this.getHlsPlaylist().files - - return this.files - } -} diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts deleted file mode 100644 index 67d8e7711..000000000 --- a/client/src/app/shared/video/video-edit.model.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum' -import { VideoUpdate } from '../../../../../shared/models/videos' -import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' -import { Video } from '../../../../../shared/models/videos/video.model' - -export class VideoEdit implements VideoUpdate { - static readonly SPECIAL_SCHEDULED_PRIVACY = -1 - - category: number - licence: number - language: string - description: string - name: string - tags: string[] - nsfw: boolean - commentsEnabled: boolean - downloadEnabled: boolean - waitTranscoding: boolean - channelId: number - privacy: VideoPrivacy - support: string - thumbnailfile?: any - previewfile?: any - thumbnailUrl: string - previewUrl: string - uuid?: string - id?: number - scheduleUpdate?: VideoScheduleUpdate - originallyPublishedAt?: Date | string - - constructor ( - video?: Video & { - tags: string[], - commentsEnabled: boolean, - downloadEnabled: boolean, - support: string, - thumbnailUrl: string, - previewUrl: string - }) { - if (video) { - this.id = video.id - this.uuid = video.uuid - this.category = video.category.id - this.licence = video.licence.id - this.language = video.language.id - this.description = video.description - this.name = video.name - this.tags = video.tags - this.nsfw = video.nsfw - this.commentsEnabled = video.commentsEnabled - this.downloadEnabled = video.downloadEnabled - this.waitTranscoding = video.waitTranscoding - this.channelId = video.channel.id - this.privacy = video.privacy.id - this.support = video.support - this.thumbnailUrl = video.thumbnailUrl - this.previewUrl = video.previewUrl - - this.scheduleUpdate = video.scheduledUpdate - this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null - } - } - - patch (values: { [ id: string ]: string }) { - Object.keys(values).forEach((key) => { - this[ key ] = values[ key ] - }) - - // If schedule publication, the video is private and will be changed to public privacy - if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) { - const updateAt = new Date(values['schedulePublicationAt']) - updateAt.setSeconds(0) - - this.privacy = VideoPrivacy.PRIVATE - this.scheduleUpdate = { - updateAt: updateAt.toISOString(), - privacy: VideoPrivacy.PUBLIC - } - } else { - this.scheduleUpdate = null - } - - // Convert originallyPublishedAt to string so that function objectToFormData() works correctly - if (this.originallyPublishedAt) { - const originallyPublishedAt = new Date(values['originallyPublishedAt']) - this.originallyPublishedAt = originallyPublishedAt.toISOString() - } - - // Use the same file than the preview for the thumbnail - if (this.previewfile) { - this.thumbnailfile = this.previewfile - } - } - - toFormPatch () { - const json = { - category: this.category, - licence: this.licence, - language: this.language, - description: this.description, - support: this.support, - name: this.name, - tags: this.tags, - nsfw: this.nsfw, - commentsEnabled: this.commentsEnabled, - downloadEnabled: this.downloadEnabled, - waitTranscoding: this.waitTranscoding, - channelId: this.channelId, - privacy: this.privacy, - originallyPublishedAt: this.originallyPublishedAt - } - - // Special case if we scheduled an update - if (this.scheduleUpdate) { - Object.assign(json, { - privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY, - schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString()) - }) - } - - return json - } -} diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html deleted file mode 100644 index 82afc866f..000000000 --- a/client/src/app/shared/video/video-miniature.component.html +++ /dev/null @@ -1,66 +0,0 @@ -
    - - Unlisted - Private - - -
    -
    -
    - - - - -
    - {{ video.name }} - - - - - - - {video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}} - - - - - - {{ video.byVideoChannel }} - - -
    - {{ video.privacy.label }} - - - {{ getStateLabel(video) }} -
    -
    -
    - -
    - Blocked - {{ video.blockedReason }} -
    - -
    - Sensitive -
    -
    - -
    - - -
    -
    -
    diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss deleted file mode 100644 index 38cac5b6e..000000000 --- a/client/src/app/shared/video/video-miniature.component.scss +++ /dev/null @@ -1,200 +0,0 @@ -@import '_variables'; -@import '_mixins'; -@import '_miniature'; - -$more-button-width: 40px; -$more-margin-right: 15px; - -.video-miniature { - display: inline-flex; - flex-direction: column; - padding-bottom: $video-miniature-margin-bottom; - vertical-align: top; - - .video-bottom { - display: flex; - - .video-miniature-information { - width: $video-miniature-width - $more-button-width - $more-margin-right; - line-height: normal; - - .avatar { - margin: 10px 10px 0 0; - - img { - @include avatar(40px); - } - } - - .video-miniature-name { - @include miniature-name; - width: calc(100% - #{$more-button-width}); - } - - .video-miniature-meta { - width: calc(100% + #{$more-button-width}); - overflow: hidden; - } - - .video-miniature-created-at-views { - display: block; - font-size: 13px; - } - - .video-miniature-account, - .video-miniature-channel { - @include disable-default-a-behaviour; - @include ellipsis; - - display: block; - font-size: 13px; - color: pvar(--greyForegroundColor); - - &:hover { - color: $grey-foreground-hover-color; - } - } - - .video-info-privacy, - .video-info-blocked .blocked-label, - .video-info-nsfw { - font-weight: $font-semibold; - } - - .video-info-blocked { - color: red; - - .blocked-reason::before { - content: ' - '; - } - } - - .video-info-nsfw { - color: red; - } - } - - .video-actions { - margin-top: 3px; - width: $more-button-width; - height: 30px; - - ::ng-deep .dropdown-root:not(.show) { - opacity: 0; - } - - ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root { - opacity: 1; - } - - ::ng-deep .more-icon { - opacity: .6; - - &:hover { - opacity: 1; - } - } - } - - @media screen and (max-width: $small-view) { - .video-miniature-information { - margin: 0 10px; - } - - .video-actions { - margin: 0; - top: -3px; - - ::ng-deep .dropdown-root { - opacity: 1 !important; - } - } - } - } - - &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay, - &:hover .video-bottom .video-actions ::ng-deep .dropdown-root { - opacity: 1; - } - - &.fit-width { - width: 100%; - - .video-bottom { - width: 100% !important; - - .video-miniature-information { - width: calc(100% - #{$more-button-width}) !important; - } - } - - my-video-thumbnail { - @include large-screen-ratio($selector: '::ng-deep .video-thumbnail'); - } - } - - &.display-as-row { - flex-direction: row; - padding-bottom: 0; - height: auto; - display: flex; - flex-grow: 1; - - my-video-thumbnail { - margin-right: 10px; - } - - .video-bottom { - .video-miniature-information { - @media screen and (min-width: $small-view) { - width: auto; - min-width: 500px; - } - - .video-miniature-name { - @include ellipsis-multiline(1.3em, 2); - - margin-top: 2px; - margin-bottom: 5px; - } - - .video-miniature-created-at-views, - .video-miniature-account, - .video-miniature-channel { - font-size: 95%; - width: fit-content; - } - - .video-miniature-created-at-views + .video-miniature-channel { - margin-top: 5px; - } - - .video-info-privacy { - margin-top: 5px; - } - - .video-info-blocked { - margin-top: 3px; - } - } - - .video-actions { - margin: 0; - top: -3px; - } - } - - @media screen and (max-width: $small-view) { - flex-direction: column; - height: auto; - - my-video-thumbnail { - margin-right: 0; - } - - .video-miniature-information { - min-width: initial; - } - } - } -} diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts deleted file mode 100644 index a08c3fc8d..000000000 --- a/client/src/app/shared/video/video-miniature.component.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { switchMap } from 'rxjs/operators' -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Inject, - Input, - LOCALE_ID, - OnInit, - Output -} from '@angular/core' -import { AuthService, ServerService } from '@app/core' -import { ScreenService } from '@app/shared/misc/screen.service' -import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' -import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared' -import { User } from '../users' -import { Video } from './video.model' - -export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' -export type MiniatureDisplayOptions = { - date?: boolean - views?: boolean - by?: boolean - avatar?: boolean - privacyLabel?: boolean - privacyText?: boolean - state?: boolean - blacklistInfo?: boolean - nsfw?: boolean -} - -@Component({ - selector: 'my-video-miniature', - styleUrls: [ './video-miniature.component.scss' ], - templateUrl: './video-miniature.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class VideoMiniatureComponent implements OnInit { - @Input() user: User - @Input() video: Video - - @Input() ownerDisplayType: OwnerDisplayType = 'account' - @Input() displayOptions: MiniatureDisplayOptions = { - date: true, - views: true, - by: true, - avatar: false, - privacyLabel: false, - privacyText: false, - state: false, - blacklistInfo: false - } - @Input() displayAsRow = false - @Input() displayVideoActions = true - @Input() fitWidth = false - - @Input() useLazyLoadUrl = false - - @Output() videoBlocked = new EventEmitter() - @Output() videoUnblocked = new EventEmitter() - @Output() videoRemoved = new EventEmitter() - - videoActionsDisplayOptions: VideoActionsDisplayType = { - playlist: true, - download: false, - update: true, - blacklist: true, - delete: true, - report: true, - duplicate: true - } - showActions = false - serverConfig: ServerConfig - - addToWatchLaterText: string - addedToWatchLaterText: string - inWatchLaterPlaylist: boolean - channelLinkTitle = '' - - watchLaterPlaylist: { - id: number - playlistElementId?: number - } - - videoLink: any[] = [] - - private ownerDisplayTypeChosen: 'account' | 'videoChannel' - - constructor ( - private screenService: ScreenService, - private serverService: ServerService, - private i18n: I18n, - private authService: AuthService, - private videoPlaylistService: VideoPlaylistService, - private cd: ChangeDetectorRef, - @Inject(LOCALE_ID) private localeId: string - ) {} - - get isVideoBlur () { - return this.video.isVideoNSFWForUser(this.user, this.serverConfig) - } - - ngOnInit () { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => { - this.serverConfig = config - this.buildVideoLink() - }) - - this.setUpBy() - - this.channelLinkTitle = this.i18n( - '{{name}} (channel page)', - { name: this.video.channel.name, handle: this.video.byVideoChannel } - ) - - // We rely on mouseenter to lazy load actions - if (this.screenService.isInTouchScreen()) { - this.loadActions() - } - } - - buildVideoLink () { - if (this.useLazyLoadUrl && this.video.url) { - const remoteUriConfig = this.serverConfig.search.remoteUri - - // Redirect on the external instance if not allowed to fetch remote data - const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users - const fromPath = window.location.pathname + window.location.search - - this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ] - return - } - - this.videoLink = [ '/videos/watch', this.video.uuid ] - } - - displayOwnerAccount () { - return this.ownerDisplayTypeChosen === 'account' - } - - displayOwnerVideoChannel () { - return this.ownerDisplayTypeChosen === 'videoChannel' - } - - isUnlistedVideo () { - return this.video.privacy.id === VideoPrivacy.UNLISTED - } - - isPrivateVideo () { - return this.video.privacy.id === VideoPrivacy.PRIVATE - } - - getStateLabel (video: Video) { - if (!video.state) return '' - - if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) { - return this.i18n('Published') - } - - if (video.scheduledUpdate) { - const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) - return this.i18n('Publication scheduled on ') + updateAt - } - - if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) { - return this.i18n('Waiting transcoding') - } - - if (video.state.id === VideoState.TO_TRANSCODE) { - return this.i18n('To transcode') - } - - if (video.state.id === VideoState.TO_IMPORT) { - return this.i18n('To import') - } - - return '' - } - - getAvatarUrl () { - if (this.ownerDisplayTypeChosen === 'account') { - return this.video.accountAvatarUrl - } - - return this.video.videoChannelAvatarUrl - } - - loadActions () { - if (this.displayVideoActions) this.showActions = true - - this.loadWatchLater() - } - - onVideoBlocked () { - this.videoBlocked.emit() - } - - onVideoUnblocked () { - this.videoUnblocked.emit() - } - - onVideoRemoved () { - this.videoRemoved.emit() - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - onWatchLaterClick (currentState: boolean) { - if (currentState === true) this.removeFromWatchLater() - else this.addToWatchLater() - - this.inWatchLaterPlaylist = !currentState - } - - addToWatchLater () { - const body = { videoId: this.video.id } - - this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe( - res => { - this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id - } - ) - } - - removeFromWatchLater () { - this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id) - .subscribe( - _ => { /* empty */ } - ) - } - - isWatchLaterPlaylistDisplayed () { - return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined - } - - private setUpBy () { - if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') { - this.ownerDisplayTypeChosen = this.ownerDisplayType - return - } - - // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12) - // -> Use the account name - if ( - this.video.channel.name === `${this.video.account.name}_channel` || - this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) - ) { - this.ownerDisplayTypeChosen = 'account' - } else { - this.ownerDisplayTypeChosen = 'videoChannel' - } - } - - private loadWatchLater () { - if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return - - this.authService.userInformationLoaded - .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id))) - .subscribe(existResult => { - const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER) - const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id) - this.inWatchLaterPlaylist = false - - this.watchLaterPlaylist = { - id: watchLaterPlaylist.id - } - - if (existsInWatchLater) { - this.inWatchLaterPlaylist = true - this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId - } - - this.cd.markForCheck() - }) - - this.videoPlaylistService.runPlaylistCheck(this.video.id) - } -} diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html deleted file mode 100644 index fe5510c56..000000000 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - - -
    - -
    - -
    -
    - - -
    - -
    -
    -
    - -
    -
    - -
    {{ video.durationLabel }}
    - -
    -
    -
    - -
    -
    -
    -
    diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss deleted file mode 100644 index feff78a87..000000000 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ /dev/null @@ -1,74 +0,0 @@ -@import '_variables'; -@import '_mixins'; -@import '_miniature'; - -.video-thumbnail { - @include miniature-thumbnail; - - .progress-bar { - height: 3px; - width: 100%; - position: absolute; - bottom: 0; - background-color: rgba(0, 0, 0, 0.20); - - div { - height: 100%; - background-color: pvar(--mainColor); - } - } - - .video-thumbnail-watch-later-overlay, - .video-thumbnail-label-overlay, - .video-thumbnail-duration-overlay { - @include static-thumbnail-overlay; - - border-radius: 3px; - font-size: 12px; - font-weight: $font-semibold; - line-height: 1.2; - z-index: z(miniature); - } - - .video-thumbnail-label-overlay { - position: absolute; - padding: 0 5px; - left: 5px; - top: 5px; - font-weight: $font-bold; - - &.warning { background-color: orange; } - &.danger { background-color: red; } - } - - .video-thumbnail-duration-overlay { - position: absolute; - padding: 0 3px; - right: 5px; - bottom: 5px; - } - - .video-thumbnail-actions-overlay { - position: absolute; - display: flex; - flex-direction: column; - right: 5px; - top: 5px; - opacity: 0; - - div:not(:first-child) { - margin-top: 2px; - } - - .video-thumbnail-watch-later-overlay { - padding: 3px; - - my-global-icon { - width: 22px; - height: 22px; - - @include apply-svg-color(#fff); - } - } - } -} diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts deleted file mode 100644 index 111b4c8bb..000000000 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core' -import { Video } from './video.model' -import { ScreenService } from '@app/shared/misc/screen.service' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'my-video-thumbnail', - styleUrls: [ './video-thumbnail.component.scss' ], - templateUrl: './video-thumbnail.component.html' -}) -export class VideoThumbnailComponent { - @Input() video: Video - @Input() nsfw = false - @Input() routerLink: any[] - @Input() queryParams: { [ p: string ]: any } - - @Input() displayWatchLaterPlaylist: boolean - @Input() inWatchLaterPlaylist: boolean - - @Output() watchLaterClick = new EventEmitter() - - addToWatchLaterText: string - addedToWatchLaterText: string - - constructor ( - private screenService: ScreenService, - private i18n: I18n - ) { - this.addToWatchLaterText = this.i18n('Add to watch later') - this.addedToWatchLaterText = this.i18n('Remove from watch later') - } - - getImageUrl () { - if (!this.video) return '' - - if (this.screenService.isInMobileView()) { - return this.video.previewUrl - } - - return this.video.thumbnailUrl - } - - getProgressPercent () { - if (!this.video.userHistory) return 0 - - const currentTime = this.video.userHistory.currentTime - - return (currentTime / this.video.duration) * 100 - } - - getVideoRouterLink () { - if (this.routerLink) return this.routerLink - - return [ '/videos/watch', this.video.uuid ] - } - - onWatchLaterClick (event: Event) { - this.watchLaterClick.emit(this.inWatchLaterPlaylist) - - event.stopPropagation() - return false - } -} diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts deleted file mode 100644 index dc5f45626..000000000 --- a/client/src/app/shared/video/video.model.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { User } from '../' -import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' -import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' -import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' -import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' -import { Actor } from '@app/shared/actor/actor.model' -import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' -import { AuthUser } from '@app/core' -import { environment } from '../../../environments/environment' - -export class Video implements VideoServerModel { - byVideoChannel: string - byAccount: string - - accountAvatarUrl: string - videoChannelAvatarUrl: string - - createdAt: Date - updatedAt: Date - publishedAt: Date - originallyPublishedAt: Date | string - category: VideoConstant - licence: VideoConstant - language: VideoConstant - privacy: VideoConstant - description: string - duration: number - durationLabel: string - id: number - uuid: string - isLocal: boolean - name: string - serverHost: string - thumbnailPath: string - thumbnailUrl: string - - previewPath: string - previewUrl: string - - embedPath: string - embedUrl: string - - url?: string - - views: number - likes: number - dislikes: number - nsfw: boolean - - originInstanceUrl: string - originInstanceHost: string - - waitTranscoding?: boolean - state?: VideoConstant - scheduledUpdate?: VideoScheduleUpdate - blacklisted?: boolean - blockedReason?: string - - account: { - id: number - name: string - displayName: string - url: string - host: string - avatar?: Avatar - } - - channel: { - id: number - name: string - displayName: string - url: string - host: string - avatar?: Avatar - } - - userHistory?: { - currentTime: number - } - - static buildClientUrl (videoUUID: string) { - return '/videos/watch/' + videoUUID - } - - constructor (hash: VideoServerModel, translations = {}) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - - this.createdAt = new Date(hash.createdAt.toString()) - this.publishedAt = new Date(hash.publishedAt.toString()) - this.category = hash.category - this.licence = hash.licence - this.language = hash.language - this.privacy = hash.privacy - this.waitTranscoding = hash.waitTranscoding - this.state = hash.state - this.description = hash.description - - this.duration = hash.duration - this.durationLabel = durationToString(hash.duration) - - this.id = hash.id - this.uuid = hash.uuid - - this.isLocal = hash.isLocal - this.name = hash.name - - this.thumbnailPath = hash.thumbnailPath - this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath) - - this.previewPath = hash.previewPath - this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) - - this.embedPath = hash.embedPath - this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath) - - this.url = hash.url - - this.views = hash.views - this.likes = hash.likes - this.dislikes = hash.dislikes - - this.nsfw = hash.nsfw - - this.account = hash.account - this.channel = hash.channel - - this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host) - this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host) - this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) - this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel) - - this.category.label = peertubeTranslate(this.category.label, translations) - this.licence.label = peertubeTranslate(this.licence.label, translations) - this.language.label = peertubeTranslate(this.language.label, translations) - this.privacy.label = peertubeTranslate(this.privacy.label, translations) - - this.scheduledUpdate = hash.scheduledUpdate - this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null - - if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) - - this.blacklisted = hash.blacklisted - this.blockedReason = hash.blacklistedReason - - this.userHistory = hash.userHistory - - this.originInstanceHost = this.account.host - this.originInstanceUrl = 'https://' + this.originInstanceHost - } - - isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { - // Video is not NSFW, skip - if (this.nsfw === false) return false - - // Return user setting if logged in - if (user) return user.nsfwPolicy !== 'display' - - // Return default instance config - return serverConfig.instance.defaultNSFWPolicy !== 'display' - } - - isRemovableBy (user: AuthUser) { - return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) - } - - isBlockableBy (user: AuthUser) { - return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true - } - - isUnblockableBy (user: AuthUser) { - return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true - } - - isUpdatableBy (user: AuthUser) { - return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) - } - - canBeDuplicatedBy (user: AuthUser) { - return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) - } -} diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts deleted file mode 100644 index d66a1f809..000000000 --- a/client/src/app/shared/video/video.service.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { catchError, map, switchMap } from 'rxjs/operators' -import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { Observable } from 'rxjs' -import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared' -import { ResultList } from '../../../../../shared/models/result-list.model' -import { - UserVideoRate, - UserVideoRateType, - UserVideoRateUpdate, - VideoConstant, - VideoFilter, - VideoPrivacy, - VideoUpdate -} from '../../../../../shared/models/videos' -import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' -import { environment } from '../../../environments/environment' -import { ComponentPaginationLight } from '../rest/component-pagination.model' -import { RestExtractor } from '../rest/rest-extractor.service' -import { RestService } from '../rest/rest.service' -import { UserService } from '../users/user.service' -import { VideoSortField } from './sort-field.type' -import { VideoDetails } from './video-details.model' -import { VideoEdit } from './video-edit.model' -import { Video } from './video.model' -import { objectToFormData } from '@app/shared/misc/utils' -import { Account } from '@app/shared/account/account.model' -import { AccountService } from '@app/shared/account/account.service' -import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' -import { ServerService, AuthService } from '@app/core' -import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' -import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' -import { FfprobeData } from 'fluent-ffmpeg' - -export interface VideosProvider { - getVideos (parameters: { - videoPagination: ComponentPaginationLight, - sort: VideoSortField, - filter?: VideoFilter, - categoryOneOf?: number[], - languageOneOf?: string[] - nsfwPolicy: NSFWPolicyType - }): Observable> -} - -@Injectable() -export class VideoService implements VideosProvider { - static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' - static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' - - constructor ( - private authHttp: HttpClient, - private authService: AuthService, - private userService: UserService, - private restExtractor: RestExtractor, - private restService: RestService, - private serverService: ServerService, - private i18n: I18n - ) {} - - getVideoViewUrl (uuid: string) { - return VideoService.BASE_VIDEO_URL + uuid + '/views' - } - - getUserWatchingVideoUrl (uuid: string) { - return VideoService.BASE_VIDEO_URL + uuid + '/watching' - } - - getVideo (options: { videoId: string }): Observable { - return this.serverService.getServerLocale() - .pipe( - switchMap(translations => { - return this.authHttp.get(VideoService.BASE_VIDEO_URL + options.videoId) - .pipe(map(videoHash => ({ videoHash, translations }))) - }), - map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - updateVideo (video: VideoEdit) { - const language = video.language || null - const licence = video.licence || null - const category = video.category || null - const description = video.description || null - const support = video.support || null - const scheduleUpdate = video.scheduleUpdate || null - const originallyPublishedAt = video.originallyPublishedAt || null - - const body: VideoUpdate = { - name: video.name, - category, - licence, - language, - support, - description, - channelId: video.channelId, - privacy: video.privacy, - tags: video.tags, - nsfw: video.nsfw, - waitTranscoding: video.waitTranscoding, - commentsEnabled: video.commentsEnabled, - downloadEnabled: video.downloadEnabled, - thumbnailfile: video.thumbnailfile, - previewfile: video.previewfile, - scheduleUpdate, - originallyPublishedAt - } - - const data = objectToFormData(body) - - return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - uploadVideo (video: FormData) { - const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true }) - - return this.authHttp - .request<{ video: { id: number, uuid: string } }>(req) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable> { - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - params = this.restService.addObjectParams(params, { search }) - - return this.authHttp - .get>(UserService.BASE_USERS_URL + 'me/videos', { params }) - .pipe( - switchMap(res => this.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getAccountVideos ( - account: Account, - videoPagination: ComponentPaginationLight, - sort: VideoSortField - ): Observable> { - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - return this.authHttp - .get>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) - .pipe( - switchMap(res => this.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideoChannelVideos ( - videoChannel: VideoChannel, - videoPagination: ComponentPaginationLight, - sort: VideoSortField, - nsfwPolicy?: NSFWPolicyType - ): Observable> { - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (nsfwPolicy) { - params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) - } - - return this.authHttp - .get>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) - .pipe( - switchMap(res => this.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getUserSubscriptionVideos (parameters: { - videoPagination: ComponentPaginationLight, - sort: VideoSortField, - skipCount?: boolean - }): Observable> { - const { videoPagination, sort, skipCount } = parameters - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (skipCount) params = params.set('skipCount', skipCount + '') - - return this.authHttp - .get>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params }) - .pipe( - switchMap(res => this.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - getVideos (parameters: { - videoPagination: ComponentPaginationLight, - sort: VideoSortField, - filter?: VideoFilter, - categoryOneOf?: number[], - languageOneOf?: string[], - skipCount?: boolean, - nsfwPolicy?: NSFWPolicyType - }): Observable> { - const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters - - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (filter) params = params.set('filter', filter) - if (skipCount) params = params.set('skipCount', skipCount + '') - - if (nsfwPolicy) { - params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) - } - - if (languageOneOf) { - for (const l of languageOneOf) { - params = params.append('languageOneOf[]', l) - } - } - - if (categoryOneOf) { - for (const c of categoryOneOf) { - params = params.append('categoryOneOf[]', c + '') - } - } - - return this.authHttp - .get>(VideoService.BASE_VIDEO_URL, { params }) - .pipe( - switchMap(res => this.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - buildBaseFeedUrls (params: HttpParams) { - const feeds = [ - { - format: FeedFormat.RSS, - label: 'rss 2.0', - url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() - }, - { - format: FeedFormat.ATOM, - label: 'atom 1.0', - url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() - }, - { - format: FeedFormat.JSON, - label: 'json 1.0', - url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() - } - ] - - if (params && params.keys().length !== 0) { - for (const feed of feeds) { - feed.url += '?' + params.toString() - } - } - - return feeds - } - - getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) { - let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort) - - if (filter) params = params.set('filter', filter) - - if (categoryOneOf) { - for (const c of categoryOneOf) { - params = params.append('categoryOneOf[]', c + '') - } - } - - return this.buildBaseFeedUrls(params) - } - - getAccountFeedUrls (accountId: number) { - let params = this.restService.addRestGetParams(new HttpParams()) - params = params.set('accountId', accountId.toString()) - - return this.buildBaseFeedUrls(params) - } - - getVideoChannelFeedUrls (videoChannelId: number) { - let params = this.restService.addRestGetParams(new HttpParams()) - params = params.set('videoChannelId', videoChannelId.toString()) - - return this.buildBaseFeedUrls(params) - } - - getVideoFileMetadata (metadataUrl: string) { - return this.authHttp - .get(metadataUrl) - .pipe( - catchError(err => this.restExtractor.handleError(err)) - ) - } - - removeVideo (id: number) { - return this.authHttp - .delete(VideoService.BASE_VIDEO_URL + id) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - loadCompleteDescription (descriptionPath: string) { - return this.authHttp - .get<{ description: string }>(environment.apiUrl + descriptionPath) - .pipe( - map(res => res.description), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - setVideoLike (id: number) { - return this.setVideoRate(id, 'like') - } - - setVideoDislike (id: number) { - return this.setVideoRate(id, 'dislike') - } - - unsetVideoLike (id: number) { - return this.setVideoRate(id, 'none') - } - - getUserVideoRating (id: number) { - const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' - - return this.authHttp.get(url) - .pipe(catchError(err => this.restExtractor.handleError(err))) - } - - extractVideos (result: ResultList) { - return this.serverService.getServerLocale() - .pipe( - map(translations => { - const videosJson = result.data - const totalVideos = result.total - const videos: Video[] = [] - - for (const videoJson of videosJson) { - videos.push(new Video(videoJson, translations)) - } - - return { total: totalVideos, data: videos } - }) - ) - } - - explainedPrivacyLabels (privacies: VideoConstant[]) { - const base = [ - { - id: VideoPrivacy.PRIVATE, - label: this.i18n('Only I can see this video') - }, - { - id: VideoPrivacy.UNLISTED, - label: this.i18n('Only people with the private link can see this video') - }, - { - id: VideoPrivacy.PUBLIC, - label: this.i18n('Anyone can see this video') - }, - { - id: VideoPrivacy.INTERNAL, - label: this.i18n('Only users of this instance can see this video') - } - ] - - return base.filter(o => !!privacies.find(p => p.id === o.id)) - } - - nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) { - return nsfwPolicy === 'do_not_list' - ? 'false' - : 'both' - } - - private setVideoRate (id: number, rateType: UserVideoRateType) { - const url = VideoService.BASE_VIDEO_URL + id + '/rate' - const body: UserVideoRateUpdate = { - rating: rateType - } - - return this.authHttp - .put(url, body) - .pipe( - map(this.restExtractor.extractDataBool), - catchError(err => this.restExtractor.handleError(err)) - ) - } -} diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html deleted file mode 100644 index 44aa567b9..000000000 --- a/client/src/app/shared/video/videos-selection.component.html +++ /dev/null @@ -1,30 +0,0 @@ -
    No results.
    - -
    -
    - -
    - -
    - - - - -
    -
    - - Cancel - - - -
    -
    - - - - -
    -
    diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss deleted file mode 100644 index d3cbabf23..000000000 --- a/client/src/app/shared/video/videos-selection.component.scss +++ /dev/null @@ -1,57 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.action-selection-mode { - display: flex; - justify-content: flex-end; - flex-grow: 1; - - .action-selection-mode-child { - position: fixed; - - .action-button { - display: inline-block; - } - - .action-button-cancel-selection { - @include peertube-button; - @include grey-button; - - margin-right: 10px; - } - } -} - -.video { - @include row-blocks; - - &:first-child { - margin-top: 47px; - } - - .checkbox-container { - display: flex; - align-items: center; - margin-right: 20px; - margin-left: 12px; - } - - my-video-miniature { - flex-grow: 1; - } -} - -@media screen and (max-width: $small-view) { - .video { - flex-direction: column; - height: auto; - - .checkbox-container { - display: none; - } - - my-button { - margin-top: 10px; - } - } -} diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts deleted file mode 100644 index 9453664dd..000000000 --- a/client/src/app/shared/video/videos-selection.component.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - AfterContentInit, - Component, - ContentChildren, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - QueryList, - TemplateRef -} from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AbstractVideoList } from '@app/shared/video/abstract-video-list' -import { AuthService, Notifier, ServerService } from '@app/core' -import { ScreenService } from '@app/shared/misc/screen.service' -import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component' -import { Observable } from 'rxjs' -import { Video } from '@app/shared/video/video.model' -import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' -import { VideoSortField } from '@app/shared/video/sort-field.type' -import { ComponentPagination } from '@app/shared/rest/component-pagination.model' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { ResultList } from '@shared/models' -import { UserService } from '../users' -import { LocalStorageService } from '../misc/storage.service' - -export type SelectionType = { [ id: number ]: boolean } - -@Component({ - selector: 'my-videos-selection', - templateUrl: './videos-selection.component.html', - styleUrls: [ './videos-selection.component.scss' ] -}) -export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { - @Input() pagination: ComponentPagination - @Input() titlePage: string - @Input() miniatureDisplayOptions: MiniatureDisplayOptions - @Input() ownerDisplayType: OwnerDisplayType - - @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable> - - @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> - - @Output() selectionChange = new EventEmitter() - @Output() videosModelChange = new EventEmitter() - - _selection: SelectionType = {} - - rowButtonsTemplate: TemplateRef - globalButtonsTemplate: TemplateRef - - constructor ( - protected i18n: I18n, - protected router: Router, - protected route: ActivatedRoute, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected serverService: ServerService - ) { - super() - } - - @Input() get selection () { - return this._selection - } - - set selection (selection: SelectionType) { - this._selection = selection - this.selectionChange.emit(this._selection) - } - - @Input() get videosModel () { - return this.videos - } - - set videosModel (videos: Video[]) { - this.videos = videos - this.videosModelChange.emit(this.videos) - } - - ngOnInit () { - super.ngOnInit() - } - - ngAfterContentInit () { - { - const t = this.templates.find(t => t.name === 'rowButtons') - if (t) this.rowButtonsTemplate = t.template - } - - { - const t = this.templates.find(t => t.name === 'globalButtons') - if (t) this.globalButtonsTemplate = t.template - } - } - - ngOnDestroy () { - super.ngOnDestroy() - } - - getVideosObservable (page: number) { - return this.getVideosObservableFunction(page, this.sort) - } - - abortSelectionMode () { - this._selection = {} - } - - isInSelectionMode () { - return Object.keys(this._selection).some(k => this._selection[ k ] === true) - } - - generateSyndicationList () { - throw new Error('Method not implemented.') - } - - protected onMoreVideos () { - this.videosModel = this.videos - } -} -- cgit v1.2.3