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 @@
- 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
- [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
- [showCurrentPageReport]="true" i18n-currentPageReportTemplate
- currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
->
-
-
-
-
-
-
- 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 @@
- 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
- [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
- [showCurrentPageReport]="true" i18n-currentPageReportTemplate
- currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
->
-
-
-
-
-
-
- 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 @@
-
-
-
-
-
-
-
-
-
-
-
Recommended
-
-
-
-
-
-
-
-
-
-
-
-
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 ''
- }
-}
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 @@
+
+
+
+
+
+
+
+
+
+
+
Recommended
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
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 ''
+ }
+}
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The notification concerns a video now unavailable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The notification concerns a comment now unavailable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your instance has
a new follower ({{ notification.actorFollow?.follower.host }})
+
awaiting your approval
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+ 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
+ [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
+ [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+ currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
+>
+
+
+
+
+
+
+ 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 @@
+ 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
+ [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
+ [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+ currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
+>
+
+
+
+
+
+
+ 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 @@
+
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 @@
+
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
+
+
+
+ 1 && videoChannel.followersCount !== 0" class="followers-count">
+ {{ 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 @@
+
+
+
+
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
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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