Reorganize client shared modules
authorChocobozzz <me@florianbigard.com>
Tue, 23 Jun 2020 12:10:17 +0000 (14:10 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 23 Jun 2020 14:00:49 +0000 (16:00 +0200)
743 files changed:
client/src/app/+about/about-follows/about-follows.component.ts
client/src/app/+about/about-instance/about-instance.component.ts
client/src/app/+about/about-instance/about-instance.resolver.ts
client/src/app/+about/about-instance/contact-admin-modal.component.ts
client/src/app/+about/about-peertube/about-peertube-contributors.component.ts
client/src/app/+about/about.module.ts
client/src/app/+accounts/account-about/account-about.component.ts
client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
client/src/app/+accounts/account-videos/account-videos.component.ts
client/src/app/+accounts/accounts.component.ts
client/src/app/+accounts/accounts.module.ts
client/src/app/+admin/admin-routing.module.ts
client/src/app/+admin/admin.component.ts
client/src/app/+admin/admin.module.ts
client/src/app/+admin/config/config.routes.ts
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+admin/config/shared/batch-domains-modal.component.html [deleted file]
client/src/app/+admin/config/shared/batch-domains-modal.component.scss [deleted file]
client/src/app/+admin/config/shared/batch-domains-modal.component.ts [deleted file]
client/src/app/+admin/config/shared/batch-domains-validators.service.ts [deleted file]
client/src/app/+admin/config/shared/config.service.ts
client/src/app/+admin/follows/followers-list/followers-list.component.ts
client/src/app/+admin/follows/following-list/following-list.component.ts
client/src/app/+admin/follows/follows.routes.ts
client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
client/src/app/+admin/follows/video-redundancies-list/index.ts
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
client/src/app/+admin/moderation/index.ts
client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
client/src/app/+admin/moderation/moderation.component.ts
client/src/app/+admin/moderation/moderation.routes.ts
client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
client/src/app/+admin/plugins/shared/plugin-api.service.ts
client/src/app/+admin/system/debug/debug.component.ts
client/src/app/+admin/system/debug/debug.service.ts
client/src/app/+admin/system/index.ts
client/src/app/+admin/system/jobs/job.service.ts
client/src/app/+admin/system/jobs/jobs.component.ts
client/src/app/+admin/system/logs/log-row.model.ts
client/src/app/+admin/system/logs/logs.component.ts
client/src/app/+admin/system/logs/logs.service.ts
client/src/app/+admin/system/system.routes.ts
client/src/app/+admin/users/user-edit/user-create.component.ts
client/src/app/+admin/users/user-edit/user-edit.ts
client/src/app/+admin/users/user-edit/user-password.component.ts
client/src/app/+admin/users/user-edit/user-update.component.ts
client/src/app/+admin/users/user-list/user-list.component.ts
client/src/app/+admin/users/users.routes.ts
client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-create.component.ts [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.html [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.scss [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.ts [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-update.component.ts [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels-routing.module.ts [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.html [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.scss [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.ts [new file with mode: 0644]
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.module.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
client/src/app/+my-account/my-account-history/my-account-history.component.ts
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
client/src/app/+my-account/my-account-routing.module.ts
client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
client/src/app/+my-account/my-account-settings/my-account-interface/index.ts [deleted file]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html [deleted file]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss [deleted file]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts [deleted file]
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
client/src/app/+my-account/my-account-settings/my-account-settings.component.html
client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
client/src/app/+my-account/my-account-settings/my-account-video-settings/index.ts [deleted file]
client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html [deleted file]
client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss [deleted file]
client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts [deleted file]
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-create.component.ts [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts [deleted file]
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts [deleted file]
client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
client/src/app/+my-account/my-account.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/+my-account/shared/actor-avatar-info.component.html [deleted file]
client/src/app/+my-account/shared/actor-avatar-info.component.scss [deleted file]
client/src/app/+my-account/shared/actor-avatar-info.component.ts [deleted file]
client/src/app/+my-account/top-menu-dropdown.component.html [new file with mode: 0644]
client/src/app/+my-account/top-menu-dropdown.component.scss [new file with mode: 0644]
client/src/app/+my-account/top-menu-dropdown.component.ts [new file with mode: 0644]
client/src/app/+page-not-found/page-not-found.module.ts
client/src/app/+signup/+register/register-routing.module.ts
client/src/app/+signup/+register/register-step-channel.component.ts
client/src/app/+signup/+register/register-step-user.component.ts
client/src/app/+signup/+register/register.component.ts
client/src/app/+signup/+register/register.module.ts
client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
client/src/app/+signup/+verify-account/verify-account.module.ts
client/src/app/+signup/shared/signup-shared.module.ts
client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts
client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts
client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
client/src/app/+video-channels/video-channels-routing.module.ts
client/src/app/+video-channels/video-channels.component.ts
client/src/app/+video-channels/video-channels.module.ts
client/src/app/app-routing.module.ts
client/src/app/app.component.ts
client/src/app/app.module.ts
client/src/app/core/auth/auth-user.model.ts
client/src/app/core/auth/auth.service.ts
client/src/app/core/core.module.ts
client/src/app/core/index.ts
client/src/app/core/menu/menu.service.ts
client/src/app/core/notification/user-notification-socket.service.ts
client/src/app/core/plugins/hooks.service.ts
client/src/app/core/plugins/index.ts [new file with mode: 0644]
client/src/app/core/plugins/plugin.service.ts
client/src/app/core/renderer/html-renderer.service.ts [new file with mode: 0644]
client/src/app/core/renderer/index.ts [new file with mode: 0644]
client/src/app/core/renderer/linkifier.service.ts [new file with mode: 0644]
client/src/app/core/renderer/markdown.service.ts [new file with mode: 0644]
client/src/app/core/rest/component-pagination.model.ts [new file with mode: 0644]
client/src/app/core/rest/index.ts [new file with mode: 0644]
client/src/app/core/rest/rest-extractor.service.ts [new file with mode: 0644]
client/src/app/core/rest/rest-pagination.ts [new file with mode: 0644]
client/src/app/core/rest/rest-table.ts [new file with mode: 0644]
client/src/app/core/rest/rest.service.ts [new file with mode: 0644]
client/src/app/core/routing/can-deactivate-guard.service.ts [new file with mode: 0644]
client/src/app/core/routing/index.ts
client/src/app/core/routing/login-guard.service.ts
client/src/app/core/routing/menu-guard.service.ts
client/src/app/core/routing/preload-selected-modules-list.ts
client/src/app/core/routing/server-config-resolver.service.ts
client/src/app/core/routing/unlogged-guard.service.ts
client/src/app/core/routing/user-right-guard.service.ts
client/src/app/core/server/server.service.ts
client/src/app/core/theme/theme.service.ts
client/src/app/core/users/index.ts [new file with mode: 0644]
client/src/app/core/users/user.model.ts [new file with mode: 0644]
client/src/app/core/users/user.service.ts [new file with mode: 0644]
client/src/app/core/wrappers/index.ts [new file with mode: 0644]
client/src/app/core/wrappers/screen.service.ts [new file with mode: 0644]
client/src/app/core/wrappers/storage.service.ts [new file with mode: 0644]
client/src/app/header/search-typeahead.component.ts
client/src/app/header/suggestion.component.ts
client/src/app/helpers/constants.ts [new file with mode: 0644]
client/src/app/helpers/i18n-utils.ts [new file with mode: 0644]
client/src/app/helpers/index.ts [new file with mode: 0644]
client/src/app/helpers/locales/index.ts [new file with mode: 0644]
client/src/app/helpers/locales/oc.ts [new file with mode: 0644]
client/src/app/helpers/peertube-web-storage.ts [new file with mode: 0644]
client/src/app/helpers/utils.ts [new file with mode: 0644]
client/src/app/helpers/zone.ts [new file with mode: 0644]
client/src/app/login/login.component.ts
client/src/app/login/login.module.ts
client/src/app/menu/avatar-notification.component.ts
client/src/app/menu/language-chooser.component.ts
client/src/app/menu/menu.component.ts
client/src/app/modal/confirm.component.html [new file with mode: 0644]
client/src/app/modal/confirm.component.scss [new file with mode: 0644]
client/src/app/modal/confirm.component.ts [new file with mode: 0644]
client/src/app/modal/instance-config-warning-modal.component.ts
client/src/app/modal/quick-settings-modal.component.html
client/src/app/modal/quick-settings-modal.component.ts
client/src/app/modal/welcome-modal.component.ts
client/src/app/reset-password/reset-password-routing.module.ts
client/src/app/reset-password/reset-password.component.ts
client/src/app/reset-password/reset-password.module.ts
client/src/app/search/advanced-search.model.ts
client/src/app/search/highlight.pipe.ts [new file with mode: 0644]
client/src/app/search/search-filters.component.ts
client/src/app/search/search.component.ts
client/src/app/search/search.module.ts
client/src/app/search/search.service.ts
client/src/app/shared/account/account.model.ts [deleted file]
client/src/app/shared/account/account.service.ts [deleted file]
client/src/app/shared/actor/actor.model.ts [deleted file]
client/src/app/shared/angular/from-now.pipe.ts [deleted file]
client/src/app/shared/angular/highlight.pipe.ts [deleted file]
client/src/app/shared/angular/number-formatter.pipe.ts [deleted file]
client/src/app/shared/angular/object-length.pipe.ts [deleted file]
client/src/app/shared/angular/peertube-template.directive.ts [deleted file]
client/src/app/shared/angular/timestamp-route-transformer.directive.ts [deleted file]
client/src/app/shared/angular/video-duration-formatter.pipe.ts [deleted file]
client/src/app/shared/auth/auth-interceptor.service.ts [deleted file]
client/src/app/shared/auth/index.ts [deleted file]
client/src/app/shared/blocklist/account-block.model.ts [deleted file]
client/src/app/shared/blocklist/account-blocklist.component.html [deleted file]
client/src/app/shared/blocklist/account-blocklist.component.scss [deleted file]
client/src/app/shared/blocklist/account-blocklist.component.ts [deleted file]
client/src/app/shared/blocklist/blocklist.service.ts [deleted file]
client/src/app/shared/blocklist/index.ts [deleted file]
client/src/app/shared/blocklist/server-blocklist.component.html [deleted file]
client/src/app/shared/blocklist/server-blocklist.component.scss [deleted file]
client/src/app/shared/blocklist/server-blocklist.component.ts [deleted file]
client/src/app/shared/bulk/bulk.service.ts [deleted file]
client/src/app/shared/buttons/action-dropdown.component.html [deleted file]
client/src/app/shared/buttons/action-dropdown.component.scss [deleted file]
client/src/app/shared/buttons/action-dropdown.component.ts [deleted file]
client/src/app/shared/buttons/button.component.html [deleted file]
client/src/app/shared/buttons/button.component.scss [deleted file]
client/src/app/shared/buttons/button.component.ts [deleted file]
client/src/app/shared/buttons/delete-button.component.html [deleted file]
client/src/app/shared/buttons/delete-button.component.ts [deleted file]
client/src/app/shared/buttons/edit-button.component.html [deleted file]
client/src/app/shared/buttons/edit-button.component.ts [deleted file]
client/src/app/shared/channel/avatar.component.html [deleted file]
client/src/app/shared/channel/avatar.component.scss [deleted file]
client/src/app/shared/channel/avatar.component.ts [deleted file]
client/src/app/shared/confirm/confirm.component.html [deleted file]
client/src/app/shared/confirm/confirm.component.scss [deleted file]
client/src/app/shared/confirm/confirm.component.ts [deleted file]
client/src/app/shared/date/date-toggle.component.html [deleted file]
client/src/app/shared/date/date-toggle.component.scss [deleted file]
client/src/app/shared/date/date-toggle.component.ts [deleted file]
client/src/app/shared/forms/form-reactive.ts [deleted file]
client/src/app/shared/forms/form-validators/custom-config-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/form-validator.service.ts [deleted file]
client/src/app/shared/forms/form-validators/host.ts [deleted file]
client/src/app/shared/forms/form-validators/index.ts [deleted file]
client/src/app/shared/forms/form-validators/instance-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/login-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/reset-password-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/user-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-abuse-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-accept-ownership-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-block-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-captions-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-channel-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-comment-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts [deleted file]
client/src/app/shared/forms/form-validators/video-validators.service.ts [deleted file]
client/src/app/shared/forms/index.ts [deleted file]
client/src/app/shared/forms/input-readonly-copy.component.html [deleted file]
client/src/app/shared/forms/input-readonly-copy.component.scss [deleted file]
client/src/app/shared/forms/input-readonly-copy.component.ts [deleted file]
client/src/app/shared/forms/markdown-textarea.component.html [deleted file]
client/src/app/shared/forms/markdown-textarea.component.scss [deleted file]
client/src/app/shared/forms/markdown-textarea.component.ts [deleted file]
client/src/app/shared/forms/peertube-checkbox.component.html [deleted file]
client/src/app/shared/forms/peertube-checkbox.component.scss [deleted file]
client/src/app/shared/forms/peertube-checkbox.component.ts [deleted file]
client/src/app/shared/forms/reactive-file.component.html [deleted file]
client/src/app/shared/forms/reactive-file.component.scss [deleted file]
client/src/app/shared/forms/reactive-file.component.ts [deleted file]
client/src/app/shared/forms/textarea-autoresize.directive.ts [deleted file]
client/src/app/shared/forms/timestamp-input.component.html [deleted file]
client/src/app/shared/forms/timestamp-input.component.scss [deleted file]
client/src/app/shared/forms/timestamp-input.component.ts [deleted file]
client/src/app/shared/guards/can-deactivate-guard.service.ts [deleted file]
client/src/app/shared/i18n/i18n-primeng-calendar.ts [deleted file]
client/src/app/shared/i18n/i18n-utils.ts [deleted file]
client/src/app/shared/images/global-icon.component.scss [deleted file]
client/src/app/shared/images/global-icon.component.ts [deleted file]
client/src/app/shared/images/preview-upload.component.html [deleted file]
client/src/app/shared/images/preview-upload.component.scss [deleted file]
client/src/app/shared/images/preview-upload.component.ts [deleted file]
client/src/app/shared/index.ts [deleted file]
client/src/app/shared/instance/feature-boolean.component.html [deleted file]
client/src/app/shared/instance/feature-boolean.component.scss [deleted file]
client/src/app/shared/instance/feature-boolean.component.ts [deleted file]
client/src/app/shared/instance/follow.service.ts [deleted file]
client/src/app/shared/instance/instance-features-table.component.html [deleted file]
client/src/app/shared/instance/instance-features-table.component.scss [deleted file]
client/src/app/shared/instance/instance-features-table.component.ts [deleted file]
client/src/app/shared/instance/instance-statistics.component.html [deleted file]
client/src/app/shared/instance/instance-statistics.component.scss [deleted file]
client/src/app/shared/instance/instance-statistics.component.ts [deleted file]
client/src/app/shared/instance/instance.service.ts [deleted file]
client/src/app/shared/locale/oc.ts [deleted file]
client/src/app/shared/menu/top-menu-dropdown.component.html [deleted file]
client/src/app/shared/menu/top-menu-dropdown.component.scss [deleted file]
client/src/app/shared/menu/top-menu-dropdown.component.ts [deleted file]
client/src/app/shared/misc/constants.ts [deleted file]
client/src/app/shared/misc/help.component.html [deleted file]
client/src/app/shared/misc/help.component.scss [deleted file]
client/src/app/shared/misc/help.component.ts [deleted file]
client/src/app/shared/misc/list-overflow.component.html [deleted file]
client/src/app/shared/misc/list-overflow.component.scss [deleted file]
client/src/app/shared/misc/list-overflow.component.ts [deleted file]
client/src/app/shared/misc/loader.component.html [deleted file]
client/src/app/shared/misc/loader.component.scss [deleted file]
client/src/app/shared/misc/loader.component.ts [deleted file]
client/src/app/shared/misc/peertube-web-storage.ts [deleted file]
client/src/app/shared/misc/screen.service.ts [deleted file]
client/src/app/shared/misc/small-loader.component.html [deleted file]
client/src/app/shared/misc/small-loader.component.ts [deleted file]
client/src/app/shared/misc/storage.service.ts [deleted file]
client/src/app/shared/misc/utils.ts [deleted file]
client/src/app/shared/moderation/index.ts [deleted file]
client/src/app/shared/moderation/user-ban-modal.component.html [deleted file]
client/src/app/shared/moderation/user-ban-modal.component.scss [deleted file]
client/src/app/shared/moderation/user-ban-modal.component.ts [deleted file]
client/src/app/shared/moderation/user-moderation-dropdown.component.html [deleted file]
client/src/app/shared/moderation/user-moderation-dropdown.component.ts [deleted file]
client/src/app/shared/overview/index.ts [deleted file]
client/src/app/shared/overview/overview.service.ts [deleted file]
client/src/app/shared/overview/videos-overview.model.ts [deleted file]
client/src/app/shared/renderer/html-renderer.service.ts [deleted file]
client/src/app/shared/renderer/index.ts [deleted file]
client/src/app/shared/renderer/linkifier.service.ts [deleted file]
client/src/app/shared/renderer/markdown.service.ts [deleted file]
client/src/app/shared/rest/component-pagination.model.ts [deleted file]
client/src/app/shared/rest/index.ts [deleted file]
client/src/app/shared/rest/rest-extractor.service.ts [deleted file]
client/src/app/shared/rest/rest-pagination.ts [deleted file]
client/src/app/shared/rest/rest-table.ts [deleted file]
client/src/app/shared/rest/rest.service.ts [deleted file]
client/src/app/shared/rxjs/zone.ts [deleted file]
client/src/app/shared/shared-forms/form-reactive.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/form-validator.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/host.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/index.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/login-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/user-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-validators/video-validators.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/index.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/input-readonly-copy.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/input-readonly-copy.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/input-readonly-copy.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/markdown-textarea.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/markdown-textarea.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/markdown-textarea.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/peertube-checkbox.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/peertube-checkbox.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/peertube-checkbox.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/preview-upload.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/preview-upload.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/preview-upload.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/reactive-file.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/reactive-file.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/reactive-file.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/shared-form.module.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/textarea-autoresize.directive.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/timestamp-input.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/timestamp-input.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/timestamp-input.component.ts [new file with mode: 0644]
client/src/app/shared/shared-icons/global-icon.component.scss [new file with mode: 0644]
client/src/app/shared/shared-icons/global-icon.component.ts [new file with mode: 0644]
client/src/app/shared/shared-icons/index.ts [new file with mode: 0644]
client/src/app/shared/shared-icons/shared-global-icon.module.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/feature-boolean.component.html [new file with mode: 0644]
client/src/app/shared/shared-instance/feature-boolean.component.scss [new file with mode: 0644]
client/src/app/shared/shared-instance/feature-boolean.component.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/index.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-features-table.component.html [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-features-table.component.scss [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-features-table.component.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-follow.service.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-statistics.component.html [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-statistics.component.scss [new file with mode: 0644]
client/src/app/shared/shared-instance/instance-statistics.component.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/instance.service.ts [new file with mode: 0644]
client/src/app/shared/shared-instance/shared-instance.module.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/account.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/account.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/actor-avatar-info.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/account/actor-avatar-info.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/account/actor-avatar-info.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/actor.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/avatar.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/account/avatar.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/account/avatar.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/from-now.pipe.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/number-formatter.pipe.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/peertube-template.directive.ts [new file with mode: 0644]
client/src/app/shared/shared-main/auth/auth-interceptor.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/auth/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/action-dropdown.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/action-dropdown.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/action-dropdown.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/button.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/button.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/button.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/delete-button.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/delete-button.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/edit-button.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/edit-button.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/buttons/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/date/date-toggle.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/date/date-toggle.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/date/date-toggle.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/date/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/feeds/feed.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/feeds/feed.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/feeds/feed.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/feeds/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/feeds/syndication.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/loader.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/loader.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/loader.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/small-loader.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/loaders/small-loader.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/misc/help.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/misc/help.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/misc/help.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/misc/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/misc/list-overflow.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/misc/list-overflow.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/misc/list-overflow.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/shared-main.module.ts [new file with mode: 0644]
client/src/app/shared/shared-main/users/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-history.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-notification.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-notification.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-notifications.component.html [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-notifications.component.scss [new file with mode: 0644]
client/src/app/shared/shared-main/users/user-notifications.component.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-caption/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-caption/video-caption.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-channel/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-channel/video-channel.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video-channel/video-channel.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/index.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/redundancy.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video-details.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video-edit.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video-import.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video-ownership.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video.model.ts [new file with mode: 0644]
client/src/app/shared/shared-main/video/video.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-block.model.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-blocklist.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-blocklist.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/account-blocklist.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/batch-domains-modal.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/batch-domains-modal.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/batch-domains-modal.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/blocklist.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/bulk.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/index.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/server-blocklist.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/server-blocklist.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/server-blocklist.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/shared-moderation.module.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/user-ban-modal.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/user-ban-modal.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/user-ban-modal.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-abuse.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-block.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-block.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-block.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-block.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-report.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-report.component.scss [new file with mode: 0644]
client/src/app/shared/shared-moderation/video-report.component.ts [new file with mode: 0644]
client/src/app/shared/shared-thumbnail/index.ts [new file with mode: 0644]
client/src/app/shared/shared-thumbnail/shared-thumbnail.module.ts [new file with mode: 0644]
client/src/app/shared/shared-thumbnail/video-thumbnail.component.html [new file with mode: 0644]
client/src/app/shared/shared-thumbnail/video-thumbnail.component.scss [new file with mode: 0644]
client/src/app/shared/shared-thumbnail/video-thumbnail.component.ts [new file with mode: 0644]
client/src/app/shared/shared-user-settings/index.ts [new file with mode: 0644]
client/src/app/shared/shared-user-settings/shared-user-settings.module.ts [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-interface-settings.component.html [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-interface-settings.component.scss [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-interface-settings.component.ts [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-video-settings.component.html [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-video-settings.component.scss [new file with mode: 0644]
client/src/app/shared/shared-user-settings/user-video-settings.component.ts [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/index.ts [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/remote-subscribe.component.html [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/subscribe-button.component.html [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/subscribe-button.component.scss [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/subscribe-button.component.ts [new file with mode: 0644]
client/src/app/shared/shared-user-subscription/user-subscription.service.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/abstract-video-list.html [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/abstract-video-list.scss [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/abstract-video-list.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/index.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-download.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-download.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-download.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-miniature.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-miniature.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/video-miniature.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/videos-selection.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/videos-selection.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-miniature/videos-selection.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/index.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist.model.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-playlist.service.ts [new file with mode: 0644]
client/src/app/shared/shared.module.ts [deleted file]
client/src/app/shared/user-subscription/index.ts [deleted file]
client/src/app/shared/user-subscription/remote-subscribe.component.html [deleted file]
client/src/app/shared/user-subscription/remote-subscribe.component.scss [deleted file]
client/src/app/shared/user-subscription/remote-subscribe.component.ts [deleted file]
client/src/app/shared/user-subscription/subscribe-button.component.html [deleted file]
client/src/app/shared/user-subscription/subscribe-button.component.scss [deleted file]
client/src/app/shared/user-subscription/subscribe-button.component.ts [deleted file]
client/src/app/shared/user-subscription/user-subscription.service.ts [deleted file]
client/src/app/shared/users/index.ts [deleted file]
client/src/app/shared/users/user-history.service.ts [deleted file]
client/src/app/shared/users/user-notification.model.ts [deleted file]
client/src/app/shared/users/user-notification.service.ts [deleted file]
client/src/app/shared/users/user-notifications.component.html [deleted file]
client/src/app/shared/users/user-notifications.component.scss [deleted file]
client/src/app/shared/users/user-notifications.component.ts [deleted file]
client/src/app/shared/users/user.model.ts [deleted file]
client/src/app/shared/users/user.service.ts [deleted file]
client/src/app/shared/video-abuse/index.ts [deleted file]
client/src/app/shared/video-abuse/video-abuse.service.ts [deleted file]
client/src/app/shared/video-block/index.ts [deleted file]
client/src/app/shared/video-block/video-block.service.ts [deleted file]
client/src/app/shared/video-caption/index.ts [deleted file]
client/src/app/shared/video-caption/video-caption-edit.model.ts [deleted file]
client/src/app/shared/video-caption/video-caption.service.ts [deleted file]
client/src/app/shared/video-channel/video-channel.model.ts [deleted file]
client/src/app/shared/video-channel/video-channel.service.ts [deleted file]
client/src/app/shared/video-import/index.ts [deleted file]
client/src/app/shared/video-import/video-import.service.ts [deleted file]
client/src/app/shared/video-ownership/index.ts [deleted file]
client/src/app/shared/video-ownership/video-ownership.service.ts [deleted file]
client/src/app/shared/video-playlist/video-add-to-playlist.component.html [deleted file]
client/src/app/shared/video-playlist/video-add-to-playlist.component.scss [deleted file]
client/src/app/shared/video-playlist/video-add-to-playlist.component.ts [deleted file]
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html [deleted file]
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss [deleted file]
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts [deleted file]
client/src/app/shared/video-playlist/video-playlist-element.model.ts [deleted file]
client/src/app/shared/video-playlist/video-playlist-miniature.component.html [deleted file]
client/src/app/shared/video-playlist/video-playlist-miniature.component.scss [deleted file]
client/src/app/shared/video-playlist/video-playlist-miniature.component.ts [deleted file]
client/src/app/shared/video-playlist/video-playlist.model.ts [deleted file]
client/src/app/shared/video-playlist/video-playlist.service.ts [deleted file]
client/src/app/shared/video/abstract-video-list.html [deleted file]
client/src/app/shared/video/abstract-video-list.scss [deleted file]
client/src/app/shared/video/abstract-video-list.ts [deleted file]
client/src/app/shared/video/feed.component.html [deleted file]
client/src/app/shared/video/feed.component.scss [deleted file]
client/src/app/shared/video/feed.component.ts [deleted file]
client/src/app/shared/video/infinite-scroller.directive.ts [deleted file]
client/src/app/shared/video/modals/video-block.component.html [deleted file]
client/src/app/shared/video/modals/video-block.component.scss [deleted file]
client/src/app/shared/video/modals/video-block.component.ts [deleted file]
client/src/app/shared/video/modals/video-download.component.html [deleted file]
client/src/app/shared/video/modals/video-download.component.scss [deleted file]
client/src/app/shared/video/modals/video-download.component.ts [deleted file]
client/src/app/shared/video/modals/video-report.component.html [deleted file]
client/src/app/shared/video/modals/video-report.component.scss [deleted file]
client/src/app/shared/video/modals/video-report.component.ts [deleted file]
client/src/app/shared/video/recommendation-info.model.ts [deleted file]
client/src/app/shared/video/redundancy.service.ts [deleted file]
client/src/app/shared/video/sort-field.type.ts [deleted file]
client/src/app/shared/video/syndication.model.ts [deleted file]
client/src/app/shared/video/video-actions-dropdown.component.html [deleted file]
client/src/app/shared/video/video-actions-dropdown.component.scss [deleted file]
client/src/app/shared/video/video-actions-dropdown.component.ts [deleted file]
client/src/app/shared/video/video-details.model.ts [deleted file]
client/src/app/shared/video/video-edit.model.ts [deleted file]
client/src/app/shared/video/video-miniature.component.html [deleted file]
client/src/app/shared/video/video-miniature.component.scss [deleted file]
client/src/app/shared/video/video-miniature.component.ts [deleted file]
client/src/app/shared/video/video-thumbnail.component.html [deleted file]
client/src/app/shared/video/video-thumbnail.component.scss [deleted file]
client/src/app/shared/video/video-thumbnail.component.ts [deleted file]
client/src/app/shared/video/video.model.ts [deleted file]
client/src/app/shared/video/video.service.ts [deleted file]
client/src/app/shared/video/videos-selection.component.html [deleted file]
client/src/app/shared/video/videos-selection.component.scss [deleted file]
client/src/app/shared/video/videos-selection.component.ts [deleted file]
client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts [new file with mode: 0644]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
client/src/app/videos/+video-edit/shared/video-edit.component.ts
client/src/app/videos/+video-edit/shared/video-edit.module.ts
client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
client/src/app/videos/+video-edit/video-add-components/video-send.ts
client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
client/src/app/videos/+video-edit/video-add-routing.module.ts
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-add.module.ts
client/src/app/videos/+video-edit/video-update-routing.module.ts
client/src/app/videos/+video-edit/video-update.component.ts
client/src/app/videos/+video-edit/video-update.module.ts
client/src/app/videos/+video-edit/video-update.resolver.ts
client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
client/src/app/videos/+video-watch/comment/video-comment.component.ts
client/src/app/videos/+video-watch/comment/video-comment.model.ts
client/src/app/videos/+video-watch/comment/video-comment.service.ts
client/src/app/videos/+video-watch/comment/video-comments.component.ts
client/src/app/videos/+video-watch/modal/video-share.component.ts
client/src/app/videos/+video-watch/modal/video-support.component.ts
client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/video-watch-playlist.component.ts
client/src/app/videos/+video-watch/video-watch-routing.module.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/+video-watch/video-watch.module.ts
client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
client/src/app/videos/recommendations/recommendation-info.model.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommendations.module.ts
client/src/app/videos/recommendations/recommendations.service.ts
client/src/app/videos/recommendations/recommended-videos.component.ts
client/src/app/videos/recommendations/recommended-videos.store.ts
client/src/app/videos/video-list/index.ts
client/src/app/videos/video-list/overview/index.ts [new file with mode: 0644]
client/src/app/videos/video-list/overview/overview.service.ts [new file with mode: 0644]
client/src/app/videos/video-list/overview/video-overview.component.html [new file with mode: 0644]
client/src/app/videos/video-list/overview/video-overview.component.scss [new file with mode: 0644]
client/src/app/videos/video-list/overview/video-overview.component.ts [new file with mode: 0644]
client/src/app/videos/video-list/overview/videos-overview.model.ts [new file with mode: 0644]
client/src/app/videos/video-list/video-local.component.ts
client/src/app/videos/video-list/video-most-liked.component.ts
client/src/app/videos/video-list/video-overview.component.html [deleted file]
client/src/app/videos/video-list/video-overview.component.scss [deleted file]
client/src/app/videos/video-list/video-overview.component.ts [deleted file]
client/src/app/videos/video-list/video-recently-added.component.ts
client/src/app/videos/video-list/video-trending.component.ts
client/src/app/videos/video-list/video-user-subscriptions.component.ts
client/src/app/videos/videos-routing.module.ts
client/src/app/videos/videos.module.ts
server/controllers/api/users/me.ts
server/lib/auth.ts
server/lib/plugins/plugin-helpers.ts
server/lib/plugins/plugin-manager.ts
server/lib/plugins/register-helpers-store.ts
server/models/utils.ts
server/types/index.ts [new file with mode: 0644]
server/types/models/account/account-blocklist.ts
server/types/models/account/account.ts
server/types/models/account/actor-follow.ts
server/types/models/account/actor.ts
server/types/models/account/avatar.ts
server/types/models/oauth/oauth-token.ts
server/types/models/server/server-blocklist.ts
server/types/models/server/server.ts
server/types/models/user/user-notification.ts
server/types/models/user/user.ts
server/types/models/video/schedule-video-update.ts
server/types/models/video/video-abuse.ts
server/types/models/video/video-blacklist.ts
server/types/models/video/video-caption.ts
server/types/models/video/video-change-ownership.ts
server/types/models/video/video-channels.ts
server/types/models/video/video-comment.ts
server/types/models/video/video-file.ts
server/types/models/video/video-import.ts
server/types/models/video/video-playlist-element.ts
server/types/models/video/video-playlist.ts
server/types/models/video/video-rate.ts
server/types/models/video/video-redundancy.ts
server/types/models/video/video-share.ts
server/types/models/video/video-streaming-playlist.ts
server/types/models/video/video.ts
server/types/plugins/index.ts [new file with mode: 0644]
server/types/plugins/plugin-library.model.ts [new file with mode: 0644]
server/types/plugins/register-server-auth.model.ts [new file with mode: 0644]
server/types/plugins/register-server-option.model.ts [new file with mode: 0644]
server/types/utils.ts [deleted file]
server/typings/express/index.d.ts
server/typings/plugins/index.d.ts [deleted file]
server/typings/plugins/plugin-library.model.ts [deleted file]
server/typings/plugins/register-server-option.model.ts [deleted file]
shared/core-utils/index.ts [new file with mode: 0644]
shared/core-utils/logs/index.ts [new file with mode: 0644]
shared/core-utils/miscs/index.ts [new file with mode: 0644]
shared/core-utils/miscs/types.ts [new file with mode: 0644]
shared/core-utils/plugins/index.ts [new file with mode: 0644]
shared/extra-utils/server/config.ts
shared/models/actors/index.ts
shared/models/index.ts
shared/models/plugins/index.ts [new file with mode: 0644]
shared/models/plugins/plugin-playlist-privacy-manager.model.ts
shared/models/plugins/plugin-video-privacy-manager.model.ts
shared/models/plugins/register-server-auth.model.ts [deleted file]
shared/models/search/index.ts
shared/models/server/index.ts
shared/models/users/index.ts
shared/models/videos/abuse/index.ts [new file with mode: 0644]
shared/models/videos/blacklist/index.ts [new file with mode: 0644]
shared/models/videos/caption/index.ts [new file with mode: 0644]
shared/models/videos/channel/index.ts [new file with mode: 0644]
shared/models/videos/import/index.ts [new file with mode: 0644]
shared/models/videos/index.ts
shared/models/videos/playlist/index.ts [new file with mode: 0644]
shared/models/videos/rate/index.ts [new file with mode: 0644]
shared/models/videos/video-file-metadata.ts
shared/models/videos/video-sort-field.type.ts [new file with mode: 0644]

index fc265fecbe24d24d767f80f2c391fa08e0d70be6..17c6903b8ec4a3be7f30e1bd5007ab7594030188 100644 (file)
@@ -1,10 +1,8 @@
-import { Component, OnInit } from '@angular/core'
-import { FollowService } from '@app/shared/instance/follow.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { Notifier } from '@app/core'
-import { RestService } from '@app/shared'
 import { SortMeta } from 'primeng/api'
 import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { ComponentPagination, hasMoreItems, Notifier, RestService } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
 
 @Component({
   selector: 'my-about-follows',
@@ -38,7 +36,7 @@ export class AboutFollowsComponent implements OnInit {
   constructor (
     private restService: RestService,
     private notifier: Notifier,
-    private followService: FollowService
+    private followService: InstanceFollowService
   ) { }
 
   ngOnInit () {
index 15841be74e34339636530a11a6cc1cf038392506..c57ac69abe4f5efcd93d9c865f4d78d3732d8e86 100644 (file)
@@ -1,11 +1,11 @@
-import { Component, OnInit, ViewChild, AfterViewChecked } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
+import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
 import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { ServerService } from '@app/core'
+import { InstanceService } from '@app/shared/shared-instance'
 import { ServerConfig } from '@shared/models'
-import { ActivatedRoute } from '@angular/router'
 import { ResolverData } from './about-instance.resolver'
-import { ViewportScroller } from '@angular/common'
 
 @Component({
   selector: 'my-about-instance',
index 94c6abe5a413d200628fd23bc85c114569bed237..b2349ba12b097fa0cfa080cdefab10a9ccebcf23 100644 (file)
@@ -1,17 +1,16 @@
+import { forkJoin } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
 import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
-import { map, switchMap } from 'rxjs/operators'
-import { forkJoin } from 'rxjs'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { InstanceService } from '@app/shared/shared-instance'
 import { About } from '@shared/models/server'
 
 export type ResolverData = { about: About, languages: string[], categories: string[] }
 
 @Injectable()
 export class AboutInstanceResolver implements Resolve<any> {
-  constructor (
-    private instanceService: InstanceService
-  ) {}
+
+  constructor (private instanceService: InstanceService) {}
 
   resolve (route: ActivatedRouteSnapshot) {
     return this.instanceService.getAbout()
index d5e146b8262c4c2bbd9d19fda6d516da6dffcf18..5199402e6f636be6dcae9b77a135d46050bc92db 100644 (file)
@@ -1,11 +1,10 @@
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Notifier, ServerService } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { FormReactive, FormValidatorService, InstanceValidatorsService } from '@app/shared/shared-forms'
+import { InstanceService } from '@app/shared/shared-instance'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormReactive, InstanceValidatorsService } from '@app/shared'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
 import { ServerConfig } from '@shared/models'
 
 @Component({
index fa2c0daa03b5d7a8e3f2b6ae15bd07cae5cd836a..da1fcdc920fdcafd776f41f352929b0c684095f6 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, OnInit } from '@angular/core'
-import { MarkdownService } from '@app/shared/renderer'
+import { MarkdownService } from '@app/core'
 
 @Component({
   selector: 'my-about-peertube-contributors',
index 84d6975402097c5f2e6060bc834eabb74a0c83b1..1aca140331cf262d9a4d9551b1df453a6619a9ca 100644 (file)
@@ -1,18 +1,25 @@
 import { NgModule } from '@angular/core'
-import { AboutRoutingModule } from './about-routing.module'
-import { AboutComponent } from './about.component'
-import { SharedModule } from '../shared'
+import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
 import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
-import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
+import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
 import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
-import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
 import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component'
-import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
+import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedInstanceModule } from '@app/shared/shared-instance'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { AboutRoutingModule } from './about-routing.module'
+import { AboutComponent } from './about.component'
 
 @NgModule({
   imports: [
     AboutRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedInstanceModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
index d1616490ff33a85b1392874780ad2b86a0684b85..8c01e4007d270018a5011409375d69f72bdd295e 100644 (file)
@@ -1,9 +1,8 @@
+import { Subscription } from 'rxjs'
 import { Component, OnDestroy, OnInit } from '@angular/core'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
+import { MarkdownService } from '@app/core'
+import { Account, AccountService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { MarkdownService } from '@app/shared/renderer'
 
 @Component({
   selector: 'my-account-about',
index eba1acfa122cce3d2d96ebe292696ae1756c5ca5..205245675af0d19b483b809df2969f93fa4a23e9 100644 (file)
@@ -1,16 +1,9 @@
 import { from, Subject, Subscription } from 'rxjs'
 import { concatMap, map, switchMap, tap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit } from '@angular/core'
-import { User, UserService } from '@app/shared'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
+import { ComponentPagination, hasMoreItems, ScreenService, User, UserService } from '@app/core'
+import { Account, AccountService, Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-account-video-channels',
index 41b27b541b2681d783280243a874c445fbe4c5c7..dd47589c1030c8e478633aa427c018e503a7550f 100644 (file)
@@ -1,25 +1,18 @@
+import { Subscription } from 'rxjs'
+import { first, tap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { first, tap } from 'rxjs/operators'
+import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { Account, AccountService, VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
 
 @Component({
   selector: 'my-account-videos',
-  templateUrl: '../../shared/video/abstract-video-list.html',
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
   styleUrls: [
-    '../../shared/video/abstract-video-list.scss',
+    '../../shared/shared-video-miniature/abstract-video-list.scss',
     './account-videos.component.scss'
   ]
 })
index bf71179f32b629c33a68d5b368e38f61d6e32013..01911cac286892648079c6dc1a38d6f5820a5bd7 100644 (file)
@@ -1,17 +1,11 @@
+import { Subscription } from 'rxjs'
+import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { AccountService } from '@app/shared/account/account.service'
-import { Account } from '@app/shared/account/account.model'
-import { RestExtractor, UserService } from '@app/shared'
-import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier, RedirectService } from '@app/core'
-import { User, UserRight } from '../../../../shared'
+import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
+import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { User, UserRight } from '@shared/models'
 
 @Component({
   templateUrl: './accounts.component.html',
index 8e679822a58eb686b84f61e2bcee25e7db6a714e..815360341fc7fc34983ba9745aee50d9f35e5492 100644 (file)
@@ -1,15 +1,26 @@
 import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
-import { AccountsRoutingModule } from './accounts-routing.module'
-import { AccountsComponent } from './accounts.component'
-import { AccountVideosComponent } from './account-videos/account-videos.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
 import { AccountAboutComponent } from './account-about/account-about.component'
 import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
+import { AccountVideosComponent } from './account-videos/account-videos.component'
+import { AccountsRoutingModule } from './accounts-routing.module'
+import { AccountsComponent } from './accounts.component'
 
 @NgModule({
   imports: [
     AccountsRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedUserSubscriptionModule,
+    SharedModerationModule,
+    SharedVideoMiniatureModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
index 79c57221b4fc949748a1b821d5fca72e0b188689..986dae8eb430843396b78ee2180638d8398d07be 100644 (file)
@@ -1,15 +1,13 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
 import { ConfigRoutes } from '@app/+admin/config'
-
+import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
+import { PluginsRoutes } from '@app/+admin/plugins/plugins.routes'
+import { SystemRoutes } from '@app/+admin/system'
 import { MetaGuard } from '@ngx-meta/core'
-
 import { AdminComponent } from './admin.component'
 import { FollowsRoutes } from './follows'
 import { UsersRoutes } from './users'
-import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
-import { SystemRoutes } from '@app/+admin/system'
-import { PluginsRoutes } from '@app/+admin/plugins/plugins.routes'
 
 const adminRoutes: Routes = [
   {
index a97a33cf58131a55a954eb9bbc819b11309ddd95..e47c7a8f42e721a34eabb1078c8952d8394f4bb5 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, OnInit } from '@angular/core'
-import { UserRight } from '../../../../shared'
-import { AuthService } from '../core/auth/auth.service'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
+import { AuthService } from '@app/core'
+import { ListOverflowItem } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRight } from '@shared/models'
 
 @Component({
   templateUrl: './admin.component.html'
index eccec8a49cb15806b015b59a646b8fd5d94743ae..728227a84d076d48ff12bd225e2c56d3988fbc50 100644 (file)
@@ -1,39 +1,41 @@
-import { NgModule } from '@angular/core'
-import { ConfigComponent, EditCustomConfigComponent } from '@app/+admin/config'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { ChartModule } from 'primeng/chart'
+import { SelectButtonModule } from 'primeng/selectbutton'
 import { TableModule } from 'primeng/table'
-import { SharedModule } from '../shared'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
 import { AdminRoutingModule } from './admin-routing.module'
 import { AdminComponent } from './admin.component'
+import { ConfigComponent, EditCustomConfigComponent } from './config'
+import { ConfigService } from './config/shared/config.service'
 import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
 import { FollowingListComponent } from './follows/following-list/following-list.component'
-import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
-import {
-  ModerationCommentModalComponent,
-  VideoAbuseListComponent,
-  VideoBlockListComponent
-} from './moderation'
-import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
-import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
-import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
-import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
-import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
-import { DebugComponent, DebugService } from '@app/+admin/system/debug'
-import { PluginsComponent } from '@app/+admin/plugins/plugins.component'
-import { PluginListInstalledComponent } from '@app/+admin/plugins/plugin-list-installed/plugin-list-installed.component'
-import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-search.component'
-import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
-import { SelectButtonModule } from 'primeng/selectbutton'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
-import { ChartModule } from 'primeng/chart'
+import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
+import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
+import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlockListComponent } from './moderation'
+import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
+import { ModerationComponent } from './moderation/moderation.component'
 import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component'
+import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
+import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
+import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
+import { PluginsComponent } from './plugins/plugins.component'
+import { PluginApiService } from './plugins/shared/plugin-api.service'
+import { JobService, LogsComponent, LogsService, SystemComponent } from './system'
+import { DebugComponent, DebugService } from './system/debug'
+import { JobsComponent } from './system/jobs/jobs.component'
+import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
 
 @NgModule({
   imports: [
     AdminRoutingModule,
 
-    SharedModule,
+    SharedMainModule,
+    SharedFormModule,
+    SharedModerationModule,
+    SharedGlobalIconModule,
 
     TableModule,
     SelectButtonModule,
index 2ca2f8fdeadd2f94608a10c68e0170db83d09c96..7c1a1a16633704c2cb090eeb7524856553230a82 100644 (file)
@@ -1,7 +1,7 @@
 import { Routes } from '@angular/router'
 import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
 import { UserRightGuard } from '@app/core'
-import { UserRight } from '../../../../../shared/models/users'
+import { UserRight } from '@shared/models'
 import { ConfigComponent } from './config.component'
 
 export const ConfigRoutes: Routes = [
index 3a47ba25e3f2ecf4286df5b46d21e69f3275da8d..69629770f518b7f704d1610796ba50b9bfd08eb9 100644 (file)
@@ -1,16 +1,14 @@
-import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { ServerService } from '@app/core/server/server.service'
-import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared'
-import { Notifier } from '@app/core'
-import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import { SelectItem } from 'primeng/api'
 import { forkJoin } from 'rxjs'
-import { ServerConfig } from '@shared/models'
 import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
+import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { Notifier } from '@app/core'
+import { ServerService } from '@app/core/server/server.service'
+import { CustomConfigValidatorsService, FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { NgbNav } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { CustomConfig, ServerConfig } from '@shared/models'
 
 @Component({
   selector: 'my-edit-custom-config',
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.html b/client/src/app/+admin/config/shared/batch-domains-modal.component.html
deleted file mode 100644 (file)
index 1b85c8f..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<ng-template #modal>
-  <div class="modal-header">
-    <h4 i18n class="modal-title">{{ action }}</h4>
-
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
-  </div>
-
-  <div class="modal-body">
-    <form novalidate [formGroup]="form" (ngSubmit)="submit()">
-      <div class="form-group">
-        <label i18n for="hosts">1 host (without "http://") per line</label>
-
-        <textarea
-          [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
-          class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
-        ></textarea>
-
-        <div *ngIf="formErrors.domains" class="form-error">
-          {{ formErrors.domains }}
-
-          <div *ngIf="form.controls['domains'].errors.validDomains">
-            {{ form.controls['domains'].errors.validDomains.value }}
-          </div>
-        </div>
-      </div>
-
-      <ng-content select="warning"></ng-content>
-
-      <div class="form-group inputs">
-        <input
-          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-          (click)="hide()" (key.enter)="hide()"
-        >
-
-        <input
-          type="submit" [value]="action" class="action-button-submit"
-          [disabled]="!form.valid"
-        >
-      </div>
-    </form>
-  </div>
-
-</ng-template>
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.scss b/client/src/app/+admin/config/shared/batch-domains-modal.component.scss
deleted file mode 100644 (file)
index 9621a56..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-textarea {
-  height: 200px;
-}
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.ts b/client/src/app/+admin/config/shared/batch-domains-modal.component.ts
deleted file mode 100644 (file)
index 620f272..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/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 } from '@app/shared/forms'
-import { BatchDomainsValidatorsService } from './batch-domains-validators.service'
-
-@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<string[]>()
-
-  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/+admin/config/shared/batch-domains-validators.service.ts b/client/src/app/+admin/config/shared/batch-domains-validators.service.ts
deleted file mode 100644 (file)
index 46fa651..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators, ValidatorFn } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator, validateHost } from '@app/shared/forms/form-validators'
-
-@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'
-        }
-      }
-    }
-  }
-}
index 874b8094dd96ce802c52f90566c8e62257be76e0..f182946b86ced6e42112150d53681cfc63b0fc02 100644 (file)
@@ -1,10 +1,10 @@
 import { catchError } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model'
-import { environment } from '../../../../environments/environment'
-import { RestExtractor } from '../../../shared'
+import { RestExtractor } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { CustomConfig } from '@shared/models'
+import { environment } from '../../../../environments/environment'
 
 @Injectable()
 export class ConfigService {
index 17352a60104c7cb264605f4b13e94c555799167f..63135f898212a9dbb6b5eaa1b8dd57223da2cc52 100644 (file)
@@ -1,10 +1,9 @@
-import { Component, OnInit } from '@angular/core'
-import { ConfirmService, Notifier } from '@app/core'
 import { SortMeta } from 'primeng/api'
-import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
-import { RestPagination, RestTable } from '../../../shared'
-import { FollowService } from '@app/shared/instance/follow.service'
+import { Component, OnInit } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ActorFollow } from '@shared/models'
 
 @Component({
   selector: 'my-followers-list',
@@ -21,7 +20,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
     private confirmService: ConfirmService,
     private notifier: Notifier,
     private i18n: I18n,
-    private followService: FollowService
+    private followService: InstanceFollowService
   ) {
     super()
   }
index 6ddbf02d6deb04e1594ed55b12c28e61f8a86c05..dae8923b5e5e2918a301384e71a6ca68ece347e2 100644 (file)
@@ -1,12 +1,10 @@
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
 import { SortMeta } from 'primeng/api'
-import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
-import { ConfirmService } from '../../../core/confirm/confirm.service'
-import { RestPagination, RestTable } from '../../../shared'
-import { FollowService } from '@app/shared/instance/follow.service'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
+import { BatchDomainsModalComponent } from '@app/shared/shared-moderation'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
+import { ActorFollow } from '@shared/models'
 
 @Component({
   selector: 'my-followers-list',
@@ -24,7 +22,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
   constructor (
     private notifier: Notifier,
     private confirmService: ConfirmService,
-    private followService: FollowService,
+    private followService: InstanceFollowService,
     private i18n: I18n
   ) {
     super()
index 8270ae444d3282b4356561733921c799822cd193..81707453615e70937b7517c8d5b45e55d5273101 100644 (file)
@@ -1,11 +1,10 @@
 import { Routes } from '@angular/router'
-
-import { UserRightGuard } from '../../core'
-import { FollowsComponent } from './follows.component'
+import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
 import { FollowersListComponent } from './followers-list'
-import { UserRight } from '../../../../../shared'
 import { FollowingListComponent } from './following-list/following-list.component'
-import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
+import { FollowsComponent } from './follows.component'
 
 export const FollowsRoutes: Routes = [
   {
index 9d7883d97ca8aded1331f73a4a9eef596ac9c432..662143abcf0713ba784eef8ae50a68fb6d19970e 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, Input } from '@angular/core'
 import { Notifier } from '@app/core'
+import { RedundancyService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
 
 @Component({
   selector: 'my-redundancy-checkbox',
index 6a7c7f483b3ee6a0b3f96d0e8a52742637420d4c..07e734b1a1cdbbe16548e4bb99af55b12bd37495 100644 (file)
@@ -1 +1,2 @@
 export * from './video-redundancies-list.component'
+export * from './video-redundancy-information.component'
index 267a1f58efeecc838a12c8e6e9847912658f87df..0048882bcbf69d05afd49b797d6e9135c68d960a 100644 (file)
@@ -1,14 +1,12 @@
-import { Component, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
+import { BytesPipe } from 'ngx-pipes'
 import { SortMeta } from 'primeng/api'
-import { ConfirmService } from '../../../core/confirm/confirm.service'
-import { RestPagination, RestTable } from '../../../shared'
+import { Component, OnInit } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
+import { RedundancyService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
 import { VideosRedundancyStats } from '@shared/models/server'
-import { BytesPipe } from 'ngx-pipes'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
 
 @Component({
   selector: 'my-video-redundancies-list',
index e99244b745d4d8c3cb875807e00e360cd3c73ef6..16249236c781e3f608c3b36e5ef94c4805a4094c 100644 (file)
@@ -1,3 +1,4 @@
+export * from './instance-blocklist'
 export * from './video-abuse-list'
 export * from './video-block-list'
 export * from './moderation.component'
index 90a17619471c877c3d410197e2e5797ba3ec3567..d9fec29ce1a6b10acf3a720c3bcdafd2ecf823c2 100644 (file)
@@ -1,10 +1,10 @@
 import { Component } from '@angular/core'
-import { GenericAccountBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/shared/shared-moderation'
 
 @Component({
   selector: 'my-instance-account-blocklist',
-  styleUrls: [ '../moderation.component.scss', '../../../shared/blocklist/account-blocklist.component.scss' ],
-  templateUrl: '../../../shared/blocklist/account-blocklist.component.html'
+  styleUrls: [ '../moderation.component.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ],
+  templateUrl: '../../../shared/shared-moderation/account-blocklist.component.html'
 })
 export class InstanceAccountBlocklistComponent extends GenericAccountBlocklistComponent {
   mode = BlocklistComponentType.Instance
index 9d4ec174a5c6e1d47dc28ebf67d677593a84d7f7..3afae529f93103219cb9abf07d7eb0a44e339282 100644 (file)
@@ -1,10 +1,10 @@
 import { Component } from '@angular/core'
-import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/shared-moderation'
 
 @Component({
   selector: 'my-instance-server-blocklist',
-  styleUrls: [ '../../../shared/blocklist/server-blocklist.component.scss' ],
-  templateUrl: '../../../shared/blocklist/server-blocklist.component.html'
+  styleUrls: [ '../../../shared/shared-moderation/server-blocklist.component.scss' ],
+  templateUrl: '../../../shared/shared-moderation/server-blocklist.component.html'
 })
 export class InstanceServerBlocklistComponent extends GenericServerBlocklistComponent {
   mode = BlocklistComponentType.Instance
index 1b1df6f09a17fe0693be09f0d612c4eaaab35e9e..806f9d100a81f56622717e13f088ebeb375e01f2 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, OnInit } from '@angular/core'
-import { UserRight } from '../../../../../shared'
 import { AuthService, ServerService } from '@app/core'
+import { UserRight } from '@shared/models'
 
 @Component({
   templateUrl: './moderation.component.html',
index c08333f17d2e27c6636f6346b7ad84fcc2b531c7..cd837bcb948c04cb4c0eaa3499db8bccaa134b6e 100644 (file)
@@ -1,10 +1,10 @@
 import { Routes } from '@angular/router'
-import { UserRight } from '../../../../../shared'
-import { UserRightGuard } from '@app/core'
+import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
+import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
 import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list'
 import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
-import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
-import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
 
 export const ModerationRoutes: Routes = [
   {
index a0471f2b062d9d7e9a84fbfd3e43f836d4d2915a..3cd763ca46ee758ddf6b0ae641b7b1ed0593a16e 100644 (file)
@@ -1,11 +1,11 @@
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
-import { FormReactive, VideoAbuseService, VideoAbuseValidatorsService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
+import { VideoAbuseService } from '@app/shared/shared-moderation'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormValidatorService } from '../../../shared/forms/form-validators/form-validator.service'
-import { VideoAbuse } from '../../../../../../shared/models/videos'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoAbuse } from '@shared/models'
 
 @Component({
   selector: 'my-moderation-comment-modal',
index 13485124f21074f9ee19d5b4b00b9914c745dda3..9aa70288dd030b7efa5f6b86b570677d155b997c 100644 (file)
@@ -1,9 +1,8 @@
 import { Component, Input } from '@angular/core'
-import { Actor } from '@app/shared/actor/actor.model'
+import { Actor } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
 import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
 import { ProcessedVideoAbuse } from './video-abuse-list.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { durationToString } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-video-abuse-details',
index d7f5beef3a4bb48a123d03a9314f3f035ed0abc9..b0a655e714d154f53a03b497ab8215cdddb54738 100644 (file)
@@ -1,23 +1,16 @@
-import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
 import { SortMeta } from 'primeng/api'
-import { VideoAbuse, VideoAbuseState } from '../../../../../../shared'
-import { RestPagination, RestTable, VideoAbuseService, VideoBlockService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
-import { ConfirmService } from '../../../core/index'
-import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
-import { Video } from '../../../shared/video/video.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Actor } from '@app/shared/actor/actor.model'
+import { filter } from 'rxjs/operators'
 import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
+import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
 import { DomSanitizer } from '@angular/platform-browser'
-import { BlocklistService } from '@app/shared/blocklist'
-import { VideoService } from '@app/shared/video/video.service'
 import { ActivatedRoute, Params, Router } from '@angular/router'
-import { filter } from 'rxjs/operators'
-import { environment } from 'src/environments/environment'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
+import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
+import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoAbuse, VideoAbuseState } from '@shared/models'
+import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
 
 export type ProcessedVideoAbuse = VideoAbuse & {
   moderationCommentHtml?: string,
index c0ac3224218df09879d9016bf7e2bdbb995744a1..c03f9248ed0fcd3206134771cf9596251ed84ab4 100644 (file)
@@ -1,16 +1,12 @@
-import { Component, OnInit, AfterViewInit } from '@angular/core'
 import { SortMeta } from 'primeng/api'
-import { Notifier, ServerService } from '@app/core'
-import { ConfirmService } from '../../../core'
-import { RestPagination, RestTable, VideoBlockService } from '../../../shared'
-import { VideoBlacklist, VideoBlacklistType } from '../../../../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
-import { Video } from '../../../shared/video/video.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Params, ActivatedRoute, Router } from '@angular/router'
 import { filter, switchMap } from 'rxjs/operators'
-import { VideoService } from '@app/shared/video/video.service'
+import { AfterViewInit, Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Params, Router } from '@angular/router'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
+import { VideoBlockService } from '@app/shared/shared-moderation'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoBlacklist, VideoBlacklistType } from '@shared/models'
 
 @Component({
   selector: 'my-video-block-list',
index a8973f2b2d6887e5966476ecedd0a90deb4d6475..af31f114444888f6e6c5d888ebcd0c69fde1d5ac 100644 (file)
@@ -1,14 +1,13 @@
+import { Subject } from 'rxjs'
 import { Component, OnInit } from '@angular/core'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { ConfirmService, Notifier } from '@app/core'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
 import { ActivatedRoute, Router } from '@angular/router'
-import { compareSemVer } from '@shared/core-utils/miscs/miscs'
+import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core'
 import { PluginService } from '@app/core/plugins/plugin.service'
-import { Subject } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { compareSemVer } from '@shared/core-utils/miscs/miscs'
+import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
+import { PluginType } from '@shared/models/plugins/plugin.type'
 
 @Component({
   selector: 'my-plugin-list-installed',
index dc59e759b5adf323ab264d2e7074718d6e24d551..ccf9f1ed50775f3f78a163f395b82537aff010a7 100644 (file)
@@ -1,14 +1,12 @@
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { ConfirmService } from '../../../core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
 import { ActivatedRoute, Router } from '@angular/router'
+import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, ServerService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
 import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
-import { Subject } from 'rxjs'
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+import { PluginType } from '@shared/models/plugins/plugin.type'
 
 @Component({
   selector: 'my-plugin-search',
index 13d12b145935489a3fffd2a7c6178d4e6023dabb..dde03f1da71b3ff1b182c5ff29e794e8f277b367 100644 (file)
@@ -1,13 +1,12 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { Notifier } from '@app/core'
-import { ActivatedRoute } from '@angular/router'
 import { Subscription } from 'rxjs'
 import { map, switchMap } from 'rxjs/operators'
-import { BuildFormArgument, FormReactive, FormValidatorService } from '@app/shared'
-import { RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { Notifier } from '@app/core'
+import { BuildFormArgument, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
+import { PluginApiService } from '../shared/plugin-api.service'
 
 @Component({
   selector: 'my-plugin-show-installed',
index f6ef68e9cc8e3ec8eb9de48359a4ab4edf6457ac..bf9129e01f5ca233f1e55f9d7e8faa489d779536 100644 (file)
@@ -1,19 +1,21 @@
+import { Observable } from 'rxjs'
 import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { peertubeTranslate, ResultList } from '@shared/models'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
-import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model'
-import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model'
-import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
-import { RegisteredServerSettings, RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+import { ComponentPagination, RestExtractor, RestService } from '@app/core'
 import { PluginService } from '@app/core/plugins/plugin.service'
-import { Observable } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import {
+  InstallOrUpdatePlugin,
+  ManagePlugin,
+  PeerTubePlugin,
+  PeerTubePluginIndex,
+  peertubeTranslate,
+  PluginType,
+  RegisteredServerSettings,
+  ResultList
+} from '@shared/models'
+import { environment } from '../../../../environments/environment'
 
 @Injectable()
 export class PluginApiService {
index 8a77f79f753e8e7c65ca3d283814a8424081178e..a88d837f31fa934e997794fe68fd92f154a4bbd5 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
-import { Debug } from '@shared/models/server'
-import { DebugService } from '@app/+admin/system/debug/debug.service'
+import { Debug } from '@shared/models'
+import { DebugService } from './debug.service'
 
 @Component({
   templateUrl: './debug.component.html',
index 6c722d177d13fd9f8ac53845c973abb4d9a39d64..ab1d0a7fa06f3b3049fbea4a577a455bdfaf48a1 100644 (file)
@@ -1,10 +1,10 @@
+import { Observable } from 'rxjs'
 import { catchError } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
+import { RestExtractor } from '@app/core'
+import { Debug } from '@shared/models'
 import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { Debug } from '@shared/models/server'
 
 @Injectable()
 export class DebugService {
@@ -12,7 +12,6 @@ export class DebugService {
 
   constructor (
     private authHttp: HttpClient,
-    private restService: RestService,
     private restExtractor: RestExtractor
   ) {}
 
index 226d999d2ac03c57cd2f6c65afd2e5f92e861eeb..8cced0cdbfa63d9654c43f43132fcf64c84ce2eb 100644 (file)
@@ -1,3 +1,4 @@
+export * from './debug'
 export * from './jobs'
 export * from './logs'
 export * from './system.component'
index 1e2291ad194f151051db59902fe6c8de6394f597..1ac50f050f9f3f39ef5cca0ed965f1d74a7e4f58 100644 (file)
@@ -1,12 +1,11 @@
+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 { SortMeta } from 'primeng/api'
-import { Observable } from 'rxjs'
-import { ResultList } from '../../../../../../shared'
-import { Job } from '../../../../../../shared/models/server/job.model'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { Job, ResultList } from '@shared/models'
 import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../../../shared'
 import { JobStateClient } from '../../../../types/job-state-client.type'
 import { JobTypeClient } from '../../../../types/job-type-client.type'
 
index 4f7f7c3681d638b011f4b22ebd6a0e3b649c3496..38a78de33daa645c2363db15e115b051282645e9 100644 (file)
@@ -1,14 +1,12 @@
-import { Component, OnInit } from '@angular/core'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { Notifier } from '@app/core'
 import { SortMeta } from 'primeng/api'
-import { Job, JobType } from '../../../../../../shared/index'
-import { JobState } from '../../../../../../shared/models'
-import { RestPagination, RestTable } from '../../../shared'
-import { JobService } from './job.service'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Job, JobState, JobType } from '@shared/models'
 import { JobStateClient } from '../../../../types/job-state-client.type'
 import { JobTypeClient } from '../../../../types/job-type-client.type'
+import { JobService } from './job.service'
 
 @Component({
   selector: 'my-jobs',
index b22581b5a4a264a509d4e8100e5ce2cd1474a161..615778210a4f0c6efe4fe371b96178863887fe52 100644 (file)
@@ -1,4 +1,4 @@
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogLevel } from '@shared/models'
 import omit from 'lodash-es/omit'
 
 export class LogRow {
index b63f1195310fb09cc9b7a22e000bc864b14d696a..51f04718817eb9b3331770b1ed9807ba84787542 100644 (file)
@@ -1,9 +1,9 @@
 import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
-import { LogsService } from '@app/+admin/system/logs/logs.service'
 import { Notifier } from '@app/core'
-import { LogRow } from '@app/+admin/system/logs/log-row.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogLevel } from '@shared/models'
+import { LogRow } from './log-row.model'
+import { LogsService } from './logs.service'
 
 @Component({
   templateUrl: './logs.component.html',
index 41b38c7baf65504b9163fa832fd92d6fd8d79cda..69439a179e302cd765684ae8e8e3b2f22e43807b 100644 (file)
@@ -1,11 +1,11 @@
+import { Observable } from 'rxjs'
 import { catchError, map } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
+import { RestExtractor } from '@app/core'
+import { LogLevel } from '@shared/models'
 import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { LogRow } from '@app/+admin/system/logs/log-row.model'
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogRow } from './log-row.model'
 
 @Injectable()
 export class LogsService {
@@ -14,7 +14,6 @@ export class LogsService {
 
   constructor (
     private authHttp: HttpClient,
-    private restService: RestService,
     private restExtractor: RestExtractor
   ) {}
 
index 2d851794d22faa038bbc28ee63591f2cd49fab24..0e8d985193552aeb9273d48cea19592dd12e1c80 100644 (file)
@@ -1,10 +1,10 @@
 import { Routes } from '@angular/router'
-import { UserRightGuard } from '../../core'
-import { UserRight } from '../../../../../shared'
-import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
-import { LogsComponent } from '@app/+admin/system/logs'
-import { SystemComponent } from '@app/+admin/system/system.component'
-import { DebugComponent } from '@app/+admin/system/debug'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
+import { DebugComponent } from './debug'
+import { JobsComponent } from './jobs/jobs.component'
+import { LogsComponent } from './logs'
+import { SystemComponent } from './system.component'
 
 export const SystemRoutes: Routes = [
   {
index b459eb8fa6beec1c89ef4fd2f617219466663793..f9f5b90bd5c39e04d4acd3f1ada21182a4509e36 100644 (file)
@@ -1,14 +1,11 @@
 import { Component, OnInit } from '@angular/core'
-import { Router, ActivatedRoute } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { UserCreate, UserRole } from '../../../../../../shared'
-import { UserEdit } from './user-edit'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
+import { ActivatedRoute, Router } from '@angular/router'
 import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserService } from '@app/shared'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { AuthService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserCreate, UserRole } from '@shared/models'
+import { UserEdit } from './user-edit'
 
 @Component({
   selector: 'my-user-create',
index 5f5cc590cc241d0c5dcc083b85e4583a0fb13858..8fa4b063501610af16c0c6b550c4f06e8cee3d43 100644 (file)
@@ -1,11 +1,8 @@
-import { AuthService, ServerService } from '../../../core'
-import { FormReactive } from '../../../shared'
-import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../../../../../../shared'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
 import { OnInit } from '@angular/core'
-import { User } from '@app/shared/users/user.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { AuthService, ScreenService, ServerService, User } from '@app/core'
+import { FormReactive } from '@app/shared/shared-forms'
+import { ServerConfig, USER_ROLE_LABELS, UserAdminFlag, UserRole, VideoResolution } from '@shared/models'
 
 export abstract class UserEdit extends FormReactive implements OnInit {
   videoQuotaOptions: { value: string, label: string, disabled?: boolean }[] = []
index ecad000f79b0327100266a1ea25e54f359d91aec..33c7de31f23172b26f9ac39c967f478a776aebbc 100644 (file)
@@ -1,12 +1,8 @@
 import { Component, Input, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { UserService } from '@app/shared/users/user.service'
-import { Notifier } from '../../../core'
-import { User, UserUpdate } from '../../../../../../shared'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { FormReactive } from '../../../shared'
+import { UserUpdate } from '@shared/models'
 
 @Component({
   selector: 'my-user-password',
index 035c0d4bb35a417b8e41ffa9c447be256f21cbbc..870880feeb25ad7432de307ee6a2800f8e6133de 100644 (file)
@@ -1,18 +1,12 @@
+import { Subscription } from 'rxjs'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier } from '@app/core'
-import { ServerService } from '../../../core'
-import { UserEdit } from './user-edit'
-import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
 import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserService } from '@app/shared'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
-import { User } from '@app/shared/users/user.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
+import { FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
+import { UserEdit } from './user-edit'
 
 @Component({
   selector: 'my-user-update',
index da50b7ed07f9188c6d4585bf583580e1061a76a8..8f01c7d51497c586e2848bc843f98900c0966300 100644 (file)
@@ -1,13 +1,10 @@
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
 import { SortMeta } from 'primeng/api'
-import { ConfirmService, ServerService } from '../../../core'
-import { RestPagination, RestTable, UserService } from '../../../shared'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService, UserService } from '@app/core'
+import { Actor, DropdownAction } from '@app/shared/shared-main'
+import { UserBanModalComponent } from '@app/shared/shared-moderation'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, User } from '../../../../../../shared'
-import { UserBanModalComponent } from '@app/shared/moderation'
-import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
-import { Actor } from '@app/shared/actor/actor.model'
+import { ServerConfig, User } from '@shared/models'
 
 @Component({
   selector: 'my-user-list',
index 2d4f9305e8d0b173fd15c2cd1877265139fe1b10..6560f0260a3fc91a07268a73f900d9aed0374a39 100644 (file)
@@ -1,11 +1,9 @@
 import { Routes } from '@angular/router'
-
-import { UserRightGuard } from '../../core'
-import { UserRight } from '../../../../../shared'
-import { UsersComponent } from './users.component'
+import { ServerConfigResolver, UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
 import { UserCreateComponent, UserUpdateComponent } from './user-edit'
 import { UserListComponent } from './user-list'
-import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
+import { UsersComponent } from './users.component'
 
 export const UsersRoutes: Routes = [
   {
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-create.component.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-create.component.ts
new file mode 100644 (file)
index 0000000..039c389
--- /dev/null
@@ -0,0 +1,82 @@
+import { Component, OnInit } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, Notifier } from '@app/core'
+import { FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoChannelCreate } from '@shared/models'
+import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
+
+@Component({
+  selector: 'my-account-video-channel-create',
+  templateUrl: './my-account-video-channel-edit.component.html',
+  styleUrls: [ './my-account-video-channel-edit.component.scss' ]
+})
+export class MyAccountVideoChannelCreateComponent extends MyAccountVideoChannelEdit implements OnInit {
+  error: string
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoChannelValidatorsService: VideoChannelValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private videoChannelService: VideoChannelService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  get instanceHost () {
+    return window.location.host
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME,
+      'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
+      description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
+      support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT
+    })
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoChannelCreate: VideoChannelCreate = {
+      name: body.name,
+      displayName: body['display-name'],
+      description: body.description || null,
+      support: body.support || null
+    }
+
+    this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe(
+      () => {
+        this.authService.refreshUserInformation()
+
+        this.notifier.success(
+          this.i18n('Video channel {{videoChannelName}} created.', { videoChannelName: videoChannelCreate.displayName })
+        )
+        this.router.navigate([ '/my-account', 'video-channels' ])
+      },
+
+      err => {
+        if (err.status === 409) {
+          this.error = this.i18n('This name already exists on this instance.')
+          return
+        }
+
+        this.error = err.message
+      }
+    )
+  }
+
+  isCreation () {
+    return true
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Create')
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.html b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.html
new file mode 100644 (file)
index 0000000..048d143
--- /dev/null
@@ -0,0 +1,105 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item">
+      <a routerLink="/my-account/video-channels" i18n>My Channels</a>
+    </li>
+
+    <ng-container *ngIf="isCreation()">
+      <li class="breadcrumb-item active" i18n>Create</li>
+    </ng-container>
+    <ng-container *ngIf="!isCreation()">
+      <li class="breadcrumb-item active" i18n>Edit</li>
+      <li class="breadcrumb-item active" aria-current="page">
+        <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
+      </li>
+    </ng-container>
+  </ol>
+</nav>
+
+<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+
+<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
+
+  <div class="form-row"> <!-- channel grid -->
+    <div class="form-group col-12 col-lg-4 col-xl-3">
+      <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
+      <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
+    </div>
+
+    <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
+
+      <div class="form-group" *ngIf="isCreation()">
+        <label i18n for="name">Name</label>
+        <div class="input-group">
+          <input
+            type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
+            formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
+          >
+          <div class="input-group-append">
+            <span class="input-group-text">@{{ instanceHost }}</span>
+          </div>
+        </div>
+        <div *ngIf="formErrors['name']" class="form-error">
+          {{ formErrors['name'] }}
+        </div>
+      </div>
+
+      <my-actor-avatar-info
+        *ngIf="!isCreation() && videoChannelToUpdate"
+        [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
+      ></my-actor-avatar-info>
+
+      <div class="form-group">
+        <label i18n for="display-name">Display name</label>
+        <input
+          type="text" id="display-name" class="form-control"
+          formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
+        >
+        <div *ngIf="formErrors['display-name']" class="form-error">
+          {{ formErrors['display-name'] }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="description">Description</label>
+        <textarea
+          id="description" formControlName="description" class="form-control"
+          [ngClass]="{ 'input-error': formErrors['description'] }"
+        ></textarea>
+        <div *ngIf="formErrors.description" class="form-error">
+          {{ formErrors.description }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="support">Support</label>
+        <my-help
+          helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
+    When you will upload a video in this channel, the video support field will be automatically filled by this text."
+        ></my-help>
+        <my-markdown-textarea
+            id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
+            [classes]="{ 'input-error': formErrors['support'] }"
+        ></my-markdown-textarea>
+        <div *ngIf="formErrors.support" class="form-error">
+          {{ formErrors.support }}
+        </div>
+      </div>
+
+      <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
+        <my-peertube-checkbox
+          inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
+          i18n-labelText labelText="Overwrite support field of all videos of this channel"
+        ></my-peertube-checkbox>
+      </div>
+
+    </div>
+  </div>
+
+  <div class="form-row"> <!-- submit placement block -->
+    <div class="col-md-7 col-xl-5"></div>
+    <div class="col-md-5 col-xl-5 d-inline-flex">
+      <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
+    </div>
+  </div>
+</form>
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.scss b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.component.scss
new file mode 100644 (file)
index 0000000..8f8af65
--- /dev/null
@@ -0,0 +1,67 @@
+@import '_variables';
+@import '_mixins';
+
+label {
+  font-weight: $font-regular;
+  font-size: 100%;
+}
+
+.video-channel-title {
+  @include settings-big-title;
+}
+
+my-actor-avatar-info {
+  display: block;
+  margin-bottom: 20px;
+}
+
+.input-group {
+  @include peertube-input-group(fit-content);
+}
+
+.input-group-append {
+  height: 30px;
+}
+
+input {
+  &[type=text] {
+    @include peertube-input-text(340px);
+
+    display: block;
+
+    &#name {
+      width: auto;
+      flex-grow: 1;
+    }
+  }
+
+  &[type=submit] {
+    @include peertube-button;
+    @include orange-button;
+    margin-left: auto;
+  }
+}
+
+textarea {
+  @include peertube-textarea(500px, 150px);
+
+  display: block;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(340px);
+}
+
+.breadcrumb {
+  @include breadcrumb;
+}
+
+@media screen and (max-width: $small-view) {
+  input[type=text]#name {
+    width: auto !important;
+  }
+
+  label[for=name] + div, textarea {
+    width: 100%;
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-edit.ts
new file mode 100644 (file)
index 0000000..710c51d
--- /dev/null
@@ -0,0 +1,19 @@
+import { FormReactive } from '@app/shared/shared-forms'
+import { VideoChannel } from '@app/shared/shared-main'
+
+export abstract class MyAccountVideoChannelEdit extends FormReactive {
+  // We need it even in the create component because it's used in the edit template
+  videoChannelToUpdate: VideoChannel
+  instanceHost: string
+
+  abstract isCreation (): boolean
+  abstract getFormButtonTitle (): string
+
+  // We need this method so angular does not complain in child template that doesn't need this
+  onAvatarChange (formData: FormData) { /* empty */ }
+
+  // Should be implemented by the child
+  isBulkUpdateVideosDisplayed () {
+    return false
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-update.component.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channel-update.component.ts
new file mode 100644 (file)
index 0000000..489c437
--- /dev/null
@@ -0,0 +1,135 @@
+import { Subscription } from 'rxjs'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoChannelUpdate } from '@shared/models'
+import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
+
+@Component({
+  selector: 'my-account-video-channel-update',
+  templateUrl: './my-account-video-channel-edit.component.html',
+  styleUrls: [ './my-account-video-channel-edit.component.scss' ]
+})
+export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy {
+  error: string
+  videoChannelToUpdate: VideoChannel
+
+  private paramsSub: Subscription
+  private oldSupportField: string
+  private serverConfig: ServerConfig
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoChannelValidatorsService: VideoChannelValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private route: ActivatedRoute,
+    private videoChannelService: VideoChannelService,
+    private i18n: I18n,
+    private serverService: ServerService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.serverConfig = this.serverService.getTmpConfig()
+    this.serverService.getConfig()
+        .subscribe(config => this.serverConfig = config)
+
+    this.buildForm({
+      'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
+      description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
+      support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT,
+      bulkVideosSupportUpdate: null
+    })
+
+    this.paramsSub = this.route.params.subscribe(routeParams => {
+      const videoChannelId = routeParams['videoChannelId']
+
+      this.videoChannelService.getVideoChannel(videoChannelId).subscribe(
+        videoChannelToUpdate => {
+          this.videoChannelToUpdate = videoChannelToUpdate
+
+          this.oldSupportField = videoChannelToUpdate.support
+
+          this.form.patchValue({
+            'display-name': videoChannelToUpdate.displayName,
+            description: videoChannelToUpdate.description,
+            support: videoChannelToUpdate.support
+          })
+        },
+
+        err => this.error = err.message
+      )
+    })
+  }
+
+  ngOnDestroy () {
+    if (this.paramsSub) this.paramsSub.unsubscribe()
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoChannelUpdate: VideoChannelUpdate = {
+      displayName: body['display-name'],
+      description: body.description || null,
+      support: body.support || null,
+      bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
+    }
+
+    this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
+      () => {
+        this.authService.refreshUserInformation()
+
+        this.notifier.success(
+          this.i18n('Video channel {{videoChannelName}} updated.', { videoChannelName: videoChannelUpdate.displayName })
+        )
+
+        this.router.navigate([ '/my-account', 'video-channels' ])
+      },
+
+      err => this.error = err.message
+    )
+  }
+
+  onAvatarChange (formData: FormData) {
+    this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
+        .subscribe(
+          data => {
+            this.notifier.success(this.i18n('Avatar changed.'))
+
+            this.videoChannelToUpdate.updateAvatar(data.avatar)
+          },
+
+          err => this.notifier.error(err.message)
+        )
+  }
+
+  get maxAvatarSize () {
+    return this.serverConfig.avatar.file.size.max
+  }
+
+  get avatarExtensions () {
+    return this.serverConfig.avatar.file.extensions.join(',')
+  }
+
+  isCreation () {
+    return false
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Update')
+  }
+
+  isBulkUpdateVideosDisplayed () {
+    if (this.oldSupportField === undefined) return false
+
+    return this.oldSupportField !== this.form.value['support']
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels-routing.module.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels-routing.module.ts
new file mode 100644 (file)
index 0000000..94037e1
--- /dev/null
@@ -0,0 +1,41 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
+import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
+import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
+
+const myAccountVideoChannelsRoutes: Routes = [
+  {
+    path: '',
+    component: MyAccountVideoChannelsComponent,
+    data: {
+      meta: {
+        title: 'Account video channels'
+      }
+    }
+  },
+  {
+    path: 'create',
+    component: MyAccountVideoChannelCreateComponent,
+    data: {
+      meta: {
+        title: 'Create new video channel'
+      }
+    }
+  },
+  {
+    path: 'update/:videoChannelId',
+    component: MyAccountVideoChannelUpdateComponent,
+    data: {
+      meta: {
+        title: 'Update video channel'
+      }
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ],
+  exports: [ RouterModule ]
+})
+export class MyAccountVideoChannelsRoutingModule {}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.html
new file mode 100644 (file)
index 0000000..b2e8210
--- /dev/null
@@ -0,0 +1,35 @@
+<h1 class="sr-only" i18n>My channels</h1>
+<div class="video-channels-header">
+  <a class="create-button" routerLink="create">
+    <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+    <ng-container i18n>Create video channel</ng-container>
+  </a>
+</div>
+
+<div class="video-channels">
+  <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
+    <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
+      <img [src]="videoChannel.avatarUrl" alt="Avatar" />
+    </a>
+
+    <div class="video-channel-info">
+      <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
+        <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
+        <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
+      </a>
+
+      <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
+
+      <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
+
+      <div class="video-channel-buttons">
+        <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
+        <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
+      </div>
+
+      <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
+        <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.scss
new file mode 100644 (file)
index 0000000..76fb2cd
--- /dev/null
@@ -0,0 +1,115 @@
+@import '_variables';
+@import '_mixins';
+
+.create-button {
+  @include create-button;
+}
+
+::ng-deep .action-button {
+  &.action-button-edit {
+    margin-right: 10px;
+  }
+}
+
+.video-channel {
+  @include row-blocks;
+  padding-bottom: 0;
+
+  img {
+    @include avatar(80px);
+
+    margin-right: 10px;
+  }
+
+  .video-channel-info {
+    flex-grow: 1;
+
+    a.video-channel-names {
+      @include disable-default-a-behaviour;
+
+      width: fit-content;
+      display: flex;
+      align-items: baseline;
+      color: pvar(--mainForegroundColor);
+
+      .video-channel-display-name {
+        font-weight: $font-semibold;
+        font-size: 18px;
+      }
+
+      .video-channel-name {
+        font-size: 14px;
+        color: $grey-actor-name;
+        margin-left: 5px;
+      }
+
+      .video-channel-followers {
+
+      }
+    }
+  }
+
+  .video-channel-buttons {
+    margin-top: 10px;
+    min-width: 190px;
+  }
+}
+
+.video-channels-header {
+  text-align: right;
+  margin: 20px 0 50px;
+}
+
+::ng-deep .chartjs-render-monitor {
+  position: relative;
+  top: 1px;
+}
+
+@media screen and (max-width: $small-view) {
+  .video-channels-header {
+    text-align: center;
+  }
+
+  .video-channel {
+    padding-bottom: 10px;
+
+    .video-channel-info {
+      padding-bottom: 10px;
+      text-align: center;
+
+      .video-channel-names {
+        flex-direction: column;
+        align-items: center !important;
+        margin: auto;
+
+        .video-channel-name {
+          margin-left: 0px !important;
+        }
+      }
+    }
+
+    img {
+      margin-right: 0;
+    }
+
+    .video-channel-buttons {
+      align-self: center;
+    }
+  }
+}
+
+@media screen and (min-width: breakpoint(lg)) {
+  :host-context(.main-col:not(.expanded)) {
+    .video-channel-buttons {
+      float: right;
+    }
+  }
+}
+
+@media screen and (min-width: $small-view) {
+  :host-context(.expanded) {
+    .video-channel-buttons {
+      float: right;
+    }
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.ts
new file mode 100644 (file)
index 0000000..70510d7
--- /dev/null
@@ -0,0 +1,154 @@
+import { ChartData } from 'chart.js'
+import { max, maxBy, min, minBy } from 'lodash-es'
+import { flatMap } from 'rxjs/operators'
+import { Component, OnInit } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+  selector: 'my-account-video-channels',
+  templateUrl: './my-account-video-channels.component.html',
+  styleUrls: [ './my-account-video-channels.component.scss' ]
+})
+export class MyAccountVideoChannelsComponent implements OnInit {
+  videoChannels: VideoChannel[] = []
+  videoChannelsChartData: ChartData[]
+  videoChannelsMinimumDailyViews = 0
+  videoChannelsMaximumDailyViews: number
+
+  private user: User
+
+  constructor (
+    private authService: AuthService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private videoChannelService: VideoChannelService,
+    private screenService: ScreenService,
+    private i18n: I18n
+  ) {}
+
+  ngOnInit () {
+    this.user = this.authService.getUser()
+
+    this.loadVideoChannels()
+  }
+
+  get isInSmallView () {
+    return this.screenService.isInSmallView()
+  }
+
+  get chartOptions () {
+    return {
+      legend: {
+        display: false
+      },
+      scales: {
+        xAxes: [{
+          display: false
+        }],
+        yAxes: [{
+          display: false,
+          ticks: {
+            min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
+            max: Math.max(1, this.videoChannelsMaximumDailyViews)
+          }
+        }]
+      },
+      layout: {
+        padding: {
+          left: 15,
+          right: 15,
+          top: 10,
+          bottom: 0
+        }
+      },
+      elements: {
+        point: {
+          radius: 0
+        }
+      },
+      tooltips: {
+        mode: 'index',
+        intersect: false,
+        custom: function (tooltip: any) {
+          if (!tooltip) return
+          // disable displaying the color box
+          tooltip.displayColors = false
+        },
+        callbacks: {
+          label: (tooltip: any, data: any) => `${tooltip.value} views`
+        }
+      },
+      hover: {
+        mode: 'index',
+        intersect: false
+      }
+    }
+  }
+
+  async deleteVideoChannel (videoChannel: VideoChannel) {
+    const res = await this.confirmService.confirmWithInput(
+      this.i18n(
+        // tslint:disable
+        'Do you really want to delete {{channelDisplayName}}? It will delete {{videosCount}} videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!',
+        { channelDisplayName: videoChannel.displayName, videosCount: videoChannel.videosCount, channelName: videoChannel.name }
+      ),
+      this.i18n(
+        'Please type the display name of the video channel ({{displayName}}) to confirm',
+        { displayName: videoChannel.displayName }
+      ),
+      videoChannel.displayName,
+      this.i18n('Delete')
+    )
+    if (res === false) return
+
+    this.videoChannelService.removeVideoChannel(videoChannel)
+      .subscribe(
+        () => {
+          this.loadVideoChannels()
+          this.notifier.success(
+            this.i18n('Video channel {{videoChannelName}} deleted.', { videoChannelName: videoChannel.displayName })
+          )
+        },
+
+        error => this.notifier.error(error.message)
+      )
+  }
+
+  private loadVideoChannels () {
+    this.authService.userInformationLoaded
+        .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
+        .subscribe(res => {
+          this.videoChannels = res.data
+
+          // chart data
+          this.videoChannelsChartData = this.videoChannels.map(v => ({
+            labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
+            datasets: [
+              {
+                  label: this.i18n('Views for the day'),
+                  data: v.viewsPerDay.map(day => day.views),
+                  fill: false,
+                  borderColor: "#c6c6c6"
+              }
+            ]
+          } as ChartData))
+
+          // chart options that depend on chart data:
+          // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
+          this.videoChannelsMinimumDailyViews = min(
+            this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute
+              v.viewsPerDay,
+              day => day.views
+            ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
+          )
+          this.videoChannelsMaximumDailyViews = max(
+            this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute
+              v.viewsPerDay,
+              day => day.views
+            ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
+          )
+        })
+  }
+}
diff --git a/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.module.ts b/client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.module.ts
new file mode 100644 (file)
index 0000000..f8c6ad5
--- /dev/null
@@ -0,0 +1,31 @@
+import { ChartModule } from 'primeng/chart'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
+import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
+import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module'
+import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
+
+@NgModule({
+  imports: [
+    MyAccountVideoChannelsRoutingModule,
+
+    ChartModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    MyAccountVideoChannelsComponent,
+    MyAccountVideoChannelCreateComponent,
+    MyAccountVideoChannelUpdateComponent
+  ],
+
+  exports: [],
+  providers: []
+})
+export class MyAccountVideoChannelsModule { }
index e48c39cdfb25d2f6cfa4a764847a98088aa5c8c3..03ec75e25ff08f52f0adccc6191b5dda12614699 100644 (file)
@@ -1,10 +1,10 @@
 import { Component } from '@angular/core'
-import { GenericAccountBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/shared/shared-moderation'
 
 @Component({
   selector: 'my-account-blocklist',
-  styleUrls: [ '../../shared/blocklist/account-blocklist.component.scss' ],
-  templateUrl: '../../shared/blocklist/account-blocklist.component.html'
+  styleUrls: [ '../../shared/shared-moderation/account-blocklist.component.scss' ],
+  templateUrl: '../../shared/shared-moderation/account-blocklist.component.html'
 })
 export class MyAccountBlocklistComponent extends GenericAccountBlocklistComponent {
   mode = BlocklistComponentType.Account
index cfaba1c7b4959f425c83bd02a42978b355cd25cb..9b983a19759f9eb7f4617b3e0e535ac59122ab92 100644 (file)
@@ -1,10 +1,10 @@
 import { Component } from '@angular/core'
-import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericServerBlocklistComponent } from '@app/shared/shared-moderation'
 
 @Component({
   selector: 'my-account-server-blocklist',
-  styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/blocklist/server-blocklist.component.scss' ],
-  templateUrl: '../../shared/blocklist/server-blocklist.component.html'
+  styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ],
+  templateUrl: '../../shared/shared-moderation/server-blocklist.component.html'
 })
 export class MyAccountServerBlocklistComponent extends GenericServerBlocklistComponent {
   mode = BlocklistComponentType.Account
index 5f0ccee5095b18c22aa4f8ad56a0225bfbfb3126..dc78b3d6ef90845cd41cfbd408ff8afa27da7690 100644 (file)
@@ -1,17 +1,19 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
+import {
+  AuthService,
+  ComponentPagination,
+  ConfirmService,
+  LocalStorageService,
+  Notifier,
+  ScreenService,
+  ServerService,
+  UserService
+} from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { UserHistoryService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { UserHistoryService } from '@app/shared/users/user-history.service'
-import { UserService } from '@app/shared'
-import { Notifier, ServerService } from '@app/core'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
 
 @Component({
   selector: 'my-account-history',
@@ -38,7 +40,6 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn
     protected screenService: ScreenService,
     protected storageService: LocalStorageService,
     private confirmService: ConfirmService,
-    private videoService: VideoService,
     private userHistoryService: UserHistoryService
   ) {
     super()
index a50cb0fb94f60d7b6c819163fadaa7f5334cc519..0c1427d962e5dc919850650efef31183d2f5aba9 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, ViewChild } from '@angular/core'
-import { UserNotificationsComponent } from '@app/shared'
+import { UserNotificationsComponent } from '@app/shared/shared-main'
 
 @Component({
   templateUrl: './my-account-notifications.component.html',
index d5682914e975936a73155bdcc0f909f3706bef8a..0e62b5ca545c2e9c146e6d0ad23f4cf60936c3eb 100644 (file)
@@ -1,14 +1,10 @@
 import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { AuthService, Notifier } from '@app/core'
-import { FormReactive } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
-import { VideoChangeOwnership } from '../../../../../../shared/models/videos'
-import { VideoAcceptOwnershipValidatorsService } from '@app/shared/forms/form-validators'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormReactive, FormValidatorService, VideoAcceptOwnershipValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannelService, VideoOwnershipService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoChangeOwnership, VideoChannel } from '@shared/models'
 
 @Component({
   selector: 'my-account-accept-ownership',
index f0a6303d1cb89dd5b7870fbf23720f70aa232fdc..5167732c2a7312d15dce24f12695a5e13f9a6123 100644 (file)
@@ -1,10 +1,8 @@
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { RestPagination, RestTable } from '@app/shared'
 import { SortMeta } from 'primeng/api'
-import { VideoChangeOwnership } from '../../../../../shared'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
-import { Account } from '@app/shared/account/account.model'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { Account, VideoOwnershipService } from '@app/shared/shared-main'
+import { VideoChangeOwnership } from '@shared/models'
 import { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership/my-account-accept-ownership.component'
 
 @Component({
index f6b711e09304e0e5683de519decf572549233e8a..ac9cf4cfd7faf0f1f8f0dc383967020a9ee8527c 100644 (file)
@@ -2,26 +2,20 @@ import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
 import { MetaGuard } from '@ngx-meta/core'
 import { LoginGuard } from '../core'
-import { MyAccountComponent } from './my-account.component'
+import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
+import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
+import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
+import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
 import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountSubscriptionsComponent } from './my-account-subscriptions/my-account-subscriptions.component'
+import { MyAccountVideoImportsComponent } from './my-account-video-imports/my-account-video-imports.component'
+import { MyAccountVideoPlaylistCreateComponent } from './my-account-video-playlists/my-account-video-playlist-create.component'
+import { MyAccountVideoPlaylistElementsComponent } from './my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
 import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
-import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
-import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
-import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component'
-import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
-import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
-import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
-import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
-import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
-import {
-  MyAccountVideoPlaylistCreateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
-import {
-  MyAccountVideoPlaylistUpdateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
-import {
-  MyAccountVideoPlaylistElementsComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountComponent } from './my-account.component'
 
 const myAccountRoutes: Routes = [
   {
@@ -46,7 +40,10 @@ const myAccountRoutes: Routes = [
 
       {
         path: 'video-channels',
-        loadChildren: () => import('./my-account-video-channels/my-account-video-channels.module').then(m => m.MyAccountVideoChannelsModule)
+        loadChildren: () => {
+          return import('./+my-account-video-channels/my-account-video-channels.module')
+            .then(m => m.MyAccountVideoChannelsModule)
+        }
       },
 
       {
index 9d406805fae8d1ec796bdb1bf20ab8ccf3430693..5444b97ae74f241cee1f8b217c81f110e9f6d014 100644 (file)
@@ -1,12 +1,10 @@
+import { forkJoin } from 'rxjs'
+import { tap } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
+import { AuthService, ServerService, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { User } from '../../../../../../shared'
-import { tap } from 'rxjs/operators'
-import { forkJoin } from 'rxjs'
+import { User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-email',
@@ -21,7 +19,6 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
   constructor (
     protected formValidatorService: FormValidatorService,
     private userValidatorsService: UserValidatorsService,
-    private notifier: Notifier,
     private authService: AuthService,
     private userService: UserService,
     private serverService: ServerService,
index cbb068c7ce76575380bfef8f6558cb1a983279d0..6a16f8a2c6d05db374b55f54e92aea293e38c111 100644 (file)
@@ -1,11 +1,9 @@
+import { filter } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
+import { AuthService, Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { filter } from 'rxjs/operators'
-import { User } from '../../../../../../shared'
+import { User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-password',
index 25d8628677b1f37f3d604c74463e335c38662c0b..ae6ac538720fb4c9266b31134bd732cc28953179 100644 (file)
@@ -1,9 +1,6 @@
 import { Component, Input } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService, ConfirmService, RedirectService } from '../../../core'
-import { UserService } from '../../../shared'
+import { AuthService, ConfirmService, Notifier, RedirectService, User, UserService } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { User } from '@app/shared'
 
 @Component({
   selector: 'my-account-danger-zone',
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/index.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/index.ts
deleted file mode 100644 (file)
index 62fce79..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './my-account-interface-settings.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
deleted file mode 100644 (file)
index 0d0ddc0..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
-
-  <div class="form-group">
-    <label i18n for="theme">Theme</label>
-
-    <div class="peertube-select-container">
-      <select formControlName="theme" id="theme" class="form-control">
-        <option i18n value="instance-default">instance default</option>
-        <option i18n value="default">peertube default</option>
-
-        <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
-      </select>
-    </div>
-  </div>
-
-  <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid">
-</form>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss
deleted file mode 100644 (file)
index 7818dfc..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-@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/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
deleted file mode 100644 (file)
index b6c17c0..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Component, Input, OnInit, OnDestroy } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { ServerConfig, UserUpdateMe } from '../../../../../../shared'
-import { AuthService } from '../../../core'
-import { FormReactive } from '../../../shared/forms/form-reactive'
-import { User, UserService } from '../../../shared/users'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { Subject, Subscription } from 'rxjs'
-
-@Component({
-  selector: 'my-account-interface-settings',
-  templateUrl: './my-account-interface-settings.component.html',
-  styleUrls: [ './my-account-interface-settings.component.scss' ]
-})
-export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy {
-  @Input() user: User = null
-  @Input() reactiveUpdate = false
-  @Input() notifyOnUpdate = true
-  @Input() userInformationLoaded: Subject<any>
-
-  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.'))
-    }
-  }
-}
index af17a0352b075f2f264d0abba61399a943dfc3fe..cfa514b260995de938cdd305a4c4f5f44543dd4f 100644 (file)
@@ -1,11 +1,10 @@
+import { debounce } from 'lodash-es'
+import { Subject } from 'rxjs'
 import { Component, Input, OnInit } from '@angular/core'
-import { User } from '@app/shared'
+import { Notifier, ServerService, User } from '@app/core'
+import { UserNotificationService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subject } from 'rxjs'
-import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
-import { Notifier, ServerService } from '@app/core'
-import { debounce } from 'lodash-es'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
+import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '@shared/models'
 
 @Component({
   selector: 'my-account-notification-preferences',
index fcad5a6c2759e3522829ab12fdbb688c0de74a8a..b0d8494e711709c72ca815563ea09f25d76d3226 100644 (file)
@@ -1,11 +1,8 @@
+import { Subject } from 'rxjs'
 import { Component, Input, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
-import { User } from '@app/shared'
+import { Notifier, User, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { Subject } from 'rxjs'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
 
 @Component({
   selector: 'my-account-profile',
index 040b2130f1c7bcb0cf138042abd0e602e485bf4d..2826d8d83d6dbc0bec7c0ce1a25a350699c5851e 100644 (file)
@@ -34,7 +34,7 @@
   </div>
 
   <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
-    <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
+    <my-user-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-video-settings>
   </div>
 </div>
 
@@ -55,7 +55,7 @@
   </div>
 
   <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
-    <my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings>
+    <my-user-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-interface-settings>
   </div>
 </div>
 
index f73f3aa1e09de556fc9812d803dfe0c0df596f6d..4800be24bce4b904c165af21a98e059e0cee574d 100644 (file)
@@ -1,11 +1,8 @@
-import { Component, OnInit, AfterViewChecked } from '@angular/core'
-import { Notifier } from '@app/core'
 import { BytesPipe } from 'ngx-pipes'
-import { AuthService } from '../../core'
-import { User } from '../../shared'
-import { UserService } from '../../shared/users'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit } from '@angular/core'
+import { AuthService, Notifier, User, UserService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
 
 @Component({
   selector: 'my-account-settings',
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/index.ts b/client/src/app/+my-account/my-account-settings/my-account-video-settings/index.ts
deleted file mode 100644 (file)
index 1253bd3..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './my-account-video-settings.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html
deleted file mode 100644 (file)
index 0dda33a..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
-  <div class="form-group form-group-select">
-    <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
-    <my-help>
-      <ng-template ptTemplate="customHtml">
-        <ng-container i18n>
-          With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
-        </ng-container>
-      </ng-template>
-    </my-help>
-
-    <div class="peertube-select-container">
-      <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
-        <option i18n value="undefined" disabled>Policy for sensitive videos</option>
-        <option i18n value="do_not_list">Do not list</option>
-        <option i18n value="blur">Blur thumbnails</option>
-        <option i18n value="display">Display</option>
-      </select>
-    </div>
-  </div>
-
-  <div class="form-group form-group-select">
-    <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label>
-    <my-help>
-      <ng-template ptTemplate="customHtml">
-        <ng-container i18n>In Recently added, Trending, Local, Most liked and Search pages</ng-container>
-      </ng-template>
-    </my-help>
-
-    <div>
-      <p-multiSelect
-        inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
-        [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
-        emptyFilterMessage="No results found" i18n-emptyFilterMessage
-      ></p-multiSelect>
-    </div>
-  </div>
-
-  <ng-content select="inner-title"></ng-content>
-
-  <div class="form-group">
-    <my-peertube-checkbox
-      inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true"
-      i18n-labelText labelText="Help share videos being played"
-    >
-      <ng-container ngProjectAs="description">
-        <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
-      </ng-container>
-    </my-peertube-checkbox>
-  </div>
-
-  <div class="form-group">
-    <my-peertube-checkbox
-      inputName="autoPlayVideo" formControlName="autoPlayVideo"
-      i18n-labelText labelText="Automatically play videos"
-    >
-      <ng-container ngProjectAs="description">
-        <span i18n>When on a video page, directly start playing the video.</span>
-      </ng-container>
-    </my-peertube-checkbox>
-  </div>
-
-  <div class="form-group">
-    <my-peertube-checkbox
-      inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo"
-      i18n-labelText labelText="Automatically start playing the next video"
-    >
-      <ng-container ngProjectAs="description">
-        <span i18n>When a video ends, follow up with the next suggested video.</span>
-      </ng-container>
-    </my-peertube-checkbox>
-  </div>
-
-  <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid">
-</form>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss
deleted file mode 100644 (file)
index 430250b..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-@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/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts
deleted file mode 100644 (file)
index 0aaa54c..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-import { Component, Input, OnInit, OnDestroy } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { UserUpdateMe } from '../../../../../../shared/models/users'
-import { User, UserService } from '@app/shared/users'
-import { AuthService } from '../../../core'
-import { FormReactive } from '@app/shared/forms/form-reactive'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { forkJoin, Subject, Subscription } from 'rxjs'
-import { SelectItem } from 'primeng/api'
-import { first } from 'rxjs/operators'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { pick } from 'lodash-es'
-
-@Component({
-  selector: 'my-account-video-settings',
-  templateUrl: './my-account-video-settings.component.html',
-  styleUrls: [ './my-account-video-settings.component.scss' ]
-})
-export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
-  @Input() user: User = null
-  @Input() reactiveUpdate = false
-  @Input() notifyOnUpdate = true
-  @Input() userInformationLoaded: Subject<any>
-
-  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')
-  }
-}
index b347fc3fef50515e27d18e41b3638ddab6be216d..390293a2814818256b7842af335e178d3640a434 100644 (file)
@@ -1,9 +1,8 @@
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { UserSubscriptionService } from '@app/shared/user-subscription'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { ComponentPagination, Notifier } from '@app/core'
+import { VideoChannel } from '@app/shared/shared-main'
+import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
 
 @Component({
   selector: 'my-account-subscriptions',
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-create.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-create.component.ts
deleted file mode 100644 (file)
index a68f79b..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Component, OnInit } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
-import { VideoChannelCreate } from '../../../../../shared/models/videos'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service'
-
-@Component({
-  selector: 'my-account-video-channel-create',
-  templateUrl: './my-account-video-channel-edit.component.html',
-  styleUrls: [ './my-account-video-channel-edit.component.scss' ]
-})
-export class MyAccountVideoChannelCreateComponent extends MyAccountVideoChannelEdit implements OnInit {
-  error: string
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private authService: AuthService,
-    private videoChannelValidatorsService: VideoChannelValidatorsService,
-    private notifier: Notifier,
-    private router: Router,
-    private videoChannelService: VideoChannelService,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  get instanceHost () {
-    return window.location.host
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME,
-      'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
-      description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
-      support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT
-    })
-  }
-
-  formValidated () {
-    this.error = undefined
-
-    const body = this.form.value
-    const videoChannelCreate: VideoChannelCreate = {
-      name: body.name,
-      displayName: body['display-name'],
-      description: body.description || null,
-      support: body.support || null
-    }
-
-    this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe(
-      () => {
-        this.authService.refreshUserInformation()
-
-        this.notifier.success(
-          this.i18n('Video channel {{videoChannelName}} created.', { videoChannelName: videoChannelCreate.displayName })
-        )
-        this.router.navigate([ '/my-account', 'video-channels' ])
-      },
-
-      err => {
-        if (err.status === 409) {
-          this.error = this.i18n('This name already exists on this instance.')
-          return
-        }
-
-        this.error = err.message
-      }
-    )
-  }
-
-  isCreation () {
-    return true
-  }
-
-  getFormButtonTitle () {
-    return this.i18n('Create')
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html
deleted file mode 100644 (file)
index 048d143..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-<nav aria-label="breadcrumb">
-  <ol class="breadcrumb">
-    <li class="breadcrumb-item">
-      <a routerLink="/my-account/video-channels" i18n>My Channels</a>
-    </li>
-
-    <ng-container *ngIf="isCreation()">
-      <li class="breadcrumb-item active" i18n>Create</li>
-    </ng-container>
-    <ng-container *ngIf="!isCreation()">
-      <li class="breadcrumb-item active" i18n>Edit</li>
-      <li class="breadcrumb-item active" aria-current="page">
-        <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
-      </li>
-    </ng-container>
-  </ol>
-</nav>
-
-<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
-
-<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
-
-  <div class="form-row"> <!-- channel grid -->
-    <div class="form-group col-12 col-lg-4 col-xl-3">
-      <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
-      <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
-    </div>
-
-    <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
-
-      <div class="form-group" *ngIf="isCreation()">
-        <label i18n for="name">Name</label>
-        <div class="input-group">
-          <input
-            type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
-            formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
-          >
-          <div class="input-group-append">
-            <span class="input-group-text">@{{ instanceHost }}</span>
-          </div>
-        </div>
-        <div *ngIf="formErrors['name']" class="form-error">
-          {{ formErrors['name'] }}
-        </div>
-      </div>
-
-      <my-actor-avatar-info
-        *ngIf="!isCreation() && videoChannelToUpdate"
-        [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
-      ></my-actor-avatar-info>
-
-      <div class="form-group">
-        <label i18n for="display-name">Display name</label>
-        <input
-          type="text" id="display-name" class="form-control"
-          formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
-        >
-        <div *ngIf="formErrors['display-name']" class="form-error">
-          {{ formErrors['display-name'] }}
-        </div>
-      </div>
-
-      <div class="form-group">
-        <label i18n for="description">Description</label>
-        <textarea
-          id="description" formControlName="description" class="form-control"
-          [ngClass]="{ 'input-error': formErrors['description'] }"
-        ></textarea>
-        <div *ngIf="formErrors.description" class="form-error">
-          {{ formErrors.description }}
-        </div>
-      </div>
-
-      <div class="form-group">
-        <label for="support">Support</label>
-        <my-help
-          helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
-    When you will upload a video in this channel, the video support field will be automatically filled by this text."
-        ></my-help>
-        <my-markdown-textarea
-            id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
-            [classes]="{ 'input-error': formErrors['support'] }"
-        ></my-markdown-textarea>
-        <div *ngIf="formErrors.support" class="form-error">
-          {{ formErrors.support }}
-        </div>
-      </div>
-
-      <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
-        <my-peertube-checkbox
-          inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
-          i18n-labelText labelText="Overwrite support field of all videos of this channel"
-        ></my-peertube-checkbox>
-      </div>
-
-    </div>
-  </div>
-
-  <div class="form-row"> <!-- submit placement block -->
-    <div class="col-md-7 col-xl-5"></div>
-    <div class="col-md-5 col-xl-5 d-inline-flex">
-      <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
-    </div>
-  </div>
-</form>
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss
deleted file mode 100644 (file)
index 8f8af65..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-label {
-  font-weight: $font-regular;
-  font-size: 100%;
-}
-
-.video-channel-title {
-  @include settings-big-title;
-}
-
-my-actor-avatar-info {
-  display: block;
-  margin-bottom: 20px;
-}
-
-.input-group {
-  @include peertube-input-group(fit-content);
-}
-
-.input-group-append {
-  height: 30px;
-}
-
-input {
-  &[type=text] {
-    @include peertube-input-text(340px);
-
-    display: block;
-
-    &#name {
-      width: auto;
-      flex-grow: 1;
-    }
-  }
-
-  &[type=submit] {
-    @include peertube-button;
-    @include orange-button;
-    margin-left: auto;
-  }
-}
-
-textarea {
-  @include peertube-textarea(500px, 150px);
-
-  display: block;
-}
-
-.peertube-select-container {
-  @include peertube-select-container(340px);
-}
-
-.breadcrumb {
-  @include breadcrumb;
-}
-
-@media screen and (max-width: $small-view) {
-  input[type=text]#name {
-    width: auto !important;
-  }
-
-  label[for=name] + div, textarea {
-    width: 100%;
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts
deleted file mode 100644 (file)
index 355cb4f..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { FormReactive } from '@app/shared'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-
-export abstract class MyAccountVideoChannelEdit extends FormReactive {
-  // We need it even in the create component because it's used in the edit template
-  videoChannelToUpdate: VideoChannel
-  instanceHost: string
-
-  abstract isCreation (): boolean
-  abstract getFormButtonTitle (): string
-
-  // We need this method so angular does not complain in child template that doesn't need this
-  onAvatarChange (formData: FormData) { /* empty */ }
-
-  // Should be implemented by the child
-  isBulkUpdateVideosDisplayed () {
-    return false
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-update.component.ts
deleted file mode 100644 (file)
index 9c948b3..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
-import { VideoChannelUpdate } from '../../../../../shared/models/videos'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { Subscription } from 'rxjs'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service'
-import { ServerConfig } from '@shared/models'
-
-@Component({
-  selector: 'my-account-video-channel-update',
-  templateUrl: './my-account-video-channel-edit.component.html',
-  styleUrls: [ './my-account-video-channel-edit.component.scss' ]
-})
-export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy {
-  error: string
-  videoChannelToUpdate: VideoChannel
-
-  private paramsSub: Subscription
-  private oldSupportField: string
-  private serverConfig: ServerConfig
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private authService: AuthService,
-    private videoChannelValidatorsService: VideoChannelValidatorsService,
-    private notifier: Notifier,
-    private router: Router,
-    private route: ActivatedRoute,
-    private videoChannelService: VideoChannelService,
-    private i18n: I18n,
-    private serverService: ServerService
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    this.serverConfig = this.serverService.getTmpConfig()
-    this.serverService.getConfig()
-        .subscribe(config => this.serverConfig = config)
-
-    this.buildForm({
-      'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
-      description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
-      support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT,
-      bulkVideosSupportUpdate: null
-    })
-
-    this.paramsSub = this.route.params.subscribe(routeParams => {
-      const videoChannelId = routeParams['videoChannelId']
-
-      this.videoChannelService.getVideoChannel(videoChannelId).subscribe(
-        videoChannelToUpdate => {
-          this.videoChannelToUpdate = videoChannelToUpdate
-
-          this.oldSupportField = videoChannelToUpdate.support
-
-          this.form.patchValue({
-            'display-name': videoChannelToUpdate.displayName,
-            description: videoChannelToUpdate.description,
-            support: videoChannelToUpdate.support
-          })
-        },
-
-        err => this.error = err.message
-      )
-    })
-  }
-
-  ngOnDestroy () {
-    if (this.paramsSub) this.paramsSub.unsubscribe()
-  }
-
-  formValidated () {
-    this.error = undefined
-
-    const body = this.form.value
-    const videoChannelUpdate: VideoChannelUpdate = {
-      displayName: body['display-name'],
-      description: body.description || null,
-      support: body.support || null,
-      bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
-    }
-
-    this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
-      () => {
-        this.authService.refreshUserInformation()
-
-        this.notifier.success(
-          this.i18n('Video channel {{videoChannelName}} updated.', { videoChannelName: videoChannelUpdate.displayName })
-        )
-
-        this.router.navigate([ '/my-account', 'video-channels' ])
-      },
-
-      err => this.error = err.message
-    )
-  }
-
-  onAvatarChange (formData: FormData) {
-    this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
-        .subscribe(
-          data => {
-            this.notifier.success(this.i18n('Avatar changed.'))
-
-            this.videoChannelToUpdate.updateAvatar(data.avatar)
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-
-  get maxAvatarSize () {
-    return this.serverConfig.avatar.file.size.max
-  }
-
-  get avatarExtensions () {
-    return this.serverConfig.avatar.file.extensions.join(',')
-  }
-
-  isCreation () {
-    return false
-  }
-
-  getFormButtonTitle () {
-    return this.i18n('Update')
-  }
-
-  isBulkUpdateVideosDisplayed () {
-    if (this.oldSupportField === undefined) return false
-
-    return this.oldSupportField !== this.form.value['support']
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts
deleted file mode 100644 (file)
index 94037e1..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
-import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
-import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
-
-const myAccountVideoChannelsRoutes: Routes = [
-  {
-    path: '',
-    component: MyAccountVideoChannelsComponent,
-    data: {
-      meta: {
-        title: 'Account video channels'
-      }
-    }
-  },
-  {
-    path: 'create',
-    component: MyAccountVideoChannelCreateComponent,
-    data: {
-      meta: {
-        title: 'Create new video channel'
-      }
-    }
-  },
-  {
-    path: 'update/:videoChannelId',
-    component: MyAccountVideoChannelUpdateComponent,
-    data: {
-      meta: {
-        title: 'Update video channel'
-      }
-    }
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ],
-  exports: [ RouterModule ]
-})
-export class MyAccountVideoChannelsRoutingModule {}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
deleted file mode 100644 (file)
index b2e8210..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<h1 class="sr-only" i18n>My channels</h1>
-<div class="video-channels-header">
-  <a class="create-button" routerLink="create">
-    <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
-    <ng-container i18n>Create video channel</ng-container>
-  </a>
-</div>
-
-<div class="video-channels">
-  <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
-    <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
-      <img [src]="videoChannel.avatarUrl" alt="Avatar" />
-    </a>
-
-    <div class="video-channel-info">
-      <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
-        <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
-        <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
-      </a>
-
-      <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
-
-      <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
-
-      <div class="video-channel-buttons">
-        <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
-        <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
-      </div>
-
-      <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
-        <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
-      </div>
-    </div>
-  </div>
-</div>
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
deleted file mode 100644 (file)
index 76fb2cd..0000000
+++ /dev/null
@@ -1,115 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.create-button {
-  @include create-button;
-}
-
-::ng-deep .action-button {
-  &.action-button-edit {
-    margin-right: 10px;
-  }
-}
-
-.video-channel {
-  @include row-blocks;
-  padding-bottom: 0;
-
-  img {
-    @include avatar(80px);
-
-    margin-right: 10px;
-  }
-
-  .video-channel-info {
-    flex-grow: 1;
-
-    a.video-channel-names {
-      @include disable-default-a-behaviour;
-
-      width: fit-content;
-      display: flex;
-      align-items: baseline;
-      color: pvar(--mainForegroundColor);
-
-      .video-channel-display-name {
-        font-weight: $font-semibold;
-        font-size: 18px;
-      }
-
-      .video-channel-name {
-        font-size: 14px;
-        color: $grey-actor-name;
-        margin-left: 5px;
-      }
-
-      .video-channel-followers {
-
-      }
-    }
-  }
-
-  .video-channel-buttons {
-    margin-top: 10px;
-    min-width: 190px;
-  }
-}
-
-.video-channels-header {
-  text-align: right;
-  margin: 20px 0 50px;
-}
-
-::ng-deep .chartjs-render-monitor {
-  position: relative;
-  top: 1px;
-}
-
-@media screen and (max-width: $small-view) {
-  .video-channels-header {
-    text-align: center;
-  }
-
-  .video-channel {
-    padding-bottom: 10px;
-
-    .video-channel-info {
-      padding-bottom: 10px;
-      text-align: center;
-
-      .video-channel-names {
-        flex-direction: column;
-        align-items: center !important;
-        margin: auto;
-
-        .video-channel-name {
-          margin-left: 0px !important;
-        }
-      }
-    }
-
-    img {
-      margin-right: 0;
-    }
-
-    .video-channel-buttons {
-      align-self: center;
-    }
-  }
-}
-
-@media screen and (min-width: breakpoint(lg)) {
-  :host-context(.main-col:not(.expanded)) {
-    .video-channel-buttons {
-      float: right;
-    }
-  }
-}
-
-@media screen and (min-width: $small-view) {
-  :host-context(.expanded) {
-    .video-channel-buttons {
-      float: right;
-    }
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts
deleted file mode 100644 (file)
index 9caefe5..0000000
+++ /dev/null
@@ -1,159 +0,0 @@
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { User } from '@app/shared'
-import { flatMap } from 'rxjs/operators'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { min, minBy, max, maxBy } from 'lodash-es'
-import { ChartData } from 'chart.js'
-
-@Component({
-  selector: 'my-account-video-channels',
-  templateUrl: './my-account-video-channels.component.html',
-  styleUrls: [ './my-account-video-channels.component.scss' ]
-})
-export class MyAccountVideoChannelsComponent implements OnInit {
-  videoChannels: VideoChannel[] = []
-  videoChannelsChartData: ChartData[]
-  videoChannelsMinimumDailyViews = 0
-  videoChannelsMaximumDailyViews: number
-
-  private user: User
-
-  constructor (
-    private authService: AuthService,
-    private notifier: Notifier,
-    private confirmService: ConfirmService,
-    private videoChannelService: VideoChannelService,
-    private screenService: ScreenService,
-    private i18n: I18n
-  ) {}
-
-  ngOnInit () {
-    this.user = this.authService.getUser()
-
-    this.loadVideoChannels()
-  }
-
-  get isInSmallView () {
-    return this.screenService.isInSmallView()
-  }
-
-  get chartOptions () {
-    return {
-      legend: {
-        display: false
-      },
-      scales: {
-        xAxes: [{
-          display: false
-        }],
-        yAxes: [{
-          display: false,
-          ticks: {
-            min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
-            max: Math.max(1, this.videoChannelsMaximumDailyViews)
-          }
-        }]
-      },
-      layout: {
-        padding: {
-          left: 15,
-          right: 15,
-          top: 10,
-          bottom: 0
-        }
-      },
-      elements: {
-        point: {
-          radius: 0
-        }
-      },
-      tooltips: {
-        mode: 'index',
-        intersect: false,
-        custom: function (tooltip: any) {
-          if (!tooltip) return
-          // disable displaying the color box
-          tooltip.displayColors = false
-        },
-        callbacks: {
-          label: (tooltip: any, data: any) => `${tooltip.value} views`
-        }
-      },
-      hover: {
-        mode: 'index',
-        intersect: false
-      }
-    }
-  }
-
-  async deleteVideoChannel (videoChannel: VideoChannel) {
-    const res = await this.confirmService.confirmWithInput(
-      this.i18n(
-        // tslint:disable
-        'Do you really want to delete {{channelDisplayName}}? It will delete {{videosCount}} videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!',
-        { channelDisplayName: videoChannel.displayName, videosCount: videoChannel.videosCount, channelName: videoChannel.name }
-      ),
-      this.i18n(
-        'Please type the display name of the video channel ({{displayName}}) to confirm',
-        { displayName: videoChannel.displayName }
-      ),
-      videoChannel.displayName,
-      this.i18n('Delete')
-    )
-    if (res === false) return
-
-    this.videoChannelService.removeVideoChannel(videoChannel)
-      .subscribe(
-        () => {
-          this.loadVideoChannels()
-          this.notifier.success(
-            this.i18n('Video channel {{videoChannelName}} deleted.', { videoChannelName: videoChannel.displayName })
-          )
-        },
-
-        error => this.notifier.error(error.message)
-      )
-  }
-
-  private loadVideoChannels () {
-    this.authService.userInformationLoaded
-        .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
-        .subscribe(res => {
-          this.videoChannels = res.data
-
-          // chart data
-          this.videoChannelsChartData = this.videoChannels.map(v => ({
-            labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
-            datasets: [
-              {
-                  label: this.i18n('Views for the day'),
-                  data: v.viewsPerDay.map(day => day.views),
-                  fill: false,
-                  borderColor: "#c6c6c6"
-              }
-            ]
-          } as ChartData))
-
-          // chart options that depend on chart data:
-          // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
-          this.videoChannelsMinimumDailyViews = min(
-            this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute
-              v.viewsPerDay,
-              day => day.views
-            ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
-          )
-          this.videoChannelsMaximumDailyViews = max(
-            this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute
-              v.viewsPerDay,
-              day => day.views
-            ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
-          )
-        })
-  }
-}
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts
deleted file mode 100644 (file)
index 87d6b76..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-import { NgModule } from '@angular/core'
-import { ChartModule } from 'primeng/chart'
-import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module'
-import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
-import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
-import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
-import { SharedModule } from '@app/shared'
-
-@NgModule({
-  imports: [
-    MyAccountVideoChannelsRoutingModule,
-    SharedModule,
-    ChartModule
-  ],
-
-  declarations: [
-    MyAccountVideoChannelsComponent,
-    MyAccountVideoChannelCreateComponent,
-    MyAccountVideoChannelUpdateComponent
-  ],
-
-  exports: [],
-  providers: []
-})
-export class MyAccountVideoChannelsModule { }
index 4452154ebf837deaa711410eee79ef34224cdca7..42ddb0ee28578bb0d041e077a9a25977967d5c83 100644 (file)
@@ -1,9 +1,8 @@
-import { Component, OnInit } from '@angular/core'
-import { RestPagination, RestTable } from '@app/shared'
 import { SortMeta } from 'primeng/api'
-import { Notifier } from '@app/core'
-import { VideoImport, VideoImportState } from '../../../../../shared/models/videos'
-import { VideoImportService } from '@app/shared/video-import'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { VideoImportService } from '@app/shared/shared-main'
+import { VideoImport, VideoImportState } from '@shared/models'
 
 @Component({
   selector: 'my-account-video-imports',
index e47e5f9807820c9d453e349926523cfb7b90cb1e..e72ae236634b5c178a475e022f82916977605814 100644 (file)
@@ -1,14 +1,13 @@
 import { Component, OnInit } from '@angular/core'
 import { Router } from '@angular/router'
 import { AuthService, Notifier, ServerService } from '@app/core'
-import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoPlaylistValidatorsService } from '@app/shared'
 import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
 import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
 
 @Component({
   selector: 'my-account-video-playlist-create',
index e9418878630ea0a458983b780489326dfa0dd4ff..7ae8de75e0263ee2f6cba280ee7ef2561a9a0571 100644 (file)
@@ -1,6 +1,6 @@
-import { FormReactive } from '@app/shared'
-import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
+import { FormReactive } from '@app/shared/shared-forms'
 import { VideoConstant, VideoPlaylistPrivacy } from '@shared/models'
+import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
 
 export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
   // Declare it here to avoid errors in create template
index 366640618d54e3c198e4da9f6ec4e3cad0b9b993..0add81c38cd4a1e4f82c92561a0360c07d053e7f 100644 (file)
@@ -1,16 +1,9 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 import { Subject, Subscription } from 'rxjs'
-import { ActivatedRoute } from '@angular/router'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
 import { CdkDragDrop } from '@angular/cdk/drag-drop'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { ComponentPagination, Notifier, ScreenService } from '@app/core'
+import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 
 @Component({
   selector: 'my-account-video-playlist-elements',
@@ -33,12 +26,8 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
   private paramsSub: Subscription
 
   constructor (
-    private authService: AuthService,
-    private serverService: ServerService,
     private notifier: Notifier,
-    private confirmService: ConfirmService,
     private route: ActivatedRoute,
-    private i18n: I18n,
     private screenService: ScreenService,
     private videoPlaylistService: VideoPlaylistService
   ) {}
index 2f85cdd963f62f17fc832ee40d8e67fe4777d308..6787fb757a9941a3807bdfdc9cdaa6171cf1726b 100644 (file)
@@ -1,16 +1,14 @@
+import { forkJoin, Subscription } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, Notifier, ServerService } from '@app/core'
-import { forkJoin, Subscription } from 'rxjs'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
+import { FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylistValidatorsService } from '@app/shared'
-import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { delayWhen, map, switchMap } from 'rxjs/operators'
+import { VideoPlaylistUpdate } from '@shared/models'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
 
 @Component({
   selector: 'my-account-video-playlist-update',
index c6728cc3001158b9c089c9c08705b77db2c8c883..ea3bcde4fddc4284ad6a7e9603c1b81caac19c42 100644 (file)
@@ -1,15 +1,10 @@
+import { Subject } from 'rxjs'
+import { debounceTime, flatMap } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { User } from '@app/shared'
-import { flatMap, debounceTime } from 'rxjs/operators'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, User } from '@app/core'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
 import { VideoPlaylistType } from '@shared/models'
-import { Subject } from 'rxjs'
 
 @Component({
   selector: 'my-account-video-playlists',
index 0ec033eaf82a894d777044833a434cba94c690da..3cfe8fb382053944dfe8b60bfe58abdc7a35ba74 100644 (file)
@@ -1,21 +1,15 @@
 import { concat, Observable, Subject } from 'rxjs'
-import { tap, toArray, debounceTime } from 'rxjs/operators'
-import { Component, ViewChild, OnInit } from '@angular/core'
+import { debounceTime, tap, toArray } from 'rxjs/operators'
+import { Component, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { Notifier, ServerService } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { Video } from '../../shared/video/video.model'
-import { VideoService } from '../../shared/video/video.service'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
+import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
+import { immutableAssign } from '@app/helpers'
+import { Video, VideoService } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions, OwnerDisplayType, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { VideoSortField } from '@shared/models'
 import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { SelectionType, VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
 
 @Component({
   selector: 'my-account-videos',
index f4e2b5955f1eba86da71b164800b8d2a9ed30f32..18e716a098eef29f80135d955dc482010387a57e 100644 (file)
@@ -1,11 +1,9 @@
 import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, VideoChangeOwnershipValidatorsService } from '@app/shared/shared-forms'
+import { Video, VideoOwnershipService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { FormReactive, UserService } from '../../../shared/index'
-import { Video } from '@app/shared/video/video.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService, VideoChangeOwnershipValidatorsService } from '@app/shared'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
 
 @Component({
   selector: 'my-video-change-ownership',
index ca447c0540a793968bbdc2097ae02b52dd31a76d..ea4da676a7367bf698197d2ea61ce6a41066356f 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, OnInit } from '@angular/core'
 import { ServerService } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { TopMenuDropdownParam } from '@app/shared/menu/top-menu-dropdown.component'
 import { ServerConfig } from '@shared/models'
+import { TopMenuDropdownParam } from './top-menu-dropdown.component'
 
 @Component({
   selector: 'my-my-account',
index 72b9fd9f21f92f6ddf99c13e5bc8c511926a84c4..9a11a89eaf7f0b0b3391575dbed9c530d6ddc265 100644 (file)
@@ -1,47 +1,55 @@
-import { NgModule } from '@angular/core'
-import { TableModule } from 'primeng/table'
 import { AutoCompleteModule } from 'primeng/autocomplete'
 import { InputSwitchModule } from 'primeng/inputswitch'
-import { SharedModule } from '../shared'
+import { TableModule } from 'primeng/table'
+import { DragDropModule } from '@angular/cdk/drag-drop'
+import { NgModule } from '@angular/core'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
+import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription/shared-user-subscription.module'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
+import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
+import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
+import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
+import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component'
+import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
 import { MyAccountRoutingModule } from './my-account-routing.module'
+import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
 import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
+import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone'
+import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
+import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
 import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
-import { MyAccountComponent } from './my-account.component'
+import { MyAccountSubscriptionsComponent } from './my-account-subscriptions/my-account-subscriptions.component'
+import { MyAccountVideoImportsComponent } from './my-account-video-imports/my-account-video-imports.component'
+import { MyAccountVideoPlaylistCreateComponent } from './my-account-video-playlists/my-account-video-playlist-create.component'
+import { MyAccountVideoPlaylistElementsComponent } from './my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
 import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
 import { VideoChangeOwnershipComponent } from './my-account-videos/video-change-ownership/video-change-ownership.component'
-import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
-import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component'
-import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component'
-import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
-import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone'
-import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
-import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
-import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
-import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
-import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
-import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
-import {
-  MyAccountVideoPlaylistCreateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
-import {
-  MyAccountVideoPlaylistUpdateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
-import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
-import {
-  MyAccountVideoPlaylistElementsComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
-import { DragDropModule } from '@angular/cdk/drag-drop'
-import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
+import { MyAccountComponent } from './my-account.component'
+import { TopMenuDropdownComponent } from './top-menu-dropdown.component'
 
 @NgModule({
   imports: [
-    TableModule,
     MyAccountRoutingModule,
+
     AutoCompleteModule,
-    SharedModule,
     TableModule,
     InputSwitchModule,
-    DragDropModule
+    DragDropModule,
+
+    SharedMainModule,
+    SharedModerationModule,
+    SharedVideoMiniatureModule,
+    SharedUserSubscriptionModule,
+    SharedVideoPlaylistModule,
+    SharedUserInterfaceSettingsModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
@@ -68,7 +76,9 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
     MyAccountVideoPlaylistCreateComponent,
     MyAccountVideoPlaylistUpdateComponent,
     MyAccountVideoPlaylistsComponent,
-    MyAccountVideoPlaylistElementsComponent
+    MyAccountVideoPlaylistElementsComponent,
+
+    TopMenuDropdownComponent
   ],
 
   exports: [
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.html b/client/src/app/+my-account/shared/actor-avatar-info.component.html
deleted file mode 100644 (file)
index d01b9ac..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<ng-container *ngIf="actor">
-  <div class="actor">
-    <div class="d-flex">
-      <img [src]="actor.avatarUrl" alt="Avatar" />
-
-      <div class="actor-img-edit-container">
-        <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
-          <my-global-icon iconName="edit"></my-global-icon>
-          <label for="avatarfile" i18n>Change your avatar</label>
-          <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
-        </div>
-      </div>
-    </div>
-
-
-    <div class="actor-info">
-      <div class="actor-info-names">
-        <div class="actor-info-display-name">{{ actor.displayName }}</div>
-        <div class="actor-info-username">{{ actor.name }}</div>
-      </div>
-      <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
-    </div>
-  </div>
-</ng-container>
\ No newline at end of file
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.scss b/client/src/app/+my-account/shared/actor-avatar-info.component.scss
deleted file mode 100644 (file)
index 5a66ecf..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-@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/+my-account/shared/actor-avatar-info.component.ts b/client/src/app/+my-account/shared/actor-avatar-info.component.ts
deleted file mode 100644 (file)
index 8e4a7a6..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { ServerService } from '../../core/server'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { BytesPipe } from 'ngx-pipes'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@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<HTMLInputElement>
-
-  @Input() actor: VideoChannel | Account
-
-  @Output() avatarChange = new EventEmitter<FormData>()
-
-  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/+my-account/top-menu-dropdown.component.html b/client/src/app/+my-account/top-menu-dropdown.component.html
new file mode 100644 (file)
index 0000000..aeaceb6
--- /dev/null
@@ -0,0 +1,50 @@
+<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
+  <ng-container *ngFor="let menuEntry of menuEntries; index as id">
+
+    <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
+
+    <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry"
+      #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
+      <span
+        *ngIf="isInSmallView"
+        [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
+        (click)="openModal(id)" role="button" class="title-page title-page-settings">
+        <ng-container i18n>{{ menuEntry.label }}</ng-container>
+        <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
+      </span>
+
+      <span
+        *ngIf="!isInSmallView"
+        (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
+        (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
+      >
+        <ng-container i18n>{{ menuEntry.label }}</ng-container>
+        <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
+      </span>
+
+      <div ngbDropdownMenu>
+        <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink">
+          <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
+
+          {{ menuChild.label }}
+        </a>
+      </div>
+    </div>
+  </ng-container>
+</div>
+
+<ng-template #modal let-close="close" let-dismiss="dismiss">
+  <div class="modal-body">
+    <ng-container *ngFor="let menuEntry of menuEntries; index as id">
+      <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
+        <a *ngFor="let menuChild of menuEntry.children"
+          [ngClass]="{ icon: hasIcons }"
+          [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
+          <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
+
+          {{ menuChild.label }}
+        </a>
+      </div>
+    </ng-container>
+  </div>
+</ng-template>
diff --git a/client/src/app/+my-account/top-menu-dropdown.component.scss b/client/src/app/+my-account/top-menu-dropdown.component.scss
new file mode 100644 (file)
index 0000000..84dd7dc
--- /dev/null
@@ -0,0 +1,56 @@
+@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/+my-account/top-menu-dropdown.component.ts b/client/src/app/+my-account/top-menu-dropdown.component.ts
new file mode 100644 (file)
index 0000000..5909db0
--- /dev/null
@@ -0,0 +1,131 @@
+import { Subscription } from 'rxjs'
+import { filter, take } from 'rxjs/operators'
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { NavigationEnd, Router } from '@angular/router'
+import { MenuService, ScreenService } from '@app/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
+
+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
+        }
+      }
+    }
+  }
+}
index ffc1f777db3f8d704394493a7ffab3fd3dd4de4c..2e09ab29927cc5071686403d789e6c130e77641a 100644 (file)
@@ -1,12 +1,13 @@
+import { CommonModule } from '@angular/common'
 import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
-import { PageNotFoundComponent } from '@app/+page-not-found/page-not-found.component'
-import { PageNotFoundRoutingModule } from '@app/+page-not-found/page-not-found-routing.module'
+import { PageNotFoundRoutingModule } from './page-not-found-routing.module'
+import { PageNotFoundComponent } from './page-not-found.component'
 
 @NgModule({
   imports: [
-    PageNotFoundRoutingModule,
-    SharedModule
+    CommonModule,
+
+    PageNotFoundRoutingModule
   ],
 
   declarations: [
index f47e80755275b1a99ece1603ecdab8deba9eb742..0deed8a9b67ff268f3aadd0c109dfd4a7c0df20d 100644 (file)
@@ -1,9 +1,8 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
+import { ServerConfigResolver, UnloggedGuard } from '@app/core'
 import { MetaGuard } from '@ngx-meta/core'
 import { RegisterComponent } from './register.component'
-import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
-import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
 
 const registerRoutes: Routes = [
   {
index e434b91a7796c6e9ebaf09d6313da0f66aa8037f..8a01208405c87333a04c2d35d7f119853cdd9345 100644 (file)
@@ -1,10 +1,9 @@
+import { concat, of } from 'rxjs'
+import { pairwise } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { AuthService } from '@app/core'
-import { FormReactive, UserService, VideoChannelValidatorsService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import { FormGroup } from '@angular/forms'
-import { pairwise } from 'rxjs/operators'
-import { concat, of } from 'rxjs'
+import { UserService } from '@app/core'
+import { FormReactive, FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
 
 @Component({
   selector: 'my-register-step-channel',
@@ -17,7 +16,6 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
 
   constructor (
     protected formValidatorService: FormValidatorService,
-    private authService: AuthService,
     private userService: UserService,
     private videoChannelValidatorsService: VideoChannelValidatorsService
   ) {
index 6c96f20b448af548bd9112a2fb828c37e822bb0b..3d9ab8b6b13646dc3b87f92664086b60f1a74a35 100644 (file)
@@ -1,10 +1,9 @@
+import { concat, of } from 'rxjs'
+import { pairwise } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { AuthService } from '@app/core'
-import { FormReactive, UserService, UserValidatorsService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import { FormGroup } from '@angular/forms'
-import { pairwise } from 'rxjs/operators'
-import { concat, of } from 'rxjs'
+import { UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 
 @Component({
   selector: 'my-register-step-user',
@@ -20,7 +19,6 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
 
   constructor (
     protected formValidatorService: FormValidatorService,
-    private authService: AuthService,
     private userService: UserService,
     private userValidatorsService: UserValidatorsService
   ) {
index ae944ec15b57594f4ca605a7e06b3ce6c9414d6c..3e8171b27ce52ff24a81bda2e54100cdee9f750f 100644 (file)
@@ -1,14 +1,13 @@
 import { Component, OnInit, ViewChild } from '@angular/core'
-import { AuthService, Notifier, RedirectService, ServerService } from '@app/core'
-import { UserService, UserValidatorsService } from '@app/shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { UserRegister } from '@shared/models/users/user-register.model'
 import { FormGroup } from '@angular/forms'
-import { About, ServerConfig } from '@shared/models/server'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
+import { InstanceService } from '@app/shared/shared-instance'
 import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
-import { ActivatedRoute } from '@angular/router'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRegister } from '@shared/models'
+import { About, ServerConfig } from '@shared/models/server'
 
 @Component({
   selector: 'my-register',
@@ -40,11 +39,8 @@ export class RegisterComponent implements OnInit {
   constructor (
     private route: ActivatedRoute,
     private authService: AuthService,
-    private userValidatorsService: UserValidatorsService,
     private notifier: Notifier,
     private userService: UserService,
-    private serverService: ServerService,
-    private redirectService: RedirectService,
     private instanceService: InstanceService,
     private hooks: HooksService,
     private i18n: I18n
index e55f83990fcb41a90d23139656ba7c4f066b7295..608045f58f39273cfbff95662bb478765e6b8c4c 100644 (file)
@@ -1,21 +1,24 @@
+import { CdkStepperModule } from '@angular/cdk/stepper'
 import { NgModule } from '@angular/core'
+import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
+import { SharedInstanceModule } from '@app/shared/shared-instance'
+import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
+import { CustomStepperComponent } from './custom-stepper.component'
 import { RegisterRoutingModule } from './register-routing.module'
-import { RegisterComponent } from './register.component'
-import { SharedModule } from '@app/shared'
-import { CdkStepperModule } from '@angular/cdk/stepper'
 import { RegisterStepChannelComponent } from './register-step-channel.component'
 import { RegisterStepUserComponent } from './register-step-user.component'
-import { CustomStepperComponent } from './custom-stepper.component'
-import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
-import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
+import { RegisterComponent } from './register.component'
 
 @NgModule({
   imports: [
     RegisterRoutingModule,
-    SharedModule,
+
     CdkStepperModule,
+    NgbAccordionModule,
+
     SignupSharedModule,
-    NgbAccordionModule
+
+    SharedInstanceModule
   ],
 
   declarations: [
index 3bd604b662ca7fdcec0e2071803bd33b676d3668..51910471be9e5abc0143bfcab4c5439203668ad1 100644 (file)
@@ -1,10 +1,7 @@
 import { Component, OnInit } from '@angular/core'
+import { Notifier, RedirectService, ServerService, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Notifier, RedirectService } from '@app/core'
-import { ServerService } from '@app/core/server'
-import { FormReactive, UserService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
 import { ServerConfig } from '@shared/models'
 
 @Component({
index 48ddd71305a970a993c8c34aded4aa1c7672452d..586f4e2315443a19b42b26422a3ee18fd5a69d28 100644 (file)
@@ -1,8 +1,7 @@
 import { Component, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, UserService } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AuthService, Notifier } from '@app/core'
-import { UserService } from '@app/shared'
 
 @Component({
   selector: 'my-verify-account-email',
@@ -21,7 +20,6 @@ export class VerifyAccountEmailComponent implements OnInit {
     private userService: UserService,
     private authService: AuthService,
     private notifier: Notifier,
-    private router: Router,
     private route: ActivatedRoute,
     private i18n: I18n
   ) {
index 9fe14e81e11e48b806e748268df7d643034d06a7..7255605d4eb95d83b3dbafb122404a35ac315455 100644 (file)
@@ -1,14 +1,13 @@
 import { NgModule } from '@angular/core'
-import { VerifyAccountRoutingModule } from './verify-account-routing.module'
-import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
+import { SignupSharedModule } from '../shared/signup-shared.module'
 import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
-import { SharedModule } from '@app/shared'
-import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
+import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
+import { VerifyAccountRoutingModule } from './verify-account-routing.module'
 
 @NgModule({
   imports: [
     VerifyAccountRoutingModule,
-    SharedModule,
+
     SignupSharedModule
   ],
 
index cd21fdef39a6237b897fb96f63376b4b857ff929..56b0b3baed07ff43ccd4faea295ebaba8664013f 100644 (file)
@@ -1,10 +1,14 @@
 import { NgModule } from '@angular/core'
-import { SignupSuccessComponent } from '../shared/signup-success.component'
-import { SharedModule } from '@app/shared'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SignupSuccessComponent } from './signup-success.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
 
 @NgModule({
   imports: [
-    SharedModule
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
@@ -12,6 +16,10 @@ import { SharedModule } from '@app/shared'
   ],
 
   exports: [
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule,
+
     SignupSuccessComponent
   ],
 
index 11f9391e124e09861357acd91ec4765d9421ed5c..19e4bc1f45be9ef3da170d8ec997ca83bb674dad 100644 (file)
@@ -1,9 +1,8 @@
+import { Subscription } from 'rxjs'
 import { Component, OnDestroy, OnInit } from '@angular/core'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { MarkdownService } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { MarkdownService } from '@app/shared/renderer'
 
 @Component({
   selector: 'my-video-channel-about',
index 0b003308221ad995378a95a65d852835759ade60..8b507c62626c7c2ced689b40a01b297b6bb03213 100644 (file)
@@ -1,12 +1,8 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ConfirmService } from '../../core/confirm'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
 import { Subject, Subscription } from 'rxjs'
-import { Notifier } from '@app/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPagination, hasMoreItems } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 
 @Component({
   selector: 'my-video-channel-playlists',
@@ -28,8 +24,6 @@ export class VideoChannelPlaylistsComponent implements OnInit, OnDestroy {
   private videoChannel: VideoChannel
 
   constructor (
-    private notifier: Notifier,
-    private confirmService: ConfirmService,
     private videoPlaylistService: VideoPlaylistService,
     private videoChannelService: VideoChannelService
   ) {}
index 5749701e83bb8cdbaab589a4498a3f551844b264..267c328f2ab2fd0d27c7e3df30d2e3295ef7657b 100644 (file)
@@ -1,25 +1,18 @@
+import { Subscription } from 'rxjs'
+import { first, tap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { first, tap } from 'rxjs/operators'
+import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
 
 @Component({
   selector: 'my-video-channel-videos',
-  templateUrl: '../../shared/video/abstract-video-list.html',
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
   styleUrls: [
-    '../../shared/video/abstract-video-list.scss',
+    '../../shared/shared-video-miniature/abstract-video-list.scss',
     './video-channel-videos.component.scss'
   ]
 })
index d4872a0a597656ce25d2e27d9ba8ac677201320f..e79e6a6804cfa6300f2c7b9e12d9fc3e3d2af64b 100644 (file)
@@ -1,10 +1,10 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
 import { MetaGuard } from '@ngx-meta/core'
-import { VideoChannelsComponent } from './video-channels.component'
-import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
 import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
-import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
+import { VideoChannelsComponent } from './video-channels.component'
 
 const videoChannelsRoutes: Routes = [
   {
index a3563c7477f3a504544eed5966176a86fd7d8238..cae442ee7d82e739547741e7dab911d241887bca 100644 (file)
@@ -1,16 +1,12 @@
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { Subscription } from 'rxjs'
+import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { RestExtractor } from '@app/shared'
-import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier } from '@app/core'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
+import { AuthService, Notifier, RestExtractor, ScreenService } from '@app/core'
+import { ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
 
 @Component({
   templateUrl: './video-channels.component.html',
index 6975d05b20a4b7847b479376f04d6b39224197d0..05236ff85905cb8ef4ec2ad0a991fd4aa143ce59 100644 (file)
@@ -1,15 +1,26 @@
 import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
+import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
 import { VideoChannelsRoutingModule } from './video-channels-routing.module'
 import { VideoChannelsComponent } from './video-channels.component'
-import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
-import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
-import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
 
 @NgModule({
   imports: [
     VideoChannelsRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedVideoPlaylistModule,
+    SharedVideoMiniatureModule,
+    SharedUserSubscriptionModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
index a87f4ce1babd219fe73280491451dffed623c004..ceda414150847c0f94fc13dd39bf69632ae3d0d1 100644 (file)
@@ -1,10 +1,9 @@
 import { NgModule } from '@angular/core'
 import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
-
-import { PreloadSelectedModulesList } from './core'
 import { AppComponent } from '@app/app.component'
 import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
 import { MenuGuards } from '@app/core/routing/menu-guard.service'
+import { PreloadSelectedModulesList } from './core'
 
 const routes: Routes = [
   {
index c77dc97deb2b78c685034a8ea16384dd7f262806..a62aa487092e04bccf4fcf1dc317a43a88209437 100644 (file)
@@ -1,28 +1,22 @@
-import { Component, OnInit, ViewChild, AfterViewInit, Inject, LOCALE_ID } from '@angular/core'
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { concat } from 'rxjs'
+import { filter, first, map, pairwise } from 'rxjs/operators'
+import { DOCUMENT, PlatformLocation, ViewportScroller } from '@angular/common'
+import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
 import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
 import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular/router'
-import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
-import { is18nPath, getShortLocale } from '../../../shared/models/i18n'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { filter, map, pairwise, first } from 'rxjs/operators'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PlatformLocation, ViewportScroller, DOCUMENT } from '@angular/common'
-import { PluginService } from '@app/core/plugins/plugin.service'
+import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants'
-import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
-import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
+import { PluginService } from '@app/core/plugins/plugin.service'
 import { CustomModalComponent } from '@app/modal/custom-modal.component'
-import { ServerConfig, UserRole } from '@shared/models'
-import { User } from '@app/shared'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
+import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { BroadcastMessageLevel, getShortLocale, is18nPath, ServerConfig, UserRole } from '@shared/models'
 import { MenuService } from './core/menu/menu.service'
-import { BroadcastMessageLevel } from '@shared/models/server'
-import { MarkdownService } from './shared/renderer'
-import { concat } from 'rxjs'
-import { peertubeLocalStorage } from './shared/misc/peertube-web-storage'
+import { peertubeLocalStorage, POP_STATE_MODAL_DISMISS } from './helpers'
+import { InstanceService } from './shared/shared-instance'
 
 @Component({
   selector: 'my-app',
index 89332ec5f4720057f163396a03b37ef370d60ad3..7fbc6463b0dd8c03ad89aa9df65e76f03eee16f8 100644 (file)
@@ -1,31 +1,36 @@
+import 'focus-visible'
+import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
 import { LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT } from '@angular/core'
 import { BrowserModule } from '@angular/platform-browser'
 import { ServerService } from '@app/core'
+import localeOc from '@app/helpers/locales/oc'
 import { ResetPasswordModule } from '@app/reset-password'
+import { SearchModule } from '@app/search'
 import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
-import 'focus-visible'
-
+import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
 import { AppRoutingModule } from './app-routing.module'
 import { AppComponent } from './app.component'
 import { CoreModule } from './core'
 import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
 import { LoginModule } from './login'
 import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
-import { SharedModule } from './shared'
+import { ConfirmComponent } from './modal/confirm.component'
+import { CustomModalComponent } from './modal/custom-modal.component'
+import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component'
+import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component'
+import { WelcomeModalComponent } from './modal/welcome-modal.component'
+import { SharedFormModule } from './shared/shared-forms'
+import { SharedGlobalIconModule } from './shared/shared-icons'
+import { SharedInstanceModule } from './shared/shared-instance'
+import { SharedMainModule } from './shared/shared-main'
+import { SharedUserInterfaceSettingsModule } from './shared/shared-user-settings'
 import { VideosModule } from './videos'
-import { SearchModule } from '@app/search'
-import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
-import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
-import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
-import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
-import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
-import { CustomModalComponent } from '@app/modal/custom-modal.component'
-import localeOc from '@app/shared/locale/oc'
 
 registerLocaleData(localeOc, 'oc')
 
 @NgModule({
   bootstrap: [ AppComponent ],
+
   declarations: [
     AppComponent,
 
@@ -39,19 +44,24 @@ registerLocaleData(localeOc, 'oc')
 
     CustomModalComponent,
     WelcomeModalComponent,
-    InstanceConfigWarningModalComponent
+    InstanceConfigWarningModalComponent,
+    ConfirmComponent
   ],
+
   imports: [
     BrowserModule,
 
     CoreModule,
-    SharedModule,
+    SharedMainModule,
+    SharedFormModule,
+    SharedUserInterfaceSettingsModule,
+    SharedGlobalIconModule,
+    SharedInstanceModule,
 
-    CoreModule,
     LoginModule,
     ResetPasswordModule,
     SearchModule,
-    SharedModule,
+
     VideosModule,
 
     MetaModule.forRoot({
index 4ad904beb2389db37e482cf1c3e848ef91ba8f69..4e7801550d05c5f2b5f0eeea2aa02db79c489600 100644 (file)
@@ -1,10 +1,14 @@
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { UserRight } from '../../../../../shared/models/users/user-right.enum'
-import { MyUser as ServerMyUserModel, User as ServerUserModel, MyUserSpecialPlaylist } from '../../../../../shared/models/users/user.model'
-// Do not use the barrel (dependency loop)
-import { hasUserRight, UserRole } from '../../../../../shared/models/users/user-role'
-import { User } from '../../shared/users/user.model'
-import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
+import { User } from '@app/core/users/user.model'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
+import {
+  hasUserRight,
+  MyUser as ServerMyUserModel,
+  MyUserSpecialPlaylist,
+  NSFWPolicyType,
+  User as ServerUserModel,
+  UserRight,
+  UserRole
+} from '@shared/models'
 
 export type TokenOptions = {
   accessToken: string
index de8c509d1d6a3d7345792fafe50aa7c35ac06f96..94262b9aacbca81d3b6f1ac75bee4f977df2812a 100644 (file)
@@ -1,20 +1,17 @@
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
 import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
 import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Router } from '@angular/router'
 import { Notifier } from '@app/core/notification/notifier.service'
-import { OAuthClientLocal, MyUser as UserServerModel, UserRefreshToken } from '../../../../../shared'
-import { User } from '../../../../../shared/models/users'
-import { UserLogin } from '../../../../../shared/models/users/user-login.model'
+import { objectToUrlEncoded, peertubeLocalStorage } from '@app/helpers'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
 import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../../shared/rest/rest-extractor.service'
+import { RestExtractor } from '../rest/rest-extractor.service'
 import { AuthStatus } from './auth-status.model'
 import { AuthUser } from './auth-user.model'
-import { objectToUrlEncoded } from '@app/shared/misc/utils'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 
 interface UserLoginWithUsername extends UserLogin {
   access_token: string
index a1734ad80e7be4cc5b6e648b78a0984eb9d91fde..22896e2e96ec04d7f136751da234ccc5fa5a909d 100644 (file)
@@ -1,35 +1,35 @@
+import { HotkeyModule } from 'angular2-hotkeys'
+import { MessageService } from 'primeng/api'
+import { ToastModule } from 'primeng/toast'
 import { CommonModule } from '@angular/common'
 import { NgModule, Optional, SkipSelf } from '@angular/core'
-import { FormsModule } from '@angular/forms'
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
-import { RouterModule } from '@angular/router'
+import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { PluginService } from '@app/core/plugins/plugin.service'
+import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
 import { LoadingBarModule } from '@ngx-loading-bar/core'
 import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
 import { LoadingBarRouterModule } from '@ngx-loading-bar/router'
-
 import { AuthService } from './auth'
 import { ConfirmService } from './confirm'
+import { CheatSheetComponent } from './hotkeys'
+import { MenuService } from './menu'
 import { throwIfAlreadyLoaded } from './module-import-guard'
+import { Notifier } from './notification'
+import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
+import { RestExtractor, RestService } from './rest'
 import { LoginGuard, RedirectService, UserRightGuard } from './routing'
+import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
+import { ServerConfigResolver } from './routing/server-config-resolver.service'
 import { ServerService } from './server'
 import { ThemeService } from './theme'
-import { MenuService } from './menu'
-import { HotkeyModule } from 'angular2-hotkeys'
-import { CheatSheetComponent } from './hotkeys'
-import { ToastModule } from 'primeng/toast'
-import { Notifier } from './notification'
-import { MessageService } from 'primeng/api'
-import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
-import { ServerConfigResolver } from './routing/server-config-resolver.service'
-import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
-import { PluginService } from '@app/core/plugins/plugin.service'
-import { HooksService } from '@app/core/plugins/hooks.service'
+import { UserService } from './users'
+import { LocalStorageService, ScreenService, SessionStorageService } from './wrappers'
 
 @NgModule({
   imports: [
     CommonModule,
-    RouterModule,
-    FormsModule,
     BrowserAnimationsModule,
 
     LoadingBarHttpClientModule,
@@ -68,11 +68,25 @@ import { HooksService } from '@app/core/plugins/hooks.service'
     PluginService,
     HooksService,
 
+    HtmlRendererService,
+    LinkifierService,
+    MarkdownService,
+
+    RestExtractor,
+    RestService,
+
+    UserService,
+
+    ScreenService,
+    LocalStorageService,
+    SessionStorageService,
+
     RedirectService,
     Notifier,
     MessageService,
     UserNotificationSocket,
-    ServerConfigResolver
+    ServerConfigResolver,
+    CanDeactivateGuard
   ]
 })
 export class CoreModule {
index f664aff41d482a55859863d3d25cd6c588651090..a0c34543d10c188f2c20d0a4bb8c12ca5a3c8f61 100644 (file)
@@ -1,8 +1,15 @@
 export * from './auth'
 export * from './confirm'
+export * from './hotkeys'
+export * from './menu'
+export * from './notification'
+export * from './plugins'
+export * from './renderer'
+export * from './rest'
 export * from './routing'
 export * from './server'
-export * from './notification'
 export * from './theme'
+export * from './users'
+export * from './wrappers'
 
 export * from './core.module'
index 81093c6663c5cd4daccbe8de0bb1090068a7cc75..ef5271f976056d30711f8c1b7a9f919e206a3eb1 100644 (file)
@@ -1,7 +1,7 @@
-import { Injectable } from '@angular/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
 import { fromEvent } from 'rxjs'
 import { debounceTime } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ScreenService } from '../wrappers'
 
 @Injectable()
 export class MenuService {
index 3f22da476fc1c223f583e90033dd0ab3e20a0ad5..37f0bc32c11dea6ea39536c96e8c330c37f498a3 100644 (file)
@@ -1,7 +1,7 @@
+import { Subject } from 'rxjs'
 import { Injectable, NgZone } from '@angular/core'
+import { UserNotification as UserNotificationServer } from '@shared/models'
 import { environment } from '../../../environments/environment'
-import { UserNotification as UserNotificationServer } from '../../../../../shared'
-import { Subject } from 'rxjs'
 import { AuthService } from '../auth'
 
 export type NotificationEvent = 'new' | 'read' | 'read-all'
index 2fbf406d119e663fb1b95f6db4064f3ca14f8435..ec47aa48c562fa621857c3b608e07edea5481dd1 100644 (file)
@@ -2,8 +2,7 @@ import { from, Observable } from 'rxjs'
 import { mergeMap, switchMap } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
 import { PluginService } from '@app/core/plugins/plugin.service'
-import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model'
-import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
+import { ClientActionHookName, ClientFilterHookName, PluginClientScope } from '@shared/models'
 
 type RawFunction<U, T> = (params: U) => T
 type ObservableFunction<U, T> = RawFunction<U, Observable<T>>
diff --git a/client/src/app/core/plugins/index.ts b/client/src/app/core/plugins/index.ts
new file mode 100644 (file)
index 0000000..b3239f1
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './hooks.service'
+export * from './plugin.service'
index c6efcac6d3db146f3c4602b871396a4e57da019f..3cab64142ac256d5ed0d7f446c14a6742d531925 100644 (file)
@@ -1,28 +1,33 @@
-import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
-import { Router } from '@angular/router'
-import { getCompleteLocale, isDefaultLocale, peertubeTranslate, ServerConfigPlugin } from '@shared/models'
-import { ServerService } from '@app/core/server/server.service'
-import { ClientScript } from '@shared/models/plugins/plugin-package-json.model'
-import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
-import { environment } from '../../../environments/environment'
 import { Observable, of, ReplaySubject } from 'rxjs'
 import { catchError, first, map, shareReplay } from 'rxjs/operators'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
-import { ClientHook, ClientHookName, clientHookObject } from '@shared/models/plugins/client-hook.model'
-import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
-import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model'
 import { HttpClient } from '@angular/common/http'
+import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
 import { AuthService } from '@app/core/auth'
 import { Notifier } from '@app/core/notification'
-import { RestExtractor } from '@app/shared/rest'
-import { MarkdownService } from '@app/shared/renderer'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { PublicServerSetting } from '@shared/models/plugins/public-server.setting'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
-import { RegisterClientHelpers } from '../../../types/register-client-option.model'
-import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model'
-import { importModule } from '@app/shared/misc/utils'
+import { MarkdownService } from '@app/core/renderer'
+import { RestExtractor } from '@app/core/rest'
+import { ServerService } from '@app/core/server/server.service'
+import { getDevLocale, importModule, isOnDevLocale } from '@app/helpers'
 import { CustomModalComponent } from '@app/modal/custom-modal.component'
+import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
+import {
+  ClientHook,
+  ClientHookName,
+  clientHookObject,
+  ClientScript,
+  getCompleteLocale,
+  isDefaultLocale,
+  peertubeTranslate,
+  PluginClientScope,
+  PluginTranslation,
+  PluginType,
+  PublicServerSetting,
+  RegisterClientHookOptions,
+  ServerConfigPlugin
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
+import { RegisterClientHelpers } from '../../../types/register-client-option.model'
 
 interface HookStructValue extends RegisterClientHookOptions {
   plugin: ServerConfigPlugin
@@ -64,7 +69,6 @@ export class PluginService implements ClientHook {
   private hooks: { [ name: string ]: HookStructValue[] } = {}
 
   constructor (
-    private router: Router,
     private authService: AuthService,
     private notifier: Notifier,
     private markdownRenderer: MarkdownService,
diff --git a/client/src/app/core/renderer/html-renderer.service.ts b/client/src/app/core/renderer/html-renderer.service.ts
new file mode 100644 (file)
index 0000000..f0527c7
--- /dev/null
@@ -0,0 +1,40 @@
+import { Injectable } from '@angular/core'
+import { LinkifierService } from './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/core/renderer/index.ts b/client/src/app/core/renderer/index.ts
new file mode 100644 (file)
index 0000000..39202b3
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './html-renderer.service'
+export * from './linkifier.service'
+export * from './markdown.service'
diff --git a/client/src/app/core/renderer/linkifier.service.ts b/client/src/app/core/renderer/linkifier.service.ts
new file mode 100644 (file)
index 0000000..46d5b00
--- /dev/null
@@ -0,0 +1,114 @@
+import { Injectable } from '@angular/core'
+import { getAbsoluteAPIUrl } from '@app/helpers/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/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts
new file mode 100644 (file)
index 0000000..0c43beb
--- /dev/null
@@ -0,0 +1,145 @@
+import * as MarkdownIt from 'markdown-it'
+import { buildVideoLink } from 'src/assets/player/utils'
+import { Injectable } from '@angular/core'
+import { HtmlRendererService } from './html-renderer.service'
+
+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 `<a class="video-timestamp" href="${url}">${str}</a>`
+    })
+  }
+
+  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[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
+      .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
+      .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
+  }
+}
diff --git a/client/src/app/core/rest/component-pagination.model.ts b/client/src/app/core/rest/component-pagination.model.ts
new file mode 100644 (file)
index 0000000..bcb73ed
--- /dev/null
@@ -0,0 +1,18 @@
+export interface ComponentPagination {
+  currentPage: number
+  itemsPerPage: number
+  totalItems: number
+}
+
+export type ComponentPaginationLight = Omit<ComponentPagination, 'totalItems'>
+
+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/core/rest/index.ts b/client/src/app/core/rest/index.ts
new file mode 100644 (file)
index 0000000..93899be
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './component-pagination.model'
+export * from './rest-extractor.service'
+export * from './rest-pagination'
+export * from './rest-table'
+export * from './rest.service'
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
new file mode 100644 (file)
index 0000000..9de964f
--- /dev/null
@@ -0,0 +1,109 @@
+import { throwError as observableThrowError } from 'rxjs'
+import { Injectable } from '@angular/core'
+import { Router } from '@angular/router'
+import { dateToHuman } from '@app/helpers'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ResultList } from '@shared/models'
+
+@Injectable()
+export class RestExtractor {
+
+  constructor (
+    private router: Router,
+    private i18n: I18n
+  ) { }
+
+  extractDataBool () {
+    return true
+  }
+
+  applyToResultListData <T> (result: ResultList<T>, fun: Function, additionalArgs?: any[]): ResultList<T> {
+    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 <T> (result: ResultList<T>, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList<T> {
+    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/core/rest/rest-pagination.ts b/client/src/app/core/rest/rest-pagination.ts
new file mode 100644 (file)
index 0000000..0faa593
--- /dev/null
@@ -0,0 +1,4 @@
+export interface RestPagination {
+  start: number
+  count: number
+}
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
new file mode 100644 (file)
index 0000000..1b35ad4
--- /dev/null
@@ -0,0 +1,105 @@
+import { peertubeLocalStorage } from '@app/helpers/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<string>
+
+  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/core/rest/rest.service.ts b/client/src/app/core/rest/rest.service.ts
new file mode 100644 (file)
index 0000000..7855885
--- /dev/null
@@ -0,0 +1,111 @@
+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/core/routing/can-deactivate-guard.service.ts b/client/src/app/core/routing/can-deactivate-guard.service.ts
new file mode 100644 (file)
index 0000000..e040529
--- /dev/null
@@ -0,0 +1,30 @@
+import { Observable } from 'rxjs'
+import { Injectable } from '@angular/core'
+import { CanDeactivate } from '@angular/router'
+import { ConfirmService } from '@app/core/confirm'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable<boolean> | boolean }
+
+export interface CanComponentDeactivate {
+  canDeactivate: () => CanComponentDeactivateResult
+}
+
+@Injectable()
+export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
+  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')
+    )
+  }
+
+}
index 58b83bb2aa90c0e397181fb95a6d748151253b77..239c27caf9bd67dbc9d0f96f7b74c3d5b9efb729 100644 (file)
@@ -1,5 +1,10 @@
+export * from './can-deactivate-guard.service'
+export * from './custom-reuse-strategy'
+export * from './disable-for-reuse-hook'
 export * from './login-guard.service'
-export * from './user-right-guard.service'
+export * from './menu-guard.service'
 export * from './preload-selected-modules-list'
 export * from './redirect.service'
-export * from './menu-guard.service'
+export * from './server-config-resolver.service'
+export * from './unlogged-guard.service'
+export * from './user-right-guard.service'
index 7b1c37ee8929976022d60ff1592d4ef9fcf28e58..a949be14c18f81af6dd79b5eced3b99ed67d5949 100644 (file)
@@ -1,6 +1,5 @@
 import { Injectable } from '@angular/core'
 import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
-
 import { AuthService } from '../auth/auth.service'
 
 @Injectable()
index 907d145fdbca0ebd3891f99d345895d8796cea40..9df2856351133da0ab9a87ef09bbdb090a5dcb1e 100644 (file)
@@ -1,7 +1,7 @@
 import { Injectable } from '@angular/core'
 import { CanActivate, CanDeactivate } from '@angular/router'
-import { MenuService } from '@app/core/menu'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { MenuService } from '../menu'
+import { ScreenService } from '../wrappers'
 
 abstract class MenuGuard implements CanActivate, CanDeactivate<any> {
   display = true
index 64af68225d20763a9a242a6afccfdce7b73f49aa..b494a40bc151c57b30d07e60ab10e207771fdb31 100644 (file)
@@ -5,6 +5,7 @@ import { Injectable } from '@angular/core'
 
 @Injectable()
 export class PreloadSelectedModulesList implements PreloadingStrategy {
+
   preload (route: Route, load: Function): Observable<any> {
     if (!route.data || !route.data.preload) return ofObservable(null)
 
index 3b7ed99bfda89924522985211cc64eb88f11cb7d..0ce2023a0b9396013e80e53d7cc6719ff02af0e4 100644 (file)
@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core'
 import { Resolve } from '@angular/router'
-import { ServerService } from '@app/core/server'
+import { ServerService } from '../server'
 import { ServerConfig } from '@shared/models'
 
 @Injectable()
index 3132a1a772aba6d55287cbfe9a1b5e2cede2b17d..0be7911a0e45f67a8858b5cb6bb70ccf4449dba5 100644 (file)
@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core'
-import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapshot } from '@angular/router'
 import { AuthService } from '../auth/auth.service'
 import { RedirectService } from './redirect.service'
 
@@ -7,7 +7,6 @@ import { RedirectService } from './redirect.service'
 export class UnloggedGuard implements CanActivate, CanActivateChild {
 
   constructor (
-    private router: Router,
     private auth: AuthService,
     private redirectService: RedirectService
   ) {}
index 50c3d8c19e59f9c7b412ad2d694230045d3124e1..a2ce772dbc6cf9e280834987909dc48a34d8d53b 100644 (file)
@@ -1,12 +1,5 @@
 import { Injectable } from '@angular/core'
-import {
-  ActivatedRouteSnapshot,
-  CanActivateChild,
-  RouterStateSnapshot,
-  CanActivate,
-  Router
-} from '@angular/router'
-
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
 import { AuthService } from '../auth/auth.service'
 
 @Injectable()
index a804efd2888ad27e9bcb87b578c3947474142b74..32a13520366eb200edba3342e7557faabff64aa4 100644 (file)
@@ -2,14 +2,16 @@ import { Observable, of, Subject } from 'rxjs'
 import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Inject, Injectable, LOCALE_ID } from '@angular/core'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { sortBy } from '@app/shared/misc/utils'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
-import { ServerStats } from '@shared/models/server'
-import { getCompleteLocale, ServerConfig } from '../../../../../shared'
-import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
-import { VideoConstant } from '../../../../../shared/models/videos'
+import { getDevLocale, isOnDevLocale, peertubeLocalStorage, sortBy } from '@app/helpers'
+import {
+  getCompleteLocale,
+  isDefaultLocale,
+  peertubeTranslate,
+  SearchTargetType,
+  ServerConfig,
+  ServerStats,
+  VideoConstant
+} from '@shared/models'
 import { environment } from '../../../environments/environment'
 
 @Injectable()
index c0189ad32029f85a4af9a6c22ede26fcab12ef25..9dbf22e200147195b35014b752ede3054fa5f2a7 100644 (file)
@@ -1,13 +1,13 @@
+import { first } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
-import { AuthService } from '@app/core/auth'
-import { ServerService } from '@app/core/server'
-import { environment } from '../../../environments/environment'
-import { PluginService } from '@app/core/plugins/plugin.service'
+import { AuthService } from '../auth'
+import { PluginService } from '../plugins/plugin.service'
+import { ServerService } from '../server'
+import { LocalStorageService } from '../wrappers/storage.service'
+import { User } from '../users/user.model'
+import { UserService } from '../users/user.service'
 import { ServerConfig, ServerConfigTheme } from '@shared/models'
-import { first } from 'rxjs/operators'
-import { User } from '@app/shared/users/user.model'
-import { UserService } from '@app/shared/users/user.service'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { environment } from '../../../environments/environment'
 
 @Injectable()
 export class ThemeService {
diff --git a/client/src/app/core/users/index.ts b/client/src/app/core/users/index.ts
new file mode 100644 (file)
index 0000000..7b5a67b
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './user.model'
+export * from './user.service'
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
new file mode 100644 (file)
index 0000000..8ecdf9f
--- /dev/null
@@ -0,0 +1,150 @@
+import { Account } from '@app/shared/shared-main/account/account.model'
+import {
+  Avatar,
+  hasUserRight,
+  NSFWPolicyType,
+  User as UserServerModel,
+  UserAdminFlag,
+  UserNotificationSetting,
+  UserRight,
+  UserRole,
+  VideoChannel
+} from '@shared/models'
+
+export class User implements UserServerModel {
+  static KEYS = {
+    ID: 'id',
+    ROLE: 'role',
+    EMAIL: 'email',
+    VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
+    USERNAME: 'username',
+    NSFW_POLICY: 'nsfw_policy',
+    WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
+    AUTO_PLAY_VIDEO: 'auto_play_video',
+    SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
+    AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
+    THEME: 'last_active_theme',
+    VIDEO_LANGUAGES: 'video_languages'
+  }
+
+  id: number
+  username: string
+  email: string
+  pendingEmail: string | null
+
+  emailVerified: boolean
+  nsfwPolicy: NSFWPolicyType
+
+  adminFlags?: UserAdminFlag
+
+  autoPlayVideo: boolean
+  autoPlayNextVideo: boolean
+  autoPlayNextVideoPlaylist: boolean
+  webTorrentEnabled: boolean
+  videosHistoryEnabled: boolean
+  videoLanguages: string[]
+
+  role: UserRole
+  roleLabel: string
+
+  videoQuota: number
+  videoQuotaDaily: number
+  videoQuotaUsed?: number
+  videoQuotaUsedDaily?: number
+  videosCount?: number
+  videoAbusesCount?: number
+  videoAbusesAcceptedCount?: number
+  videoAbusesCreatedCount?: number
+  videoCommentsCount?: number
+
+  theme: string
+
+  account: Account
+  notificationSettings?: UserNotificationSetting
+  videoChannels?: VideoChannel[]
+
+  blocked: boolean
+  blockedReason?: string
+
+  noInstanceConfigWarningModal: boolean
+  noWelcomeModal: boolean
+
+  pluginAuth: string | null
+
+  lastLoginDate: Date | null
+
+  createdAt: Date
+
+  constructor (hash: Partial<UserServerModel>) {
+    this.id = hash.id
+    this.username = hash.username
+    this.email = hash.email
+
+    this.role = hash.role
+
+    this.videoChannels = hash.videoChannels
+
+    this.videoQuota = hash.videoQuota
+    this.videoQuotaDaily = hash.videoQuotaDaily
+    this.videoQuotaUsed = hash.videoQuotaUsed
+    this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
+    this.videosCount = hash.videosCount
+    this.videoAbusesCount = hash.videoAbusesCount
+    this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
+    this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
+    this.videoCommentsCount = hash.videoCommentsCount
+
+    this.nsfwPolicy = hash.nsfwPolicy
+    this.webTorrentEnabled = hash.webTorrentEnabled
+    this.autoPlayVideo = hash.autoPlayVideo
+    this.autoPlayNextVideo = hash.autoPlayNextVideo
+    this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
+    this.videosHistoryEnabled = hash.videosHistoryEnabled
+    this.videoLanguages = hash.videoLanguages
+
+    this.theme = hash.theme
+
+    this.adminFlags = hash.adminFlags
+
+    this.blocked = hash.blocked
+    this.blockedReason = hash.blockedReason
+
+    this.noInstanceConfigWarningModal = hash.noInstanceConfigWarningModal
+    this.noWelcomeModal = hash.noWelcomeModal
+
+    this.notificationSettings = hash.notificationSettings
+
+    this.createdAt = hash.createdAt
+
+    this.pluginAuth = hash.pluginAuth
+    this.lastLoginDate = hash.lastLoginDate
+
+    if (hash.account !== undefined) {
+      this.account = new Account(hash.account)
+    }
+  }
+
+  get accountAvatarUrl () {
+    if (!this.account) return ''
+
+    return this.account.avatarUrl
+  }
+
+  hasRight (right: UserRight) {
+    return hasUserRight(this.role, right)
+  }
+
+  patch (obj: UserServerModel) {
+    for (const key of Object.keys(obj)) {
+      this[key] = obj[key]
+    }
+
+    if (obj.account !== undefined) {
+      this.account = new Account(obj.account)
+    }
+  }
+
+  updateAccountAvatar (newAccountAvatar: Avatar) {
+    this.account.updateAvatar(newAccountAvatar)
+  }
+}
diff --git a/client/src/app/core/users/user.service.ts b/client/src/app/core/users/user.service.ts
new file mode 100644 (file)
index 0000000..ab395b1
--- /dev/null
@@ -0,0 +1,375 @@
+import { has } from 'lodash-es'
+import { BytesPipe } from 'ngx-pipes'
+import { SortMeta } from 'primeng/api'
+import { from, Observable, of } from 'rxjs'
+import { catchError, concatMap, filter, first, map, shareReplay, throttleTime, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { AuthService } from '@app/core/auth'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import {
+  Avatar,
+  NSFWPolicyType,
+  ResultList,
+  User as UserServerModel,
+  UserCreate,
+  UserRegister,
+  UserRole,
+  UserUpdate,
+  UserUpdateMe,
+  UserVideoQuota
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { RestExtractor, RestPagination, RestService } from '../rest'
+import { LocalStorageService, SessionStorageService } from '../wrappers/storage.service'
+import { User } from './user.model'
+
+@Injectable()
+export class UserService {
+  static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
+
+  private bytesPipe = new BytesPipe()
+
+  private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
+
+  constructor (
+    private authHttp: HttpClient,
+    private authService: AuthService,
+    private restExtractor: RestExtractor,
+    private restService: RestService,
+    private localStorageService: LocalStorageService,
+    private sessionStorageService: SessionStorageService,
+    private i18n: I18n
+  ) { }
+
+  changePassword (currentPassword: string, newPassword: string) {
+    const url = UserService.BASE_USERS_URL + 'me'
+    const body: UserUpdateMe = {
+      currentPassword,
+      password: newPassword
+    }
+
+    return this.authHttp.put(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  changeEmail (password: string, newEmail: string) {
+    const url = UserService.BASE_USERS_URL + 'me'
+    const body: UserUpdateMe = {
+      currentPassword: password,
+      email: newEmail
+    }
+
+    return this.authHttp.put(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateMyProfile (profile: UserUpdateMe) {
+    const url = UserService.BASE_USERS_URL + 'me'
+
+    return this.authHttp.put(url, profile)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateMyAnonymousProfile (profile: UserUpdateMe) {
+    const supportedKeys = {
+      // local storage keys
+      nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
+      webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
+      autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
+      autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
+      theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
+      videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
+
+      // session storage keys
+      autoPlayNextVideo: (val: boolean) =>
+        this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
+    }
+
+    for (const key of Object.keys(profile)) {
+      try {
+        if (has(supportedKeys, key)) supportedKeys[key](profile[key])
+      } catch (err) {
+        console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
+      }
+    }
+  }
+
+  listenAnonymousUpdate () {
+    return this.localStorageService.watch([
+      User.KEYS.NSFW_POLICY,
+      User.KEYS.WEBTORRENT_ENABLED,
+      User.KEYS.AUTO_PLAY_VIDEO,
+      User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST,
+      User.KEYS.THEME,
+      User.KEYS.VIDEO_LANGUAGES
+    ]).pipe(
+      throttleTime(200),
+      filter(() => this.authService.isLoggedIn() !== true),
+      map(() => this.getAnonymousUser())
+    )
+  }
+
+  deleteMe () {
+    const url = UserService.BASE_USERS_URL + 'me'
+
+    return this.authHttp.delete(url)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  changeAvatar (avatarForm: FormData) {
+    const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
+
+    return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  signup (userCreate: UserRegister) {
+    return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getMyVideoQuotaUsed () {
+    const url = UserService.BASE_USERS_URL + 'me/video-quota-used'
+
+    return this.authHttp.get<UserVideoQuota>(url)
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  askResetPassword (email: string) {
+    const url = UserService.BASE_USERS_URL + '/ask-reset-password'
+
+    return this.authHttp.post(url, { email })
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  resetPassword (userId: number, verificationString: string, password: string) {
+    const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
+    const body = {
+      verificationString,
+      password
+    }
+
+    return this.authHttp.post(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+
+  verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
+    const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
+    const body = {
+      verificationString,
+      isPendingEmail
+    }
+
+    return this.authHttp.post(url, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(res => this.restExtractor.handleError(res))
+               )
+  }
+
+  askSendVerifyEmail (email: string) {
+    const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
+
+    return this.authHttp.post(url, { email })
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  autocomplete (search: string): Observable<string[]> {
+    const url = UserService.BASE_USERS_URL + 'autocomplete'
+    const params = new HttpParams().append('search', search)
+
+    return this.authHttp
+      .get<string[]>(url, { params })
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
+    // Don't update display name, the user seems to have changed it
+    if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
+
+    return this.displayNameToUsername(newDisplayName)
+  }
+
+  displayNameToUsername (displayName: string) {
+    if (!displayName) return ''
+
+    return displayName
+      .toLowerCase()
+      .replace(/\s/g, '_')
+      .replace(/[^a-z0-9_.]/g, '')
+  }
+
+  /* ###### Admin methods ###### */
+
+  addUser (userCreate: UserCreate) {
+    return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateUser (userId: number, userUpdate: UserUpdate) {
+    return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+
+  getUserWithCache (userId: number) {
+    if (!this.userCache[userId]) {
+      this.userCache[ userId ] = this.getUser(userId).pipe(shareReplay())
+    }
+
+    return this.userCache[userId]
+  }
+
+  getUser (userId: number, withStats = false) {
+    const params = new HttpParams().append('withStats', withStats + '')
+    return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  getAnonymousUser () {
+    let videoLanguages: string[]
+
+    try {
+      videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
+    } catch (err) {
+      videoLanguages = null
+      console.error('Cannot parse desired video languages from localStorage.', err)
+    }
+
+    return new User({
+      // local storage keys
+      nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
+      webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
+      theme: this.localStorageService.getItem(User.KEYS.THEME) || 'instance-default',
+      videoLanguages,
+
+      autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
+      autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
+
+      // session storage keys
+      autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
+    })
+  }
+
+  getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (search) params = params.append('search', search)
+
+    return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
+               .pipe(
+                 map(res => this.restExtractor.convertResultListDateToHuman(res)),
+                 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  removeUser (usersArg: UserServerModel | UserServerModel[]) {
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+
+  banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
+    const body = reason ? { reason } : {}
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+
+  unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
+  }
+
+  getAnonymousOrLoggedUser () {
+    if (!this.authService.isLoggedIn()) {
+      return of(this.getAnonymousUser())
+    }
+
+    return this.authService.userInformationLoaded
+        .pipe(
+          first(),
+          map(() => this.authService.getUser())
+        )
+  }
+
+  private formatUser (user: UserServerModel) {
+    let videoQuota
+    if (user.videoQuota === -1) {
+      videoQuota = this.i18n('Unlimited')
+    } else {
+      videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
+    }
+
+    const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
+
+    const roleLabels: { [ id in UserRole ]: string } = {
+      [UserRole.USER]: this.i18n('User'),
+      [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
+      [UserRole.MODERATOR]: this.i18n('Moderator')
+    }
+
+    return Object.assign(user, {
+      roleLabel: roleLabels[user.role],
+      videoQuota,
+      videoQuotaUsed
+    })
+  }
+}
diff --git a/client/src/app/core/wrappers/index.ts b/client/src/app/core/wrappers/index.ts
new file mode 100644 (file)
index 0000000..d82b700
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './screen.service'
+export * from './storage.service'
diff --git a/client/src/app/core/wrappers/screen.service.ts b/client/src/app/core/wrappers/screen.service.ts
new file mode 100644 (file)
index 0000000..a69fad3
--- /dev/null
@@ -0,0 +1,66 @@
+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/core/wrappers/storage.service.ts b/client/src/app/core/wrappers/storage.service.ts
new file mode 100644 (file)
index 0000000..9a60b97
--- /dev/null
@@ -0,0 +1,37 @@
+import { Observable, Subject } from 'rxjs'
+import { filter } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers'
+
+abstract class StorageService {
+  protected instance: Storage
+  static storageSub = new Subject<string>()
+
+  watch (keys?: string[]): Observable<string> {
+    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
+}
index 7d04e0f6d42b616535e896e332b334898378c6b6..c546628ee66fb52b820fad529aed4de46fe4dd76 100644 (file)
@@ -1,11 +1,10 @@
 import { of } from 'rxjs'
-import { first, tap, delay } from 'rxjs/operators'
+import { first, tap } from 'rxjs/operators'
 import { ListKeyManager } from '@angular/cdk/a11y'
-import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
+import { AfterViewChecked, AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'
 import { ActivatedRoute, Params, Router } from '@angular/router'
 import { AuthService, ServerService } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { SearchTargetType, ServerConfig } from '@shared/models'
 import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
 
 @Component({
index 250a5411e49f9b785d4399ea4afe32278d27a6da..c874212a5a9ea51556307014bcb186c7a62c8c6b 100644 (file)
@@ -1,6 +1,6 @@
-import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
-import { RouterLink } from '@angular/router'
 import { ListKeyManagerOption } from '@angular/cdk/a11y'
+import { Component, Input, OnInit } from '@angular/core'
+import { RouterLink } from '@angular/router'
 
 export type SuggestionPayload = {
   text: string
diff --git a/client/src/app/helpers/constants.ts b/client/src/app/helpers/constants.ts
new file mode 100644 (file)
index 0000000..bb4a088
--- /dev/null
@@ -0,0 +1 @@
+export const POP_STATE_MODAL_DISMISS = 'pop state dismiss'
diff --git a/client/src/app/helpers/i18n-utils.ts b/client/src/app/helpers/i18n-utils.ts
new file mode 100644 (file)
index 0000000..bbfb129
--- /dev/null
@@ -0,0 +1,14 @@
+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/helpers/index.ts b/client/src/app/helpers/index.ts
new file mode 100644 (file)
index 0000000..0680640
--- /dev/null
@@ -0,0 +1,6 @@
+export * from './locales'
+export * from './constants'
+export * from './i18n-utils'
+export * from './peertube-web-storage'
+export * from './utils'
+export * from './zone'
diff --git a/client/src/app/helpers/locales/index.ts b/client/src/app/helpers/locales/index.ts
new file mode 100644 (file)
index 0000000..b0e4d61
--- /dev/null
@@ -0,0 +1 @@
+export * from './oc'
diff --git a/client/src/app/helpers/locales/oc.ts b/client/src/app/helpers/locales/oc.ts
new file mode 100644 (file)
index 0000000..d3b2e84
--- /dev/null
@@ -0,0 +1,104 @@
+
+// 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/helpers/peertube-web-storage.ts b/client/src/app/helpers/peertube-web-storage.ts
new file mode 100644 (file)
index 0000000..0db1301
--- /dev/null
@@ -0,0 +1,81 @@
+// 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/helpers/utils.ts b/client/src/app/helpers/utils.ts
new file mode 100644 (file)
index 0000000..879f697
--- /dev/null
@@ -0,0 +1,210 @@
+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 <A, B> (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, '<br />')
+}
+
+function removeElementFromArray <T> (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/helpers/zone.ts b/client/src/app/helpers/zone.ts
new file mode 100644 (file)
index 0000000..74eed70
--- /dev/null
@@ -0,0 +1,40 @@
+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)
+}
index fff4b43f6f27f968bdd6cc2129e91dff82b21812..cbc51ee216132fba8e82a6e65647fd394867b7dd 100644 (file)
@@ -1,16 +1,12 @@
-import { Component, ElementRef, OnInit, ViewChild, AfterViewInit } from '@angular/core'
-import { Notifier, RedirectService } from '@app/core'
-import { UserService } from '@app/shared'
-import { AuthService } from '../core'
-import { FormReactive } from '../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service'
-import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { ActivatedRoute } from '@angular/router'
-import { ServerConfig, RegisteredExternalAuthConfig } from '@shared/models/server/server-config.model'
 import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, RedirectService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
+import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
 
 @Component({
   selector: 'my-login',
index 1de72dbaacbd192086d373b9cdbed3ce38d31856..c419024267d1921b98b63aba8476086bba984512 100644 (file)
@@ -1,13 +1,17 @@
 import { NgModule } from '@angular/core'
-
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
 import { LoginRoutingModule } from './login-routing.module'
 import { LoginComponent } from './login.component'
-import { SharedModule } from '../shared'
 
 @NgModule({
   imports: [
     LoginRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
index c447f031c61196fa7bb7a871cde7d9593e9b909c..9a64faa6adf90c8a53c83dd4f59f40142ebdc3af 100644 (file)
@@ -1,11 +1,10 @@
-import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { User } from '../shared/users/user.model'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
 import { Subject, Subscription } from 'rxjs'
-import { Notifier, UserNotificationSocket } from '@app/core'
-import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
-import { NavigationEnd, Router } from '@angular/router'
 import { filter } from 'rxjs/operators'
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { NavigationEnd, Router } from '@angular/router'
+import { Notifier, User, UserNotificationSocket } from '@app/core'
+import { UserNotificationService } from '@app/shared/shared-main'
+import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
 
 @Component({
   selector: 'my-avatar-notification',
index c9b775921d18346f8a70c5e8756052806e5b21db..3e89f72b8461a27c831b0950fadf9bb5ad2ad215 100644 (file)
@@ -1,9 +1,7 @@
-import { Component, ElementRef, ViewChild, Inject, LOCALE_ID } from '@angular/core'
-import { I18N_LOCALES, getShortLocale } from '../../../../shared'
+import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
+import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { sortBy } from '@app/shared/misc/utils'
-import { getCompleteLocale } from '@shared/models/i18n'
-import { isOnDevLocale, getDevLocale } from '@app/shared/i18n/i18n-utils'
+import { getCompleteLocale, getShortLocale, I18N_LOCALES } from '@shared/models'
 
 @Component({
   selector: 'my-language-chooser',
index ba33425416077f6435ec3f82ba12197893dc0582..2dbe695c9c3f51420b7767b579d32e341a453c5e 100644 (file)
@@ -1,14 +1,10 @@
+import { HotkeysService } from 'angular2-hotkeys'
 import { Component, OnInit, ViewChild } from '@angular/core'
-import { UserRight } from '../../../../shared/models/users/user-right.enum'
-import { AuthService, AuthStatus, RedirectService, ServerService } from '../core'
-import { User } from '@app/shared/users/user.model'
-import { UserService } from '@app/shared/users/user.service'
+import { AuthService, AuthStatus, RedirectService, ScreenService, ServerService, User, UserService } from '@app/core'
 import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
-import { HotkeysService } from 'angular2-hotkeys'
-import { ServerConfig, VideoConstant } from '@shared/models'
 import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { ServerConfig, UserRight, VideoConstant } from '@shared/models'
 
 @Component({
   selector: 'my-menu',
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
new file mode 100644 (file)
index 0000000..dbc8c23
--- /dev/null
@@ -0,0 +1,30 @@
+<ng-template #confirmModal let-close="close" let-dismiss="dismiss">
+
+  <div class="modal-header">
+    <h4 class="modal-title">{{ title }}</h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
+  </div>
+
+  <div class="modal-body" >
+    <div [innerHtml]="message"></div>
+
+    <div *ngIf="inputLabel && expectedInputValue" class="form-group">
+      <label for="confirmInput">{{ inputLabel }}</label>
+      <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+    </div>
+  </div>
+
+  <div class="modal-footer inputs">
+    <input
+      type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+      (click)="dismiss()" (key.enter)="dismiss()"
+    >
+
+    <input
+      ngbAutofocus
+      type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
+      (click)="close()" (key.enter)="confirm()"
+    >
+  </div>
+</ng-template>
diff --git a/client/src/app/modal/confirm.component.scss b/client/src/app/modal/confirm.component.scss
new file mode 100644 (file)
index 0000000..ed226bc
--- /dev/null
@@ -0,0 +1,21 @@
+@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/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
new file mode 100644 (file)
index 0000000..2c7ef46
--- /dev/null
@@ -0,0 +1,73 @@
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { ConfirmService } from '@app/core/confirm/confirm.service'
+import { POP_STATE_MODAL_DISMISS } from '@app/helpers'
+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-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)
+          }
+        })
+  }
+}
index 5e1433548f767d85f6ad606277938ecf5c4ae16c..1c90f190a0d55722cf573db87ad24eaeba6c1f7a 100644 (file)
@@ -1,8 +1,7 @@
 import { Component, ElementRef, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { About } from '@shared/models/server'
-import { UserService } from '@app/shared'
 
 @Component({
   selector: 'my-instance-config-warning-modal',
index 188a51173facde430d88901dbd5fc258a6778bd4..b95c14309226d31e8605efefb3dac37b72ee9c35 100644 (file)
@@ -7,7 +7,7 @@
   <div class="modal-body">
     <div i18n class="mb-4 quick-settings-title">Display settings</div>
 
-    <my-account-video-settings
+    <my-user-video-settings
       *ngIf="!isUserLoggedIn()"
       [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
     >
       <ng-container ngProjectAs="inner-title">
         <div i18n class="mb-4 mt-4 quick-settings-title">Video settings</div>
       </ng-container>
-    </my-account-video-settings>
+    </my-user-video-settings>
 
     <div i18n class="mb-4 mt-4 quick-settings-title">Interface settings</div>
 
-    <my-account-interface-settings
+    <my-user-interface-settings
       *ngIf="!isUserLoggedIn()"
       [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
-    ></my-account-interface-settings>
+    ></my-user-interface-settings>
   </div>
 </ng-template>
index 155794d1b1769050b445eb20ca77991e021ff20e..95726ab63f540f2a03dfc3c27c6ae4d8415fd996 100644 (file)
@@ -1,11 +1,10 @@
-import { Component, ViewChild, OnInit } from '@angular/core'
-import { AuthService, AuthStatus } from '@app/core'
-import { FormReactive, FormValidatorService, UserService, User } from '@app/shared'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { ReplaySubject } from 'rxjs'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
 import { filter } from 'rxjs/operators'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { AuthService, AuthStatus, LocalStorageService, User, UserService } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 
 @Component({
   selector: 'my-quick-settings',
index e022776e37b77dbfcd059484eddf1a063b25e5c3..c2f28960037c4c6551ef6b35188d363a49ed15bd 100644 (file)
@@ -1,7 +1,6 @@
 import { Component, ElementRef, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { UserService } from '@app/shared'
 
 @Component({
   selector: 'my-welcome-modal',
index b4106956815cd64bf1d9f9b4498ae68cb8849b48..d443b51d64e71fd383fb362982f00e067ae8d041 100644 (file)
@@ -1,8 +1,6 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
-
 import { MetaGuard } from '@ngx-meta/core'
-
 import { ResetPasswordComponent } from './reset-password.component'
 
 const resetPasswordRoutes: Routes = [
index 07b93ee7383ca28a29cb31d3345d403c3ab601d0..8d50e983934713370e0d829330081219e5f4c3ad 100644 (file)
@@ -1,10 +1,8 @@
 import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { UserService, UserValidatorsService, FormReactive } from '@app/shared'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { ResetPasswordValidatorsService } from '@app/shared/forms/form-validators/reset-password-validators.service'
 
 @Component({
   selector: 'my-login',
index c2711981a7791f29032360fc06acc84f7aa913eb..c77f1c4b02ee33e7594a870c86c4d5ab1134219a 100644 (file)
@@ -1,13 +1,15 @@
 import { NgModule } from '@angular/core'
-
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
 import { ResetPasswordRoutingModule } from './reset-password-routing.module'
 import { ResetPasswordComponent } from './reset-password.component'
-import { SharedModule } from '../shared'
 
 @NgModule({
   imports: [
     ResetPasswordRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule
   ],
 
   declarations: [
index 643cc9a2912a06b5369570538d1a18e608600545..516854a8c210360a6feb67e27346f4b9bf5ebcfc 100644 (file)
@@ -1,5 +1,4 @@
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
-import { NSFWQuery } from '../../../../shared/models/search'
+import { NSFWQuery, SearchTargetType } from '@shared/models'
 
 export class AdvancedSearch {
   startDate: string // ISO 8601
diff --git a/client/src/app/search/highlight.pipe.ts b/client/src/app/search/highlight.pipe.ts
new file mode 100644 (file)
index 0000000..50ee5c1
--- /dev/null
@@ -0,0 +1,54 @@
+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) => `<span class="${highlightStyleName}">${match}</span>`
+      )
+
+      return replaced
+    } else {
+      return contentString
+    }
+  }
+}
index af76260a7b22b31799f117f94bfbbcc7ec07a7be..14a5d04846f495236e8b9b020acf2bcab58eefd4 100644 (file)
@@ -1,10 +1,10 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { ValidatorFn } from '@angular/forms'
-import { VideoValidatorsService } from '@app/shared'
 import { ServerService } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { ServerConfig, VideoConstant } from '../../../../shared'
+import { VideoValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoConstant } from '@shared/models'
 
 @Component({
   selector: 'my-search-filters',
index 6486085be60623d623043e6765b60094a3f8e8c9..83b06e0cec4fc08b57e22a13b79f90f9479cd0a6 100644 (file)
@@ -1,20 +1,15 @@
 import { forkJoin, of, Subscription } from 'rxjs'
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { SearchService } from '@app/search/search.service'
-import { User, UserService } from '@app/shared'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
-import { Video } from '@app/shared/video/video.model'
+import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { Video, VideoChannel } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
 import { MetaService } from '@ngx-meta/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig } from '@shared/models'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { SearchTargetType, ServerConfig } from '@shared/models'
+import { AdvancedSearch } from './advanced-search.model'
+import { SearchService } from './search.service'
 
 @Component({
   selector: 'my-search',
index df5459802433163d763a52a73a131b66e60c645a..65c954de890a0483d10f6d4218c3d6f52498a6b9 100644 (file)
@@ -1,11 +1,15 @@
 import { TagInputModule } from 'ngx-chips'
 import { NgModule } from '@angular/core'
-import { SearchFiltersComponent } from '@app/search/search-filters.component'
-import { SearchRoutingModule } from '@app/search/search-routing.module'
-import { SearchComponent } from '@app/search/search.component'
-import { SearchService } from '@app/search/search.service'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
 import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
+import { HighlightPipe } from './highlight.pipe'
+import { SearchFiltersComponent } from './search-filters.component'
+import { SearchRoutingModule } from './search-routing.module'
+import { SearchComponent } from './search.component'
+import { SearchService } from './search.service'
 import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
 
 @NgModule({
@@ -13,7 +17,10 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
     TagInputModule,
 
     SearchRoutingModule,
-    SharedModule
+    SharedMainModule,
+    SharedFormModule,
+    SharedUserSubscriptionModule,
+    SharedVideoMiniatureModule
   ],
 
   declarations: [
@@ -29,7 +36,8 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
   providers: [
     SearchService,
     VideoLazyLoadResolver,
-    ChannelLazyLoadResolver
+    ChannelLazyLoadResolver,
+    HighlightPipe
   ]
 })
 export class SearchModule { }
index fdb12ea2c51fcda6a1859041f6d527f4995446bf..36342034f199604489ae44e6924f180ddfa9c3a3 100644 (file)
@@ -2,17 +2,13 @@ import { Observable } from 'rxjs'
 import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { RestExtractor, RestPagination, RestService } from '@app/shared'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
-import { environment } from '../../environments/environment'
+import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models'
 import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { environment } from '../../environments/environment'
 
 @Injectable()
 export class SearchService {
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts
deleted file mode 100644 (file)
index 61f09fc..0000000
+++ /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 (file)
index 6b261cf..0000000
+++ /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<Account>(1)
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor
-  ) {}
-
-  getAccount (id: number | string): Observable<Account> {
-    return this.authHttp.get<ServerAccount>(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 (file)
index a78303a..0000000
+++ /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 (file)
index 9851468..0000000
+++ /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 (file)
index 50ee5c1..0000000
+++ /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) => `<span class="${highlightStyleName}">${match}</span>`
-      )
-
-      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 (file)
index 8a0756a..0000000
+++ /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 (file)
index 84d1820..0000000
+++ /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 (file)
index e04c25d..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Directive, Input, TemplateRef } from '@angular/core'
-
-@Directive({
-  selector: '[ptTemplate]'
-})
-export class PeerTubeTemplateDirective <T extends string> {
-  @Input('ptTemplate') name: T
-
-  constructor (public template: TemplateRef<any>) {
-    // 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 (file)
index 45e0236..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
-
-@Directive({
-  selector: '[timestampRouteTransformer]'
-})
-export class TimestampRouteTransformerDirective {
-  @Output() timestampClicked = new EventEmitter<number>()
-
-  @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 (file)
index 4b67674..0000000
+++ /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 (file)
index bb236bf..0000000
+++ /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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
-    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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
-    return this.authService.refreshAccessToken()
-               .pipe(
-                 switchMap(() => {
-                   const authReq = this.cloneRequestWithAuth(req)
-
-                   return next.handle(authReq)
-                 })
-               )
-  }
-
-  private cloneRequestWithAuth (req: HttpRequest<any>) {
-    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 (file)
index 84a0719..0000000
+++ /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 (file)
index e7b433d..0000000
+++ /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 (file)
index 486785f..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-<p-table
-  [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 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"
->
-  <ng-template pTemplate="caption">
-    <div class="caption">
-      <div class="ml-auto has-feedback has-clear">
-        <input
-          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
-          (keyup)="onSearch($event)"
-        >
-        <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
-        <span class="sr-only" i18n>Clear filters</span>
-      </div>
-    </div>
-  </ng-template>
-
-  <ng-template pTemplate="header">
-    <tr>
-      <th style="width: 100%;" i18n>Account</th>
-      <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th style="width: 150px;"></th> <!-- column for action buttons -->
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="body" let-accountBlock>
-    <tr>
-      <td>
-        <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
-          <div class="chip two-lines">
-            <img
-              class="avatar"
-              [src]="accountBlock.blockedAccount.avatar?.path"
-              (error)="switchToDefaultAvatar($event)"
-              alt="Avatar"
-            >
-            <div>
-              {{ accountBlock.blockedAccount.displayName }}
-              <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
-            </div>
-          </div>
-        </a>
-      </td>
-
-      <td>{{ accountBlock.createdAt | date: 'short' }}</td>
-      <td class="action-cell">
-        <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
-      </td>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="emptymessage">
-    <tr>
-      <td colspan="6">
-        <div class="no-results">
-          <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
-          <ng-container *ngIf="!search" i18n>No account found.</ng-container>
-        </div>
-      </td>
-    </tr>
-  </ng-template>
-</p-table>
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 (file)
index aa8363f..0000000
+++ /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 (file)
index dc5ac40..0000000
+++ /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 (file)
index c70a817..0000000
+++ /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<ResultList<AccountBlock>>(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<ResultList<ServerBlock>>(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<ResultList<AccountBlock>>(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<ResultList<ServerBlock>>(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 (file)
index 188057b..0000000
+++ /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 (file)
index 977e0e1..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-<p-table
-  [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 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"
->
-  <ng-template pTemplate="caption">
-    <div class="caption">
-      <div class="ml-auto has-feedback has-clear">
-        <input
-          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
-          (keyup)="onSearch($event)"
-        >
-        <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
-        <span class="sr-only" i18n>Clear filters</span>
-      </div>
-      <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
-        <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
-        <ng-container i18n>Mute domain</ng-container>
-      </a>
-    </div>
-  </ng-template>
-
-  <ng-template pTemplate="header">
-    <tr>
-      <th style="width: 100%;" i18n>Instance</th>
-      <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th style="width: 150px;"></th> <!-- column for action buttons -->
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="body" let-serverBlock>
-    <tr>
-      <td>
-        <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
-          {{ serverBlock.blockedServer.host }}
-          <span class="glyphicon glyphicon-new-window"></span>
-        </a>
-      </td>
-      <td>{{ serverBlock.createdAt | date: 'short' }}</td>
-      <td class="action-cell">
-        <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
-      </td>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="emptymessage">
-    <tr>
-      <td colspan="6">
-        <div class="no-results">
-          <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
-          <ng-container *ngIf="!search" i18n>No server found.</ng-container>
-        </div>
-      </td>
-    </tr>
-  </ng-template>
-</p-table>
-
-<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
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 (file)
index 9ddb768..0000000
+++ /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 (file)
index f2b36ba..0000000
+++ /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 (file)
index b00db31..0000000
+++ /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 (file)
index 12933d4..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
-  <button
-    class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
-    ngbDropdownToggle role="button"
-  >
-    <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
-    <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
-
-    <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
-</button>
-
-  <div ngbDropdownMenu class="dropdown-menu">
-    <ng-container *ngFor="let actions of getActions()">
-
-      <ng-container *ngFor="let action of actions">
-        <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
-
-          <ng-template #templateActionLabel let-action>
-            <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
-
-            <div class="d-flex flex-column">
-              <span i18n>{{ action.label }}</span>
-              <small class="text-muted" *ngIf="action.description">{{ action.description }}</small>
-            </div>
-          </ng-template>
-
-          <a
-            *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
-            class="dropdown-item"  [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
-          >
-            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
-          </a>
-
-          <span
-            *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
-            class="custom-action dropdown-item" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
-          >
-            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
-          </span>
-
-          <h6
-            *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
-            class="dropdown-header" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
-          >
-            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
-          </h6>
-
-        </ng-container>
-      </ng-container>
-
-      <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
-
-    </ng-container>
-  </div>
-</div>
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 (file)
index 724a04e..0000000
+++ /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 (file)
index 15f9556..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-
-export type DropdownAction<T> = {
-  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<T> {
-  @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
-  @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<T>[][] {
-    if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
-
-    return [ this.actions as DropdownAction<T>[] ]
-  }
-
-  areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, 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 (file)
index d2b0eb8..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<span class="action-button" [ngClass]="className" [title]="getTitle()">
-  <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
-  <my-small-loader [loading]="loading"></my-small-loader>
-
-  <span class="button-label">{{ label }}</span>
-</span>
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
deleted file mode 100644 (file)
index 3ccfefd..0000000
+++ /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 (file)
index cac5ad2..0000000
+++ /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 (file)
index 398b6db..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<span class="action-button action-button-delete grey-button" [title]="title" role="button">
-  <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
-
-  <span class="button-label" *ngIf="label">{{ label }}</span>
-  <span class="button-label" i18n *ngIf="!label">Delete</span>
-</span>
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 (file)
index 39e3190..0000000
+++ /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 (file)
index b852bb3..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
-  <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
-
-  <span class="button-label" *ngIf="label">{{ label }}</span>
-  <span i18n class="button-label" *ngIf="!label">Edit</span>
-</a>
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 (file)
index 9cfe1a3..0000000
+++ /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 (file)
index 09871fc..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<div class="wrapper" [ngClass]="'avatar-' + size">
-  <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
-    <img [src]="video.videoChannelAvatarUrl" i18n-alt alt="Channel avatar" />
-  </a>
-  <a [routerLink]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
-    <img [src]="video.accountAvatarUrl" i18n-alt alt="Account avatar" />
-  </a>
-</div>
diff --git a/client/src/app/shared/channel/avatar.component.scss b/client/src/app/shared/channel/avatar.component.scss
deleted file mode 100644 (file)
index 37709fc..0000000
+++ /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 (file)
index 31f39c2..0000000
+++ /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 (file)
index dbc8c23..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<ng-template #confirmModal let-close="close" let-dismiss="dismiss">
-
-  <div class="modal-header">
-    <h4 class="modal-title">{{ title }}</h4>
-
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
-  </div>
-
-  <div class="modal-body" >
-    <div [innerHtml]="message"></div>
-
-    <div *ngIf="inputLabel && expectedInputValue" class="form-group">
-      <label for="confirmInput">{{ inputLabel }}</label>
-      <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
-    </div>
-  </div>
-
-  <div class="modal-footer inputs">
-    <input
-      type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-      (click)="dismiss()" (key.enter)="dismiss()"
-    >
-
-    <input
-      ngbAutofocus
-      type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
-      (click)="close()" (key.enter)="confirm()"
-    >
-  </div>
-</ng-template>
diff --git a/client/src/app/shared/confirm/confirm.component.scss b/client/src/app/shared/confirm/confirm.component.scss
deleted file mode 100644 (file)
index ed226bc..0000000
+++ /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 (file)
index c6e40fe..0000000
+++ /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 (file)
index ebd4ce4..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<span
-  class="date-toggle"
-  [title]="getTitle()"
-  [innerHtml]="getContent()"
-  (click)="toggle()"
-></span>
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 (file)
index 86700d1..0000000
+++ /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 (file)
index fa48da8..0000000
+++ /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 (file)
index 6aec293..0000000
+++ /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 (file)
index fdb19e0..0000000
+++ /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 (file)
index 249fdf1..0000000
+++ /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 (file)
index c18a35f..0000000
+++ /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 (file)
index 4a01b16..0000000
+++ /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 (file)
index cc5f3c5..0000000
+++ /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 (file)
index 9d68f83..0000000
+++ /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 (file)
index df20625..0000000
+++ /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 (file)
index 13b9228..0000000
+++ /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 (file)
index fcc966b..0000000
+++ /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 (file)
index 48c7054..0000000
+++ /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 (file)
index dc82577..0000000
+++ /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 (file)
index d1b4667..0000000
+++ /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 (file)
index c6fbb75..0000000
+++ /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 (file)
index 1c519c1..0000000
+++ /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 (file)
index 45c7081..0000000
+++ /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 (file)
index a2c9a53..0000000
+++ /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 (file)
index e3f7a09..0000000
+++ /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 (file)
index 8febbfe..0000000
+++ /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 (file)
index 9566e97..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<div class="input-group input-group-sm">
-  <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
-
-  <div class="input-group-append">
-    <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
-      <span class="glyphicon glyphicon-copy"></span>
-    </button>
-  </div>
-</div>
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 (file)
index 8dc4f11..0000000
+++ /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 (file)
index 7528fb7..0000000
+++ /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 (file)
index a519f3e..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
-  <textarea #textarea
-    [(ngModel)]="content" (ngModelChange)="onModelChange()"
-    class="form-control" [ngClass]="classes"
-    [ngStyle]="{ height: textareaHeight }"
-    [id]="name" [name]="name">
-  </textarea>
-
-  <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
-    <ng-container ngbNavItem *ngIf="truncate !== undefined">
-      <a ngbNavLink i18n>Truncated preview</a>
-
-      <ng-template ngbNavContent>
-        <div [innerHTML]="truncatedPreviewHTML"></div>
-      </ng-template>
-    </ng-container>
-
-    <ng-container ngbNavItem>
-      <a ngbNavLink i18n>Complete preview</a>
-
-      <ng-template ngbNavContent>
-        <div [innerHTML]="previewHTML"></div>
-      </ng-template>
-    </ng-container>
-
-    <my-button
-      *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
-    ></my-button>
-
-    <my-button
-      *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
-    ></my-button>
-  </div>
-
-  <div [ngbNavOutlet]="nav"></div>
-</div>
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 (file)
index f2c76f7..0000000
+++ /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 (file)
index dde7b4d..0000000
+++ /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<string>()
-
-  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 (file)
index 704f3e6..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-<div class="root flex-column">
-  <div class="d-flex">
-    <label class="form-group-checkbox">
-      <input
-        type="checkbox"
-        [(ngModel)]="checked"
-        (ngModelChange)="onModelChange()"
-        [id]="inputName"
-        [disabled]="disabled"
-      />
-      <span role="checkbox" [attr.aria-checked]="checked"></span>
-      <span *ngIf="labelText">{{ labelText }}</span>
-      <span
-        *ngIf="!labelText && labelInnerHTML"
-        [innerHTML]="labelInnerHTML"
-      ></span>
-
-      <span *ngIf="labelTemplate">
-        <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
-      </span>
-    </label>
-
-    <my-help
-      *ngIf="helpTemplate"
-      [tooltipPlacement]="helpPlacement"
-      helpType="custom"
-    >
-      <ng-template ptTemplate="customHtml">
-        <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
-      </ng-template>
-    </my-help>
-
-    <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
-  </div>
-
-  <div class="ml-4 d-flex flex-column">
-    <small class="wrapper mt-2 text-muted">
-      <ng-content select="description"></ng-content>
-    </small>
-
-    <span class="wrapper mt-3">
-      <ng-content select="extra"></ng-content>
-    </span>
-  </div>
-</div>
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 (file)
index cf8540d..0000000
+++ /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 (file)
index 89e79fe..0000000
+++ /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<PeerTubeTemplateDirective<'label' | 'help'>>
-
-  // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
-  @Input() onPushWorkaround = false
-
-  labelTemplate: TemplateRef<any>
-  helpTemplate: TemplateRef<any>
-
-  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 (file)
index f6bf5f9..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<div class="root">
-  <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
-    <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
-
-    <span>{{ inputLabel }}</span>
-
-    <input
-      type="file"
-      [name]="inputName" [id]="inputName" [accept]="extensions"
-      (change)="fileChange($event)" [(ngModel)]="fileInputValue"
-    />
-  </div>
-
-  <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
-</div>
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 (file)
index 84c23c1..0000000
+++ /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 (file)
index b7a821d..0000000
+++ /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<Blob>()
-
-  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 (file)
index f8c855c..0000000
+++ /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 (file)
index c57a4b3..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<p-inputMask
-  [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
-  mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
-></p-inputMask>
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 (file)
index 8092b09..0000000
+++ /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 (file)
index 8d67a96..0000000
+++ /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 (file)
index 3a35fcf..0000000
+++ /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> | boolean }
-
-export interface CanComponentDeactivate {
-  canDeactivate: () => CanComponentDeactivateResult
-}
-
-@Injectable()
-export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
-  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 (file)
index b05852f..0000000
+++ /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 (file)
index 30d65a2..0000000
+++ /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 (file)
index 6795d66..0000000
+++ /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 (file)
index 1698826..0000000
+++ /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 (file)
index 7c3a2b5..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<div class="root">
-  <div class="preview-container">
-    <my-reactive-file
-      [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
-      icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
-    ></my-reactive-file>
-
-    <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
-    <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
-  </div>
-</div>
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 (file)
index 88eccd5..0000000
+++ /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 (file)
index 7519734..0000000
+++ /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 (file)
index 8be578d..0000000
+++ /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 (file)
index ccb8a30..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<span *ngIf="value === true" class="glyphicon glyphicon-ok" i18n-aria-label aria-label="yes"></span>
-<span *ngIf="value === false" class="glyphicon glyphicon-remove" i18n-aria-label aria-label="no"></span>
-
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 (file)
index 56d08af..0000000
+++ /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 (file)
index d02d513..0000000
+++ /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 (file)
index ef4c095..0000000
+++ /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<ResultList<ActorFollow>> {
-    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<ResultList<ActorFollow>>(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<ResultList<ActorFollow>> {
-    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<ResultList<ActorFollow>>(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 (file)
index f6a3b7f..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-<div class="feature-table">
-
-  <table class="table" *ngIf="serverConfig">
-    <caption i18n>Features found on this instance</caption>
-    <tr>
-      <th i18n class="label" scope="row">PeerTube version</th>
-
-      <td class="value">{{ getServerVersionAndCommit() }}</td>
-    </tr>
-
-    <tr>
-      <th i18n class="label" scope="row">
-        <div>Default NSFW/sensitive videos policy</div>
-        <div class="more-info">can be redefined by the users</div>
-      </th>
-
-      <td class="value">{{ buildNSFWLabel() }}</td>
-    </tr>
-
-    <tr>
-      <th i18n class="label" scope="row">User registration allowed</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="label" colspan="2">Video uploads</th>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">Transcoding in multiple resolutions</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.transcoding.enabledResolutions.length !== 0"></my-feature-boolean>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">Video uploads</th>
-      <td>
-        <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
-        <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">Video quota</th>
-
-      <td class="value">
-        <ng-container *ngIf="initialUserVideoQuota !== -1">
-          {{ initialUserVideoQuota | bytes: 0 }} <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
-
-          <my-help tooltipPlacement="auto" helpType="custom">
-            <ng-template ptTemplate="customHtml">
-              <div [innerHTML]="quotaHelpIndication"></div>
-            </ng-template>
-          </my-help>
-        </ng-container>
-
-        <ng-container i18n *ngIf="initialUserVideoQuota === -1">
-          Unlimited <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
-        </ng-container>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="label" colspan="2">Import</th>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">HTTP import (YouTube, Vimeo, direct URL...)</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.import.videos.http.enabled"></my-feature-boolean>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">Torrent import</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.import.videos.torrent.enabled"></my-feature-boolean>
-      </td>
-    </tr>
-
-
-    <tr>
-      <th i18n class="label" colspan="2">Player</th>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">P2P enabled</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
-      </td>
-    </tr>
-
-    <tr>
-      <th i18n class="label" colspan="2">Search</th>
-    </tr>
-
-    <tr>
-      <th i18n class="sub-label" scope="row">Users can resolve distant content</th>
-      <td>
-        <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
-      </td>
-    </tr>
-  </table>
-</div>
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 (file)
index a515747..0000000
+++ /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 (file)
index 8fd15eb..0000000
+++ /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('<br />')
-  }
-}
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 (file)
index 399cf10..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
-
-<section *ngIf="null !== serverStats">
-  <h3 i18n>Local</h3>
-
-  <div class="row">
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalUsers }}</p>
-          <p class="stat-label" i18n>users</p>
-        </div>
-        <i class="glyphicon glyphicon-user icon-bottom"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalLocalVideos }}</p>
-          <p class="stat-label" i18n>videos</p>
-        </div>
-        <i class="glyphicon glyphicon-facetime-video"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalLocalVideoViews }}</p>
-          <p class="stat-label" i18n>video views</p>
-        </div>
-        <i class="glyphicon glyphicon-eye-open"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalLocalVideoComments }}</p>
-          <p class="stat-label" i18n>video comments</p>
-        </div>
-        <i class="glyphicon glyphicon-comment"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
-          <p class="stat-label" i18n>of hosted video</p>
-        </div>
-        <i class="glyphicon glyphicon-hdd"></i>
-      </div>
-    </div>
-  </div>
-
-  <h3 i18n>Federation</h3>
-
-  <div class="row">
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalVideos }}</p>
-          <p class="stat-label" i18n>videos</p>
-        </div>
-        <i class="glyphicon glyphicon-facetime-video"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalVideoComments }}</p>
-          <p class="stat-label" i18n>video comments</p>
-        </div>
-        <i class="glyphicon glyphicon-comment"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalInstanceFollowers }}</p>
-          <p class="stat-label" i18n>followers</p>
-        </div>
-        <i class="glyphicon glyphicon-retweet"></i>
-      </div>
-    </div>
-
-    <div class="col-6 col-lg-4 col-xl-3">
-      <div class="card stat">
-        <div class="card-body">
-          <p class="stat-value">{{ serverStats.totalInstanceFollowing }}</p>
-          <p class="stat-label" i18n>following</p>
-        </div>
-        <i class="glyphicon glyphicon-retweet"></i>
-      </div>
-    </div>
-  </div>
-</section>
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 (file)
index 5286ab0..0000000
+++ /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 (file)
index 40aa8a4..0000000
+++ /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 (file)
index 8b26063..0000000
+++ /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<About>(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 (file)
index d3b2e84..0000000
+++ /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 (file)
index aeaceb6..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
-  <ng-container *ngFor="let menuEntry of menuEntries; index as id">
-
-    <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
-
-    <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry"
-      #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
-      <span
-        *ngIf="isInSmallView"
-        [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
-        (click)="openModal(id)" role="button" class="title-page title-page-settings">
-        <ng-container i18n>{{ menuEntry.label }}</ng-container>
-        <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
-      </span>
-
-      <span
-        *ngIf="!isInSmallView"
-        (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
-        (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
-      >
-        <ng-container i18n>{{ menuEntry.label }}</ng-container>
-        <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
-      </span>
-
-      <div ngbDropdownMenu>
-        <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink">
-          <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
-
-          {{ menuChild.label }}
-        </a>
-      </div>
-    </div>
-  </ng-container>
-</div>
-
-<ng-template #modal let-close="close" let-dismiss="dismiss">
-  <div class="modal-body">
-    <ng-container *ngFor="let menuEntry of menuEntries; index as id">
-      <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
-        <a *ngFor="let menuChild of menuEntry.children"
-          [ngClass]="{ icon: hasIcons }"
-          [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
-          <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
-
-          {{ menuChild.label }}
-        </a>
-      </div>
-    </ng-container>
-  </div>
-</ng-template>
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 (file)
index 84dd7dc..0000000
+++ /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 (file)
index 3f121e7..0000000
+++ /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 (file)
index bb4a088..0000000
+++ /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 (file)
index 9a6d3e4..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-<ng-template #tooltipTemplate>
-  <p *ngIf="preHtmlTemplate">
-    <ng-template *ngTemplateOutlet="preHtmlTemplate"></ng-template>
-  </p>
-
-  <ng-container *ngIf="preHtmlTemplate && (customHtmlTemplate || mainHtml || postHtmlTemplate)">
-    <br /><br />
-  </ng-container>
-
-  <p *ngIf="customHtmlTemplate">
-    <ng-template *ngTemplateOutlet="customHtmlTemplate"></ng-template>
-  </p>
-
-  <p *ngIf="mainHtml" [innerHTML]="mainHtml"></p>
-
-  <ng-container *ngIf="(customHtmlTemplate || mainHtml) && postHtmlTemplate">
-    <br /><br />
-  </ng-container>
-
-  <p *ngIf="postHtmlTemplate">
-    <ng-template *ngTemplateOutlet="postHtmlTemplate"></ng-template>
-  </p>
-</ng-template>
-
-<span
-  role="button"
-  class="help-tooltip-button"
-  container="body"
-  title="Get help"
-  i18n-title
-  popoverClass="help-popover"
-  [attr.aria-pressed]="isPopoverOpened"
-  [ngbPopover]="tooltipTemplate"
-  [placement]="tooltipPlacement"
-  autoClose="outside"
-  (onHidden)="onPopoverHidden()"
-  (onShown)="onPopoverShown()"
->
-  <my-global-icon iconName="help"></my-global-icon>
-</span>
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss
deleted file mode 100644 (file)
index 43f33a5..0000000
+++ /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 (file)
index e8c199e..0000000
+++ /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<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
-
-  isPopoverOpened = false
-  mainHtml = ''
-
-  preHtmlTemplate: TemplateRef<any>
-  customHtmlTemplate: TemplateRef<any>
-  postHtmlTemplate: TemplateRef<any>
-
-  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('<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> 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 => '<li>' + text + '</li>')
-      .join('')
-
-    return '<ul>' + bullets + '</ul>'
-  }
-}
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 (file)
index 9865728..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
-  <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
-    <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
-  </span>
-  
-  <ng-container *ngIf="isMenuDisplayed()">
-    <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
-      <span class="glyphicon glyphicon-chevron-down"></span>
-    </button>
-  
-    <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
-      <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
-        ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
-      >
-        <span class="glyphicon glyphicon-chevron-down"></span>
-      </button>
-  
-      <div ngbDropdownMenu>
-        <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
-          [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
-          {{ item.label }}
-        </a>
-      </div>
-    </div>
-  </ng-container>
-</div >
-
-<ng-template #modal let-close="close" let-dismiss="dismiss">
-  <div class="modal-body">
-    <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
-       [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
-       {{ item.label }}
-    </a>
-  </div>
-</ng-template>
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 (file)
index 1ec0444..0000000
+++ /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 (file)
index 30f43ba..0000000
+++ /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<T extends ListOverflowItem> implements AfterViewInit {
-  @Input() items: T[]
-  @Input() itemTemplate: TemplateRef<{item: T}>
-
-  @ViewChild('modal', { static: true }) modal: ElementRef
-  @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
-  @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
-
-  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 (file)
index ca8ed06..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<div *ngIf="loading">
-  <div class="loader">
-    <div></div>
-    <div></div>
-    <div></div>
-    <div></div>
-  </div>
-</div>
diff --git a/client/src/app/shared/misc/loader.component.scss b/client/src/app/shared/misc/loader.component.scss
deleted file mode 100644 (file)
index ffac9c7..0000000
+++ /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 (file)
index e3b1eea..0000000
+++ /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 (file)
index 0db1301..0000000
+++ /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 (file)
index a69fad3..0000000
+++ /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 (file)
index 7886f89..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="root" *ngIf="loading">
-  <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
-</div>
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 (file)
index 191877f..0000000
+++ /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 (file)
index 0d4a8ab..0000000
+++ /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<string>()
-
-  watch (keys?: string[]): Observable<string> {
-    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 (file)
index bc3ab85..0000000
+++ /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 <A, B> (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, '<br />')
-}
-
-function removeElementFromArray <T> (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 (file)
index 9a77c64..0000000
+++ /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 (file)
index 365eb19..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<ng-template #modal>
-  <div class="modal-header">
-    <h4 i18n class="modal-title">Ban</h4>
-
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
-  </div>
-
-  <div class="modal-body">
-    <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
-      <div class="form-group">
-        <textarea
-          i18n-placeholder placeholder="Reason..." formControlName="reason"
-          class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
-        ></textarea>
-        <div *ngIf="formErrors.reason" class="form-error">
-          {{ formErrors.reason }}
-        </div>
-      </div>
-
-      <div i18n>
-        A banned user will no longer be able to login.
-      </div>
-
-      <div class="form-group inputs">
-        <input
-          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-          (click)="hide()" (key.enter)="hide()"
-        >
-
-        <input
-          type="submit" i18n-value value="Ban this user" class="action-button-submit"
-          [disabled]="!form.valid"
-        >
-      </div>
-    </form>
-  </div>
-
-</ng-template>
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 (file)
index 84562f1..0000000
+++ /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 (file)
index 1647e36..0000000
+++ /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<User | User[]>()
-
-  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 (file)
index 4d56238..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<ng-container *ngIf="userActions.length !== 0">
-  <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
-
-  <my-action-dropdown
-    [actions]="userActions" [entry]="{ user: user, account: account }"
-    [buttonSize]="buttonSize" [placement]="placement" [label]="label"
-    [container]="container"
-  ></my-action-dropdown>
-</ng-container>
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 (file)
index 82f3905..0000000
+++ /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 (file)
index 2f7e412..0000000
+++ /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 (file)
index 6d8af80..0000000
+++ /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<VideosOverview> {
-    let params = new HttpParams()
-    params = params.append('page', page + '')
-
-    return this.authHttp
-               .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
-               .pipe(
-                 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
-    const observables: Observable<any>[] = []
-    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 (file)
index 21abe16..0000000
+++ /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<number>
-    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 (file)
index 1ddd8fe..0000000
+++ /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 (file)
index 39202b3..0000000
+++ /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 (file)
index 95d5f17..0000000
+++ /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 (file)
index f0c8732..0000000
+++ /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 `<a class="video-timestamp" href="${url}">${str}</a>`
-    })
-  }
-
-  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[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
-      .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
-      .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
-  }
-}
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 (file)
index bcb73ed..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-export interface ComponentPagination {
-  currentPage: number
-  itemsPerPage: number
-  totalItems: number
-}
-
-export type ComponentPaginationLight = Omit<ComponentPagination, 'totalItems'>
-
-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 (file)
index f00cda2..0000000
+++ /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 (file)
index e6518dd..0000000
+++ /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 <T> (result: ResultList<T>, fun: Function, additionalArgs?: any[]): ResultList<T> {
-    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 <T> (result: ResultList<T>, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList<T> {
-    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 (file)
index 0faa593..0000000
+++ /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 (file)
index d4e6cf5..0000000
+++ /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<string>
-
-  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 (file)
index 7855885..0000000
+++ /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 (file)
index 74eed70..0000000
+++ /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 (file)
index 0000000..caa31d8
--- /dev/null
@@ -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 (file)
index 0000000..f270b60
--- /dev/null
@@ -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 (file)
index 0000000..c77aba6
--- /dev/null
@@ -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 (file)
index 0000000..dec7d8d
--- /dev/null
@@ -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 (file)
index 0000000..c18a35f
--- /dev/null
@@ -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 (file)
index 0000000..8b71841
--- /dev/null
@@ -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 (file)
index 0000000..96a35a4
--- /dev/null
@@ -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 (file)
index 0000000..a583735
--- /dev/null
@@ -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 (file)
index 0000000..d2085a3
--- /dev/null
@@ -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 (file)
index 0000000..bd3030a
--- /dev/null
@@ -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 (file)
index 0000000..aae56d6
--- /dev/null
@@ -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 (file)
index 0000000..998d616
--- /dev/null
@@ -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 (file)
index 0000000..ddf0ab5
--- /dev/null
@@ -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 (file)
index 0000000..280d284
--- /dev/null
@@ -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 (file)
index 0000000..59659de
--- /dev/null
@@ -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 (file)
index 0000000..bb650b1
--- /dev/null
@@ -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 (file)
index 0000000..97c8e96
--- /dev/null
@@ -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 (file)
index 0000000..ab9c436
--- /dev/null
@@ -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 (file)
index 0000000..9b24e4f
--- /dev/null
@@ -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 (file)
index 0000000..aa0ee01
--- /dev/null
@@ -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 (file)
index 0000000..9566e97
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="input-group input-group-sm">
+  <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
+
+  <div class="input-group-append">
+    <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
+      <span class="glyphicon glyphicon-copy"></span>
+    </button>
+  </div>
+</div>
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 (file)
index 0000000..8dc4f11
--- /dev/null
@@ -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 (file)
index 0000000..7528fb7
--- /dev/null
@@ -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 (file)
index 0000000..a519f3e
--- /dev/null
@@ -0,0 +1,36 @@
+<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
+  <textarea #textarea
+    [(ngModel)]="content" (ngModelChange)="onModelChange()"
+    class="form-control" [ngClass]="classes"
+    [ngStyle]="{ height: textareaHeight }"
+    [id]="name" [name]="name">
+  </textarea>
+
+  <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
+    <ng-container ngbNavItem *ngIf="truncate !== undefined">
+      <a ngbNavLink i18n>Truncated preview</a>
+
+      <ng-template ngbNavContent>
+        <div [innerHTML]="truncatedPreviewHTML"></div>
+      </ng-template>
+    </ng-container>
+
+    <ng-container ngbNavItem>
+      <a ngbNavLink i18n>Complete preview</a>
+
+      <ng-template ngbNavContent>
+        <div [innerHTML]="previewHTML"></div>
+      </ng-template>
+    </ng-container>
+
+    <my-button
+      *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
+    ></my-button>
+
+    <my-button
+      *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
+    ></my-button>
+  </div>
+
+  <div [ngbNavOutlet]="nav"></div>
+</div>
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 (file)
index 0000000..f2c76f7
--- /dev/null
@@ -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 (file)
index 0000000..8dad531
--- /dev/null
@@ -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<string>()
+
+  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 (file)
index 0000000..704f3e6
--- /dev/null
@@ -0,0 +1,45 @@
+<div class="root flex-column">
+  <div class="d-flex">
+    <label class="form-group-checkbox">
+      <input
+        type="checkbox"
+        [(ngModel)]="checked"
+        (ngModelChange)="onModelChange()"
+        [id]="inputName"
+        [disabled]="disabled"
+      />
+      <span role="checkbox" [attr.aria-checked]="checked"></span>
+      <span *ngIf="labelText">{{ labelText }}</span>
+      <span
+        *ngIf="!labelText && labelInnerHTML"
+        [innerHTML]="labelInnerHTML"
+      ></span>
+
+      <span *ngIf="labelTemplate">
+        <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
+      </span>
+    </label>
+
+    <my-help
+      *ngIf="helpTemplate"
+      [tooltipPlacement]="helpPlacement"
+      helpType="custom"
+    >
+      <ng-template ptTemplate="customHtml">
+        <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
+      </ng-template>
+    </my-help>
+
+    <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
+  </div>
+
+  <div class="ml-4 d-flex flex-column">
+    <small class="wrapper mt-2 text-muted">
+      <ng-content select="description"></ng-content>
+    </small>
+
+    <span class="wrapper mt-3">
+      <ng-content select="extra"></ng-content>
+    </span>
+  </div>
+</div>
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 (file)
index 0000000..cf8540d
--- /dev/null
@@ -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 (file)
index 0000000..76ef77e
--- /dev/null
@@ -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<PeerTubeTemplateDirective<'label' | 'help'>>
+
+  // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
+  @Input() onPushWorkaround = false
+
+  labelTemplate: TemplateRef<any>
+  helpTemplate: TemplateRef<any>
+
+  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 (file)
index 0000000..7c3a2b5
--- /dev/null
@@ -0,0 +1,11 @@
+<div class="root">
+  <div class="preview-container">
+    <my-reactive-file
+      [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
+      icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
+    ></my-reactive-file>
+
+    <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
+    <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
+  </div>
+</div>
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 (file)
index 0000000..88eccd5
--- /dev/null
@@ -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 (file)
index 0000000..7519734
--- /dev/null
@@ -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 (file)
index 0000000..f6bf5f9
--- /dev/null
@@ -0,0 +1,15 @@
+<div class="root">
+  <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
+    <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
+
+    <span>{{ inputLabel }}</span>
+
+    <input
+      type="file"
+      [name]="inputName" [id]="inputName" [accept]="extensions"
+      (change)="fileChange($event)" [(ngModel)]="fileInputValue"
+    />
+  </div>
+
+  <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
+</div>
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 (file)
index 0000000..84c23c1
--- /dev/null
@@ -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 (file)
index 0000000..9ebf487
--- /dev/null
@@ -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<Blob>()
+
+  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 (file)
index 0000000..e82fa97
--- /dev/null
@@ -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 (file)
index 0000000..f8c855c
--- /dev/null
@@ -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 (file)
index 0000000..c57a4b3
--- /dev/null
@@ -0,0 +1,4 @@
+<p-inputMask
+  [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
+  mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
+></p-inputMask>
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 (file)
index 0000000..8092b09
--- /dev/null
@@ -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 (file)
index 0000000..8d67a96
--- /dev/null
@@ -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 (file)
index 0000000..6795d66
--- /dev/null
@@ -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 (file)
index 0000000..1698826
--- /dev/null
@@ -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 (file)
index 0000000..478e5c9
--- /dev/null
@@ -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 (file)
index 0000000..b3020c7
--- /dev/null
@@ -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 (file)
index 0000000..ccb8a30
--- /dev/null
@@ -0,0 +1,3 @@
+<span *ngIf="value === true" class="glyphicon glyphicon-ok" i18n-aria-label aria-label="yes"></span>
+<span *ngIf="value === false" class="glyphicon glyphicon-remove" i18n-aria-label aria-label="no"></span>
+
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 (file)
index 0000000..56d08af
--- /dev/null
@@ -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 (file)
index 0000000..d02d513
--- /dev/null
@@ -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 (file)
index 0000000..1aeed35
--- /dev/null
@@ -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 (file)
index 0000000..f6a3b7f
--- /dev/null
@@ -0,0 +1,107 @@
+<div class="feature-table">
+
+  <table class="table" *ngIf="serverConfig">
+    <caption i18n>Features found on this instance</caption>
+    <tr>
+      <th i18n class="label" scope="row">PeerTube version</th>
+
+      <td class="value">{{ getServerVersionAndCommit() }}</td>
+    </tr>
+
+    <tr>
+      <th i18n class="label" scope="row">
+        <div>Default NSFW/sensitive videos policy</div>
+        <div class="more-info">can be redefined by the users</div>
+      </th>
+
+      <td class="value">{{ buildNSFWLabel() }}</td>
+    </tr>
+
+    <tr>
+      <th i18n class="label" scope="row">User registration allowed</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="label" colspan="2">Video uploads</th>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">Transcoding in multiple resolutions</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.transcoding.enabledResolutions.length !== 0"></my-feature-boolean>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">Video uploads</th>
+      <td>
+        <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
+        <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">Video quota</th>
+
+      <td class="value">
+        <ng-container *ngIf="initialUserVideoQuota !== -1">
+          {{ initialUserVideoQuota | bytes: 0 }} <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
+
+          <my-help tooltipPlacement="auto" helpType="custom">
+            <ng-template ptTemplate="customHtml">
+              <div [innerHTML]="quotaHelpIndication"></div>
+            </ng-template>
+          </my-help>
+        </ng-container>
+
+        <ng-container i18n *ngIf="initialUserVideoQuota === -1">
+          Unlimited <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
+        </ng-container>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="label" colspan="2">Import</th>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">HTTP import (YouTube, Vimeo, direct URL...)</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.import.videos.http.enabled"></my-feature-boolean>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">Torrent import</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.import.videos.torrent.enabled"></my-feature-boolean>
+      </td>
+    </tr>
+
+
+    <tr>
+      <th i18n class="label" colspan="2">Player</th>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">P2P enabled</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
+      </td>
+    </tr>
+
+    <tr>
+      <th i18n class="label" colspan="2">Search</th>
+    </tr>
+
+    <tr>
+      <th i18n class="sub-label" scope="row">Users can resolve distant content</th>
+      <td>
+        <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
+      </td>
+    </tr>
+  </table>
+</div>
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 (file)
index 0000000..a515747
--- /dev/null
@@ -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 (file)
index 0000000..8fd15eb
--- /dev/null
@@ -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('<br />')
+  }
+}
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 (file)
index 0000000..3c9ccc4
--- /dev/null
@@ -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<ResultList<ActorFollow>> {
+    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<ResultList<ActorFollow>>(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<ResultList<ActorFollow>> {
+    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<ResultList<ActorFollow>>(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 (file)
index 0000000..399cf10
--- /dev/null
@@ -0,0 +1,101 @@
+<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
+
+<section *ngIf="null !== serverStats">
+  <h3 i18n>Local</h3>
+
+  <div class="row">
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalUsers }}</p>
+          <p class="stat-label" i18n>users</p>
+        </div>
+        <i class="glyphicon glyphicon-user icon-bottom"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalLocalVideos }}</p>
+          <p class="stat-label" i18n>videos</p>
+        </div>
+        <i class="glyphicon glyphicon-facetime-video"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalLocalVideoViews }}</p>
+          <p class="stat-label" i18n>video views</p>
+        </div>
+        <i class="glyphicon glyphicon-eye-open"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalLocalVideoComments }}</p>
+          <p class="stat-label" i18n>video comments</p>
+        </div>
+        <i class="glyphicon glyphicon-comment"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
+          <p class="stat-label" i18n>of hosted video</p>
+        </div>
+        <i class="glyphicon glyphicon-hdd"></i>
+      </div>
+    </div>
+  </div>
+
+  <h3 i18n>Federation</h3>
+
+  <div class="row">
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalVideos }}</p>
+          <p class="stat-label" i18n>videos</p>
+        </div>
+        <i class="glyphicon glyphicon-facetime-video"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalVideoComments }}</p>
+          <p class="stat-label" i18n>video comments</p>
+        </div>
+        <i class="glyphicon glyphicon-comment"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalInstanceFollowers }}</p>
+          <p class="stat-label" i18n>followers</p>
+        </div>
+        <i class="glyphicon glyphicon-retweet"></i>
+      </div>
+    </div>
+
+    <div class="col-6 col-lg-4 col-xl-3">
+      <div class="card stat">
+        <div class="card-body">
+          <p class="stat-value">{{ serverStats.totalInstanceFollowing }}</p>
+          <p class="stat-label" i18n>following</p>
+        </div>
+        <i class="glyphicon glyphicon-retweet"></i>
+      </div>
+    </div>
+  </div>
+</section>
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 (file)
index 0000000..5286ab0
--- /dev/null
@@ -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 (file)
index 0000000..40aa8a4
--- /dev/null
@@ -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 (file)
index 0000000..ba9797b
--- /dev/null
@@ -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<About>(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 (file)
index 0000000..b75ad1a
--- /dev/null
@@ -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 (file)
index 0000000..6df2e9d
--- /dev/null
@@ -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 (file)
index 0000000..8f4abf0
--- /dev/null
@@ -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<Account>(1)
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  getAccount (id: number | string): Observable<Account> {
+    return this.authHttp.get<ServerAccount>(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 (file)
index 0000000..d01b9ac
--- /dev/null
@@ -0,0 +1,24 @@
+<ng-container *ngIf="actor">
+  <div class="actor">
+    <div class="d-flex">
+      <img [src]="actor.avatarUrl" alt="Avatar" />
+
+      <div class="actor-img-edit-container">
+        <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
+          <my-global-icon iconName="edit"></my-global-icon>
+          <label for="avatarfile" i18n>Change your avatar</label>
+          <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
+        </div>
+      </div>
+    </div>
+
+
+    <div class="actor-info">
+      <div class="actor-info-names">
+        <div class="actor-info-display-name">{{ actor.displayName }}</div>
+        <div class="actor-info-username">{{ actor.name }}</div>
+      </div>
+      <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
+    </div>
+  </div>
+</ng-container>
\ 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 (file)
index 0000000..5a66ecf
--- /dev/null
@@ -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 (file)
index 0000000..0c04ae4
--- /dev/null
@@ -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<HTMLInputElement>
+
+  @Input() actor: VideoChannel | Account
+
+  @Output() avatarChange = new EventEmitter<FormData>()
+
+  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 (file)
index 0000000..5fc7989
--- /dev/null
@@ -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 (file)
index 0000000..09871fc
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="wrapper" [ngClass]="'avatar-' + size">
+  <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
+    <img [src]="video.videoChannelAvatarUrl" i18n-alt alt="Channel avatar" />
+  </a>
+  <a [routerLink]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
+    <img [src]="video.accountAvatarUrl" i18n-alt alt="Account avatar" />
+  </a>
+</div>
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 (file)
index 0000000..37709fc
--- /dev/null
@@ -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 (file)
index 0000000..31f39c2
--- /dev/null
@@ -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 (file)
index 0000000..f5b9f36
--- /dev/null
@@ -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 (file)
index 0000000..9851468
--- /dev/null
@@ -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 (file)
index 0000000..3b072fb
--- /dev/null
@@ -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 (file)
index 0000000..f09c3d1
--- /dev/null
@@ -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<any[]>
+
+  @Output() nearOfBottom = new EventEmitter<void>()
+
+  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 (file)
index 0000000..8a0756a
--- /dev/null
@@ -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 (file)
index 0000000..e04c25d
--- /dev/null
@@ -0,0 +1,12 @@
+import { Directive, Input, TemplateRef } from '@angular/core'
+
+@Directive({
+  selector: '[ptTemplate]'
+})
+export class PeerTubeTemplateDirective <T extends string> {
+  @Input('ptTemplate') name: T
+
+  constructor (public template: TemplateRef<any>) {
+    // 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 (file)
index 0000000..68a4acd
--- /dev/null
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
+    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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
+    return this.authService.refreshAccessToken()
+               .pipe(
+                 switchMap(() => {
+                   const authReq = this.cloneRequestWithAuth(req)
+
+                   return next.handle(authReq)
+                 })
+               )
+  }
+
+  private cloneRequestWithAuth (req: HttpRequest<any>) {
+    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 (file)
index 0000000..84a0719
--- /dev/null
@@ -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 (file)
index 0000000..12933d4
--- /dev/null
@@ -0,0 +1,55 @@
+<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
+  <button
+    class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
+    ngbDropdownToggle role="button"
+  >
+    <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
+    <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
+
+    <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
+</button>
+
+  <div ngbDropdownMenu class="dropdown-menu">
+    <ng-container *ngFor="let actions of getActions()">
+
+      <ng-container *ngFor="let action of actions">
+        <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
+
+          <ng-template #templateActionLabel let-action>
+            <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
+
+            <div class="d-flex flex-column">
+              <span i18n>{{ action.label }}</span>
+              <small class="text-muted" *ngIf="action.description">{{ action.description }}</small>
+            </div>
+          </ng-template>
+
+          <a
+            *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+            class="dropdown-item"  [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
+          >
+            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+          </a>
+
+          <span
+            *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+            class="custom-action dropdown-item" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
+          >
+            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+          </span>
+
+          <h6
+            *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+            class="dropdown-header" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
+          >
+            <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+          </h6>
+
+        </ng-container>
+      </ng-container>
+
+      <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
+
+    </ng-container>
+  </div>
+</div>
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 (file)
index 0000000..724a04e
--- /dev/null
@@ -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 (file)
index 0000000..36d7d62
--- /dev/null
@@ -0,0 +1,52 @@
+import { Component, Input } from '@angular/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+
+export type DropdownAction<T> = {
+  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<T> {
+  @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
+  @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<T>[][] {
+    if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
+
+    return [ this.actions as DropdownAction<T>[] ]
+  }
+
+  areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, 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 (file)
index 0000000..d2b0eb8
--- /dev/null
@@ -0,0 +1,6 @@
+<span class="action-button" [ngClass]="className" [title]="getTitle()">
+  <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
+  <my-small-loader [loading]="loading"></my-small-loader>
+
+  <span class="button-label">{{ label }}</span>
+</span>
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 (file)
index 0000000..3ccfefd
--- /dev/null
@@ -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 (file)
index 0000000..e23b909
--- /dev/null
@@ -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 (file)
index 0000000..398b6db
--- /dev/null
@@ -0,0 +1,6 @@
+<span class="action-button action-button-delete grey-button" [title]="title" role="button">
+  <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
+
+  <span class="button-label" *ngIf="label">{{ label }}</span>
+  <span class="button-label" i18n *ngIf="!label">Delete</span>
+</span>
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 (file)
index 0000000..39e3190
--- /dev/null
@@ -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 (file)
index 0000000..b852bb3
--- /dev/null
@@ -0,0 +1,6 @@
+<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
+  <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
+
+  <span class="button-label" *ngIf="label">{{ label }}</span>
+  <span i18n class="button-label" *ngIf="!label">Edit</span>
+</a>
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 (file)
index 0000000..9cfe1a3
--- /dev/null
@@ -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 (file)
index 0000000..775a47a
--- /dev/null
@@ -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 (file)
index 0000000..ebd4ce4
--- /dev/null
@@ -0,0 +1,6 @@
+<span
+  class="date-toggle"
+  [title]="getTitle()"
+  [innerHtml]="getContent()"
+  (click)="toggle()"
+></span>
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 (file)
index 0000000..86700d1
--- /dev/null
@@ -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 (file)
index 0000000..bedf0ba
--- /dev/null
@@ -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 (file)
index 0000000..db00aef
--- /dev/null
@@ -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 (file)
index 0000000..ac0b1f4
--- /dev/null
@@ -0,0 +1,15 @@
+<div class="video-feed"
+  [ngbTooltip]="'Feeds available'"
+  placement="right auto"
+  container="body"
+>
+  <my-global-icon
+    *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
+    class="icon-syndication" role="button" iconName="syndication"
+  >
+  </my-global-icon>
+
+  <ng-template #feedsList>
+    <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
+  </ng-template>
+</div>
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 (file)
index 0000000..34dd0e9
--- /dev/null
@@ -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 (file)
index 0000000..ee3731c
--- /dev/null
@@ -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 (file)
index 0000000..6bc3966
--- /dev/null
@@ -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 (file)
index 0000000..2466ae7
--- /dev/null
@@ -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 (file)
index 0000000..a4d813c
--- /dev/null
@@ -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 (file)
index 0000000..a061914
--- /dev/null
@@ -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 (file)
index 0000000..ca8ed06
--- /dev/null
@@ -0,0 +1,8 @@
+<div *ngIf="loading">
+  <div class="loader">
+    <div></div>
+    <div></div>
+    <div></div>
+    <div></div>
+  </div>
+</div>
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 (file)
index 0000000..ffac9c7
--- /dev/null
@@ -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 (file)
index 0000000..e3b1eea
--- /dev/null
@@ -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 (file)
index 0000000..7886f89
--- /dev/null
@@ -0,0 +1,3 @@
+<div class="root" *ngIf="loading">
+  <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
+</div>
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 (file)
index 0000000..191877f
--- /dev/null
@@ -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 (file)
index 0000000..9a6d3e4
--- /dev/null
@@ -0,0 +1,40 @@
+<ng-template #tooltipTemplate>
+  <p *ngIf="preHtmlTemplate">
+    <ng-template *ngTemplateOutlet="preHtmlTemplate"></ng-template>
+  </p>
+
+  <ng-container *ngIf="preHtmlTemplate && (customHtmlTemplate || mainHtml || postHtmlTemplate)">
+    <br /><br />
+  </ng-container>
+
+  <p *ngIf="customHtmlTemplate">
+    <ng-template *ngTemplateOutlet="customHtmlTemplate"></ng-template>
+  </p>
+
+  <p *ngIf="mainHtml" [innerHTML]="mainHtml"></p>
+
+  <ng-container *ngIf="(customHtmlTemplate || mainHtml) && postHtmlTemplate">
+    <br /><br />
+  </ng-container>
+
+  <p *ngIf="postHtmlTemplate">
+    <ng-template *ngTemplateOutlet="postHtmlTemplate"></ng-template>
+  </p>
+</ng-template>
+
+<span
+  role="button"
+  class="help-tooltip-button"
+  container="body"
+  title="Get help"
+  i18n-title
+  popoverClass="help-popover"
+  [attr.aria-pressed]="isPopoverOpened"
+  [ngbPopover]="tooltipTemplate"
+  [placement]="tooltipPlacement"
+  autoClose="outside"
+  (onHidden)="onPopoverHidden()"
+  (onShown)="onPopoverShown()"
+>
+  <my-global-icon iconName="help"></my-global-icon>
+</span>
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 (file)
index 0000000..43f33a5
--- /dev/null
@@ -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 (file)
index 0000000..0825b96
--- /dev/null
@@ -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<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
+
+  isPopoverOpened = false
+  mainHtml = ''
+
+  preHtmlTemplate: TemplateRef<any>
+  customHtmlTemplate: TemplateRef<any>
+  postHtmlTemplate: TemplateRef<any>
+
+  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('<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> 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 => '<li>' + text + '</li>')
+      .join('')
+
+    return '<ul>' + bullets + '</ul>'
+  }
+}
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 (file)
index 0000000..d3e7e4b
--- /dev/null
@@ -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 (file)
index 0000000..9865728
--- /dev/null
@@ -0,0 +1,35 @@
+<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
+  <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
+    <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
+  </span>
+  
+  <ng-container *ngIf="isMenuDisplayed()">
+    <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
+      <span class="glyphicon glyphicon-chevron-down"></span>
+    </button>
+  
+    <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
+      <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
+        ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
+      >
+        <span class="glyphicon glyphicon-chevron-down"></span>
+      </button>
+  
+      <div ngbDropdownMenu>
+        <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
+          [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
+          {{ item.label }}
+        </a>
+      </div>
+    </div>
+  </ng-container>
+</div >
+
+<ng-template #modal let-close="close" let-dismiss="dismiss">
+  <div class="modal-body">
+    <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
+       [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
+       {{ item.label }}
+    </a>
+  </div>
+</ng-template>
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 (file)
index 0000000..1ec0444
--- /dev/null
@@ -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 (file)
index 0000000..144e0f1
--- /dev/null
@@ -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<T extends ListOverflowItem> implements AfterViewInit {
+  @Input() items: T[]
+  @Input() itemTemplate: TemplateRef<{item: T}>
+
+  @ViewChild('modal', { static: true }) modal: ElementRef
+  @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
+  @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
+
+  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 (file)
index 0000000..fd96a42
--- /dev/null
@@ -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 (file)
index 0000000..83401ab
--- /dev/null
@@ -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 (file)
index 0000000..43970dc
--- /dev/null
@@ -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<ResultList<Video>>(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 (file)
index 0000000..de25d3a
--- /dev/null
@@ -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 (file)
index 0000000..8dd9472
--- /dev/null
@@ -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<ResultList<UserNotification>>(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 (file)
index 0000000..0877111
--- /dev/null
@@ -0,0 +1,166 @@
+<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
+
+<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+  <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
+
+    <ng-container [ngSwitch]="notification.type">
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
+        <ng-container *ngIf="notification.video; then hasVideo; else noVideo"></ng-container>
+
+        <ng-template #hasVideo>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+            <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
+          </a>
+
+          <div class="message" i18n>
+            {{ notification.video.channel.displayName }} published a new video: <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a>
+          </div>
+        </ng-template>
+
+        <ng-template #noVideo>
+          <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+  
+          <div class="message" i18n>
+            The notification concerns a video now unavailable
+          </div>
+        </ng-template>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
+        <my-global-icon iconName="undo" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblocked
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
+        <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blocked
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
+        <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
+        <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">automatically blocked</a>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
+        <ng-container *ngIf="notification.comment; then hasComment; else noComment"></ng-container>
+
+        <ng-template #hasComment>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+            <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
+          </a>
+  
+          <div class="message" i18n>
+            <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
+          </div>
+        </ng-template>
+
+        <ng-template #noComment>
+          <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+  
+          <div class="message" i18n>
+            The notification concerns a comment now unavailable
+          </div>
+        </ng-template>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
+        <my-global-icon iconName="sparkle" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
+        <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
+        <my-global-icon iconName="cloud-error" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
+        <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }}</a> registered on your instance
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+          <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
+        </a>
+
+        <div class="message" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
+
+          <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
+          <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
+        <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+          <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
+        </a>
+
+        <div class="message" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
+        <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow?.follower.host }})
+          <ng-container *ngIf="notification.actorFollow?.state === 'pending'"> awaiting your approval</ng-container>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchCase="UserNotificationType.AUTO_INSTANCE_FOLLOWING">
+        <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          Your instance automatically followed <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">{{ notification.actorFollow.following.host }}</a>
+        </div>
+      </ng-container>
+
+      <ng-container *ngSwitchDefault>
+        <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+        <div class="message" i18n>
+          The notification points to a content now unavailable
+        </div>
+      </ng-container>
+    </ng-container>
+
+    <div [title]="notification.createdAt" class="from-date">{{ notification.createdAt | myFromNow }}</div>
+  </div>
+</div>
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 (file)
index 0000000..5166bd5
--- /dev/null
@@ -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 (file)
index 0000000..6abd8b7
--- /dev/null
@@ -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<boolean>
+
+  @Output() notificationsLoaded = new EventEmitter()
+
+  notifications: UserNotification[] = []
+
+  // So we can access it in the template
+  UserNotificationType = UserNotificationType
+
+  componentPagination: ComponentPagination
+
+  onDataSubject = new Subject<any[]>()
+
+  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 (file)
index 0000000..308200f
--- /dev/null
@@ -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 (file)
index 0000000..732f201
--- /dev/null
@@ -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 (file)
index 0000000..d45fb83
--- /dev/null
@@ -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<ResultList<VideoCaption>> {
+    return this.authHttp.get<ResultList<VideoCaption>>(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 (file)
index 0000000..1fcf6d3
--- /dev/null
@@ -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 (file)
index 0000000..123389a
--- /dev/null
@@ -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 (file)
index 0000000..5483e30
--- /dev/null
@@ -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<VideoChannel>(1)
+
+  static extractVideoChannels (result: ResultList<VideoChannelServer>) {
+    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<VideoChannel>(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<ResultList<VideoChannel>> {
+    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<ResultList<VideoChannelServer>>(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 (file)
index 0000000..3053df4
--- /dev/null
@@ -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 (file)
index 0000000..6e839e6
--- /dev/null
@@ -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<ResultList<VideoRedundancy>> {
+    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<ResultList<VideoRedundancy>>(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 (file)
index 0000000..a1cb051
--- /dev/null
@@ -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<VideoState>
+
+  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 (file)
index 0000000..6a529e0
--- /dev/null
@@ -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 (file)
index 0000000..a700aba
--- /dev/null
@@ -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<VideoImport> {
+    const url = VideoImportService.BASE_VIDEO_IMPORT_URL
+
+    const body = this.buildImportVideoObject(video)
+    body.targetUrl = targetUrl
+
+    const data = objectToFormData(body)
+    return this.authHttp.post<VideoImport>(url, data)
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable<VideoImport> {
+    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<VideoImport>(url, data)
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    return this.authHttp
+               .get<ResultList<VideoImport>>(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<VideoImport>): Observable<ResultList<VideoImport>> {
+    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 (file)
index 0000000..273930a
--- /dev/null
@@ -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<ResultList<VideoChangeOwnership>> {
+    const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership'
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    return this.authHttp.get<ResultList<VideoChangeOwnership>>(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 (file)
index 0000000..3e6d6a3
--- /dev/null
@@ -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<number>
+  licence: VideoConstant<number>
+  language: VideoConstant<string>
+  privacy: VideoConstant<VideoPrivacy>
+  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<VideoState>
+  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 (file)
index 0000000..20d13fa
--- /dev/null
@@ -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<ResultList<Video>>
+}
+
+@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<VideoDetails> {
+    return this.serverService.getServerLocale()
+               .pipe(
+                 switchMap(translations => {
+                   return this.authHttp.get<VideoDetailsServerModel>(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<ResultList<Video>> {
+    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<ResultList<Video>>(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<ResultList<Video>> {
+    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    return this.authHttp
+               .get<ResultList<Video>>(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<ResultList<Video>> {
+    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<ResultList<Video>>(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<ResultList<Video>> {
+    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<ResultList<Video>>(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<FfprobeData>(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<UserVideoRate>(url)
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  extractVideos (result: ResultList<VideoServerModel>) {
+    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<VideoPrivacy>[]) {
+    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 (file)
index 0000000..8f76c69
--- /dev/null
@@ -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 (file)
index 0000000..486785f
--- /dev/null
@@ -0,0 +1,64 @@
+<p-table
+  [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 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"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div class="ml-auto has-feedback has-clear">
+        <input
+          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+          (keyup)="onSearch($event)"
+        >
+        <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+        <span class="sr-only" i18n>Clear filters</span>
+      </div>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr>
+      <th style="width: 100%;" i18n>Account</th>
+      <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 150px;"></th> <!-- column for action buttons -->
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-accountBlock>
+    <tr>
+      <td>
+        <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
+          <div class="chip two-lines">
+            <img
+              class="avatar"
+              [src]="accountBlock.blockedAccount.avatar?.path"
+              (error)="switchToDefaultAvatar($event)"
+              alt="Avatar"
+            >
+            <div>
+              {{ accountBlock.blockedAccount.displayName }}
+              <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
+            </div>
+          </div>
+        </a>
+      </td>
+
+      <td>{{ accountBlock.createdAt | date: 'short' }}</td>
+      <td class="action-cell">
+        <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td colspan="6">
+        <div class="no-results">
+          <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
+          <ng-container *ngIf="!search" i18n>No account found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
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 (file)
index 0000000..aa8363f
--- /dev/null
@@ -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 (file)
index 0000000..38e0d04
--- /dev/null
@@ -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 (file)
index 0000000..1b85c8f
--- /dev/null
@@ -0,0 +1,43 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">{{ action }}</h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="submit()">
+      <div class="form-group">
+        <label i18n for="hosts">1 host (without "http://") per line</label>
+
+        <textarea
+          [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
+          class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
+        ></textarea>
+
+        <div *ngIf="formErrors.domains" class="form-error">
+          {{ formErrors.domains }}
+
+          <div *ngIf="form.controls['domains'].errors.validDomains">
+            {{ form.controls['domains'].errors.validDomains.value }}
+          </div>
+        </div>
+      </div>
+
+      <ng-content select="warning"></ng-content>
+
+      <div class="form-group inputs">
+        <input
+          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+          (click)="hide()" (key.enter)="hide()"
+        >
+
+        <input
+          type="submit" [value]="action" class="action-button-submit"
+          [disabled]="!form.valid"
+        >
+      </div>
+    </form>
+  </div>
+
+</ng-template>
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 (file)
index 0000000..9621a56
--- /dev/null
@@ -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 (file)
index 0000000..fdd4a79
--- /dev/null
@@ -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<string[]>()
+
+  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 (file)
index 0000000..0caa927
--- /dev/null
@@ -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<ResultList<AccountBlock>>(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<ResultList<ServerBlock>>(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<ResultList<AccountBlock>>(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<ResultList<ServerBlock>>(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 (file)
index 0000000..f0b8694
--- /dev/null
@@ -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 (file)
index 0000000..8e74254
--- /dev/null
@@ -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 (file)
index 0000000..977e0e1
--- /dev/null
@@ -0,0 +1,59 @@
+<p-table
+  [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 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"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div class="ml-auto has-feedback has-clear">
+        <input
+          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+          (keyup)="onSearch($event)"
+        >
+        <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+        <span class="sr-only" i18n>Clear filters</span>
+      </div>
+      <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
+        <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+        <ng-container i18n>Mute domain</ng-container>
+      </a>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr>
+      <th style="width: 100%;" i18n>Instance</th>
+      <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 150px;"></th> <!-- column for action buttons -->
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-serverBlock>
+    <tr>
+      <td>
+        <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
+          {{ serverBlock.blockedServer.host }}
+          <span class="glyphicon glyphicon-new-window"></span>
+        </a>
+      </td>
+      <td>{{ serverBlock.createdAt | date: 'short' }}</td>
+      <td class="action-cell">
+        <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td colspan="6">
+        <div class="no-results">
+          <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
+          <ng-container *ngIf="!search" i18n>No server found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
+<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
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 (file)
index 0000000..9ddb768
--- /dev/null
@@ -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 (file)
index 0000000..d904d06
--- /dev/null
@@ -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 (file)
index 0000000..f7e64df
--- /dev/null
@@ -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 (file)
index 0000000..365eb19
--- /dev/null
@@ -0,0 +1,38 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Ban</h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
+      <div class="form-group">
+        <textarea
+          i18n-placeholder placeholder="Reason..." formControlName="reason"
+          class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
+        ></textarea>
+        <div *ngIf="formErrors.reason" class="form-error">
+          {{ formErrors.reason }}
+        </div>
+      </div>
+
+      <div i18n>
+        A banned user will no longer be able to login.
+      </div>
+
+      <div class="form-group inputs">
+        <input
+          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+          (click)="hide()" (key.enter)="hide()"
+        >
+
+        <input
+          type="submit" i18n-value value="Ban this user" class="action-button-submit"
+          [disabled]="!form.valid"
+        >
+      </div>
+    </form>
+  </div>
+
+</ng-template>
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 (file)
index 0000000..84562f1
--- /dev/null
@@ -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 (file)
index 0000000..124e586
--- /dev/null
@@ -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<User | User[]>()
+
+  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 (file)
index 0000000..4d56238
--- /dev/null
@@ -0,0 +1,9 @@
+<ng-container *ngIf="userActions.length !== 0">
+  <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
+
+  <my-action-dropdown
+    [actions]="userActions" [entry]="{ user: user, account: account }"
+    [buttonSize]="buttonSize" [placement]="placement" [label]="label"
+    [container]="container"
+  ></my-action-dropdown>
+</ng-container>
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 (file)
index 0000000..d3c37f0
--- /dev/null
@@ -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 (file)
index 0000000..44dea44
--- /dev/null
@@ -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<ResultList<VideoAbuse>> {
+    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<ResultList<VideoAbuse>>(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 (file)
index 0000000..5e73d66
--- /dev/null
@@ -0,0 +1,45 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+
+    <form novalidate [formGroup]="form" (ngSubmit)="block()">
+      <div class="form-group">
+        <textarea
+          i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
+          [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
+        ></textarea>
+        <div *ngIf="formErrors.reason" class="form-error">
+          {{ formErrors.reason }}
+        </div>
+      </div>
+
+      <div class="form-group" *ngIf="video.isLocal">
+        <my-peertube-checkbox
+          inputName="unfederate" formControlName="unfederate"
+          i18n-labelText labelText="Unfederate the video"
+        >
+          <ng-container ngProjectAs="description">
+            <span i18n>This will ask remote instances to delete it</span>
+          </ng-container>
+        </my-peertube-checkbox>
+      </div>
+
+      <div class="form-group inputs">
+        <input
+          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+          (click)="hide()" (key.enter)="hide()"
+        >
+
+        <input
+          type="submit" i18n-value value="Submit" class="action-button-submit"
+          [disabled]="!form.valid"
+        >
+      </div>
+    </form>
+
+  </div>
+</ng-template>
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 (file)
index 0000000..afcdb9a
--- /dev/null
@@ -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 (file)
index 0000000..054651e
--- /dev/null
@@ -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 (file)
index 0000000..c22ceef
--- /dev/null
@@ -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<ResultList<VideoBlacklist>> {
+    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<ResultList<VideoBlacklist>>(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 (file)
index 0000000..d6beb6d
--- /dev/null
@@ -0,0 +1,97 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="report()">
+
+    <div class="row">
+      <div class="col-5 form-group">
+
+          <label i18n for="reportPredefinedReasons">What is the issue?</label>
+
+          <div class="ml-2 mt-2 d-flex flex-column">
+            <ng-container formGroupName="predefinedReasons">
+              <div class="form-group" *ngFor="let reason of predefinedReasons">
+                <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
+                  <ng-template *ngIf="reason.help" ptTemplate="help">
+                    <div [innerHTML]="reason.help"></div>
+                  </ng-template>
+                  <ng-container *ngIf="reason.description" ngProjectAs="description">
+                    <div [innerHTML]="reason.description"></div>
+                  </ng-container>
+                </my-peertube-checkbox>
+              </div>
+            </ng-container>
+          </div>
+
+      </div>
+
+      <div class="col-7">
+        <div class="row justify-content-center">
+          <div class="col-12 col-lg-9 mb-2">
+            <div class="screenratio">
+              <div [innerHTML]="embedHtml"></div>
+            </div>
+          </div>
+        </div>
+
+        <div class="mb-1 start-at" formGroupName="timestamp">
+          <my-peertube-checkbox
+            formControlName="hasStart"
+            i18n-labelText labelText="Start at"
+          ></my-peertube-checkbox>
+
+          <my-timestamp-input
+            [timestamp]="timestamp.startAt"
+            [maxTimestamp]="video.duration"
+            formControlName="startAt"
+            inputName="startAt"
+          >
+          </my-timestamp-input>
+        </div>
+
+        <div class="mb-3 stop-at"  formGroupName="timestamp" *ngIf="timestamp.hasStart">
+          <my-peertube-checkbox
+            formControlName="hasEnd"
+            i18n-labelText labelText="Stop at"
+          ></my-peertube-checkbox>
+
+          <my-timestamp-input
+            [timestamp]="timestamp.endAt"
+            [maxTimestamp]="video.duration"
+            formControlName="endAt"
+            inputName="endAt"
+          >
+          </my-timestamp-input>
+        </div>
+
+        <div i18n class="information">
+          Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
+        </div>
+
+        <div class="form-group">
+          <textarea 
+            i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
+            [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
+          ></textarea>
+          <div *ngIf="formErrors.reason" class="form-error">
+            {{ formErrors.reason }}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-group inputs">
+      <input
+        type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+        (click)="hide()" (key.enter)="hide()"
+      >
+      <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
+    </div>
+
+    </form>
+  </div>
+</ng-template>
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 (file)
index 0000000..b2606cb
--- /dev/null
@@ -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 (file)
index 0000000..11c8056
--- /dev/null
@@ -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 (file)
index 0000000..e096928
--- /dev/null
@@ -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 (file)
index 0000000..8ac557c
--- /dev/null
@@ -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 (file)
index 0000000..fe5510c
--- /dev/null
@@ -0,0 +1,33 @@
+<a
+  [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
+  class="video-thumbnail"
+>
+  <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
+
+  <div *ngIf="displayWatchLaterPlaylist" class="video-thumbnail-actions-overlay">
+    <ng-container *ngIf="inWatchLaterPlaylist !== true">
+      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
+        <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
+      </div>
+    </ng-container>
+
+    <ng-container *ngIf="inWatchLaterPlaylist === true">
+      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
+        <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
+      </div>
+    </ng-container>
+  </div>
+
+  <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
+  <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
+
+  <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
+
+  <div class="play-overlay">
+    <div class="icon"></div>
+  </div>
+
+  <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
+    <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
+  </div>
+</a>
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 (file)
index 0000000..feff78a
--- /dev/null
@@ -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 (file)
index 0000000..3ff45d9
--- /dev/null
@@ -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<boolean>()
+
+  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 (file)
index 0000000..dcc08bd
--- /dev/null
@@ -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 (file)
index 0000000..395f2e3
--- /dev/null
@@ -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 (file)
index 0000000..0d0ddc0
--- /dev/null
@@ -0,0 +1,17 @@
+<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
+
+  <div class="form-group">
+    <label i18n for="theme">Theme</label>
+
+    <div class="peertube-select-container">
+      <select formControlName="theme" id="theme" class="form-control">
+        <option i18n value="instance-default">instance default</option>
+        <option i18n value="default">peertube default</option>
+
+        <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
+      </select>
+    </div>
+  </div>
+
+  <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid">
+</form>
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 (file)
index 0000000..7818dfc
--- /dev/null
@@ -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 (file)
index 0000000..875ffa3
--- /dev/null
@@ -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<any>
+
+  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 (file)
index 0000000..0dda33a
--- /dev/null
@@ -0,0 +1,75 @@
+<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
+  <div class="form-group form-group-select">
+    <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
+    <my-help>
+      <ng-template ptTemplate="customHtml">
+        <ng-container i18n>
+          With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
+        </ng-container>
+      </ng-template>
+    </my-help>
+
+    <div class="peertube-select-container">
+      <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
+        <option i18n value="undefined" disabled>Policy for sensitive videos</option>
+        <option i18n value="do_not_list">Do not list</option>
+        <option i18n value="blur">Blur thumbnails</option>
+        <option i18n value="display">Display</option>
+      </select>
+    </div>
+  </div>
+
+  <div class="form-group form-group-select">
+    <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label>
+    <my-help>
+      <ng-template ptTemplate="customHtml">
+        <ng-container i18n>In Recently added, Trending, Local, Most liked and Search pages</ng-container>
+      </ng-template>
+    </my-help>
+
+    <div>
+      <p-multiSelect
+        inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
+        [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
+        emptyFilterMessage="No results found" i18n-emptyFilterMessage
+      ></p-multiSelect>
+    </div>
+  </div>
+
+  <ng-content select="inner-title"></ng-content>
+
+  <div class="form-group">
+    <my-peertube-checkbox
+      inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true"
+      i18n-labelText labelText="Help share videos being played"
+    >
+      <ng-container ngProjectAs="description">
+        <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
+      </ng-container>
+    </my-peertube-checkbox>
+  </div>
+
+  <div class="form-group">
+    <my-peertube-checkbox
+      inputName="autoPlayVideo" formControlName="autoPlayVideo"
+      i18n-labelText labelText="Automatically play videos"
+    >
+      <ng-container ngProjectAs="description">
+        <span i18n>When on a video page, directly start playing the video.</span>
+      </ng-container>
+    </my-peertube-checkbox>
+  </div>
+
+  <div class="form-group">
+    <my-peertube-checkbox
+      inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo"
+      i18n-labelText labelText="Automatically start playing the next video"
+    >
+      <ng-container ngProjectAs="description">
+        <span i18n>When a video ends, follow up with the next suggested video.</span>
+      </ng-container>
+    </my-peertube-checkbox>
+  </div>
+
+  <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid">
+</form>
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 (file)
index 0000000..430250b
--- /dev/null
@@ -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 (file)
index 0000000..4e45399
--- /dev/null
@@ -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<any>
+
+  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 (file)
index 0000000..fd53d14
--- /dev/null
@@ -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 (file)
index 0000000..acfec0a
--- /dev/null
@@ -0,0 +1,32 @@
+<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
+  <div class="form-group mb-2">
+    <input type="email"
+      formControlName="text"
+      class="form-control"
+      (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()"
+      placeholder="jane_doe@example.com">
+  </div>
+
+  <button type="submit" [disabled]="!form.valid" class="btn btn-sm btn-remote-follow" i18n>
+    <span *ngIf="!interact">Remote subscribe</span>
+    <span *ngIf="interact">Remote interact</span>
+  </button>
+
+  <my-help *ngIf="!interact && showHelp">
+    <ng-template ptTemplate="customHtml">
+      <ng-container i18n>
+        You can subscribe to the channel via any ActivityPub-capable fediverse instance.<br /><br />
+        For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there.
+      </ng-container>
+    </ng-template>
+  </my-help>
+
+  <my-help *ngIf="showHelp && interact">
+    <ng-template ptTemplate="customHtml">
+      <ng-container i18n>
+        You can interact with this via any ActivityPub-capable fediverse instance.<br /><br />
+        For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there.
+      </ng-container>
+    </ng-template>
+  </my-help>
+</form>
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 (file)
index 0000000..698c586
--- /dev/null
@@ -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 (file)
index 0000000..09164a5
--- /dev/null
@@ -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 (file)
index 0000000..cddea80
--- /dev/null
@@ -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 (file)
index 0000000..85b3d1f
--- /dev/null
@@ -0,0 +1,67 @@
+<div class="btn-group-subscribe btn-group"
+    [ngClass]="{'subscribe-button': !isAllChannelsSubscribed, 'unsubscribe-button': isAllChannelsSubscribed, 'big': isBigButton }">
+
+  <ng-template #userLoggedOut>
+    <span [ngClass]="{ 'extra-text': isAtLeastOneChannelSubscribed }">
+      <ng-container *ngIf="account; then multiple; else single"></ng-container>
+      <ng-template i18n #single>Subscribe</ng-template>
+      <ng-template #multiple>
+        <span i18n>Subscribe to all channels</span>
+        <span *ngIf="isAtLeastOneChannelSubscribed">{{ subscribeStatus(true).length }}/{{ subscribed.size }}
+          <ng-container i18n>channels subscribed</ng-container>
+        </span>
+      </ng-template>
+    </span>
+    <span *ngIf="!isBigButton && displayFollowers && videoChannels.length > 1 && videoChannel.followersCount !== 0" class="followers-count">
+      {{ videoChannels[0].followersCount | myNumberFormatter }}
+    </span>
+  </ng-template>
+
+  <ng-template #userLoggedIn>
+    <button *ngIf="!isAllChannelsSubscribed" type="button"
+            class="btn btn-sm" role="button"
+            (click)="subscribe()">
+      <ng-template [ngTemplateOutlet]="userLoggedOut"></ng-template>
+    </button>
+
+    <button
+      *ngIf="isAllChannelsSubscribed" type="button"
+      class="btn btn-sm" role="button"
+      (click)="unsubscribe()">
+      <ng-container i18n>{account + "", select, undefined {Unsubscribe} other {Unsubscribe from all channels}}</ng-container>
+    </button>
+  </ng-template>
+
+  <ng-container
+    *ngIf="isUserLoggedIn(); then userLoggedIn">
+  </ng-container>
+
+  <div class="btn-group" ngbDropdown autoClose="outside"
+       placement="bottom-right" role="group"
+       aria-label="Multiple ways to subscribe to the current channel">
+    <button class="btn btn-sm dropdown-toggle-split" ngbDropdownToggle>
+      <ng-container
+        *ngIf="!isUserLoggedIn(); then userLoggedOut">
+      </ng-container>
+    </button>
+
+    <div class="dropdown-menu" ngbDropdownMenu>
+
+      <h6 class="dropdown-header" i18n>Using an ActivityPub account</h6>
+
+      <button class="dropdown-item" (click)="subscribe()">
+        <span *ngIf="!isUserLoggedIn()" i18n>Subscribe with an account on this instance</span>
+        <span *ngIf="isUserLoggedIn()" i18n>Subscribe with your local account</span>
+      </button>
+
+      <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
+      <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
+
+      <div class="dropdown-divider"></div>
+
+      <h6 class="dropdown-header" i18n>Using a syndication feed</h6>
+      <a [href]="rssUri" target="_blank" class="dropdown-item" i18n>Subscribe via RSS</a>
+
+    </div>
+  </div>
+</div>
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 (file)
index 0000000..b739c5a
--- /dev/null
@@ -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 (file)
index 0000000..72fa3f4
--- /dev/null
@@ -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<string, boolean>()
+
+  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 (file)
index 0000000..732ed6b
--- /dev/null
@@ -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<boolean> }
+
+@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<string>(1)
+  private readonly existsObservable: Observable<SubscriptionExistResult>
+
+  private myAccountSubscriptionCache: SubscriptionExistResult = {}
+  private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {}
+  private myAccountSubscriptionCacheSubject = new Subject<SubscriptionExistResult>()
+
+  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<ResultList<Video>> {
+    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<ResultList<Video>>(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<ResultList<VideoChannel>> {
+    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<ResultList<VideoChannelServer>>(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<SubscriptionExistResult> {
+    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
+    let params = new HttpParams()
+
+    params = this.restService.addObjectParams(params, { uris })
+
+    return this.authHttp.get<SubscriptionExistResult>(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 (file)
index 0000000..1e919ee
--- /dev/null
@@ -0,0 +1,49 @@
+<div class="margin-content">
+  <div class="videos-header">
+    <h1 *ngIf="titlePage" class="title-page title-page-single">
+      <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
+        {{ titlePage }}
+      </div>
+      <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
+    </h1>
+
+    <div class="action-block" *ngIf="actions.length > 0">
+      <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
+        <button class="btn">
+          <my-global-icon [iconName]="action.iconName" aria-hidden="true"></my-global-icon>
+          <span>{{ action.label }}</span>
+        </button>
+      </a>
+    </div>
+
+    <div class="moderation-block" *ngIf="displayModerationBlock">
+      <my-peertube-checkbox
+        (change)="toggleModerationDisplay()"
+        inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
+      >
+      </my-peertube-checkbox>
+    </div>
+  </div>
+
+  <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
+  <div
+    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+    class="videos"
+  >
+    <ng-container *ngFor="let video of videos; trackBy: videoById;">
+      <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
+        {{ getCurrentGroupedDateLabel(video) }}
+      </h2>
+
+      <div class="video-wrapper">
+        <my-video-miniature
+          [fitWidth]="true"
+          [video]="video" [user]="userMiniature" [ownerDisplayType]="ownerDisplayType"
+          [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
+          (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
+        >
+        </my-video-miniature>
+      </div>
+    </ng-container>
+  </div>
+</div>
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 (file)
index 0000000..7f23098
--- /dev/null
@@ -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 (file)
index 0000000..0ef8426
--- /dev/null
@@ -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<any[]>()
+
+  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 (file)
index 0000000..47ca6f5
--- /dev/null
@@ -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 (file)
index 0000000..6661448
--- /dev/null
@@ -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 (file)
index 0000000..3c8271b
--- /dev/null
@@ -0,0 +1,21 @@
+<ng-container *ngIf="videoActions.length !== 0">
+
+  <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
+    *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
+  >
+    <span class="anchor" ngbDropdownAnchor></span>
+
+    <div ngbDropdownMenu>
+      <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
+    </div>
+  </div>
+
+  <my-action-dropdown
+    [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
+    [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
+  ></my-action-dropdown>
+
+  <my-video-download #videoDownloadModal></my-video-download>
+  <my-video-report #videoReportModal [video]="video"></my-video-report>
+  <my-video-block #videoBlockModal [video]="video" (videoBlocked)="onVideoBlocked()"></my-video-block>
+</ng-container>
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 (file)
index 0000000..67d7ee8
--- /dev/null
@@ -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 (file)
index 0000000..db8d1c3
--- /dev/null
@@ -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 (file)
index 0000000..c65e371
--- /dev/null
@@ -0,0 +1,108 @@
+<ng-template #modal let-hide="close">
+  <div class="modal-header">
+    <h4 class="modal-title">
+      <ng-container i18n>Download</ng-container>
+
+      <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
+        <span id="dropdownDownloadType" ngbDropdownToggle>
+          {{ type }}
+        </span>
+        <div ngbDropdownMenu aria-labelledby="dropdownDownloadType">
+          <button *ngIf="type === 'video'" (click)="switchToType('subtitles')" ngbDropdownItem i18n>subtitles</button>
+          <button *ngIf="type === 'subtitles'" (click)="switchToType('video')" ngbDropdownItem i18n>video</button>
+        </div>
+      </div>
+    </h4>
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <div class="form-group">
+      <div class="input-group input-group-sm">
+        <div class="input-group-prepend peertube-select-container">
+          <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
+            <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
+          </select>
+
+          <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
+            <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
+          </select>
+        </div>
+
+        <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
+        <div class="input-group-append">
+          <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
+            <span class="glyphicon glyphicon-copy"></span>
+          </button>
+        </div>
+      </div>
+    </div>
+
+    <ng-container *ngIf="type === 'video' && videoFile?.metadata">
+      <div ngbNav #nav="ngbNav" class="nav-tabs">
+
+        <ng-container ngbNavItem>
+          <a ngbNavLink i18n>Format</a>
+          <ng-template ngbNavContent>
+            <div class="file-metadata">
+              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
+                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+                <span class="metadata-attribute-value">{{ item.value.value }}</span>
+              </div>
+            </div>
+          </ng-template>
+        </ng-container>
+
+        <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
+          <a ngbNavLink i18n>Video stream</a>
+          <ng-template ngbNavContent>
+            <div class="file-metadata">
+              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
+                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+                <span class="metadata-attribute-value">{{ item.value.value }}</span>
+              </div>
+            </div>
+          </ng-template>
+        </ng-container>
+
+        <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
+          <a ngbNavLink i18n>Audio stream</a>
+          <ng-template ngbNavContent>
+            <div class="file-metadata">
+              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
+                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+                <span class="metadata-attribute-value">{{ item.value.value }}</span>
+              </div>
+            </div>
+          </ng-template>
+        </ng-container>
+      </div>
+
+      <div [ngbNavOutlet]="nav"></div>
+    </ng-container>
+
+    <div class="download-type" *ngIf="type === 'video'">
+      <div class="peertube-radio-container">
+        <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
+        <label i18n for="download-direct">Direct download</label>
+      </div>
+
+      <div class="peertube-radio-container">
+        <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
+        <label i18n for="download-torrent">Torrent (.torrent file)</label>
+      </div>
+    </div>
+  </div>
+
+  <div class="modal-footer inputs">
+    <input
+      type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+      (click)="hide()" (key.enter)="hide()"
+    >
+
+    <input
+      type="submit" i18n-value value="Download" class="action-button-submit"
+      (click)="download()"
+    >
+  </div>
+</ng-template>
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 (file)
index 0000000..b09078b
--- /dev/null
@@ -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 (file)
index 0000000..21df8b6
--- /dev/null
@@ -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 (file)
index 0000000..82afc86
--- /dev/null
@@ -0,0 +1,66 @@
+<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
+  <my-video-thumbnail
+    [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
+    [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
+  >
+    <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
+    <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
+  </my-video-thumbnail>
+
+  <div class="video-bottom">
+    <div class="video-miniature-information">
+      <div class="d-inline-flex video-miniature-meta">
+        <a *ngIf="displayOptions.avatar" class="avatar" [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
+          <img [src]="getAvatarUrl()" alt="" />
+        </a>
+
+        <div class="w-100 d-flex flex-column">
+          <a
+            tabindex="-1"
+            class="video-miniature-name"
+            [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
+          >{{ video.name }}</a>
+
+          <span class="video-miniature-created-at-views">
+            <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
+
+            <span class="views">
+              <ng-container *ngIf="displayOptions.date && displayOptions.views"> â€¢ </ng-container>
+              <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
+            </span>
+          </span>
+
+          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
+            {{ video.byAccount }}
+          </a>
+          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
+            {{ video.byVideoChannel }}
+          </a>
+
+          <div class="video-info-privacy">
+            <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
+            <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
+            <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
+          </div>
+        </div>
+      </div>
+
+      <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blocked">
+        <span class="blocked-label" i18n>Blocked</span>
+        <span class="blocked-reason" *ngIf="video.blockedReason">{{ video.blockedReason }}</span>
+      </div>
+
+      <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
+        Sensitive
+      </div>
+    </div>
+
+    <div class="video-actions">
+      <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
+      <my-video-actions-dropdown
+        *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
+        (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()"
+      ></my-video-actions-dropdown>
+    </div>
+  </div>
+</div>
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 (file)
index 0000000..38cac5b
--- /dev/null
@@ -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 (file)
index 0000000..6f32977
--- /dev/null
@@ -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 (file)
index 0000000..44aa567
--- /dev/null
@@ -0,0 +1,30 @@
+<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
+
+<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
+  <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
+
+    <div class="checkbox-container">
+      <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
+    </div>
+
+    <my-video-miniature
+      [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
+      [displayVideoActions]="false" [ownerDisplayType]="ownerDisplayType"
+    ></my-video-miniature>
+
+    <!-- Display only once -->
+    <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
+      <div class="action-selection-mode-child">
+        <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
+          Cancel
+        </span>
+
+        <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
+      </div>
+    </div>
+
+    <ng-container  *ngIf="isInSelectionMode() === false">
+      <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
+    </ng-container>
+  </div>
+</div>
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 (file)
index 0000000..d3cbabf
--- /dev/null
@@ -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 (file)
index 0000000..3e0e3b9
--- /dev/null
@@ -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<ResultList<Video>>
+
+  @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
+
+  @Output() selectionChange = new EventEmitter<SelectionType>()
+  @Output() videosModelChange = new EventEmitter<Video[]>()
+
+  _selection: SelectionType = {}
+
+  rowButtonsTemplate: TemplateRef<any>
+  globalButtonsTemplate: TemplateRef<any>
+
+  constructor (
+    protected i18n: I18n,
+    protected router: Router,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    protected serverService: ServerService
+  ) {
+    super()
+  }
+
+  @Input() get selection () {
+    return this._selection
+  }
+
+  set selection (selection: SelectionType) {
+    this._selection = selection
+    this.selectionChange.emit(this._selection)
+  }
+
+  @Input() get videosModel () {
+    return this.videos
+  }
+
+  set videosModel (videos: Video[]) {
+    this.videos = videos
+    this.videosModelChange.emit(this.videos)
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+  }
+
+  ngAfterContentInit () {
+    {
+      const t = this.templates.find(t => t.name === 'rowButtons')
+      if (t) this.rowButtonsTemplate = t.template
+    }
+
+    {
+      const t = this.templates.find(t => t.name === 'globalButtons')
+      if (t) this.globalButtonsTemplate = t.template
+    }
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    return this.getVideosObservableFunction(page, this.sort)
+  }
+
+  abortSelectionMode () {
+    this._selection = {}
+  }
+
+  isInSelectionMode () {
+    return Object.keys(this._selection).some(k => this._selection[ k ] === true)
+  }
+
+  generateSyndicationList () {
+    throw new Error('Method not implemented.')
+  }
+
+  protected onMoreVideos () {
+    this.videosModel = this.videos
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/index.ts b/client/src/app/shared/shared-video-playlist/index.ts
new file mode 100644 (file)
index 0000000..63bb046
--- /dev/null
@@ -0,0 +1,8 @@
+export * from './video-add-to-playlist.component'
+export * from './video-playlist-element-miniature.component'
+export * from './video-playlist-element.model'
+export * from './video-playlist-miniature.component'
+export * from './video-playlist.model'
+export * from './video-playlist.service'
+
+export * from './shared-video-playlist.module'
diff --git a/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts b/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts
new file mode 100644 (file)
index 0000000..0566b15
--- /dev/null
@@ -0,0 +1,36 @@
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { SharedThumbnailModule } from '../shared-thumbnail'
+import { VideoAddToPlaylistComponent } from './video-add-to-playlist.component'
+import { VideoPlaylistElementMiniatureComponent } from './video-playlist-element-miniature.component'
+import { VideoPlaylistMiniatureComponent } from './video-playlist-miniature.component'
+import { VideoPlaylistService } from './video-playlist.service'
+
+@NgModule({
+  imports: [
+    SharedMainModule,
+    SharedFormModule,
+    SharedThumbnailModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    VideoAddToPlaylistComponent,
+    VideoPlaylistElementMiniatureComponent,
+    VideoPlaylistMiniatureComponent
+  ],
+
+  exports: [
+    VideoAddToPlaylistComponent,
+    VideoPlaylistElementMiniatureComponent,
+    VideoPlaylistMiniatureComponent
+  ],
+
+  providers: [
+    VideoPlaylistService
+  ]
+})
+export class SharedVideoPlaylistModule { }
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html
new file mode 100644 (file)
index 0000000..a40e069
--- /dev/null
@@ -0,0 +1,82 @@
+<div class="root">
+  <div class="header">
+    <div class="first-row">
+      <div i18n class="title">Save to</div>
+
+      <div class="options" (click)="displayOptions = !displayOptions">
+        <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
+
+        <span i18n>Options</span>
+      </div>
+    </div>
+
+    <div class="options-row" *ngIf="displayOptions">
+      <div>
+        <my-peertube-checkbox
+          inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
+          i18n-labelText labelText="Start at"
+        ></my-peertube-checkbox>
+
+        <my-timestamp-input
+          [timestamp]="timestampOptions.startTimestamp"
+          [maxTimestamp]="video.duration"
+          [disabled]="!timestampOptions.startTimestampEnabled"
+          [(ngModel)]="timestampOptions.startTimestamp"
+        ></my-timestamp-input>
+      </div>
+
+      <div>
+        <my-peertube-checkbox
+          inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
+          i18n-labelText labelText="Stop at"
+        ></my-peertube-checkbox>
+
+        <my-timestamp-input
+          [timestamp]="timestampOptions.stopTimestamp"
+          [maxTimestamp]="video.duration"
+          [disabled]="!timestampOptions.stopTimestampEnabled"
+          [(ngModel)]="timestampOptions.stopTimestamp"
+        ></my-timestamp-input>
+      </div>
+    </div>
+  </div>
+
+  <div class="input-container">
+    <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
+  </div>
+
+  <div class="playlists">
+    <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
+      <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
+
+      <div class="display-name">
+        {{ playlist.displayName }}
+
+        <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
+          {{ formatTimestamp(playlist) }}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
+    <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+
+    <span i18n>Create a private playlist</span>
+  </div>
+
+  <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
+    <div class="form-group">
+      <label i18n for="displayName">Display name</label>
+      <input
+        type="text" id="displayName"
+        formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
+      >
+      <div *ngIf="formErrors['displayName']" class="form-error">
+        {{ formErrors['displayName'] }}
+      </div>
+    </div>
+
+    <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
+  </form>
+</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss
new file mode 100644 (file)
index 0000000..47baa99
--- /dev/null
@@ -0,0 +1,107 @@
+@import '_variables';
+@import '_mixins';
+
+.header,
+.dropdown-item,
+.input-container {
+  padding: 8px 24px;
+}
+
+.header {
+  min-width: 240px;
+  margin-bottom: 10px;
+  border-bottom: 1px solid $separator-border-color;
+
+  .first-row {
+    display: flex;
+    align-items: center;
+
+    .title {
+      font-size: 18px;
+      flex-grow: 1;
+    }
+
+    .options {
+      display: flex;
+      align-items: center;
+      font-size: 14px;
+      cursor: pointer;
+
+      my-global-icon {
+        @include apply-svg-color(#333);
+
+        width: 16px;
+        height: 23px;
+        margin-right: 3px;
+      }
+    }
+  }
+
+  .options-row {
+    margin-top: 10px;
+    padding-left: 10px;
+
+    > div {
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+
+.playlists {
+  max-height: 180px;
+  overflow-y: auto;
+}
+
+.playlist {
+  display: inline-flex;
+  cursor: pointer;
+
+  my-peertube-checkbox {
+    margin-right: 10px;
+    align-self: center;
+  }
+
+  .display-name {
+    display: flex;
+    align-items: flex-end;
+
+    .timestamp-info {
+      font-size: 0.9em;
+      color: pvar(--greyForegroundColor);
+      margin-left: 5px;
+    }
+  }
+}
+
+.new-playlist-button,
+.new-playlist-block {
+  padding-top: 10px;
+  border-top: 1px solid $separator-border-color;
+}
+
+.new-playlist-button  {
+  cursor: pointer;
+
+  my-global-icon {
+    @include apply-svg-color(#333);
+
+    position: relative;
+    left: -1px;
+    top: -1px;
+    margin-right: 4px;
+    width: 21px;
+    height: 21px;
+  }
+}
+
+input[type=text] {
+  @include peertube-input-text(200px);
+
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
new file mode 100644 (file)
index 0000000..f611fc4
--- /dev/null
@@ -0,0 +1,278 @@
+import * as debug from 'debug'
+import { Subject, Subscription } from 'rxjs'
+import { debounceTime, filter } from 'rxjs/operators'
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
+import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
+import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
+import { secondsToTime } from '../../../assets/player/utils'
+import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service'
+
+const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
+
+type PlaylistSummary = {
+  id: number
+  inPlaylist: boolean
+  displayName: string
+
+  playlistElementId?: number
+  startTimestamp?: number
+  stopTimestamp?: number
+}
+
+@Component({
+  selector: 'my-video-add-to-playlist',
+  styleUrls: [ './video-add-to-playlist.component.scss' ],
+  templateUrl: './video-add-to-playlist.component.html',
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
+  @Input() video: Video
+  @Input() currentVideoTimestamp: number
+  @Input() lazyLoad = false
+
+  isNewPlaylistBlockOpened = false
+  videoPlaylistSearch: string
+  videoPlaylistSearchChanged = new Subject<string>()
+  videoPlaylists: PlaylistSummary[] = []
+  timestampOptions: {
+    startTimestampEnabled: boolean
+    startTimestamp: number
+    stopTimestampEnabled: boolean
+    stopTimestamp: number
+  }
+  displayOptions = false
+
+  private disabled = false
+
+  private listenToPlaylistChangeSub: Subscription
+  private playlistsData: CachedPlaylist[] = []
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private notifier: Notifier,
+    private i18n: I18n,
+    private videoPlaylistService: VideoPlaylistService,
+    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+    private cd: ChangeDetectorRef
+  ) {
+    super()
+  }
+
+  get user () {
+    return this.authService.getUser()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
+    })
+
+    this.videoPlaylistService.listenToMyAccountPlaylistsChange()
+        .subscribe(result => {
+          this.playlistsData = result.data
+
+          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+        })
+
+    this.videoPlaylistSearchChanged
+        .pipe(debounceTime(500))
+        .subscribe(() => this.load())
+
+    if (this.lazyLoad === false) this.load()
+  }
+
+  ngOnChanges (simpleChanges: SimpleChanges) {
+    if (simpleChanges['video']) {
+      this.reload()
+    }
+  }
+
+  ngOnDestroy () {
+    this.unsubscribePlaylistChanges()
+  }
+
+  disableForReuse () {
+    this.disabled = true
+  }
+
+  enabledForReuse () {
+    this.disabled = false
+  }
+
+  reload () {
+    logger('Reloading component')
+
+    this.videoPlaylists = []
+    this.videoPlaylistSearch = undefined
+
+    this.resetOptions(true)
+    this.load()
+
+    this.cd.markForCheck()
+  }
+
+  load () {
+    logger('Loading component')
+
+    this.listenToPlaylistChanges()
+
+    this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
+        .subscribe(playlistsResult => {
+          this.playlistsData = playlistsResult.data
+
+          this.videoPlaylistService.runPlaylistCheck(this.video.id)
+        })
+  }
+
+  openChange (opened: boolean) {
+    if (opened === false) {
+      this.isNewPlaylistBlockOpened = false
+      this.displayOptions = false
+    }
+  }
+
+  openCreateBlock (event: Event) {
+    event.preventDefault()
+
+    this.isNewPlaylistBlockOpened = true
+  }
+
+  togglePlaylist (event: Event, playlist: PlaylistSummary) {
+    event.preventDefault()
+
+    if (playlist.inPlaylist === true) {
+      this.removeVideoFromPlaylist(playlist)
+    } else {
+      this.addVideoInPlaylist(playlist)
+    }
+
+    playlist.inPlaylist = !playlist.inPlaylist
+    this.resetOptions()
+
+    this.cd.markForCheck()
+  }
+
+  createPlaylist () {
+    const displayName = this.form.value[ 'displayName' ]
+
+    const videoPlaylistCreate: VideoPlaylistCreate = {
+      displayName,
+      privacy: VideoPlaylistPrivacy.PRIVATE
+    }
+
+    this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
+      () => {
+        this.isNewPlaylistBlockOpened = false
+
+        this.cd.markForCheck()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+
+  resetOptions (resetTimestamp = false) {
+    this.displayOptions = false
+
+    this.timestampOptions = {} as any
+    this.timestampOptions.startTimestampEnabled = false
+    this.timestampOptions.stopTimestampEnabled = false
+
+    if (resetTimestamp) {
+      this.timestampOptions.startTimestamp = 0
+      this.timestampOptions.stopTimestamp = this.video.duration
+    }
+  }
+
+  formatTimestamp (playlist: PlaylistSummary) {
+    const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
+    const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
+
+    return `(${start}-${stop})`
+  }
+
+  onVideoPlaylistSearchChanged () {
+    this.videoPlaylistSearchChanged.next()
+  }
+
+  private removeVideoFromPlaylist (playlist: PlaylistSummary) {
+    if (!playlist.playlistElementId) return
+
+    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
+        .subscribe(
+          () => {
+            this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
+          },
+
+          err => {
+            this.notifier.error(err.message)
+          },
+
+          () => this.cd.markForCheck()
+        )
+  }
+
+  private listenToPlaylistChanges () {
+    this.unsubscribePlaylistChanges()
+
+    this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
+                                         .pipe(filter(() => this.disabled === false))
+                                         .subscribe(existResult => this.rebuildPlaylists(existResult))
+  }
+
+  private unsubscribePlaylistChanges () {
+    if (this.listenToPlaylistChangeSub) {
+      this.listenToPlaylistChangeSub.unsubscribe()
+      this.listenToPlaylistChangeSub = undefined
+    }
+  }
+
+  private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
+    logger('Got existing results for %d.', this.video.id, existResult)
+
+    this.videoPlaylists = []
+    for (const playlist of this.playlistsData) {
+      const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
+
+      this.videoPlaylists.push({
+        id: playlist.id,
+        displayName: playlist.displayName,
+        inPlaylist: !!existingPlaylist,
+        playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
+        startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
+        stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
+      })
+    }
+
+    logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
+
+    this.cd.markForCheck()
+  }
+
+  private addVideoInPlaylist (playlist: PlaylistSummary) {
+    const body: VideoPlaylistElementCreate = { videoId: this.video.id }
+
+    if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
+    if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
+
+    this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
+      .subscribe(
+        () => {
+          const message = body.startTimestamp || body.stopTimestamp
+            ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
+            : this.i18n('Video added in {{n}}', { n: playlist.displayName })
+
+          this.notifier.success(message)
+        },
+
+        err => {
+          this.notifier.error(err.message)
+        },
+
+        () => this.cd.markForCheck()
+      )
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
new file mode 100644 (file)
index 0000000..e3f7ef0
--- /dev/null
@@ -0,0 +1,92 @@
+<div class="video" [ngClass]="{ playing: playing }">
+  <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
+    <div class="position">
+      <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
+      <ng-container *ngIf="!playing">{{ position }}</ng-container>
+    </div>
+
+    <my-video-thumbnail
+      *ngIf="playlistElement.video"
+      [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
+      [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
+    ></my-video-thumbnail>
+
+    <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
+
+    <div class="video-info">
+      <ng-container *ngIf="playlistElement.video">
+        <a tabindex="-1" class="video-info-name"
+          [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
+          [attr.title]="playlistElement.video.name"
+        >{{ playlistElement.video.name }}</a>
+
+        <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
+          {{ playlistElement.video.byAccount }}
+        </a>
+        <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
+
+        <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
+      </ng-container>
+
+      <span *ngIf="!playlistElement.video" class="video-info-name">
+        <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
+        <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
+        <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
+      </span>
+    </div>
+  </a>
+
+  <my-edit-button *ngIf="owned && touchScreenEditButton" [routerLink]="[ '/my-account', 'video-playlists', playlist.uuid ]"></my-edit-button>
+
+  <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom auto"
+       (openChange)="onDropdownOpenChange()" autoClose="outside"
+  >
+    <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
+
+    <div ngbDropdownMenu>
+      <ng-container *ngIf="playlistElement.video">
+        <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
+          <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
+          <ng-container i18n>Edit starts/stops at</ng-container>
+        </div>
+
+        <div class="timestamp-options" *ngIf="displayTimestampOptions">
+          <div>
+            <my-peertube-checkbox
+              inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
+              i18n-labelText labelText="Start at"
+            ></my-peertube-checkbox>
+
+            <my-timestamp-input
+              [timestamp]="timestampOptions.startTimestamp"
+              [maxTimestamp]="playlistElement.video.duration"
+              [disabled]="!timestampOptions.startTimestampEnabled"
+              [(ngModel)]="timestampOptions.startTimestamp"
+            ></my-timestamp-input>
+          </div>
+
+          <div>
+            <my-peertube-checkbox
+              inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
+              i18n-labelText labelText="Stop at"
+            ></my-peertube-checkbox>
+
+            <my-timestamp-input
+              [timestamp]="timestampOptions.stopTimestamp"
+              [maxTimestamp]="playlistElement.video.duration"
+              [disabled]="!timestampOptions.stopTimestampEnabled"
+              [(ngModel)]="timestampOptions.stopTimestamp"
+            ></my-timestamp-input>
+          </div>
+
+          <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
+        </div>
+      </ng-container>
+
+      <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
+        <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
+        <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
+      </span>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
new file mode 100644 (file)
index 0000000..afd775b
--- /dev/null
@@ -0,0 +1,224 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+$thumbnail-width: 130px;
+$thumbnail-height: 72px;
+
+my-video-thumbnail {
+  @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
+}
+
+.fake-thumbnail {
+  width: $thumbnail-width;
+  height: $thumbnail-height;
+  background-color: #ececec;
+}
+
+my-video-thumbnail,
+.fake-thumbnail {
+  display: flex; // Avoids an issue with line-height that adds space below the element
+  margin-right: 10px;
+}
+
+.video {
+  display: flex;
+  align-items: center;
+  background-color: pvar(--mainBackgroundColor);
+  padding: 10px;
+  border-bottom: 1px solid $separator-border-color;
+
+  &:hover {
+    background-color: rgba(0, 0, 0, 0.05);
+
+    .more {
+      opacity: 1;
+    }
+  }
+
+  @media not all and (hover: hover) and (pointer: fine) {
+    .more {
+      opacity: 1 !important;
+    }
+  }
+
+  &.playing {
+    background-color: rgba(0, 0, 0, 0.02);
+  }
+
+  a {
+    @include disable-default-a-behaviour;
+
+    color: pvar(--mainForegroundColor);
+    display: flex;
+    min-width: 0;
+    align-items: center;
+    cursor: pointer;
+
+    .position {
+      font-weight: $font-semibold;
+      margin-right: 10px;
+      color: pvar(--greyForegroundColor);
+      min-width: 25px;
+
+      my-global-icon {
+        @include apply-svg-color(pvar(--greyForegroundColor));
+
+        width: 17px;
+        position: relative;
+        left: -2px;
+      }
+    }
+
+    .video-info {
+      display: flex;
+      flex-direction: column;
+      align-self: flex-start;
+      min-width: 0;
+
+      a {
+        width: auto;
+      }
+
+      .video-info-account, .video-info-timestamp {
+        color: pvar(--greyForegroundColor);
+      }
+    }
+  }
+
+  .video-info-name {
+    font-size: 18px;
+    font-weight: $font-semibold;
+    display: inline-block;
+
+    @include ellipsis;
+  }
+
+  .more, my-edit-button {
+    justify-self: flex-end;
+    margin-left: auto;
+    cursor: pointer;
+    min-width: 24px;
+  }
+
+  .more {
+    opacity: 0;
+
+    &.show {
+      opacity: 1;
+    }
+
+    .icon-more {
+      @include apply-svg-color(pvar(--greyForegroundColor));
+
+      display: flex;
+
+      &::after {
+        border: none;
+      }
+    }
+
+    .dropdown-item {
+      @include dropdown-with-icon-item;
+    }
+
+    .timestamp-options {
+      padding-top: 0;
+      padding-left: 35px;
+      margin-bottom: 15px;
+
+      > div {
+        display: flex;
+        align-items: center;
+      }
+
+      input {
+        @include peertube-button;
+        @include orange-button;
+
+        margin-top: 10px;
+      }
+    }
+  }
+}
+
+@mixin more-dropdown-control {
+  .video {
+    my-edit-button {
+      display: none;
+
+      + .more {
+        display: inline-flex;
+      }
+    }
+  }
+}
+
+@mixin edit-button-control {
+  .video {
+    my-edit-button {
+      display: none;
+    }
+
+    &.playing {
+      my-edit-button {
+        display: inline-flex;
+        height: max-content;
+      }
+    }
+
+    my-edit-button + .more {
+      display: none;
+    }
+  }
+}
+
+@mixin edit-button-in-mobile-view {
+  .video {
+    my-edit-button {
+      ::ng-deep .action-button-edit {
+        padding: 0 13px;
+
+        .button-label {
+          display: none;
+        }
+      }
+    }
+  }
+}
+
+@media screen and (min-width: $small-view) {
+  :host-context(.expanded) {
+    @include more-dropdown-control();
+  }
+}
+
+@media screen and (max-width: $small-view) {
+  :host-context(.expanded) {
+    @include edit-button-control();
+  }
+}
+
+@media screen and (max-width: $mobile-view) {
+  :host-context(.expanded) {
+    @include edit-button-in-mobile-view();
+  }
+}
+
+@media screen and (min-width: #{$small-view + $menu-width}) {
+  :host-context(.main-col:not(.expanded)) {
+    @include more-dropdown-control();
+  }
+}
+
+@media screen and (max-width: #{$small-view + $menu-width}) {
+  :host-context(.main-col:not(.expanded)) {
+    @include edit-button-control();
+  }
+}
+
+@media screen and (max-width: #{$mobile-view + $menu-width}) {
+  :host-context(.main-col:not(.expanded)) {
+    @include edit-button-in-mobile-view();
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
new file mode 100644 (file)
index 0000000..57a5fbe
--- /dev/null
@@ -0,0 +1,182 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
+import { secondsToTime } from '../../../assets/player/utils'
+import { VideoPlaylistElement } from './video-playlist-element.model'
+import { VideoPlaylist } from './video-playlist.model'
+import { VideoPlaylistService } from './video-playlist.service'
+
+@Component({
+  selector: 'my-video-playlist-element-miniature',
+  styleUrls: [ './video-playlist-element-miniature.component.scss' ],
+  templateUrl: './video-playlist-element-miniature.component.html',
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class VideoPlaylistElementMiniatureComponent implements OnInit {
+  @ViewChild('moreDropdown') moreDropdown: NgbDropdown
+
+  @Input() playlist: VideoPlaylist
+  @Input() playlistElement: VideoPlaylistElement
+  @Input() owned = false
+  @Input() playing = false
+  @Input() rowLink = false
+  @Input() accountLink = true
+  @Input() position: number // Keep this property because we're in the OnPush change detection strategy
+  @Input() touchScreenEditButton = false
+
+  @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
+
+  displayTimestampOptions = false
+
+  timestampOptions: {
+    startTimestampEnabled: boolean
+    startTimestamp: number
+    stopTimestampEnabled: boolean
+    stopTimestamp: number
+  } = {} as any
+
+  private serverConfig: ServerConfig
+
+  constructor (
+    private authService: AuthService,
+    private serverService: ServerService,
+    private notifier: Notifier,
+    private i18n: I18n,
+    private videoPlaylistService: VideoPlaylistService,
+    private cdr: ChangeDetectorRef
+  ) {}
+
+  ngOnInit (): void {
+    this.serverConfig = this.serverService.getTmpConfig()
+    this.serverService.getConfig()
+        .subscribe(config => {
+          this.serverConfig = config
+          this.cdr.detectChanges()
+        })
+  }
+
+  isUnavailable (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.UNAVAILABLE
+  }
+
+  isPrivate (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.PRIVATE
+  }
+
+  isDeleted (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.DELETED
+  }
+
+  buildRouterLink () {
+    if (!this.playlist) return null
+
+    return [ '/videos/watch/playlist', this.playlist.uuid ]
+  }
+
+  buildRouterQuery () {
+    if (!this.playlistElement || !this.playlistElement.video) return {}
+
+    return {
+      videoId: this.playlistElement.video.uuid,
+      start: this.playlistElement.startTimestamp,
+      stop: this.playlistElement.stopTimestamp,
+      resume: true
+    }
+  }
+
+  isVideoBlur (video: Video) {
+    return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
+  }
+
+  removeFromPlaylist (playlistElement: VideoPlaylistElement) {
+    const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
+
+    this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
+        .subscribe(
+          () => {
+            this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
+
+            this.elementRemoved.emit(playlistElement)
+          },
+
+          err => this.notifier.error(err.message)
+        )
+
+    this.moreDropdown.close()
+  }
+
+  updateTimestamps (playlistElement: VideoPlaylistElement) {
+    const body: VideoPlaylistElementUpdate = {}
+
+    body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
+    body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
+
+    this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
+        .subscribe(
+          () => {
+            this.notifier.success(this.i18n('Timestamps updated'))
+
+            playlistElement.startTimestamp = body.startTimestamp
+            playlistElement.stopTimestamp = body.stopTimestamp
+
+            this.cdr.detectChanges()
+          },
+
+          err => this.notifier.error(err.message)
+        )
+
+    this.moreDropdown.close()
+  }
+
+  formatTimestamp (playlistElement: VideoPlaylistElement) {
+    const start = playlistElement.startTimestamp
+    const stop = playlistElement.stopTimestamp
+
+    const startFormatted = secondsToTime(start, true, ':')
+    const stopFormatted = secondsToTime(stop, true, ':')
+
+    if (start === null && stop === null) return ''
+
+    if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
+    if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
+
+    return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
+  }
+
+  onDropdownOpenChange () {
+    this.displayTimestampOptions = false
+  }
+
+  toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
+    event.preventDefault()
+
+    this.displayTimestampOptions = !this.displayTimestampOptions
+
+    if (this.displayTimestampOptions === true) {
+      this.timestampOptions = {
+        startTimestampEnabled: false,
+        stopTimestampEnabled: false,
+        startTimestamp: 0,
+        stopTimestamp: playlistElement.video.duration
+      }
+
+      if (playlistElement.startTimestamp) {
+        this.timestampOptions.startTimestampEnabled = true
+        this.timestampOptions.startTimestamp = playlistElement.startTimestamp
+      }
+
+      if (playlistElement.stopTimestamp) {
+        this.timestampOptions.stopTimestampEnabled = true
+        this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
+      }
+    }
+
+    // FIXME: why do we have to use setTimeout here?
+    setTimeout(() => {
+      this.cdr.detectChanges()
+    })
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts
new file mode 100644 (file)
index 0000000..27a79d1
--- /dev/null
@@ -0,0 +1,24 @@
+import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
+import { Video } from '@app/shared/shared-main'
+
+export class VideoPlaylistElement implements ServerVideoPlaylistElement {
+  id: number
+  position: number
+  startTimestamp: number
+  stopTimestamp: number
+
+  type: VideoPlaylistElementType
+
+  video?: Video
+
+  constructor (hash: ServerVideoPlaylistElement, translations: {}) {
+    this.id = hash.id
+    this.position = hash.position
+    this.startTimestamp = hash.startTimestamp
+    this.stopTimestamp = hash.stopTimestamp
+
+    this.type = hash.type
+
+    if (hash.video) this.video = new Video(hash.video, translations)
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
new file mode 100644 (file)
index 0000000..86f6664
--- /dev/null
@@ -0,0 +1,34 @@
+<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
+  <a
+    [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
+    class="miniature-thumbnail"
+  >
+    <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
+
+    <div class="miniature-playlist-info-overlay">
+      <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
+    </div>
+
+    <div class="play-overlay">
+      <div class="icon"></div>
+    </div>
+  </a>
+
+  <div class="miniature-info">
+    <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
+      {{ playlist.displayName }}
+    </a>
+
+    <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
+      {{ playlist.videoChannelBy }}
+    </a>
+
+    <div class="privacy-date">
+      <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
+
+      <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
+    </div>
+
+    <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
+  </div>
+</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
new file mode 100644 (file)
index 0000000..1b16dbb
--- /dev/null
@@ -0,0 +1,78 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.miniature {
+  display: inline-block;
+
+  &.no-videos:not(.to-manage){
+    a {
+      cursor: default !important;
+    }
+  }
+
+  &.to-manage,
+  &.no-videos {
+    .play-overlay {
+      display: none;
+    }
+  }
+
+  .miniature-thumbnail {
+    @include miniature-thumbnail;
+
+    .miniature-playlist-info-overlay {
+      @include static-thumbnail-overlay;
+
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      height: $video-thumbnail-height;
+      padding: 0 10px;
+      display: flex;
+      align-items: center;
+      font-size: 14px;
+      font-weight: $font-semibold;
+    }
+  }
+
+  .miniature-info {
+    width: 200px;
+    margin-top: 2px;
+    line-height: normal;
+
+    .miniature-name {
+      @include miniature-name;
+
+      @include ellipsis-multiline(1.3em, 2);
+
+      margin: 0;
+    }
+
+    .by {
+      @include disable-default-a-behaviour;
+
+      display: block;
+      color: pvar(--greyForegroundColor);
+    }
+
+    .privacy-date {
+      margin-top: 5px;
+
+      .video-info-privacy {
+        font-size: 14px;
+        font-weight: $font-semibold;
+
+        &::after {
+          content: '-';
+          margin: 0 3px;
+        }
+      }
+    }
+
+    .video-info-description {
+      margin-top: 10px;
+      color: pvar(--greyForegroundColor);
+    }
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
new file mode 100644 (file)
index 0000000..4b0669a
--- /dev/null
@@ -0,0 +1,22 @@
+import { Component, Input } from '@angular/core'
+import { VideoPlaylist } from './video-playlist.model'
+
+@Component({
+  selector: 'my-video-playlist-miniature',
+  styleUrls: [ './video-playlist-miniature.component.scss' ],
+  templateUrl: './video-playlist-miniature.component.html'
+})
+export class VideoPlaylistMiniatureComponent {
+  @Input() playlist: VideoPlaylist
+  @Input() toManage = false
+  @Input() displayChannel = false
+  @Input() displayDescription = false
+  @Input() displayPrivacy = false
+
+  getPlaylistUrl () {
+    if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
+    if (this.playlist.videosLength === 0) return null
+
+    return [ '/videos/watch/playlist', this.playlist.uuid ]
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
new file mode 100644 (file)
index 0000000..8f63d2a
--- /dev/null
@@ -0,0 +1,98 @@
+import { getAbsoluteAPIUrl } from '@app/helpers'
+import { Actor } from '@app/shared/shared-main'
+import {
+  AccountSummary,
+  peertubeTranslate,
+  VideoChannelSummary,
+  VideoConstant,
+  VideoPlaylist as ServerVideoPlaylist,
+  VideoPlaylistPrivacy,
+  VideoPlaylistType
+} from '@shared/models'
+
+export class VideoPlaylist implements ServerVideoPlaylist {
+  id: number
+  uuid: string
+  isLocal: boolean
+
+  displayName: string
+  description: string
+  privacy: VideoConstant<VideoPlaylistPrivacy>
+
+  thumbnailPath: string
+
+  videosLength: number
+
+  type: VideoConstant<VideoPlaylistType>
+
+  createdAt: Date | string
+  updatedAt: Date | string
+
+  ownerAccount: AccountSummary
+  videoChannel?: VideoChannelSummary
+
+  thumbnailUrl: string
+
+  ownerBy: string
+  ownerAvatarUrl: string
+
+  videoChannelBy?: string
+  videoChannelAvatarUrl?: string
+
+  private thumbnailVersion: number
+  private originThumbnailUrl: string
+
+  constructor (hash: ServerVideoPlaylist, translations: {}) {
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+    this.id = hash.id
+    this.uuid = hash.uuid
+    this.isLocal = hash.isLocal
+
+    this.displayName = hash.displayName
+
+    this.description = hash.description
+    this.privacy = hash.privacy
+
+    this.thumbnailPath = hash.thumbnailPath
+
+    if (this.thumbnailPath) {
+      this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
+      this.originThumbnailUrl = this.thumbnailUrl
+    } else {
+      this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
+    }
+
+    this.videosLength = hash.videosLength
+
+    this.type = hash.type
+
+    this.createdAt = new Date(hash.createdAt)
+    this.updatedAt = new Date(hash.updatedAt)
+
+    this.ownerAccount = hash.ownerAccount
+    this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
+    this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
+
+    if (hash.videoChannel) {
+      this.videoChannel = hash.videoChannel
+      this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
+      this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
+    }
+
+    this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+
+    if (this.type.id === VideoPlaylistType.WATCH_LATER) {
+      this.displayName = peertubeTranslate(this.displayName, translations)
+    }
+  }
+
+  refreshThumbnail () {
+    if (!this.originThumbnailUrl) return
+
+    if (!this.thumbnailVersion) this.thumbnailVersion = 0
+    this.thumbnailVersion++
+
+    this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
+  }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
new file mode 100644 (file)
index 0000000..cc3d04b
--- /dev/null
@@ -0,0 +1,355 @@
+import * as debug from 'debug'
+import { uniq } from 'lodash-es'
+import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
+import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable, NgZone } from '@angular/core'
+import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
+import { enterZone, leaveZone, objectToFormData } from '@app/helpers'
+import { Account, AccountService, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import {
+  ResultList,
+  VideoExistInPlaylist,
+  VideoPlaylist as VideoPlaylistServerModel,
+  VideoPlaylistCreate,
+  VideoPlaylistElement as ServerVideoPlaylistElement,
+  VideoPlaylistElementCreate,
+  VideoPlaylistElementUpdate,
+  VideoPlaylistReorder,
+  VideoPlaylistUpdate,
+  VideosExistInPlaylists
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { VideoPlaylistElement } from './video-playlist-element.model'
+import { VideoPlaylist } from './video-playlist.model'
+
+const logger = debug('peertube:playlists:VideoPlaylistService')
+
+export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
+
+@Injectable()
+export class VideoPlaylistService {
+  static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
+  static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
+
+  // Use a replay subject because we "next" a value before subscribing
+  private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
+  private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
+  private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
+
+  private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
+  private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
+
+  private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
+  private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
+  private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
+
+  constructor (
+    private authHttp: HttpClient,
+    private serverService: ServerService,
+    private restExtractor: RestExtractor,
+    private restService: RestService,
+    private ngZone: NgZone
+  ) {
+    this.videoExistsInPlaylistObservable = merge(
+      this.videoExistsInPlaylistNotifier.pipe(
+        // We leave Angular zone so Protractor does not get stuck
+        bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
+        filter(videoIds => videoIds.length !== 0),
+        map(videoIds => uniq(videoIds)),
+        observeOn(enterZone(this.ngZone, asyncScheduler)),
+        switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
+        share()
+      ),
+
+      this.videoExistsInPlaylistCacheSubject
+    )
+  }
+
+  listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
+    const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination)
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  listMyPlaylistWithCache (user: AuthUser, search?: string) {
+    if (!search) {
+      if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
+      if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
+    }
+
+    const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
+               .pipe(
+                 tap(result => {
+                   if (!search) {
+                     this.myAccountPlaylistCacheRunning = undefined
+                     this.myAccountPlaylistCache = result
+                   }
+                 }),
+                 share()
+               )
+
+    if (!search) this.myAccountPlaylistCacheRunning = obs
+    return obs
+  }
+
+  listAccountPlaylists (
+    account: Account,
+    componentPagination: ComponentPaginationLight,
+    sort: string,
+    search?: string
+  ): Observable<ResultList<VideoPlaylist>> {
+    const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
+    const pagination = componentPagination
+      ? this.restService.componentPaginationToRestPagination(componentPagination)
+      : undefined
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+    if (search) params = this.restService.addObjectParams(params, { search })
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getVideoPlaylist (id: string | number) {
+    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
+
+    return this.authHttp.get<VideoPlaylist>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylist(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  createVideoPlaylist (body: VideoPlaylistCreate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
+               .pipe(
+                 tap(res => {
+                   if (!this.myAccountPlaylistCache) return
+
+                   this.myAccountPlaylistCache.total++
+
+                   this.myAccountPlaylistCache.data.push({
+                     id: res.videoPlaylist.id,
+                     displayName: body.displayName
+                   })
+
+                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 tap(() => {
+                   if (!this.myAccountPlaylistCache) return
+
+                   const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
+                   playlist.displayName = body.displayName
+
+                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
+    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 tap(() => {
+                   if (!this.myAccountPlaylistCache) return
+
+                   this.myAccountPlaylistCache.total--
+                   this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
+                                                          .filter(p => p.id !== videoPlaylist.id)
+
+                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
+    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
+
+    return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
+               .pipe(
+                 tap(res => {
+                   const existsResult = this.videoExistsCache[body.videoId]
+                   existsResult.push({
+                     playlistId,
+                     playlistElementId: res.videoPlaylistElement.id,
+                     startTimestamp: body.startTimestamp,
+                     stopTimestamp: body.stopTimestamp
+                   })
+
+                   this.runPlaylistCheck(body.videoId)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
+    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 tap(() => {
+                   const existsResult = this.videoExistsCache[videoId]
+                   const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
+
+                   elem.startTimestamp = body.startTimestamp
+                   elem.stopTimestamp = body.stopTimestamp
+
+                   this.runPlaylistCheck(videoId)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
+    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 tap(() => {
+                   if (!videoId) return
+
+                   this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
+                   this.runPlaylistCheck(videoId)
+                 }),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
+    const body: VideoPlaylistReorder = {
+      startPosition: oldPosition,
+      insertAfterPosition: newPosition
+    }
+
+    return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getPlaylistVideos (
+    videoPlaylistId: number | string,
+    componentPagination: ComponentPaginationLight
+  ): Observable<ResultList<VideoPlaylistElement>> {
+    const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination)
+
+    return this.authHttp
+               .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
+               .pipe(
+                 switchMap(res => this.extractVideoPlaylistElements(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  listenToMyAccountPlaylistsChange () {
+    return this.myAccountPlaylistCacheSubject.asObservable()
+  }
+
+  listenToVideoPlaylistChange (videoId: number) {
+    if (this.videoExistsObservableCache[ videoId ]) {
+      return this.videoExistsObservableCache[ videoId ]
+    }
+
+    const obs = this.videoExistsInPlaylistObservable
+                    .pipe(
+                      map(existsResult => existsResult[ videoId ]),
+                      filter(r => !!r),
+                      tap(result => this.videoExistsCache[ videoId ] = result)
+                    )
+
+    this.videoExistsObservableCache[ videoId ] = obs
+    return obs
+  }
+
+  runPlaylistCheck (videoId: number) {
+    logger('Running playlist check.')
+
+    if (this.videoExistsCache[videoId]) {
+      logger('Found cache for %d.', videoId)
+
+      return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
+    }
+
+    logger('Fetching from network for %d.', videoId)
+    return this.videoExistsInPlaylistNotifier.next(videoId)
+  }
+
+  extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
+    return this.serverService.getServerLocale()
+               .pipe(
+                 map(translations => {
+                   const playlistsJSON = result.data
+                   const total = result.total
+                   const playlists: VideoPlaylist[] = []
+
+                   for (const playlistJSON of playlistsJSON) {
+                     playlists.push(new VideoPlaylist(playlistJSON, translations))
+                   }
+
+                   return { data: playlists, total }
+                 })
+               )
+  }
+
+  extractPlaylist (playlist: VideoPlaylistServerModel) {
+    return this.serverService.getServerLocale()
+               .pipe(map(translations => new VideoPlaylist(playlist, translations)))
+  }
+
+  extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
+    return this.serverService.getServerLocale()
+               .pipe(
+                 map(translations => {
+                   const elementsJson = result.data
+                   const total = result.total
+                   const elements: VideoPlaylistElement[] = []
+
+                   for (const elementJson of elementsJson) {
+                     elements.push(new VideoPlaylistElement(elementJson, translations))
+                   }
+
+                   return { total, data: elements }
+                 })
+               )
+  }
+
+  private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
+    const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
+
+    let params = new HttpParams()
+    params = this.restService.addObjectParams(params, { videoIds })
+
+    return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
+               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
deleted file mode 100644 (file)
index 98fab9e..0000000
+++ /dev/null
@@ -1,337 +0,0 @@
-import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
-import { SharedModule as PrimeSharedModule } from 'primeng/api'
-import { InputMaskModule } from 'primeng/inputmask'
-import { InputSwitchModule } from 'primeng/inputswitch'
-import { MultiSelectModule } from 'primeng/multiselect'
-import { ClipboardModule } from '@angular/cdk/clipboard'
-import { CommonModule } from '@angular/common'
-import { HttpClientModule } from '@angular/common/http'
-import { NgModule } from '@angular/core'
-import { FormsModule, ReactiveFormsModule } from '@angular/forms'
-import { RouterModule } from '@angular/router'
-import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service'
-import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
-import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
-import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
-import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
-import { AccountService } from '@app/shared/account/account.service'
-import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
-import { HighlightPipe } from '@app/shared/angular/highlight.pipe'
-import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
-import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
-import { BlocklistService } from '@app/shared/blocklist'
-import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component'
-import { AvatarComponent } from '@app/shared/channel/avatar.component'
-import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
-import { DateToggleComponent } from '@app/shared/date/date-toggle.component'
-import {
-  CustomConfigValidatorsService,
-  InstanceValidatorsService,
-  LoginValidatorsService,
-  ReactiveFileComponent,
-  ResetPasswordValidatorsService,
-  TextareaAutoResizeDirective,
-  UserValidatorsService,
-  VideoAbuseValidatorsService,
-  VideoAcceptOwnershipValidatorsService,
-  VideoBlockValidatorsService,
-  VideoChangeOwnershipValidatorsService,
-  VideoChannelValidatorsService,
-  VideoCommentValidatorsService,
-  VideoPlaylistValidatorsService,
-  VideoValidatorsService
-} from '@app/shared/forms'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
-import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
-import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
-import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
-import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
-import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
-import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
-import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component'
-import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
-import { FollowService } from '@app/shared/instance/follow.service'
-import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
-import { InstanceStatisticsComponent } from '@app/shared/instance/instance-statistics.component'
-import { InstanceService } from '@app/shared/instance/instance.service'
-import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
-import { HelpComponent } from '@app/shared/misc/help.component'
-import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
-import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service'
-import { UserBanModalComponent } from '@app/shared/moderation'
-import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component'
-import { OverviewService } from '@app/shared/overview'
-import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
-import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
-import { UserHistoryService } from '@app/shared/users/user-history.service'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
-import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoImportService } from '@app/shared/video-import/video-import.service'
-import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
-import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
-import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
-import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component'
-import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
-import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
-import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
-import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
-import {
-  NgbCollapseModule,
-  NgbDropdownModule,
-  NgbModalModule,
-  NgbNavModule,
-  NgbPopoverModule,
-  NgbTooltipModule
-} from '@ng-bootstrap/ng-bootstrap'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
-import { BulkService } from './bulk/bulk.service'
-import { ButtonComponent } from './buttons/button.component'
-import { DeleteButtonComponent } from './buttons/delete-button.component'
-import { EditButtonComponent } from './buttons/edit-button.component'
-import { LoaderComponent } from './misc/loader.component'
-import { RestExtractor, RestService } from './rest'
-import { UserService } from './users'
-import { VideoAbuseService } from './video-abuse'
-import { VideoBlockService } from './video-block'
-import { VideoOwnershipService } from './video-ownership'
-import { FeedComponent } from './video/feed.component'
-import { VideoMiniatureComponent } from './video/video-miniature.component'
-import { VideoThumbnailComponent } from './video/video-thumbnail.component'
-import { VideoService } from './video/video.service'
-
-@NgModule({
-  imports: [
-    CommonModule,
-    FormsModule,
-    ReactiveFormsModule,
-    RouterModule,
-    HttpClientModule,
-
-    NgbDropdownModule,
-    NgbModalModule,
-    NgbPopoverModule,
-    NgbNavModule,
-    NgbTooltipModule,
-    NgbCollapseModule,
-
-    ClipboardModule,
-
-    PrimeSharedModule,
-    InputMaskModule,
-    NgPipesModule,
-    MultiSelectModule,
-    InputSwitchModule
-  ],
-
-  declarations: [
-    LoaderComponent,
-    SmallLoaderComponent,
-
-    VideoThumbnailComponent,
-    VideoMiniatureComponent,
-    VideoPlaylistMiniatureComponent,
-    VideoAddToPlaylistComponent,
-    VideoPlaylistElementMiniatureComponent,
-    VideosSelectionComponent,
-    VideoActionsDropdownComponent,
-
-    VideoDownloadComponent,
-    VideoReportComponent,
-    VideoBlockComponent,
-
-    FeedComponent,
-
-    ButtonComponent,
-    DeleteButtonComponent,
-    EditButtonComponent,
-
-    NumberFormatterPipe,
-    ObjectLengthPipe,
-    FromNowPipe,
-    HighlightPipe,
-    PeerTubeTemplateDirective,
-    VideoDurationPipe,
-
-    ActionDropdownComponent,
-    MarkdownTextareaComponent,
-    InfiniteScrollerDirective,
-    TextareaAutoResizeDirective,
-    HelpComponent,
-    ListOverflowComponent,
-
-    ReactiveFileComponent,
-    PeertubeCheckboxComponent,
-    TimestampInputComponent,
-    InputReadonlyCopyComponent,
-
-    AvatarComponent,
-    SubscribeButtonComponent,
-    RemoteSubscribeComponent,
-    InstanceFeaturesTableComponent,
-    InstanceStatisticsComponent,
-    FeatureBooleanComponent,
-    UserBanModalComponent,
-    UserModerationDropdownComponent,
-    TopMenuDropdownComponent,
-    UserNotificationsComponent,
-    ConfirmComponent,
-    DateToggleComponent,
-
-    GlobalIconComponent,
-    PreviewUploadComponent,
-
-    MyAccountVideoSettingsComponent,
-    MyAccountInterfaceSettingsComponent,
-    ActorAvatarInfoComponent,
-    BatchDomainsModalComponent
-  ],
-
-  exports: [
-    CommonModule,
-    FormsModule,
-    ReactiveFormsModule,
-    RouterModule,
-    HttpClientModule,
-
-    NgbDropdownModule,
-    NgbModalModule,
-    NgbPopoverModule,
-    NgbNavModule,
-    NgbTooltipModule,
-    NgbCollapseModule,
-
-    ClipboardModule,
-
-    PrimeSharedModule,
-    InputMaskModule,
-    BytesPipe,
-    KeysPipe,
-    MultiSelectModule,
-
-    LoaderComponent,
-    SmallLoaderComponent,
-
-    VideoThumbnailComponent,
-    VideoMiniatureComponent,
-    VideoPlaylistMiniatureComponent,
-    VideoAddToPlaylistComponent,
-    VideoPlaylistElementMiniatureComponent,
-    VideosSelectionComponent,
-    VideoActionsDropdownComponent,
-
-    VideoDownloadComponent,
-    VideoReportComponent,
-    VideoBlockComponent,
-
-    FeedComponent,
-
-    ButtonComponent,
-    DeleteButtonComponent,
-    EditButtonComponent,
-
-    ActionDropdownComponent,
-    MarkdownTextareaComponent,
-    InfiniteScrollerDirective,
-    TextareaAutoResizeDirective,
-    HelpComponent,
-    ListOverflowComponent,
-    InputReadonlyCopyComponent,
-
-    ReactiveFileComponent,
-    PeertubeCheckboxComponent,
-    TimestampInputComponent,
-
-    AvatarComponent,
-    SubscribeButtonComponent,
-    RemoteSubscribeComponent,
-    InstanceFeaturesTableComponent,
-    InstanceStatisticsComponent,
-    UserBanModalComponent,
-    UserModerationDropdownComponent,
-    TopMenuDropdownComponent,
-    UserNotificationsComponent,
-    ConfirmComponent,
-    DateToggleComponent,
-
-    GlobalIconComponent,
-    PreviewUploadComponent,
-
-    NumberFormatterPipe,
-    ObjectLengthPipe,
-    FromNowPipe,
-    HighlightPipe,
-    PeerTubeTemplateDirective,
-    VideoDurationPipe,
-
-    MyAccountVideoSettingsComponent,
-    MyAccountInterfaceSettingsComponent,
-    ActorAvatarInfoComponent,
-    BatchDomainsModalComponent
-  ],
-
-  providers: [
-    AUTH_INTERCEPTOR_PROVIDER,
-    RestExtractor,
-    RestService,
-    VideoAbuseService,
-    VideoBlockService,
-    VideoOwnershipService,
-    UserService,
-    VideoService,
-    AccountService,
-    VideoChannelService,
-    VideoPlaylistService,
-    VideoCaptionService,
-    VideoImportService,
-    UserSubscriptionService,
-
-    FormValidatorService,
-    CustomConfigValidatorsService,
-    LoginValidatorsService,
-    ResetPasswordValidatorsService,
-    UserValidatorsService,
-    BatchDomainsValidatorsService,
-    VideoPlaylistValidatorsService,
-    VideoAbuseValidatorsService,
-    VideoChannelValidatorsService,
-    VideoCommentValidatorsService,
-    VideoValidatorsService,
-    VideoCaptionsValidatorsService,
-    VideoBlockValidatorsService,
-    OverviewService,
-    VideoChangeOwnershipValidatorsService,
-    VideoAcceptOwnershipValidatorsService,
-    InstanceValidatorsService,
-    BlocklistService,
-    UserHistoryService,
-    InstanceService,
-    BulkService,
-
-    MarkdownService,
-    LinkifierService,
-    HtmlRendererService,
-
-    I18nPrimengCalendarService,
-    ScreenService,
-    LocalStorageService, SessionStorageService,
-
-    UserNotificationService,
-
-    FollowService,
-    RedundancyService,
-
-    I18n
-  ]
-})
-export class SharedModule { }
diff --git a/client/src/app/shared/user-subscription/index.ts b/client/src/app/shared/user-subscription/index.ts
deleted file mode 100644 (file)
index e76940f..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './user-subscription.service'
-export * from './subscribe-button.component'
-export * from './remote-subscribe.component'
diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.html b/client/src/app/shared/user-subscription/remote-subscribe.component.html
deleted file mode 100644 (file)
index acfec0a..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
-  <div class="form-group mb-2">
-    <input type="email"
-      formControlName="text"
-      class="form-control"
-      (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()"
-      placeholder="jane_doe@example.com">
-  </div>
-
-  <button type="submit" [disabled]="!form.valid" class="btn btn-sm btn-remote-follow" i18n>
-    <span *ngIf="!interact">Remote subscribe</span>
-    <span *ngIf="interact">Remote interact</span>
-  </button>
-
-  <my-help *ngIf="!interact && showHelp">
-    <ng-template ptTemplate="customHtml">
-      <ng-container i18n>
-        You can subscribe to the channel via any ActivityPub-capable fediverse instance.<br /><br />
-        For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there.
-      </ng-container>
-    </ng-template>
-  </my-help>
-
-  <my-help *ngIf="showHelp && interact">
-    <ng-template ptTemplate="customHtml">
-      <ng-container i18n>
-        You can interact with this via any ActivityPub-capable fediverse instance.<br /><br />
-        For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there.
-      </ng-container>
-    </ng-template>
-  </my-help>
-</form>
diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.scss b/client/src/app/shared/user-subscription/remote-subscribe.component.scss
deleted file mode 100644 (file)
index 698c586..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-@import '_mixins';
-
-.btn-remote-follow {
-  @include peertube-button;
-  @include orange-button;
-}
\ No newline at end of file
diff --git a/client/src/app/shared/user-subscription/remote-subscribe.component.ts b/client/src/app/shared/user-subscription/remote-subscribe.component.ts
deleted file mode 100644 (file)
index befdb71..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Component, Input, OnInit } from '@angular/core'
-import { FormReactive } from '@app/shared/forms/form-reactive'
-import {
-  FormValidatorService,
-  UserValidatorsService
-} from '@app/shared/forms/form-validators'
-
-@Component({
-  selector: 'my-remote-subscribe',
-  templateUrl: './remote-subscribe.component.html',
-  styleUrls: ['./remote-subscribe.component.scss']
-})
-export class RemoteSubscribeComponent extends FormReactive implements OnInit {
-  @Input() uri: string
-  @Input() interact = false
-  @Input() showHelp = false
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private userValidatorsService: UserValidatorsService
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      text: this.userValidatorsService.USER_EMAIL
-    })
-  }
-
-  onValidKey () {
-    this.check()
-    if (!this.form.valid) return
-
-    this.formValidated()
-  }
-
-  formValidated () {
-    const address = this.form.value['text']
-    const [ username, hostname ] = address.split('@')
-
-    // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5
-    fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`)
-      .then(response => response.json())
-      .then(data => new Promise((resolve, reject) => {
-        console.log(data)
-
-        if (data && Array.isArray(data.links)) {
-          const link: { template: string } = data.links.find((link: any) => {
-            return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe'
-          })
-
-          if (link && link.template.includes('{uri}')) {
-            resolve(link.template.replace('{uri}', encodeURIComponent(this.uri)))
-          }
-        }
-        reject()
-      }))
-      .then(window.open)
-      .catch(err => console.error(err))
-  }
-}
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
deleted file mode 100644 (file)
index 85b3d1f..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-<div class="btn-group-subscribe btn-group"
-    [ngClass]="{'subscribe-button': !isAllChannelsSubscribed, 'unsubscribe-button': isAllChannelsSubscribed, 'big': isBigButton }">
-
-  <ng-template #userLoggedOut>
-    <span [ngClass]="{ 'extra-text': isAtLeastOneChannelSubscribed }">
-      <ng-container *ngIf="account; then multiple; else single"></ng-container>
-      <ng-template i18n #single>Subscribe</ng-template>
-      <ng-template #multiple>
-        <span i18n>Subscribe to all channels</span>
-        <span *ngIf="isAtLeastOneChannelSubscribed">{{ subscribeStatus(true).length }}/{{ subscribed.size }}
-          <ng-container i18n>channels subscribed</ng-container>
-        </span>
-      </ng-template>
-    </span>
-    <span *ngIf="!isBigButton && displayFollowers && videoChannels.length > 1 && videoChannel.followersCount !== 0" class="followers-count">
-      {{ videoChannels[0].followersCount | myNumberFormatter }}
-    </span>
-  </ng-template>
-
-  <ng-template #userLoggedIn>
-    <button *ngIf="!isAllChannelsSubscribed" type="button"
-            class="btn btn-sm" role="button"
-            (click)="subscribe()">
-      <ng-template [ngTemplateOutlet]="userLoggedOut"></ng-template>
-    </button>
-
-    <button
-      *ngIf="isAllChannelsSubscribed" type="button"
-      class="btn btn-sm" role="button"
-      (click)="unsubscribe()">
-      <ng-container i18n>{account + "", select, undefined {Unsubscribe} other {Unsubscribe from all channels}}</ng-container>
-    </button>
-  </ng-template>
-
-  <ng-container
-    *ngIf="isUserLoggedIn(); then userLoggedIn">
-  </ng-container>
-
-  <div class="btn-group" ngbDropdown autoClose="outside"
-       placement="bottom-right" role="group"
-       aria-label="Multiple ways to subscribe to the current channel">
-    <button class="btn btn-sm dropdown-toggle-split" ngbDropdownToggle>
-      <ng-container
-        *ngIf="!isUserLoggedIn(); then userLoggedOut">
-      </ng-container>
-    </button>
-
-    <div class="dropdown-menu" ngbDropdownMenu>
-
-      <h6 class="dropdown-header" i18n>Using an ActivityPub account</h6>
-
-      <button class="dropdown-item" (click)="subscribe()">
-        <span *ngIf="!isUserLoggedIn()" i18n>Subscribe with an account on this instance</span>
-        <span *ngIf="isUserLoggedIn()" i18n>Subscribe with your local account</span>
-      </button>
-
-      <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
-      <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
-
-      <div class="dropdown-divider"></div>
-
-      <h6 class="dropdown-header" i18n>Using a syndication feed</h6>
-      <a [href]="rssUri" target="_blank" class="dropdown-item" i18n>Subscribe via RSS</a>
-
-    </div>
-  </div>
-</div>
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss
deleted file mode 100644 (file)
index b739c5a..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.btn-group-subscribe {
-  @include peertube-button;
-  @include disable-default-a-behaviour;
-
-  float: right;
-  padding: 0;
-
-  & > .btn,
-  & > .dropdown > .dropdown-toggle {
-    font-size: 15px;
-  }
-
-  &:not(.big) {
-    white-space: nowrap;
-  }
-
-  &.big {
-    height: 35px;
-
-    & > button:first-child {
-      width: 175px;
-    }
-
-    button .extra-text {
-      span:first-child {
-        line-height: 80%;
-      }
-    
-      span:not(:first-child) {
-        font-size: 75%;
-      }
-    }
-  }
-
-  // Unlogged
-  & > .dropdown > .dropdown-toggle span {
-    padding-right: 3px;
-  }
-
-  // Logged
-  & > .btn {
-    padding-right: 4px;
-
-    & + .dropdown > button {
-      padding-left: 2px;
-
-      &::after {
-        position: relative;
-        top: 1px;
-      }
-    }
-  }
-
-  &.subscribe-button {
-    .btn {
-      @include orange-button;
-      font-weight: 600;
-    }
-
-    span.followers-count {
-      padding-left: 5px;
-    }
-  }
-  &.unsubscribe-button {
-    .btn {
-      @include grey-button;
-      font-weight: 600;
-    }
-  }
-
-  .dropdown-menu {
-    cursor: default;
-
-    button {
-      cursor: pointer;
-    }
-
-    .dropdown-item-neutral {
-      cursor: default;
-
-      &:hover,
-      &:focus {
-        background-color: inherit;
-      }
-    }
-  }
-
-  ::ng-deep form {
-    padding: 0.25rem 1rem;
-  }
-
-  input {
-    @include peertube-input-text(100%);
-  }
-}
-
-.extra-text {
-  display: flex;
-  flex-direction: column;
-
-  span:first-child {
-    line-height: 75%;
-  }
-
-  span:not(:first-child) {
-    font-size: 60%;
-    text-align: left;
-  }
-}
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
deleted file mode 100644 (file)
index 947f34c..0000000
+++ /dev/null
@@ -1,198 +0,0 @@
-import { Component, Input, OnInit, OnChanges } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoService } from '@app/shared/video/video.service'
-import { FeedFormat } from '../../../../../shared/models/feeds'
-import { Account } from '@app/shared/account/account.model'
-import { concat, forkJoin, merge } from 'rxjs'
-
-@Component({
-  selector: 'my-subscribe-button',
-  templateUrl: './subscribe-button.component.html',
-  styleUrls: [ './subscribe-button.component.scss' ]
-})
-export class SubscribeButtonComponent implements OnInit, OnChanges {
-  /**
-   * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel],
-   * or with an account and a full list of that account's videoChannels. The latter is intended
-   * to allow mass un/subscription from an account's page, while keeping the channel-centric
-   * subscription model.
-   */
-  @Input() account: Account
-  @Input() videoChannels: VideoChannel[]
-  @Input() displayFollowers = false
-  @Input() size: 'small' | 'normal' = 'normal'
-
-  subscribed = new Map<string, boolean>()
-
-  constructor (
-    private authService: AuthService,
-    private router: Router,
-    private notifier: Notifier,
-    private userSubscriptionService: UserSubscriptionService,
-    private i18n: I18n,
-    private videoService: VideoService
-  ) { }
-
-  get handle () {
-    return this.account
-      ? this.account.nameWithHost
-      : this.videoChannel.name + '@' + this.videoChannel.host
-  }
-
-  get channelHandle () {
-    return this.getChannelHandler(this.videoChannel)
-  }
-
-  get uri () {
-    return this.account
-      ? this.account.url
-      : this.videoChannels[0].url
-  }
-
-  get rssUri () {
-    const rssFeed = this.account
-      ? this.videoService
-          .getAccountFeedUrls(this.account.id)
-          .find(i => i.format === FeedFormat.RSS)
-      : this.videoService
-          .getVideoChannelFeedUrls(this.videoChannels[0].id)
-          .find(i => i.format === FeedFormat.RSS)
-
-    return rssFeed.url
-  }
-
-  get videoChannel () {
-    return this.videoChannels[0]
-  }
-
-  get isAllChannelsSubscribed () {
-    return this.subscribeStatus(true).length === this.videoChannels.length
-  }
-
-  get isAtLeastOneChannelSubscribed () {
-    return this.subscribeStatus(true).length > 0
-  }
-
-  get isBigButton () {
-    return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed
-  }
-
-  ngOnInit () {
-    this.loadSubscribedStatus()
-  }
-
-  ngOnChanges () {
-    this.ngOnInit()
-  }
-
-  subscribe () {
-    if (this.isUserLoggedIn()) {
-      return this.localSubscribe()
-    }
-
-    return this.gotoLogin()
-  }
-
-  localSubscribe () {
-    const subscribedStatus = this.subscribeStatus(false)
-
-    const observableBatch = this.videoChannels
-      .map(videoChannel => this.getChannelHandler(videoChannel))
-      .filter(handle => subscribedStatus.includes(handle))
-      .map(handle => this.userSubscriptionService.addSubscription(handle))
-
-    forkJoin(observableBatch)
-      .subscribe(
-        () => {
-          this.notifier.success(
-            this.account
-              ? this.i18n(
-                  'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.',
-                  { nameWithHost: this.account.displayName }
-                )
-              : this.i18n(
-                  'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.',
-                  { nameWithHost: this.videoChannels[0].displayName }
-                )
-            ,
-            this.i18n('Subscribed')
-          )
-        },
-
-          err => this.notifier.error(err.message)
-      )
-  }
-
-  unsubscribe () {
-    if (this.isUserLoggedIn()) {
-      this.localUnsubscribe()
-    }
-  }
-
-  localUnsubscribe () {
-    const subscribeStatus = this.subscribeStatus(true)
-
-    const observableBatch = this.videoChannels
-                                .map(videoChannel => this.getChannelHandler(videoChannel))
-                                .filter(handle => subscribeStatus.includes(handle))
-                                .map(handle => this.userSubscriptionService.deleteSubscription(handle))
-
-    concat(...observableBatch)
-      .subscribe({
-        complete: () => {
-          this.notifier.success(
-            this.account
-              ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost })
-              : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost })
-            ,
-            this.i18n('Unsubscribed')
-          )
-        },
-
-        error: err => this.notifier.error(err.message)
-      })
-  }
-
-  isUserLoggedIn () {
-    return this.authService.isLoggedIn()
-  }
-
-  gotoLogin () {
-    this.router.navigate([ '/login' ])
-  }
-
-  subscribeStatus (subscribed: boolean) {
-    const accumulator: string[] = []
-    for (const [key, value] of this.subscribed.entries()) {
-      if (value === subscribed) accumulator.push(key)
-    }
-
-    return accumulator
-  }
-
-  private getChannelHandler (videoChannel: VideoChannel) {
-    return videoChannel.name + '@' + videoChannel.host
-  }
-
-  private loadSubscribedStatus () {
-    if (!this.isUserLoggedIn()) return
-
-    for (const videoChannel of this.videoChannels) {
-      const handle = this.getChannelHandler(videoChannel)
-      this.subscribed.set(handle, false)
-
-      merge(
-        this.userSubscriptionService.listenToSubscriptionCacheChange(handle),
-        this.userSubscriptionService.doesSubscriptionExist(handle)
-      ).subscribe(
-        res => this.subscribed.set(handle, res),
-
-        err => this.notifier.error(err.message)
-      )
-    }
-  }
-}
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
deleted file mode 100644 (file)
index 9af9ba2..0000000
+++ /dev/null
@@ -1,163 +0,0 @@
-import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
-import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable, NgZone } from '@angular/core'
-import { ResultList } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { uniq } from 'lodash-es'
-import * as debug from 'debug'
-import { enterZone, leaveZone } from '@app/shared/rxjs/zone'
-
-const logger = debug('peertube:subscriptions:UserSubscriptionService')
-
-type SubscriptionExistResult = { [ uri: string ]: boolean }
-type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean> }
-
-@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<string>(1)
-  private readonly existsObservable: Observable<SubscriptionExistResult>
-
-  private myAccountSubscriptionCache: SubscriptionExistResult = {}
-  private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {}
-  private myAccountSubscriptionCacheSubject = new Subject<SubscriptionExistResult>()
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private ngZone: NgZone
-  ) {
-    this.existsObservable = merge(
-      this.existsSubject.pipe(
-        // We leave Angular zone so Protractor does not get stuck
-        bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
-        filter(uris => uris.length !== 0),
-        map(uris => uniq(uris)),
-        observeOn(enterZone(this.ngZone, asyncScheduler)),
-        switchMap(uris => this.doSubscriptionsExist(uris)),
-        share()
-      ),
-
-      this.myAccountSubscriptionCacheSubject
-    )
-  }
-
-  /**
-   * Subscription part
-   */
-
-  deleteSubscription (nameWithHost: string) {
-    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost
-
-    return this.authHttp.delete(url)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   this.myAccountSubscriptionCache[nameWithHost] = false
-
-                   this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  addSubscription (nameWithHost: string) {
-    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
-
-    const body = { uri: nameWithHost }
-    return this.authHttp.post(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   this.myAccountSubscriptionCache[nameWithHost] = true
-
-                   this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  listSubscriptions (componentPagination: ComponentPaginationLight): Observable<ResultList<VideoChannel>> {
-    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<ResultList<VideoChannelServer>>(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<SubscriptionExistResult> {
-    const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
-    let params = new HttpParams()
-
-    params = this.restService.addObjectParams(params, { uris })
-
-    return this.authHttp.get<SubscriptionExistResult>(url, { params })
-               .pipe(
-                 tap(res => {
-                   this.myAccountSubscriptionCache = {
-                     ...this.myAccountSubscriptionCache,
-                     ...res
-                   }
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-}
diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts
deleted file mode 100644 (file)
index ebd715f..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './user.model'
-export * from './user.service'
-export * from './user-notifications.component'
diff --git a/client/src/app/shared/users/user-history.service.ts b/client/src/app/shared/users/user-history.service.ts
deleted file mode 100644 (file)
index b358cdf..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { RestService } from '../rest/rest.service'
-import { Video } from '../video/video.model'
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { ResultList } from '../../../../../shared'
-
-@Injectable()
-export class UserHistoryService {
-  static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private videoService: VideoService
-  ) {}
-
-  getUserVideosHistory (historyPagination: ComponentPaginationLight) {
-    const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination)
-
-    return this.authHttp
-               .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
-               .pipe(
-                 switchMap(res => this.videoService.extractVideos(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  deleteUserVideosHistory () {
-    return this.authHttp
-               .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {})
-               .pipe(
-                 map(() => this.restExtractor.extractDataBool()),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-}
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
deleted file mode 100644 (file)
index 7b8368d..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
-import { Actor } from '@app/shared/actor/actor.model'
-
-export class UserNotification implements UserNotificationServer {
-  id: number
-  type: UserNotificationType
-  read: boolean
-
-  video?: VideoInfo & {
-    channel: ActorInfo & { avatarUrl?: string }
-  }
-
-  videoImport?: {
-    id: number
-    video?: VideoInfo
-    torrentName?: string
-    magnetUri?: string
-    targetUrl?: string
-  }
-
-  comment?: {
-    id: number
-    threadId: number
-    account: ActorInfo & { avatarUrl?: string }
-    video: VideoInfo
-  }
-
-  videoAbuse?: {
-    id: number
-    video: VideoInfo
-  }
-
-  videoBlacklist?: {
-    id: number
-    video: VideoInfo
-  }
-
-  account?: ActorInfo & { avatarUrl?: string }
-
-  actorFollow?: {
-    id: number
-    state: FollowState
-    follower: ActorInfo & { avatarUrl?: string }
-    following: {
-      type: 'account' | 'channel' | 'instance'
-      name: string
-      displayName: string
-      host: string
-    }
-  }
-
-  createdAt: string
-  updatedAt: string
-
-  // Additional fields
-  videoUrl?: string
-  commentUrl?: any[]
-  videoAbuseUrl?: string
-  videoAutoBlacklistUrl?: string
-  accountUrl?: string
-  videoImportIdentifier?: string
-  videoImportUrl?: string
-  instanceFollowUrl?: string
-
-  constructor (hash: UserNotificationServer) {
-    this.id = hash.id
-    this.type = hash.type
-    this.read = hash.read
-
-    // We assume that some fields exist
-    // To prevent a notification popup crash in case of bug, wrap it inside a try/catch
-    try {
-      this.video = hash.video
-      if (this.video) this.setAvatarUrl(this.video.channel)
-
-      this.videoImport = hash.videoImport
-
-      this.comment = hash.comment
-      if (this.comment) this.setAvatarUrl(this.comment.account)
-
-      this.videoAbuse = hash.videoAbuse
-
-      this.videoBlacklist = hash.videoBlacklist
-
-      this.account = hash.account
-      if (this.account) this.setAvatarUrl(this.account)
-
-      this.actorFollow = hash.actorFollow
-      if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
-
-      this.createdAt = hash.createdAt
-      this.updatedAt = hash.updatedAt
-
-      switch (this.type) {
-        case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
-          this.videoUrl = this.buildVideoUrl(this.video)
-          break
-
-        case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
-          this.videoUrl = this.buildVideoUrl(this.video)
-          break
-
-        case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
-        case UserNotificationType.COMMENT_MENTION:
-          if (!this.comment) break
-          this.accountUrl = this.buildAccountUrl(this.comment.account)
-          this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
-          break
-
-        case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
-          this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
-          this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
-          break
-
-        case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
-          this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
-          // Backward compatibility where we did not assign videoBlacklist to this type of notification before
-          if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video }
-
-          this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
-          break
-
-        case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
-          this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
-          break
-
-        case UserNotificationType.MY_VIDEO_PUBLISHED:
-          this.videoUrl = this.buildVideoUrl(this.video)
-          break
-
-        case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
-          this.videoImportUrl = this.buildVideoImportUrl()
-          this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
-
-          if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video)
-          break
-
-        case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
-          this.videoImportUrl = this.buildVideoImportUrl()
-          this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
-          break
-
-        case UserNotificationType.NEW_USER_REGISTRATION:
-          this.accountUrl = this.buildAccountUrl(this.account)
-          break
-
-        case UserNotificationType.NEW_FOLLOW:
-          this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
-          break
-
-        case UserNotificationType.NEW_INSTANCE_FOLLOWER:
-          this.instanceFollowUrl = '/admin/follows/followers-list'
-          break
-
-        case UserNotificationType.AUTO_INSTANCE_FOLLOWING:
-          this.instanceFollowUrl = '/admin/follows/following-list'
-          break
-      }
-    } catch (err) {
-      this.type = null
-      console.error(err)
-    }
-  }
-
-  private buildVideoUrl (video: { uuid: string }) {
-    return '/videos/watch/' + video.uuid
-  }
-
-  private buildAccountUrl (account: { name: string, host: string }) {
-    return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
-  }
-
-  private buildVideoImportUrl () {
-    return '/my-account/video-imports'
-  }
-
-  private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
-    return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
-  }
-
-  private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
-    actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
-  }
-}
diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts
deleted file mode 100644 (file)
index e525a1d..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Injectable } from '@angular/core'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { RestExtractor, RestService } from '../rest'
-import { catchError, map, tap } from 'rxjs/operators'
-import { environment } from '../../../environments/environment'
-import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
-import { UserNotification } from './user-notification.model'
-import { AuthService } from '../../core'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { User } from '../users/user.model'
-import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
-
-@Injectable()
-export class UserNotificationService {
-  static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
-  static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
-
-  constructor (
-    private auth: AuthService,
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private userNotificationSocket: UserNotificationSocket
-  ) {}
-
-  listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) {
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
-
-    if (unread) params = params.append('unread', `${unread}`)
-
-    const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
-
-    return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
-               .pipe(
-                 map(res => this.restExtractor.convertResultListDateToHuman(res)),
-                 map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  countUnreadNotifications () {
-    return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
-      .pipe(map(n => n.total))
-  }
-
-  markAsRead (notification: UserNotification) {
-    const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
-
-    const body = { ids: [ notification.id ] }
-    const headers = { ignoreLoadingBar: '' }
-
-    return this.authHttp.post(url, body, { headers })
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => this.userNotificationSocket.dispatch('read')),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  markAllAsRead () {
-    const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
-    const headers = { ignoreLoadingBar: '' }
-
-    return this.authHttp.post(url, {}, { headers })
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => this.userNotificationSocket.dispatch('read-all')),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  updateNotificationSettings (user: User, settings: UserNotificationSetting) {
-    const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
-
-    return this.authHttp.put(url, settings)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  private formatNotification (notification: UserNotificationServer) {
-    return new UserNotification(notification)
-  }
-}
diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html
deleted file mode 100644 (file)
index 0877111..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
-
-<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
-  <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
-
-    <ng-container [ngSwitch]="notification.type">
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
-        <ng-container *ngIf="notification.video; then hasVideo; else noVideo"></ng-container>
-
-        <ng-template #hasVideo>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
-            <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
-          </a>
-
-          <div class="message" i18n>
-            {{ notification.video.channel.displayName }} published a new video: <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a>
-          </div>
-        </ng-template>
-
-        <ng-template #noVideo>
-          <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-  
-          <div class="message" i18n>
-            The notification concerns a video now unavailable
-          </div>
-        </ng-template>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
-        <my-global-icon iconName="undo" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblocked
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
-        <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blocked
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
-        <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
-        <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">automatically blocked</a>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
-        <ng-container *ngIf="notification.comment; then hasComment; else noComment"></ng-container>
-
-        <ng-template #hasComment>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
-            <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
-          </a>
-  
-          <div class="message" i18n>
-            <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
-          </div>
-        </ng-template>
-
-        <ng-template #noComment>
-          <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-  
-          <div class="message" i18n>
-            The notification concerns a comment now unavailable
-          </div>
-        </ng-template>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
-        <my-global-icon iconName="sparkle" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
-        <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
-        <my-global-icon iconName="cloud-error" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
-        <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }}</a> registered on your instance
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
-        <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
-          <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
-        </a>
-
-        <div class="message" i18n>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
-
-          <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
-          <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
-        <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
-          <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
-        </a>
-
-        <div class="message" i18n>
-          <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
-        <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow?.follower.host }})
-          <ng-container *ngIf="notification.actorFollow?.state === 'pending'"> awaiting your approval</ng-container>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchCase="UserNotificationType.AUTO_INSTANCE_FOLLOWING">
-        <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          Your instance automatically followed <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">{{ notification.actorFollow.following.host }}</a>
-        </div>
-      </ng-container>
-
-      <ng-container *ngSwitchDefault>
-        <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
-        <div class="message" i18n>
-          The notification points to a content now unavailable
-        </div>
-      </ng-container>
-    </ng-container>
-
-    <div [title]="notification.createdAt" class="from-date">{{ notification.createdAt | myFromNow }}</div>
-  </div>
-</div>
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
deleted file mode 100644 (file)
index 5166bd5..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.no-notification {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  padding: 20px 0;
-}
-
-.notification {
-  display: flex;
-  align-items: center;
-  font-size: inherit;
-  padding: 15px 5px 15px 10px;
-  border-bottom: 1px solid $separator-border-color;
-  word-break: break-word;
-
-  &.unread {
-    background-color: rgba(0, 0, 0, 0.05);
-  }
-
-  my-global-icon {
-    width: 24px;
-    margin-right: 11px;
-    margin-left: 3px;
-
-    @include apply-svg-color(#333);
-  }
-
-  .avatar {
-    @include avatar(30px);
-
-    margin-right: 10px;
-  }
-
-  .message {
-    flex-grow: 1;
-
-    a {
-      font-weight: $font-semibold;
-    }
-  }
-
-  .from-date {
-    font-size: 0.85em;
-    color: pvar(--greyForegroundColor);
-    padding-left: 5px;
-    min-width: 70px;
-    text-align: right;
-    margin-left: auto;
-  }
-}
diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts
deleted file mode 100644 (file)
index 977dd89..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
-import { UserNotificationType } from '../../../../../shared'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { Notifier } from '@app/core'
-import { UserNotification } from '@app/shared/users/user-notification.model'
-import { Subject } from 'rxjs'
-
-@Component({
-  selector: 'my-user-notifications',
-  templateUrl: 'user-notifications.component.html',
-  styleUrls: [ 'user-notifications.component.scss' ]
-})
-export class UserNotificationsComponent implements OnInit {
-  @Input() ignoreLoadingBar = false
-  @Input() infiniteScroll = true
-  @Input() itemsPerPage = 20
-  @Input() markAllAsReadSubject: Subject<boolean>
-
-  @Output() notificationsLoaded = new EventEmitter()
-
-  notifications: UserNotification[] = []
-
-  // So we can access it in the template
-  UserNotificationType = UserNotificationType
-
-  componentPagination: ComponentPagination
-
-  onDataSubject = new Subject<any[]>()
-
-  constructor (
-    private userNotificationService: UserNotificationService,
-    private notifier: Notifier
-  ) { }
-
-  ngOnInit () {
-    this.componentPagination = {
-      currentPage: 1,
-      itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
-      totalItems: null
-    }
-
-    this.loadMoreNotifications()
-
-    if (this.markAllAsReadSubject) {
-      this.markAllAsReadSubject.subscribe(() => this.markAllAsRead())
-    }
-  }
-
-  loadMoreNotifications () {
-    this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
-        .subscribe(
-          result => {
-            this.notifications = this.notifications.concat(result.data)
-            this.componentPagination.totalItems = result.total
-
-            this.notificationsLoaded.emit()
-
-            this.onDataSubject.next(result.data)
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-
-  onNearOfBottom () {
-    if (this.infiniteScroll === false) return
-
-    this.componentPagination.currentPage++
-
-    if (hasMoreItems(this.componentPagination)) {
-      this.loadMoreNotifications()
-    }
-  }
-
-  markAsRead (notification: UserNotification) {
-    if (notification.read) return
-
-    this.userNotificationService.markAsRead(notification)
-        .subscribe(
-          () => {
-            notification.read = true
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-
-  markAllAsRead () {
-    this.userNotificationService.markAllAsRead()
-        .subscribe(
-          () => {
-            for (const notification of this.notifications) {
-              notification.read = true
-            }
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-}
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
deleted file mode 100644 (file)
index 3348fe7..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-import {
-  hasUserRight,
-  User as UserServerModel,
-  UserNotificationSetting,
-  UserRight,
-  UserRole
-} from '../../../../../shared/models/users'
-import { VideoChannel } from '../../../../../shared/models/videos'
-import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
-import { Account } from '@app/shared/account/account.model'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
-
-export class User implements UserServerModel {
-  static KEYS = {
-    ID: 'id',
-    ROLE: 'role',
-    EMAIL: 'email',
-    VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
-    USERNAME: 'username',
-    NSFW_POLICY: 'nsfw_policy',
-    WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
-    AUTO_PLAY_VIDEO: 'auto_play_video',
-    SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
-    AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
-    THEME: 'last_active_theme',
-    VIDEO_LANGUAGES: 'video_languages'
-  }
-
-  id: number
-  username: string
-  email: string
-  pendingEmail: string | null
-
-  emailVerified: boolean
-  nsfwPolicy: NSFWPolicyType
-
-  adminFlags?: UserAdminFlag
-
-  autoPlayVideo: boolean
-  autoPlayNextVideo: boolean
-  autoPlayNextVideoPlaylist: boolean
-  webTorrentEnabled: boolean
-  videosHistoryEnabled: boolean
-  videoLanguages: string[]
-
-  role: UserRole
-  roleLabel: string
-
-  videoQuota: number
-  videoQuotaDaily: number
-  videoQuotaUsed?: number
-  videoQuotaUsedDaily?: number
-  videosCount?: number
-  videoAbusesCount?: number
-  videoAbusesAcceptedCount?: number
-  videoAbusesCreatedCount?: number
-  videoCommentsCount?: number
-
-  theme: string
-
-  account: Account
-  notificationSettings?: UserNotificationSetting
-  videoChannels?: VideoChannel[]
-
-  blocked: boolean
-  blockedReason?: string
-
-  noInstanceConfigWarningModal: boolean
-  noWelcomeModal: boolean
-
-  pluginAuth: string | null
-
-  lastLoginDate: Date | null
-
-  createdAt: Date
-
-  constructor (hash: Partial<UserServerModel>) {
-    this.id = hash.id
-    this.username = hash.username
-    this.email = hash.email
-
-    this.role = hash.role
-
-    this.videoChannels = hash.videoChannels
-
-    this.videoQuota = hash.videoQuota
-    this.videoQuotaDaily = hash.videoQuotaDaily
-    this.videoQuotaUsed = hash.videoQuotaUsed
-    this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
-    this.videosCount = hash.videosCount
-    this.videoAbusesCount = hash.videoAbusesCount
-    this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
-    this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
-    this.videoCommentsCount = hash.videoCommentsCount
-
-    this.nsfwPolicy = hash.nsfwPolicy
-    this.webTorrentEnabled = hash.webTorrentEnabled
-    this.autoPlayVideo = hash.autoPlayVideo
-    this.autoPlayNextVideo = hash.autoPlayNextVideo
-    this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
-    this.videosHistoryEnabled = hash.videosHistoryEnabled
-    this.videoLanguages = hash.videoLanguages
-
-    this.theme = hash.theme
-
-    this.adminFlags = hash.adminFlags
-
-    this.blocked = hash.blocked
-    this.blockedReason = hash.blockedReason
-
-    this.noInstanceConfigWarningModal = hash.noInstanceConfigWarningModal
-    this.noWelcomeModal = hash.noWelcomeModal
-
-    this.notificationSettings = hash.notificationSettings
-
-    this.createdAt = hash.createdAt
-
-    this.pluginAuth = hash.pluginAuth
-    this.lastLoginDate = hash.lastLoginDate
-
-    if (hash.account !== undefined) {
-      this.account = new Account(hash.account)
-    }
-  }
-
-  get accountAvatarUrl () {
-    if (!this.account) return ''
-
-    return this.account.avatarUrl
-  }
-
-  hasRight (right: UserRight) {
-    return hasUserRight(this.role, right)
-  }
-
-  patch (obj: UserServerModel) {
-    for (const key of Object.keys(obj)) {
-      this[key] = obj[key]
-    }
-
-    if (obj.account !== undefined) {
-      this.account = new Account(obj.account)
-    }
-  }
-
-  updateAccountAvatar (newAccountAvatar: Avatar) {
-    this.account.updateAvatar(newAccountAvatar)
-  }
-}
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
deleted file mode 100644 (file)
index de1c8ec..0000000
+++ /dev/null
@@ -1,367 +0,0 @@
-import { has } from 'lodash-es'
-import { BytesPipe } from 'ngx-pipes'
-import { SortMeta } from 'primeng/api'
-import { from, Observable, of } from 'rxjs'
-import { catchError, concatMap, first, map, shareReplay, toArray, throttleTime, filter } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { AuthService } from '@app/core/auth'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { UserRegister } from '@shared/models/users/user-register.model'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { environment } from '../../../environments/environment'
-import { LocalStorageService, SessionStorageService } from '../misc/storage.service'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { User } from './user.model'
-
-@Injectable()
-export class UserService {
-  static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
-
-  private bytesPipe = new BytesPipe()
-
-  private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
-
-  constructor (
-    private authHttp: HttpClient,
-    private authService: AuthService,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private localStorageService: LocalStorageService,
-    private sessionStorageService: SessionStorageService,
-    private i18n: I18n
-  ) { }
-
-  changePassword (currentPassword: string, newPassword: string) {
-    const url = UserService.BASE_USERS_URL + 'me'
-    const body: UserUpdateMe = {
-      currentPassword,
-      password: newPassword
-    }
-
-    return this.authHttp.put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  changeEmail (password: string, newEmail: string) {
-    const url = UserService.BASE_USERS_URL + 'me'
-    const body: UserUpdateMe = {
-      currentPassword: password,
-      email: newEmail
-    }
-
-    return this.authHttp.put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateMyProfile (profile: UserUpdateMe) {
-    const url = UserService.BASE_USERS_URL + 'me'
-
-    return this.authHttp.put(url, profile)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateMyAnonymousProfile (profile: UserUpdateMe) {
-    const supportedKeys = {
-      // local storage keys
-      nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
-      webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
-      autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
-      autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
-      theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
-      videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
-
-      // session storage keys
-      autoPlayNextVideo: (val: boolean) =>
-        this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
-    }
-
-    for (const key of Object.keys(profile)) {
-      try {
-        if (has(supportedKeys, key)) supportedKeys[key](profile[key])
-      } catch (err) {
-        console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
-      }
-    }
-  }
-
-  listenAnonymousUpdate () {
-    return this.localStorageService.watch([
-      User.KEYS.NSFW_POLICY,
-      User.KEYS.WEBTORRENT_ENABLED,
-      User.KEYS.AUTO_PLAY_VIDEO,
-      User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST,
-      User.KEYS.THEME,
-      User.KEYS.VIDEO_LANGUAGES
-    ]).pipe(
-      throttleTime(200),
-      filter(() => this.authService.isLoggedIn() !== true),
-      map(() => this.getAnonymousUser())
-    )
-  }
-
-  deleteMe () {
-    const url = UserService.BASE_USERS_URL + 'me'
-
-    return this.authHttp.delete(url)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  changeAvatar (avatarForm: FormData) {
-    const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
-
-    return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-
-  signup (userCreate: UserRegister) {
-    return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getMyVideoQuotaUsed () {
-    const url = UserService.BASE_USERS_URL + 'me/video-quota-used'
-
-    return this.authHttp.get<UserVideoQuota>(url)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-
-  askResetPassword (email: string) {
-    const url = UserService.BASE_USERS_URL + '/ask-reset-password'
-
-    return this.authHttp.post(url, { email })
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  resetPassword (userId: number, verificationString: string, password: string) {
-    const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
-    const body = {
-      verificationString,
-      password
-    }
-
-    return this.authHttp.post(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
-    const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
-    const body = {
-      verificationString,
-      isPendingEmail
-    }
-
-    return this.authHttp.post(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  askSendVerifyEmail (email: string) {
-    const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
-
-    return this.authHttp.post(url, { email })
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  autocomplete (search: string): Observable<string[]> {
-    const url = UserService.BASE_USERS_URL + 'autocomplete'
-    const params = new HttpParams().append('search', search)
-
-    return this.authHttp
-      .get<string[]>(url, { params })
-      .pipe(catchError(res => this.restExtractor.handleError(res)))
-  }
-
-  getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
-    // Don't update display name, the user seems to have changed it
-    if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
-
-    return this.displayNameToUsername(newDisplayName)
-  }
-
-  displayNameToUsername (displayName: string) {
-    if (!displayName) return ''
-
-    return displayName
-      .toLowerCase()
-      .replace(/\s/g, '_')
-      .replace(/[^a-z0-9_.]/g, '')
-  }
-
-  /* ###### Admin methods ###### */
-
-  addUser (userCreate: UserCreate) {
-    return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateUser (userId: number, userUpdate: UserUpdate) {
-    return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
-    return from(users)
-      .pipe(
-        concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
-        toArray(),
-        catchError(err => this.restExtractor.handleError(err))
-      )
-  }
-
-  getUserWithCache (userId: number) {
-    if (!this.userCache[userId]) {
-      this.userCache[ userId ] = this.getUser(userId).pipe(shareReplay())
-    }
-
-    return this.userCache[userId]
-  }
-
-  getUser (userId: number, withStats = false) {
-    const params = new HttpParams().append('withStats', withStats + '')
-    return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-
-  getAnonymousUser () {
-    let videoLanguages: string[]
-
-    try {
-      videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
-    } catch (err) {
-      videoLanguages = null
-      console.error('Cannot parse desired video languages from localStorage.', err)
-    }
-
-    return new User({
-      // local storage keys
-      nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
-      webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
-      theme: this.localStorageService.getItem(User.KEYS.THEME) || 'instance-default',
-      videoLanguages,
-
-      autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
-      autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
-
-      // session storage keys
-      autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
-    })
-  }
-
-  getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    if (search) params = params.append('search', search)
-
-    return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
-               .pipe(
-                 map(res => this.restExtractor.convertResultListDateToHuman(res)),
-                 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  removeUser (usersArg: UserServerModel | UserServerModel[]) {
-    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
-    return from(users)
-      .pipe(
-        concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
-        toArray(),
-        catchError(err => this.restExtractor.handleError(err))
-      )
-  }
-
-  banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
-    const body = reason ? { reason } : {}
-    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
-    return from(users)
-      .pipe(
-        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
-        toArray(),
-        catchError(err => this.restExtractor.handleError(err))
-      )
-  }
-
-  unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
-    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
-    return from(users)
-      .pipe(
-        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
-        toArray(),
-        catchError(err => this.restExtractor.handleError(err))
-      )
-  }
-
-  getAnonymousOrLoggedUser () {
-    if (!this.authService.isLoggedIn()) {
-      return of(this.getAnonymousUser())
-    }
-
-    return this.authService.userInformationLoaded
-        .pipe(
-          first(),
-          map(() => this.authService.getUser())
-        )
-  }
-
-  private formatUser (user: UserServerModel) {
-    let videoQuota
-    if (user.videoQuota === -1) {
-      videoQuota = this.i18n('Unlimited')
-    } else {
-      videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
-    }
-
-    const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
-
-    const roleLabels: { [ id in UserRole ]: string } = {
-      [UserRole.USER]: this.i18n('User'),
-      [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
-      [UserRole.MODERATOR]: this.i18n('Moderator')
-    }
-
-    return Object.assign(user, {
-      roleLabel: roleLabels[user.role],
-      videoQuota,
-      videoQuotaUsed
-    })
-  }
-}
diff --git a/client/src/app/shared/video-abuse/index.ts b/client/src/app/shared/video-abuse/index.ts
deleted file mode 100644 (file)
index 92cbfb5..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './video-abuse.service'
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts
deleted file mode 100644 (file)
index 43f4674..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { SortMeta } from 'primeng/api'
-import { Observable } from 'rxjs'
-import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { omit } from 'lodash-es'
-
-@Injectable()
-export class VideoAbuseService {
-  private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor
-  ) {}
-
-  getVideoAbuses (options: {
-    pagination: RestPagination,
-    sort: SortMeta,
-    search?: string
-  }): Observable<ResultList<VideoAbuse>> {
-    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<ResultList<VideoAbuse>>(url, { params })
-               .pipe(
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  reportVideo (parameters: { id: number } & VideoAbuseCreate) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
-
-    const body = omit(parameters, [ 'id' ])
-
-    return this.authHttp.post(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
-    return this.authHttp.put(url, abuseUpdate)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  removeVideoAbuse (videoAbuse: VideoAbuse) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
-    return this.authHttp.delete(url)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }}
diff --git a/client/src/app/shared/video-block/index.ts b/client/src/app/shared/video-block/index.ts
deleted file mode 100644 (file)
index a99551a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './video-block.service'
diff --git a/client/src/app/shared/video-block/video-block.service.ts b/client/src/app/shared/video-block/video-block.service.ts
deleted file mode 100644 (file)
index d0673dd..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-import { catchError, map, concatMap, toArray } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { SortMeta } from 'primeng/api'
-import { from as observableFrom, Observable } from 'rxjs'
-import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-
-@Injectable()
-export class VideoBlockService {
-  private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor
-  ) {}
-
-  listBlocks (options: {
-    pagination: RestPagination
-    sort: SortMeta
-    search?: string
-    type?: VideoBlacklistType
-  }): Observable<ResultList<VideoBlacklist>> {
-    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<ResultList<VideoBlacklist>>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params })
-               .pipe(
-                 map(res => this.restExtractor.convertResultListDateToHuman(res)),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  unblockVideo (videoIdArgs: number | number[]) {
-    const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ]
-
-    return observableFrom(videoIds)
-      .pipe(
-        concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')),
-        toArray(),
-        catchError(err => this.restExtractor.handleError(err))
-      )
-  }
-
-  blockVideo (videoId: number, reason: string, unfederate: boolean) {
-    const body = {
-      unfederate,
-      reason
-    }
-
-    return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-}
diff --git a/client/src/app/shared/video-caption/index.ts b/client/src/app/shared/video-caption/index.ts
deleted file mode 100644 (file)
index c48a705..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './video-caption.service'
diff --git a/client/src/app/shared/video-caption/video-caption-edit.model.ts b/client/src/app/shared/video-caption/video-caption-edit.model.ts
deleted file mode 100644 (file)
index 732f201..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-export interface VideoCaptionEdit {
-  language: {
-    id: string
-    label?: string
-  }
-
-  action?: 'CREATE' | 'REMOVE'
-  captionfile?: any
-}
diff --git a/client/src/app/shared/video-caption/video-caption.service.ts b/client/src/app/shared/video-caption/video-caption.service.ts
deleted file mode 100644 (file)
index 6bfe674..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable, of } from 'rxjs'
-import { peertubeTranslate, ResultList } from '../../../../../shared'
-import { RestExtractor } from '../rest'
-import { VideoService } from '@app/shared/video/video.service'
-import { objectToFormData, sortBy } from '@app/shared/misc/utils'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoCaption } from '../../../../../shared/models/videos/caption/video-caption.model'
-import { ServerService } from '@app/core'
-
-@Injectable()
-export class VideoCaptionService {
-  constructor (
-    private authHttp: HttpClient,
-    private serverService: ServerService,
-    private restExtractor: RestExtractor
-  ) {}
-
-  listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
-    return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
-               .pipe(
-                 switchMap(captionsResult => {
-                   return this.serverService.getServerLocale()
-                     .pipe(map(translations => ({ captionsResult, translations })))
-                 }),
-                 map(({ captionsResult, translations }) => {
-                   for (const c of captionsResult.data) {
-                     c.language.label = peertubeTranslate(c.language.label, translations)
-                   }
-
-                   return captionsResult
-                 }),
-                 map(captionsResult => {
-                   sortBy(captionsResult.data, 'language', 'label')
-
-                   return captionsResult
-                 })
-               )
-               .pipe(catchError(res => this.restExtractor.handleError(res)))
-  }
-
-  removeCaption (videoId: number | string, language: string) {
-    return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  addCaption (videoId: number | string, language: string, captionfile: File) {
-    const body = { captionfile }
-    const data = objectToFormData(body)
-
-    return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
-    let obs = of(true)
-
-    for (const videoCaption of videoCaptions) {
-      if (videoCaption.action === 'CREATE') {
-        obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)))
-      } else if (videoCaption.action === 'REMOVE') {
-        obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id)))
-      }
-    }
-
-    return obs
-  }
-}
diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts
deleted file mode 100644 (file)
index 2f45973..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos'
-import { Actor } from '../actor/actor.model'
-import { Account } from '../../../../../shared/models/actors'
-
-export class VideoChannel extends Actor implements ServerVideoChannel {
-  displayName: string
-  description: string
-  support: string
-  isLocal: boolean
-  nameWithHost: string
-  nameWithHostForced: string
-
-  ownerAccount?: Account
-  ownerBy?: string
-  ownerAvatarUrl?: string
-
-  videosCount?: number
-
-  viewsPerDay?: ViewsPerDate[]
-
-  constructor (hash: ServerVideoChannel) {
-    super(hash)
-
-    this.displayName = hash.displayName
-    this.description = hash.description
-    this.support = hash.support
-    this.isLocal = hash.isLocal
-    this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
-    this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
-
-    this.videosCount = hash.videosCount
-
-    if (hash.viewsPerDay) {
-      this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
-    }
-
-    if (hash.ownerAccount) {
-      this.ownerAccount = hash.ownerAccount
-      this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
-      this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
-    }
-  }
-}
diff --git a/client/src/app/shared/video-channel/video-channel.service.ts b/client/src/app/shared/video-channel/video-channel.service.ts
deleted file mode 100644 (file)
index 0e036bd..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-import { catchError, map, tap } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { Observable, ReplaySubject } from 'rxjs'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '../../../../../shared/models/videos'
-import { AccountService } from '../account/account.service'
-import { ResultList } from '../../../../../shared'
-import { VideoChannel } from './video-channel.model'
-import { environment } from '../../../environments/environment'
-import { Account } from '@app/shared/account/account.model'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { RestService } from '@app/shared/rest'
-
-@Injectable()
-export class VideoChannelService {
-  static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/'
-
-  videoChannelLoaded = new ReplaySubject<VideoChannel>(1)
-
-  static extractVideoChannels (result: ResultList<VideoChannelServer>) {
-    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<VideoChannel>(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<ResultList<VideoChannel>> {
-    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<ResultList<VideoChannelServer>>(url, { params })
-               .pipe(
-                 map(res => VideoChannelService.extractVideoChannels(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  createVideoChannel (videoChannel: VideoChannelCreate) {
-    return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) {
-    return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) {
-    const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick'
-
-    return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-
-  removeVideoChannel (videoChannel: VideoChannel) {
-    return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-}
diff --git a/client/src/app/shared/video-import/index.ts b/client/src/app/shared/video-import/index.ts
deleted file mode 100644 (file)
index 9bb73ec..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './video-import.service'
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts
deleted file mode 100644 (file)
index afd9e3f..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { peertubeTranslate, VideoImport } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/videos'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { ResultList } from '../../../../../shared/models/result-list.model'
-import { UserService } from '@app/shared/users/user.service'
-import { SortMeta } from 'primeng/api'
-import { RestPagination } from '@app/shared/rest'
-import { ServerService } from '@app/core'
-
-@Injectable()
-export class VideoImportService {
-  private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor,
-    private serverService: ServerService
-  ) {}
-
-  importVideoUrl (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
-    const url = VideoImportService.BASE_VIDEO_IMPORT_URL
-
-    const body = this.buildImportVideoObject(video)
-    body.targetUrl = targetUrl
-
-    const data = objectToFormData(body)
-    return this.authHttp.post<VideoImport>(url, data)
-               .pipe(catchError(res => this.restExtractor.handleError(res)))
-  }
-
-  importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable<VideoImport> {
-    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<VideoImport>(url, data)
-               .pipe(catchError(res => this.restExtractor.handleError(res)))
-  }
-
-  getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    return this.authHttp
-               .get<ResultList<VideoImport>>(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<VideoImport>): Observable<ResultList<VideoImport>> {
-    return this.serverService.getServerLocale()
-               .pipe(
-                 map(translations => {
-                   result.data.forEach(d =>
-                     d.state.label = peertubeTranslate(d.state.label, translations)
-                   )
-
-                   return result
-                 })
-               )
-  }
-}
diff --git a/client/src/app/shared/video-ownership/index.ts b/client/src/app/shared/video-ownership/index.ts
deleted file mode 100644 (file)
index fe8902e..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './video-ownership.service'
diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts
deleted file mode 100644 (file)
index b95d5b7..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos'
-import { Observable } from 'rxjs/index'
-import { SortMeta } from 'primeng/api'
-import { ResultList, VideoChangeOwnership } from '../../../../../shared'
-import { RestPagination } from '@app/shared/rest'
-import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model'
-
-@Injectable()
-export class VideoOwnershipService {
-  private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor
-  ) {
-  }
-
-  changeOwnership (id: number, username: string) {
-    const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership'
-    const body: VideoChangeOwnershipCreate = {
-      username
-    }
-
-    return this.authHttp.post(url, body)
-      .pipe(
-        map(this.restExtractor.extractDataBool),
-        catchError(res => this.restExtractor.handleError(res))
-      )
-  }
-
-  getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoChangeOwnership>> {
-    const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership'
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    return this.authHttp.get<ResultList<VideoChangeOwnership>>(url, { params })
-      .pipe(
-        map(res => this.restExtractor.convertResultListDateToHuman(res)),
-        catchError(res => this.restExtractor.handleError(res))
-      )
-  }
-
-  acceptOwnership (id: number, input: VideoChangeOwnershipAccept) {
-    const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept'
-    return this.authHttp.post(url, input)
-      .pipe(
-        map(this.restExtractor.extractDataBool),
-        catchError(this.restExtractor.handleError)
-      )
-  }
-
-  refuseOwnership (id: number) {
-    const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse'
-    return this.authHttp.post(url, {})
-      .pipe(
-        map(this.restExtractor.extractDataBool),
-        catchError(this.restExtractor.handleError)
-      )
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
deleted file mode 100644 (file)
index a40e069..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-<div class="root">
-  <div class="header">
-    <div class="first-row">
-      <div i18n class="title">Save to</div>
-
-      <div class="options" (click)="displayOptions = !displayOptions">
-        <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
-
-        <span i18n>Options</span>
-      </div>
-    </div>
-
-    <div class="options-row" *ngIf="displayOptions">
-      <div>
-        <my-peertube-checkbox
-          inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
-          i18n-labelText labelText="Start at"
-        ></my-peertube-checkbox>
-
-        <my-timestamp-input
-          [timestamp]="timestampOptions.startTimestamp"
-          [maxTimestamp]="video.duration"
-          [disabled]="!timestampOptions.startTimestampEnabled"
-          [(ngModel)]="timestampOptions.startTimestamp"
-        ></my-timestamp-input>
-      </div>
-
-      <div>
-        <my-peertube-checkbox
-          inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
-          i18n-labelText labelText="Stop at"
-        ></my-peertube-checkbox>
-
-        <my-timestamp-input
-          [timestamp]="timestampOptions.stopTimestamp"
-          [maxTimestamp]="video.duration"
-          [disabled]="!timestampOptions.stopTimestampEnabled"
-          [(ngModel)]="timestampOptions.stopTimestamp"
-        ></my-timestamp-input>
-      </div>
-    </div>
-  </div>
-
-  <div class="input-container">
-    <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
-  </div>
-
-  <div class="playlists">
-    <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
-      <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
-
-      <div class="display-name">
-        {{ playlist.displayName }}
-
-        <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
-          {{ formatTimestamp(playlist) }}
-        </div>
-      </div>
-    </div>
-  </div>
-
-  <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
-    <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
-
-    <span i18n>Create a private playlist</span>
-  </div>
-
-  <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
-    <div class="form-group">
-      <label i18n for="displayName">Display name</label>
-      <input
-        type="text" id="displayName"
-        formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
-      >
-      <div *ngIf="formErrors['displayName']" class="form-error">
-        {{ formErrors['displayName'] }}
-      </div>
-    </div>
-
-    <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
-  </form>
-</div>
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
deleted file mode 100644 (file)
index 47baa99..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.header,
-.dropdown-item,
-.input-container {
-  padding: 8px 24px;
-}
-
-.header {
-  min-width: 240px;
-  margin-bottom: 10px;
-  border-bottom: 1px solid $separator-border-color;
-
-  .first-row {
-    display: flex;
-    align-items: center;
-
-    .title {
-      font-size: 18px;
-      flex-grow: 1;
-    }
-
-    .options {
-      display: flex;
-      align-items: center;
-      font-size: 14px;
-      cursor: pointer;
-
-      my-global-icon {
-        @include apply-svg-color(#333);
-
-        width: 16px;
-        height: 23px;
-        margin-right: 3px;
-      }
-    }
-  }
-
-  .options-row {
-    margin-top: 10px;
-    padding-left: 10px;
-
-    > div {
-      display: flex;
-      align-items: center;
-    }
-  }
-}
-
-.playlists {
-  max-height: 180px;
-  overflow-y: auto;
-}
-
-.playlist {
-  display: inline-flex;
-  cursor: pointer;
-
-  my-peertube-checkbox {
-    margin-right: 10px;
-    align-self: center;
-  }
-
-  .display-name {
-    display: flex;
-    align-items: flex-end;
-
-    .timestamp-info {
-      font-size: 0.9em;
-      color: pvar(--greyForegroundColor);
-      margin-left: 5px;
-    }
-  }
-}
-
-.new-playlist-button,
-.new-playlist-block {
-  padding-top: 10px;
-  border-top: 1px solid $separator-border-color;
-}
-
-.new-playlist-button  {
-  cursor: pointer;
-
-  my-global-icon {
-    @include apply-svg-color(#333);
-
-    position: relative;
-    left: -1px;
-    top: -1px;
-    margin-right: 4px;
-    width: 21px;
-    height: 21px;
-  }
-}
-
-input[type=text] {
-  @include peertube-input-text(200px);
-
-  display: block;
-}
-
-input[type=submit] {
-  @include peertube-button;
-  @include orange-button;
-}
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
deleted file mode 100644 (file)
index 0c593a7..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
-import { CachedPlaylist, VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { AuthService, Notifier } from '@app/core'
-import { Subject, Subscription } from 'rxjs'
-import { debounceTime, filter } from 'rxjs/operators'
-import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
-import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { secondsToTime } from '../../../assets/player/utils'
-import * as debug from 'debug'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
-
-const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
-
-type PlaylistSummary = {
-  id: number
-  inPlaylist: boolean
-  displayName: string
-
-  playlistElementId?: number
-  startTimestamp?: number
-  stopTimestamp?: number
-}
-
-@Component({
-  selector: 'my-video-add-to-playlist',
-  styleUrls: [ './video-add-to-playlist.component.scss' ],
-  templateUrl: './video-add-to-playlist.component.html',
-  changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
-  @Input() video: Video
-  @Input() currentVideoTimestamp: number
-  @Input() lazyLoad = false
-
-  isNewPlaylistBlockOpened = false
-  videoPlaylistSearch: string
-  videoPlaylistSearchChanged = new Subject<string>()
-  videoPlaylists: PlaylistSummary[] = []
-  timestampOptions: {
-    startTimestampEnabled: boolean
-    startTimestamp: number
-    stopTimestampEnabled: boolean
-    stopTimestamp: number
-  }
-  displayOptions = false
-
-  private disabled = false
-
-  private listenToPlaylistChangeSub: Subscription
-  private playlistsData: CachedPlaylist[] = []
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private authService: AuthService,
-    private notifier: Notifier,
-    private i18n: I18n,
-    private videoPlaylistService: VideoPlaylistService,
-    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
-    private cd: ChangeDetectorRef
-  ) {
-    super()
-  }
-
-  get user () {
-    return this.authService.getUser()
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
-    })
-
-    this.videoPlaylistService.listenToMyAccountPlaylistsChange()
-        .subscribe(result => {
-          this.playlistsData = result.data
-
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
-        })
-
-    this.videoPlaylistSearchChanged
-        .pipe(debounceTime(500))
-        .subscribe(() => this.load())
-
-    if (this.lazyLoad === false) this.load()
-  }
-
-  ngOnChanges (simpleChanges: SimpleChanges) {
-    if (simpleChanges['video']) {
-      this.reload()
-    }
-  }
-
-  ngOnDestroy () {
-    this.unsubscribePlaylistChanges()
-  }
-
-  disableForReuse () {
-    this.disabled = true
-  }
-
-  enabledForReuse () {
-    this.disabled = false
-  }
-
-  reload () {
-    logger('Reloading component')
-
-    this.videoPlaylists = []
-    this.videoPlaylistSearch = undefined
-
-    this.resetOptions(true)
-    this.load()
-
-    this.cd.markForCheck()
-  }
-
-  load () {
-    logger('Loading component')
-
-    this.listenToPlaylistChanges()
-
-    this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
-        .subscribe(playlistsResult => {
-          this.playlistsData = playlistsResult.data
-
-          this.videoPlaylistService.runPlaylistCheck(this.video.id)
-        })
-  }
-
-  openChange (opened: boolean) {
-    if (opened === false) {
-      this.isNewPlaylistBlockOpened = false
-      this.displayOptions = false
-    }
-  }
-
-  openCreateBlock (event: Event) {
-    event.preventDefault()
-
-    this.isNewPlaylistBlockOpened = true
-  }
-
-  togglePlaylist (event: Event, playlist: PlaylistSummary) {
-    event.preventDefault()
-
-    if (playlist.inPlaylist === true) {
-      this.removeVideoFromPlaylist(playlist)
-    } else {
-      this.addVideoInPlaylist(playlist)
-    }
-
-    playlist.inPlaylist = !playlist.inPlaylist
-    this.resetOptions()
-
-    this.cd.markForCheck()
-  }
-
-  createPlaylist () {
-    const displayName = this.form.value[ 'displayName' ]
-
-    const videoPlaylistCreate: VideoPlaylistCreate = {
-      displayName,
-      privacy: VideoPlaylistPrivacy.PRIVATE
-    }
-
-    this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
-      () => {
-        this.isNewPlaylistBlockOpened = false
-
-        this.cd.markForCheck()
-      },
-
-      err => this.notifier.error(err.message)
-    )
-  }
-
-  resetOptions (resetTimestamp = false) {
-    this.displayOptions = false
-
-    this.timestampOptions = {} as any
-    this.timestampOptions.startTimestampEnabled = false
-    this.timestampOptions.stopTimestampEnabled = false
-
-    if (resetTimestamp) {
-      this.timestampOptions.startTimestamp = 0
-      this.timestampOptions.stopTimestamp = this.video.duration
-    }
-  }
-
-  formatTimestamp (playlist: PlaylistSummary) {
-    const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
-    const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
-
-    return `(${start}-${stop})`
-  }
-
-  onVideoPlaylistSearchChanged () {
-    this.videoPlaylistSearchChanged.next()
-  }
-
-  private removeVideoFromPlaylist (playlist: PlaylistSummary) {
-    if (!playlist.playlistElementId) return
-
-    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
-        .subscribe(
-          () => {
-            this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
-          },
-
-          err => {
-            this.notifier.error(err.message)
-          },
-
-          () => this.cd.markForCheck()
-        )
-  }
-
-  private listenToPlaylistChanges () {
-    this.unsubscribePlaylistChanges()
-
-    this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
-                                         .pipe(filter(() => this.disabled === false))
-                                         .subscribe(existResult => this.rebuildPlaylists(existResult))
-  }
-
-  private unsubscribePlaylistChanges () {
-    if (this.listenToPlaylistChangeSub) {
-      this.listenToPlaylistChangeSub.unsubscribe()
-      this.listenToPlaylistChangeSub = undefined
-    }
-  }
-
-  private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
-    logger('Got existing results for %d.', this.video.id, existResult)
-
-    this.videoPlaylists = []
-    for (const playlist of this.playlistsData) {
-      const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
-
-      this.videoPlaylists.push({
-        id: playlist.id,
-        displayName: playlist.displayName,
-        inPlaylist: !!existingPlaylist,
-        playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
-        startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
-        stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
-      })
-    }
-
-    logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
-
-    this.cd.markForCheck()
-  }
-
-  private addVideoInPlaylist (playlist: PlaylistSummary) {
-    const body: VideoPlaylistElementCreate = { videoId: this.video.id }
-
-    if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
-    if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
-
-    this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
-      .subscribe(
-        () => {
-          const message = body.startTimestamp || body.stopTimestamp
-            ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
-            : this.i18n('Video added in {{n}}', { n: playlist.displayName })
-
-          this.notifier.success(message)
-        },
-
-        err => {
-          this.notifier.error(err.message)
-        },
-
-        () => this.cd.markForCheck()
-      )
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
deleted file mode 100644 (file)
index e3f7ef0..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-<div class="video" [ngClass]="{ playing: playing }">
-  <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
-    <div class="position">
-      <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
-      <ng-container *ngIf="!playing">{{ position }}</ng-container>
-    </div>
-
-    <my-video-thumbnail
-      *ngIf="playlistElement.video"
-      [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
-      [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
-    ></my-video-thumbnail>
-
-    <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
-
-    <div class="video-info">
-      <ng-container *ngIf="playlistElement.video">
-        <a tabindex="-1" class="video-info-name"
-          [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
-          [attr.title]="playlistElement.video.name"
-        >{{ playlistElement.video.name }}</a>
-
-        <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
-          {{ playlistElement.video.byAccount }}
-        </a>
-        <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
-
-        <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
-      </ng-container>
-
-      <span *ngIf="!playlistElement.video" class="video-info-name">
-        <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
-        <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
-        <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
-      </span>
-    </div>
-  </a>
-
-  <my-edit-button *ngIf="owned && touchScreenEditButton" [routerLink]="[ '/my-account', 'video-playlists', playlist.uuid ]"></my-edit-button>
-
-  <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom auto"
-       (openChange)="onDropdownOpenChange()" autoClose="outside"
-  >
-    <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
-
-    <div ngbDropdownMenu>
-      <ng-container *ngIf="playlistElement.video">
-        <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
-          <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Edit starts/stops at</ng-container>
-        </div>
-
-        <div class="timestamp-options" *ngIf="displayTimestampOptions">
-          <div>
-            <my-peertube-checkbox
-              inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
-              i18n-labelText labelText="Start at"
-            ></my-peertube-checkbox>
-
-            <my-timestamp-input
-              [timestamp]="timestampOptions.startTimestamp"
-              [maxTimestamp]="playlistElement.video.duration"
-              [disabled]="!timestampOptions.startTimestampEnabled"
-              [(ngModel)]="timestampOptions.startTimestamp"
-            ></my-timestamp-input>
-          </div>
-
-          <div>
-            <my-peertube-checkbox
-              inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
-              i18n-labelText labelText="Stop at"
-            ></my-peertube-checkbox>
-
-            <my-timestamp-input
-              [timestamp]="timestampOptions.stopTimestamp"
-              [maxTimestamp]="playlistElement.video.duration"
-              [disabled]="!timestampOptions.stopTimestampEnabled"
-              [(ngModel)]="timestampOptions.stopTimestamp"
-            ></my-timestamp-input>
-          </div>
-
-          <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
-        </div>
-      </ng-container>
-
-      <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
-        <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
-        <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
-      </span>
-    </div>
-  </div>
-</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
deleted file mode 100644 (file)
index afd775b..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-$thumbnail-width: 130px;
-$thumbnail-height: 72px;
-
-my-video-thumbnail {
-  @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
-}
-
-.fake-thumbnail {
-  width: $thumbnail-width;
-  height: $thumbnail-height;
-  background-color: #ececec;
-}
-
-my-video-thumbnail,
-.fake-thumbnail {
-  display: flex; // Avoids an issue with line-height that adds space below the element
-  margin-right: 10px;
-}
-
-.video {
-  display: flex;
-  align-items: center;
-  background-color: pvar(--mainBackgroundColor);
-  padding: 10px;
-  border-bottom: 1px solid $separator-border-color;
-
-  &:hover {
-    background-color: rgba(0, 0, 0, 0.05);
-
-    .more {
-      opacity: 1;
-    }
-  }
-
-  @media not all and (hover: hover) and (pointer: fine) {
-    .more {
-      opacity: 1 !important;
-    }
-  }
-
-  &.playing {
-    background-color: rgba(0, 0, 0, 0.02);
-  }
-
-  a {
-    @include disable-default-a-behaviour;
-
-    color: pvar(--mainForegroundColor);
-    display: flex;
-    min-width: 0;
-    align-items: center;
-    cursor: pointer;
-
-    .position {
-      font-weight: $font-semibold;
-      margin-right: 10px;
-      color: pvar(--greyForegroundColor);
-      min-width: 25px;
-
-      my-global-icon {
-        @include apply-svg-color(pvar(--greyForegroundColor));
-
-        width: 17px;
-        position: relative;
-        left: -2px;
-      }
-    }
-
-    .video-info {
-      display: flex;
-      flex-direction: column;
-      align-self: flex-start;
-      min-width: 0;
-
-      a {
-        width: auto;
-      }
-
-      .video-info-account, .video-info-timestamp {
-        color: pvar(--greyForegroundColor);
-      }
-    }
-  }
-
-  .video-info-name {
-    font-size: 18px;
-    font-weight: $font-semibold;
-    display: inline-block;
-
-    @include ellipsis;
-  }
-
-  .more, my-edit-button {
-    justify-self: flex-end;
-    margin-left: auto;
-    cursor: pointer;
-    min-width: 24px;
-  }
-
-  .more {
-    opacity: 0;
-
-    &.show {
-      opacity: 1;
-    }
-
-    .icon-more {
-      @include apply-svg-color(pvar(--greyForegroundColor));
-
-      display: flex;
-
-      &::after {
-        border: none;
-      }
-    }
-
-    .dropdown-item {
-      @include dropdown-with-icon-item;
-    }
-
-    .timestamp-options {
-      padding-top: 0;
-      padding-left: 35px;
-      margin-bottom: 15px;
-
-      > div {
-        display: flex;
-        align-items: center;
-      }
-
-      input {
-        @include peertube-button;
-        @include orange-button;
-
-        margin-top: 10px;
-      }
-    }
-  }
-}
-
-@mixin more-dropdown-control {
-  .video {
-    my-edit-button {
-      display: none;
-
-      + .more {
-        display: inline-flex;
-      }
-    }
-  }
-}
-
-@mixin edit-button-control {
-  .video {
-    my-edit-button {
-      display: none;
-    }
-
-    &.playing {
-      my-edit-button {
-        display: inline-flex;
-        height: max-content;
-      }
-    }
-
-    my-edit-button + .more {
-      display: none;
-    }
-  }
-}
-
-@mixin edit-button-in-mobile-view {
-  .video {
-    my-edit-button {
-      ::ng-deep .action-button-edit {
-        padding: 0 13px;
-
-        .button-label {
-          display: none;
-        }
-      }
-    }
-  }
-}
-
-@media screen and (min-width: $small-view) {
-  :host-context(.expanded) {
-    @include more-dropdown-control();
-  }
-}
-
-@media screen and (max-width: $small-view) {
-  :host-context(.expanded) {
-    @include edit-button-control();
-  }
-}
-
-@media screen and (max-width: $mobile-view) {
-  :host-context(.expanded) {
-    @include edit-button-in-mobile-view();
-  }
-}
-
-@media screen and (min-width: #{$small-view + $menu-width}) {
-  :host-context(.main-col:not(.expanded)) {
-    @include more-dropdown-control();
-  }
-}
-
-@media screen and (max-width: #{$small-view + $menu-width}) {
-  :host-context(.main-col:not(.expanded)) {
-    @include edit-button-control();
-  }
-}
-
-@media screen and (max-width: #{$mobile-view + $menu-width}) {
-  :host-context(.main-col:not(.expanded)) {
-    @include edit-button-in-mobile-view();
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
deleted file mode 100644 (file)
index fad03e0..0000000
+++ /dev/null
@@ -1,187 +0,0 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { Video } from '@app/shared/video/video.model'
-import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
-import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
-import { ActivatedRoute } from '@angular/router'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { secondsToTime } from '../../../assets/player/utils'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-
-@Component({
-  selector: 'my-video-playlist-element-miniature',
-  styleUrls: [ './video-playlist-element-miniature.component.scss' ],
-  templateUrl: './video-playlist-element-miniature.component.html',
-  changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoPlaylistElementMiniatureComponent implements OnInit {
-  @ViewChild('moreDropdown') moreDropdown: NgbDropdown
-
-  @Input() playlist: VideoPlaylist
-  @Input() playlistElement: VideoPlaylistElement
-  @Input() owned = false
-  @Input() playing = false
-  @Input() rowLink = false
-  @Input() accountLink = true
-  @Input() position: number // Keep this property because we're in the OnPush change detection strategy
-  @Input() touchScreenEditButton = false
-
-  @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
-
-  displayTimestampOptions = false
-
-  timestampOptions: {
-    startTimestampEnabled: boolean
-    startTimestamp: number
-    stopTimestampEnabled: boolean
-    stopTimestamp: number
-  } = {} as any
-
-  private serverConfig: ServerConfig
-
-  constructor (
-    private authService: AuthService,
-    private serverService: ServerService,
-    private notifier: Notifier,
-    private confirmService: ConfirmService,
-    private route: ActivatedRoute,
-    private i18n: I18n,
-    private videoService: VideoService,
-    private videoPlaylistService: VideoPlaylistService,
-    private cdr: ChangeDetectorRef
-  ) {}
-
-  ngOnInit (): void {
-    this.serverConfig = this.serverService.getTmpConfig()
-    this.serverService.getConfig()
-        .subscribe(config => {
-          this.serverConfig = config
-          this.cdr.detectChanges()
-        })
-  }
-
-  isUnavailable (e: VideoPlaylistElement) {
-    return e.type === VideoPlaylistElementType.UNAVAILABLE
-  }
-
-  isPrivate (e: VideoPlaylistElement) {
-    return e.type === VideoPlaylistElementType.PRIVATE
-  }
-
-  isDeleted (e: VideoPlaylistElement) {
-    return e.type === VideoPlaylistElementType.DELETED
-  }
-
-  buildRouterLink () {
-    if (!this.playlist) return null
-
-    return [ '/videos/watch/playlist', this.playlist.uuid ]
-  }
-
-  buildRouterQuery () {
-    if (!this.playlistElement || !this.playlistElement.video) return {}
-
-    return {
-      videoId: this.playlistElement.video.uuid,
-      start: this.playlistElement.startTimestamp,
-      stop: this.playlistElement.stopTimestamp,
-      resume: true
-    }
-  }
-
-  isVideoBlur (video: Video) {
-    return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
-  }
-
-  removeFromPlaylist (playlistElement: VideoPlaylistElement) {
-    const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
-
-    this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
-        .subscribe(
-          () => {
-            this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
-
-            this.elementRemoved.emit(playlistElement)
-          },
-
-          err => this.notifier.error(err.message)
-        )
-
-    this.moreDropdown.close()
-  }
-
-  updateTimestamps (playlistElement: VideoPlaylistElement) {
-    const body: VideoPlaylistElementUpdate = {}
-
-    body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
-    body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
-
-    this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
-        .subscribe(
-          () => {
-            this.notifier.success(this.i18n('Timestamps updated'))
-
-            playlistElement.startTimestamp = body.startTimestamp
-            playlistElement.stopTimestamp = body.stopTimestamp
-
-            this.cdr.detectChanges()
-          },
-
-          err => this.notifier.error(err.message)
-        )
-
-    this.moreDropdown.close()
-  }
-
-  formatTimestamp (playlistElement: VideoPlaylistElement) {
-    const start = playlistElement.startTimestamp
-    const stop = playlistElement.stopTimestamp
-
-    const startFormatted = secondsToTime(start, true, ':')
-    const stopFormatted = secondsToTime(stop, true, ':')
-
-    if (start === null && stop === null) return ''
-
-    if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
-    if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
-
-    return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
-  }
-
-  onDropdownOpenChange () {
-    this.displayTimestampOptions = false
-  }
-
-  toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
-    event.preventDefault()
-
-    this.displayTimestampOptions = !this.displayTimestampOptions
-
-    if (this.displayTimestampOptions === true) {
-      this.timestampOptions = {
-        startTimestampEnabled: false,
-        stopTimestampEnabled: false,
-        startTimestamp: 0,
-        stopTimestamp: playlistElement.video.duration
-      }
-
-      if (playlistElement.startTimestamp) {
-        this.timestampOptions.startTimestampEnabled = true
-        this.timestampOptions.startTimestamp = playlistElement.startTimestamp
-      }
-
-      if (playlistElement.stopTimestamp) {
-        this.timestampOptions.stopTimestampEnabled = true
-        this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
-      }
-    }
-
-    // FIXME: why do we have to use setTimeout here?
-    setTimeout(() => {
-      this.cdr.detectChanges()
-    })
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element.model.ts b/client/src/app/shared/video-playlist/video-playlist-element.model.ts
deleted file mode 100644 (file)
index f1c46d1..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
-import { Video } from '@app/shared/video/video.model'
-
-export class VideoPlaylistElement implements ServerVideoPlaylistElement {
-  id: number
-  position: number
-  startTimestamp: number
-  stopTimestamp: number
-
-  type: VideoPlaylistElementType
-
-  video?: Video
-
-  constructor (hash: ServerVideoPlaylistElement, translations: {}) {
-    this.id = hash.id
-    this.position = hash.position
-    this.startTimestamp = hash.startTimestamp
-    this.stopTimestamp = hash.stopTimestamp
-
-    this.type = hash.type
-
-    if (hash.video) this.video = new Video(hash.video, translations)
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
deleted file mode 100644 (file)
index 86f6664..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
-  <a
-    [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
-    class="miniature-thumbnail"
-  >
-    <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
-
-    <div class="miniature-playlist-info-overlay">
-      <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
-    </div>
-
-    <div class="play-overlay">
-      <div class="icon"></div>
-    </div>
-  </a>
-
-  <div class="miniature-info">
-    <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
-      {{ playlist.displayName }}
-    </a>
-
-    <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
-      {{ playlist.videoChannelBy }}
-    </a>
-
-    <div class="privacy-date">
-      <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
-
-      <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
-    </div>
-
-    <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
-  </div>
-</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
deleted file mode 100644 (file)
index 1b16dbb..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.miniature {
-  display: inline-block;
-
-  &.no-videos:not(.to-manage){
-    a {
-      cursor: default !important;
-    }
-  }
-
-  &.to-manage,
-  &.no-videos {
-    .play-overlay {
-      display: none;
-    }
-  }
-
-  .miniature-thumbnail {
-    @include miniature-thumbnail;
-
-    .miniature-playlist-info-overlay {
-      @include static-thumbnail-overlay;
-
-      position: absolute;
-      right: 0;
-      bottom: 0;
-      height: $video-thumbnail-height;
-      padding: 0 10px;
-      display: flex;
-      align-items: center;
-      font-size: 14px;
-      font-weight: $font-semibold;
-    }
-  }
-
-  .miniature-info {
-    width: 200px;
-    margin-top: 2px;
-    line-height: normal;
-
-    .miniature-name {
-      @include miniature-name;
-
-      @include ellipsis-multiline(1.3em, 2);
-
-      margin: 0;
-    }
-
-    .by {
-      @include disable-default-a-behaviour;
-
-      display: block;
-      color: pvar(--greyForegroundColor);
-    }
-
-    .privacy-date {
-      margin-top: 5px;
-
-      .video-info-privacy {
-        font-size: 14px;
-        font-weight: $font-semibold;
-
-        &::after {
-          content: '-';
-          margin: 0 3px;
-        }
-      }
-    }
-
-    .video-info-description {
-      margin-top: 10px;
-      color: pvar(--greyForegroundColor);
-    }
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
deleted file mode 100644 (file)
index 523e96f..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-
-@Component({
-  selector: 'my-video-playlist-miniature',
-  styleUrls: [ './video-playlist-miniature.component.scss' ],
-  templateUrl: './video-playlist-miniature.component.html'
-})
-export class VideoPlaylistMiniatureComponent {
-  @Input() playlist: VideoPlaylist
-  @Input() toManage = false
-  @Input() displayChannel = false
-  @Input() displayDescription = false
-  @Input() displayPrivacy = false
-
-  getPlaylistUrl () {
-    if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
-    if (this.playlist.videosLength === 0) return null
-
-    return [ '/videos/watch/playlist', this.playlist.uuid ]
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
deleted file mode 100644 (file)
index 6f27e74..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-import {
-  VideoChannelSummary,
-  VideoConstant,
-  VideoPlaylist as ServerVideoPlaylist,
-  VideoPlaylistPrivacy,
-  VideoPlaylistType
-} from '../../../../../shared/models/videos'
-import { AccountSummary, peertubeTranslate } from '@shared/models'
-import { Actor } from '@app/shared/actor/actor.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-
-export class VideoPlaylist implements ServerVideoPlaylist {
-  id: number
-  uuid: string
-  isLocal: boolean
-
-  displayName: string
-  description: string
-  privacy: VideoConstant<VideoPlaylistPrivacy>
-
-  thumbnailPath: string
-
-  videosLength: number
-
-  type: VideoConstant<VideoPlaylistType>
-
-  createdAt: Date | string
-  updatedAt: Date | string
-
-  ownerAccount: AccountSummary
-  videoChannel?: VideoChannelSummary
-
-  thumbnailUrl: string
-
-  ownerBy: string
-  ownerAvatarUrl: string
-
-  videoChannelBy?: string
-  videoChannelAvatarUrl?: string
-
-  private thumbnailVersion: number
-  private originThumbnailUrl: string
-
-  constructor (hash: ServerVideoPlaylist, translations: {}) {
-    const absoluteAPIUrl = getAbsoluteAPIUrl()
-
-    this.id = hash.id
-    this.uuid = hash.uuid
-    this.isLocal = hash.isLocal
-
-    this.displayName = hash.displayName
-
-    this.description = hash.description
-    this.privacy = hash.privacy
-
-    this.thumbnailPath = hash.thumbnailPath
-
-    if (this.thumbnailPath) {
-      this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
-      this.originThumbnailUrl = this.thumbnailUrl
-    } else {
-      this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
-    }
-
-    this.videosLength = hash.videosLength
-
-    this.type = hash.type
-
-    this.createdAt = new Date(hash.createdAt)
-    this.updatedAt = new Date(hash.updatedAt)
-
-    this.ownerAccount = hash.ownerAccount
-    this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
-    this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
-
-    if (hash.videoChannel) {
-      this.videoChannel = hash.videoChannel
-      this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
-      this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
-    }
-
-    this.privacy.label = peertubeTranslate(this.privacy.label, translations)
-
-    if (this.type.id === VideoPlaylistType.WATCH_LATER) {
-      this.displayName = peertubeTranslate(this.displayName, translations)
-    }
-  }
-
-  refreshThumbnail () {
-    if (!this.originThumbnailUrl) return
-
-    if (!this.thumbnailVersion) this.thumbnailVersion = 0
-    this.thumbnailVersion++
-
-    this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
-  }
-}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
deleted file mode 100644 (file)
index 38d915c..0000000
+++ /dev/null
@@ -1,357 +0,0 @@
-import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
-import { Injectable, NgZone } from '@angular/core'
-import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
-import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { AuthUser, ServerService } from '@app/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { Account } from '@app/shared/account/account.model'
-import { RestService } from '@app/shared/rest'
-import { VideoExistInPlaylist, VideosExistInPlaylists } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
-import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-import { uniq } from 'lodash-es'
-import * as debug from 'debug'
-import { enterZone, leaveZone } from '@app/shared/rxjs/zone'
-
-const logger = debug('peertube:playlists:VideoPlaylistService')
-
-export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
-
-@Injectable()
-export class VideoPlaylistService {
-  static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
-  static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
-
-  // Use a replay subject because we "next" a value before subscribing
-  private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
-  private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
-  private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
-
-  private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
-  private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
-
-  private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
-  private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
-  private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
-
-  constructor (
-    private authHttp: HttpClient,
-    private serverService: ServerService,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private ngZone: NgZone
-  ) {
-    this.videoExistsInPlaylistObservable = merge(
-      this.videoExistsInPlaylistNotifier.pipe(
-        // We leave Angular zone so Protractor does not get stuck
-        bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
-        filter(videoIds => videoIds.length !== 0),
-        map(videoIds => uniq(videoIds)),
-        observeOn(enterZone(this.ngZone, asyncScheduler)),
-        switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
-        share()
-      ),
-
-      this.videoExistsInPlaylistCacheSubject
-    )
-  }
-
-  listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
-    const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
-    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination)
-
-    return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
-               .pipe(
-                 switchMap(res => this.extractPlaylists(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  listMyPlaylistWithCache (user: AuthUser, search?: string) {
-    if (!search) {
-      if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
-      if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
-    }
-
-    const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
-               .pipe(
-                 tap(result => {
-                   if (!search) {
-                     this.myAccountPlaylistCacheRunning = undefined
-                     this.myAccountPlaylistCache = result
-                   }
-                 }),
-                 share()
-               )
-
-    if (!search) this.myAccountPlaylistCacheRunning = obs
-    return obs
-  }
-
-  listAccountPlaylists (
-    account: Account,
-    componentPagination: ComponentPaginationLight,
-    sort: string,
-    search?: string
-  ): Observable<ResultList<VideoPlaylist>> {
-    const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
-    const pagination = componentPagination
-      ? this.restService.componentPaginationToRestPagination(componentPagination)
-      : undefined
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-    if (search) params = this.restService.addObjectParams(params, { search })
-
-    return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
-               .pipe(
-                 switchMap(res => this.extractPlaylists(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getVideoPlaylist (id: string | number) {
-    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
-
-    return this.authHttp.get<VideoPlaylist>(url)
-               .pipe(
-                 switchMap(res => this.extractPlaylist(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  createVideoPlaylist (body: VideoPlaylistCreate) {
-    const data = objectToFormData(body)
-
-    return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
-               .pipe(
-                 tap(res => {
-                   if (!this.myAccountPlaylistCache) return
-
-                   this.myAccountPlaylistCache.total++
-
-                   this.myAccountPlaylistCache.data.push({
-                     id: res.videoPlaylist.id,
-                     displayName: body.displayName
-                   })
-
-                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
-    const data = objectToFormData(body)
-
-    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   if (!this.myAccountPlaylistCache) return
-
-                   const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
-                   playlist.displayName = body.displayName
-
-                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
-    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   if (!this.myAccountPlaylistCache) return
-
-                   this.myAccountPlaylistCache.total--
-                   this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
-                                                          .filter(p => p.id !== videoPlaylist.id)
-
-                   this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
-    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
-
-    return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
-               .pipe(
-                 tap(res => {
-                   const existsResult = this.videoExistsCache[body.videoId]
-                   existsResult.push({
-                     playlistId,
-                     playlistElementId: res.videoPlaylistElement.id,
-                     startTimestamp: body.startTimestamp,
-                     stopTimestamp: body.stopTimestamp
-                   })
-
-                   this.runPlaylistCheck(body.videoId)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
-    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   const existsResult = this.videoExistsCache[videoId]
-                   const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
-
-                   elem.startTimestamp = body.startTimestamp
-                   elem.stopTimestamp = body.stopTimestamp
-
-                   this.runPlaylistCheck(videoId)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
-    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 tap(() => {
-                   if (!videoId) return
-
-                   this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
-                   this.runPlaylistCheck(videoId)
-                 }),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
-    const body: VideoPlaylistReorder = {
-      startPosition: oldPosition,
-      insertAfterPosition: newPosition
-    }
-
-    return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getPlaylistVideos (
-    videoPlaylistId: number | string,
-    componentPagination: ComponentPaginationLight
-  ): Observable<ResultList<VideoPlaylistElement>> {
-    const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
-    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination)
-
-    return this.authHttp
-               .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
-               .pipe(
-                 switchMap(res => this.extractVideoPlaylistElements(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  listenToMyAccountPlaylistsChange () {
-    return this.myAccountPlaylistCacheSubject.asObservable()
-  }
-
-  listenToVideoPlaylistChange (videoId: number) {
-    if (this.videoExistsObservableCache[ videoId ]) {
-      return this.videoExistsObservableCache[ videoId ]
-    }
-
-    const obs = this.videoExistsInPlaylistObservable
-                    .pipe(
-                      map(existsResult => existsResult[ videoId ]),
-                      filter(r => !!r),
-                      tap(result => this.videoExistsCache[ videoId ] = result)
-                    )
-
-    this.videoExistsObservableCache[ videoId ] = obs
-    return obs
-  }
-
-  runPlaylistCheck (videoId: number) {
-    logger('Running playlist check.')
-
-    if (this.videoExistsCache[videoId]) {
-      logger('Found cache for %d.', videoId)
-
-      return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
-    }
-
-    logger('Fetching from network for %d.', videoId)
-    return this.videoExistsInPlaylistNotifier.next(videoId)
-  }
-
-  extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
-    return this.serverService.getServerLocale()
-               .pipe(
-                 map(translations => {
-                   const playlistsJSON = result.data
-                   const total = result.total
-                   const playlists: VideoPlaylist[] = []
-
-                   for (const playlistJSON of playlistsJSON) {
-                     playlists.push(new VideoPlaylist(playlistJSON, translations))
-                   }
-
-                   return { data: playlists, total }
-                 })
-               )
-  }
-
-  extractPlaylist (playlist: VideoPlaylistServerModel) {
-    return this.serverService.getServerLocale()
-               .pipe(map(translations => new VideoPlaylist(playlist, translations)))
-  }
-
-  extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
-    return this.serverService.getServerLocale()
-               .pipe(
-                 map(translations => {
-                   const elementsJson = result.data
-                   const total = result.total
-                   const elements: VideoPlaylistElement[] = []
-
-                   for (const elementJson of elementsJson) {
-                     elements.push(new VideoPlaylistElement(elementJson, translations))
-                   }
-
-                   return { total, data: elements }
-                 })
-               )
-  }
-
-  private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
-    const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
-
-    let params = new HttpParams()
-    params = this.restService.addObjectParams(params, { videoIds })
-
-    return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-}
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
deleted file mode 100644 (file)
index 1e919ee..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-<div class="margin-content">
-  <div class="videos-header">
-    <h1 *ngIf="titlePage" class="title-page title-page-single">
-      <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
-        {{ titlePage }}
-      </div>
-      <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
-    </h1>
-
-    <div class="action-block" *ngIf="actions.length > 0">
-      <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
-        <button class="btn">
-          <my-global-icon [iconName]="action.iconName" aria-hidden="true"></my-global-icon>
-          <span>{{ action.label }}</span>
-        </button>
-      </a>
-    </div>
-
-    <div class="moderation-block" *ngIf="displayModerationBlock">
-      <my-peertube-checkbox
-        (change)="toggleModerationDisplay()"
-        inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
-      >
-      </my-peertube-checkbox>
-    </div>
-  </div>
-
-  <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
-  <div
-    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
-    class="videos"
-  >
-    <ng-container *ngFor="let video of videos; trackBy: videoById;">
-      <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
-        {{ getCurrentGroupedDateLabel(video) }}
-      </h2>
-
-      <div class="video-wrapper">
-        <my-video-miniature
-          [fitWidth]="true"
-          [video]="video" [user]="userMiniature" [ownerDisplayType]="ownerDisplayType"
-          [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
-          (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
-        >
-        </my-video-miniature>
-      </div>
-    </ng-container>
-  </div>
-</div>
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
deleted file mode 100644 (file)
index 7f23098..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-@import '_bootstrap-variables';
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.videos-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: baseline;
-
-  .title-page.title-page-single {
-    display: flex;
-
-    my-feed {
-      display: inline-block;
-      top: 1px;
-      margin-left: 5px;
-      width: max-content;
-      opacity: 0;
-      transition: ease-in .2s opacity;
-    }
-    &:hover my-feed {
-      opacity: 1;
-    }
-  }
-
-  .action-block {
-    a button {
-      @include peertube-button;
-      @include grey-button;
-      @include button-with-icon(18px, 3px, -1px);
-    }
-  }
-
-  .moderation-block {
-    display: flex;
-    flex-grow: 1;
-    justify-content: flex-end;
-    align-items: center;
-  }
-}
-
-.date-title {
-  font-size: 16px;
-  font-weight: $font-semibold;
-  margin-bottom: 20px;
-  margin-top: -10px;
-
-  // make the element span a full grid row within .videos grid
-  grid-column: 1 / -1;
-
-  &:not(:first-child) {
-    margin-top: .5rem;
-    padding-top: 20px;
-    border-top: 1px solid $separator-border-color;
-  }
-}
-
-.margin-content {
-  @include fluid-videos-miniature-layout;
-}
-
-@media screen and (max-width: $mobile-view) {
-  .videos-header {
-    flex-direction: column;
-    align-items: center;
-    height: auto;
-    margin-bottom: 10px;
-
-    .title-page {
-      margin-bottom: 10px;
-      margin-right: 0px;
-    }
-  }
-}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
deleted file mode 100644 (file)
index 0bc339f..0000000
+++ /dev/null
@@ -1,308 +0,0 @@
-import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
-import { debounceTime, tap, throttleTime, switchMap } from 'rxjs/operators'
-import { OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { Notifier, ServerService } from '@app/core'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Syndication } from '@app/shared/video/syndication.model'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
-import { ServerConfig } from '@shared/models'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { AuthService } from '../../core/auth'
-import { LocalStorageService } from '../misc/storage.service'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { User, UserService } from '../users'
-import { VideoSortField } from './sort-field.type'
-import { Video } from './video.model'
-
-enum GroupDate {
-  UNKNOWN = 0,
-  TODAY = 1,
-  YESTERDAY = 2,
-  LAST_WEEK = 3,
-  LAST_MONTH = 4,
-  OLDER = 5
-}
-
-export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
-  pagination: ComponentPaginationLight = {
-    currentPage: 1,
-    itemsPerPage: 25
-  }
-  sort: VideoSortField = '-publishedAt'
-
-  categoryOneOf?: number[]
-  languageOneOf?: string[]
-  nsfwPolicy?: NSFWPolicyType
-  defaultSort: VideoSortField = '-publishedAt'
-
-  syndicationItems: Syndication[] = []
-
-  loadOnInit = true
-  useUserVideoPreferences = false
-  ownerDisplayType: OwnerDisplayType = 'account'
-  displayModerationBlock = false
-  titleTooltip: string
-  displayVideoActions = true
-  groupByDate = false
-
-  videos: Video[] = []
-  hasDoneFirstQuery = false
-  disabled = false
-
-  displayOptions: MiniatureDisplayOptions = {
-    date: true,
-    views: true,
-    by: true,
-    avatar: false,
-    privacyLabel: true,
-    privacyText: false,
-    state: false,
-    blacklistInfo: false
-  }
-
-  actions: {
-    routerLink: string
-    iconName: GlobalIconName
-    label: string
-  }[] = []
-
-  onDataSubject = new Subject<any[]>()
-
-  userMiniature: User
-
-  protected serverConfig: ServerConfig
-
-  protected abstract notifier: Notifier
-  protected abstract authService: AuthService
-  protected abstract userService: UserService
-  protected abstract route: ActivatedRoute
-  protected abstract serverService: ServerService
-  protected abstract screenService: ScreenService
-  protected abstract storageService: LocalStorageService
-  protected abstract router: Router
-  protected abstract i18n: I18n
-  abstract titlePage: string
-
-  private resizeSubscription: Subscription
-  private angularState: number
-
-  private groupedDateLabels: { [id in GroupDate]: string }
-  private groupedDates: { [id: number]: GroupDate } = {}
-
-  private lastQueryLength: number
-
-  abstract getVideosObservable (page: number): Observable<{ data: Video[] }>
-
-  abstract generateSyndicationList (): void
-
-  ngOnInit () {
-    this.serverConfig = this.serverService.getTmpConfig()
-    this.serverService.getConfig()
-      .subscribe(config => this.serverConfig = config)
-
-    this.groupedDateLabels = {
-      [GroupDate.UNKNOWN]: null,
-      [GroupDate.TODAY]: this.i18n('Today'),
-      [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
-      [GroupDate.LAST_WEEK]: this.i18n('Last week'),
-      [GroupDate.LAST_MONTH]: this.i18n('Last month'),
-      [GroupDate.OLDER]: this.i18n('Older')
-    }
-
-    // Subscribe to route changes
-    const routeParams = this.route.snapshot.queryParams
-    this.loadRouteParams(routeParams)
-
-    this.resizeSubscription = fromEvent(window, 'resize')
-      .pipe(debounceTime(500))
-      .subscribe(() => this.calcPageSizes())
-
-    this.calcPageSizes()
-
-    const loadUserObservable = this.loadUserAndSettings()
-
-    if (this.loadOnInit === true) {
-      loadUserObservable.subscribe(() => this.loadMoreVideos())
-    }
-
-    this.userService.listenAnonymousUpdate()
-      .pipe(switchMap(() => this.loadUserAndSettings()))
-      .subscribe(() => {
-        if (this.hasDoneFirstQuery) this.reloadVideos()
-      })
-
-    // Display avatar in mobile view
-    if (this.screenService.isInMobileView()) {
-      this.displayOptions.avatar = true
-    }
-  }
-
-  ngOnDestroy () {
-    if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
-  }
-
-  disableForReuse () {
-    this.disabled = true
-  }
-
-  enabledForReuse () {
-    this.disabled = false
-  }
-
-  videoById (index: number, video: Video) {
-    return video.id
-  }
-
-  onNearOfBottom () {
-    if (this.disabled) return
-
-    // No more results
-    if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
-
-    this.pagination.currentPage += 1
-
-    this.setScrollRouteParams()
-
-    this.loadMoreVideos()
-  }
-
-  loadMoreVideos (reset = false) {
-    this.getVideosObservable(this.pagination.currentPage).subscribe(
-      ({ data }) => {
-        this.hasDoneFirstQuery = true
-        this.lastQueryLength = data.length
-
-        if (reset) this.videos = []
-        this.videos = this.videos.concat(data)
-
-        if (this.groupByDate) this.buildGroupedDateLabels()
-
-        this.onMoreVideos()
-
-        this.onDataSubject.next(data)
-      },
-
-      error => {
-        const message = this.i18n('Cannot load more videos. Try again later.')
-
-        console.error(message, { error })
-        this.notifier.error(message)
-      }
-    )
-  }
-
-  reloadVideos () {
-    this.pagination.currentPage = 1
-    this.loadMoreVideos(true)
-  }
-
-  toggleModerationDisplay () {
-    throw new Error('toggleModerationDisplay is not implemented')
-  }
-
-  removeVideoFromArray (video: Video) {
-    this.videos = this.videos.filter(v => v.id !== video.id)
-  }
-
-  buildGroupedDateLabels () {
-    let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
-
-    for (const video of this.videos) {
-      const publishedDate = video.publishedAt
-
-      if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
-        if (currentGroupedDate === GroupDate.TODAY) continue
-
-        currentGroupedDate = GroupDate.TODAY
-        this.groupedDates[ video.id ] = currentGroupedDate
-        continue
-      }
-
-      if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
-        if (currentGroupedDate === GroupDate.YESTERDAY) continue
-
-        currentGroupedDate = GroupDate.YESTERDAY
-        this.groupedDates[ video.id ] = currentGroupedDate
-        continue
-      }
-
-      if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
-        if (currentGroupedDate === GroupDate.LAST_WEEK) continue
-
-        currentGroupedDate = GroupDate.LAST_WEEK
-        this.groupedDates[ video.id ] = currentGroupedDate
-        continue
-      }
-
-      if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
-        if (currentGroupedDate === GroupDate.LAST_MONTH) continue
-
-        currentGroupedDate = GroupDate.LAST_MONTH
-        this.groupedDates[ video.id ] = currentGroupedDate
-        continue
-      }
-
-      if (currentGroupedDate <= GroupDate.OLDER) {
-        if (currentGroupedDate === GroupDate.OLDER) continue
-
-        currentGroupedDate = GroupDate.OLDER
-        this.groupedDates[ video.id ] = currentGroupedDate
-      }
-    }
-  }
-
-  getCurrentGroupedDateLabel (video: Video) {
-    if (this.groupByDate === false) return undefined
-
-    return this.groupedDateLabels[this.groupedDates[video.id]]
-  }
-
-  // On videos hook for children that want to do something
-  protected onMoreVideos () { /* empty */ }
-
-  protected loadRouteParams (routeParams: { [ key: string ]: any }) {
-    this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
-    this.categoryOneOf = routeParams[ 'categoryOneOf' ]
-    this.angularState = routeParams[ 'a-state' ]
-  }
-
-  private calcPageSizes () {
-    if (this.screenService.isInMobileView()) {
-      this.pagination.itemsPerPage = 5
-    }
-  }
-
-  private setScrollRouteParams () {
-    // Already set
-    if (this.angularState) return
-
-    this.angularState = 42
-
-    const queryParams = {
-      'a-state': this.angularState,
-      categoryOneOf: this.categoryOneOf
-    }
-
-    let path = this.router.url
-    if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute
-
-    this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
-  }
-
-  private loadUserAndSettings () {
-    return this.userService.getAnonymousOrLoggedUser()
-      .pipe(tap(user => {
-        this.userMiniature = user
-
-        if (!this.useUserVideoPreferences) return
-
-        this.languageOneOf = user.videoLanguages
-        this.nsfwPolicy = user.nsfwPolicy
-      }))
-  }
-}
diff --git a/client/src/app/shared/video/feed.component.html b/client/src/app/shared/video/feed.component.html
deleted file mode 100644 (file)
index ac0b1f4..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<div class="video-feed"
-  [ngbTooltip]="'Feeds available'"
-  placement="right auto"
-  container="body"
->
-  <my-global-icon
-    *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
-    class="icon-syndication" role="button" iconName="syndication"
-  >
-  </my-global-icon>
-
-  <ng-template #feedsList>
-    <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
-  </ng-template>
-</div>
diff --git a/client/src/app/shared/video/feed.component.scss b/client/src/app/shared/video/feed.component.scss
deleted file mode 100644 (file)
index 34dd0e9..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.video-feed {
-  width: min-content;
-
-  a {
-    color: black;
-    display: block;
-  }
-
-  my-global-icon {
-    cursor: pointer;
-    width: 12px;
-    position: relative;
-    top: -2px;
-
-    @include apply-svg-color(pvar(--mainForegroundColor))
-  }
-}
diff --git a/client/src/app/shared/video/feed.component.ts b/client/src/app/shared/video/feed.component.ts
deleted file mode 100644 (file)
index 1250745..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { Syndication } from '@app/shared/video/syndication.model'
-
-@Component({
-  selector: 'my-feed',
-  styleUrls: [ './feed.component.scss' ],
-  templateUrl: './feed.component.html'
-})
-export class FeedComponent {
-  @Input() syndicationItems: Syndication[]
-}
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
deleted file mode 100644 (file)
index f09c3d1..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
-import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
-import { fromEvent, Observable, Subscription } from 'rxjs'
-
-@Directive({
-  selector: '[myInfiniteScroller]'
-})
-export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked {
-  @Input() percentLimit = 70
-  @Input() autoInit = false
-  @Input() onItself = false
-  @Input() dataObservable: Observable<any[]>
-
-  @Output() nearOfBottom = new EventEmitter<void>()
-
-  private decimalLimit = 0
-  private lastCurrentBottom = -1
-  private scrollDownSub: Subscription
-  private container: HTMLElement
-
-  private checkScroll = false
-
-  constructor (private el: ElementRef) {
-    this.decimalLimit = this.percentLimit / 100
-  }
-
-  ngAfterContentChecked () {
-    if (this.checkScroll) {
-      this.checkScroll = false
-
-      console.log('Checking if the initial state has a scroll.')
-
-      if (this.hasScroll() === false) this.nearOfBottom.emit()
-    }
-  }
-
-  ngOnInit () {
-    if (this.autoInit === true) return this.initialize()
-  }
-
-  ngOnDestroy () {
-    if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
-  }
-
-  initialize () {
-    this.container = this.onItself
-      ? this.el.nativeElement
-      : document.documentElement
-
-    // Emit the last value
-    const throttleOptions = { leading: true, trailing: true }
-
-    const scrollableElement = this.onItself ? this.container : window
-    const scrollObservable = fromEvent(scrollableElement, 'scroll')
-      .pipe(
-        startWith(true),
-        throttleTime(200, undefined, throttleOptions),
-        map(() => this.getScrollInfo()),
-        distinctUntilChanged((o1, o2) => o1.current === o2.current),
-        share()
-      )
-
-    // Scroll Down
-    this.scrollDownSub = scrollObservable
-      .pipe(
-        filter(({ current }) => this.isScrollingDown(current)),
-        filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
-      )
-      .subscribe(() => this.nearOfBottom.emit())
-
-    if (this.dataObservable) {
-      this.dataObservable
-          .pipe(filter(d => d.length !== 0))
-          .subscribe(() => this.checkScroll = true)
-    }
-  }
-
-  private getScrollInfo () {
-    return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() }
-  }
-
-  private getMaximumScroll () {
-    return this.container.scrollHeight - window.innerHeight
-  }
-
-  private hasScroll () {
-    return this.getMaximumScroll() > 0
-  }
-
-  private isScrollingDown (current: number) {
-    const result = this.lastCurrentBottom < current
-
-    this.lastCurrentBottom = current
-    return result
-  }
-}
diff --git a/client/src/app/shared/video/modals/video-block.component.html b/client/src/app/shared/video/modals/video-block.component.html
deleted file mode 100644 (file)
index 5e73d66..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-<ng-template #modal>
-  <div class="modal-header">
-    <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
-  </div>
-
-  <div class="modal-body">
-
-    <form novalidate [formGroup]="form" (ngSubmit)="block()">
-      <div class="form-group">
-        <textarea
-          i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
-          [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
-        ></textarea>
-        <div *ngIf="formErrors.reason" class="form-error">
-          {{ formErrors.reason }}
-        </div>
-      </div>
-
-      <div class="form-group" *ngIf="video.isLocal">
-        <my-peertube-checkbox
-          inputName="unfederate" formControlName="unfederate"
-          i18n-labelText labelText="Unfederate the video"
-        >
-          <ng-container ngProjectAs="description">
-            <span i18n>This will ask remote instances to delete it</span>
-          </ng-container>
-        </my-peertube-checkbox>
-      </div>
-
-      <div class="form-group inputs">
-        <input
-          type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-          (click)="hide()" (key.enter)="hide()"
-        >
-
-        <input
-          type="submit" i18n-value value="Submit" class="action-button-submit"
-          [disabled]="!form.valid"
-        >
-      </div>
-    </form>
-
-  </div>
-</ng-template>
diff --git a/client/src/app/shared/video/modals/video-block.component.scss b/client/src/app/shared/video/modals/video-block.component.scss
deleted file mode 100644 (file)
index afcdb9a..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-textarea {
-  @include peertube-textarea(100%, 100px);
-}
diff --git a/client/src/app/shared/video/modals/video-block.component.ts b/client/src/app/shared/video/modals/video-block.component.ts
deleted file mode 100644 (file)
index 1a25e05..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { Notifier, RedirectService } from '@app/core'
-import { VideoBlockService } from '../../video-block'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormReactive, VideoBlockValidatorsService } from '@app/shared/forms'
-import { Video } from '@app/shared/video/video.model'
-
-@Component({
-  selector: 'my-video-block',
-  templateUrl: './video-block.component.html',
-  styleUrls: [ './video-block.component.scss' ]
-})
-export class VideoBlockComponent extends FormReactive implements OnInit {
-  @Input() video: Video = null
-
-  @ViewChild('modal', { static: true }) modal: NgbModal
-
-  @Output() videoBlocked = new EventEmitter()
-
-  error: string = null
-
-  private openedModal: NgbModalRef
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private modalService: NgbModal,
-    private videoBlockValidatorsService: VideoBlockValidatorsService,
-    private videoBlocklistService: VideoBlockService,
-    private notifier: Notifier,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    const defaultValues = { unfederate: 'true' }
-
-    this.buildForm({
-      reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON,
-      unfederate: null
-    }, defaultValues)
-  }
-
-  show () {
-    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
-  }
-
-  hide () {
-    this.openedModal.close()
-    this.openedModal = null
-  }
-
-  block () {
-    const reason = this.form.value[ 'reason' ] || undefined
-    const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
-
-    this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate)
-        .subscribe(
-          () => {
-            this.notifier.success(this.i18n('Video blocked.'))
-            this.hide()
-
-            this.video.blacklisted = true
-            this.video.blockedReason = reason
-
-            this.videoBlocked.emit()
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-}
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
deleted file mode 100644 (file)
index c65e371..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-<ng-template #modal let-hide="close">
-  <div class="modal-header">
-    <h4 class="modal-title">
-      <ng-container i18n>Download</ng-container>
-
-      <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
-        <span id="dropdownDownloadType" ngbDropdownToggle>
-          {{ type }}
-        </span>
-        <div ngbDropdownMenu aria-labelledby="dropdownDownloadType">
-          <button *ngIf="type === 'video'" (click)="switchToType('subtitles')" ngbDropdownItem i18n>subtitles</button>
-          <button *ngIf="type === 'subtitles'" (click)="switchToType('video')" ngbDropdownItem i18n>video</button>
-        </div>
-      </div>
-    </h4>
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
-  </div>
-
-  <div class="modal-body">
-    <div class="form-group">
-      <div class="input-group input-group-sm">
-        <div class="input-group-prepend peertube-select-container">
-          <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
-            <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
-          </select>
-
-          <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
-            <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
-          </select>
-        </div>
-
-        <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
-        <div class="input-group-append">
-          <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
-            <span class="glyphicon glyphicon-copy"></span>
-          </button>
-        </div>
-      </div>
-    </div>
-
-    <ng-container *ngIf="type === 'video' && videoFile?.metadata">
-      <div ngbNav #nav="ngbNav" class="nav-tabs">
-
-        <ng-container ngbNavItem>
-          <a ngbNavLink i18n>Format</a>
-          <ng-template ngbNavContent>
-            <div class="file-metadata">
-              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
-                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
-                <span class="metadata-attribute-value">{{ item.value.value }}</span>
-              </div>
-            </div>
-          </ng-template>
-        </ng-container>
-
-        <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
-          <a ngbNavLink i18n>Video stream</a>
-          <ng-template ngbNavContent>
-            <div class="file-metadata">
-              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
-                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
-                <span class="metadata-attribute-value">{{ item.value.value }}</span>
-              </div>
-            </div>
-          </ng-template>
-        </ng-container>
-
-        <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
-          <a ngbNavLink i18n>Audio stream</a>
-          <ng-template ngbNavContent>
-            <div class="file-metadata">
-              <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
-                <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
-                <span class="metadata-attribute-value">{{ item.value.value }}</span>
-              </div>
-            </div>
-          </ng-template>
-        </ng-container>
-      </div>
-
-      <div [ngbNavOutlet]="nav"></div>
-    </ng-container>
-
-    <div class="download-type" *ngIf="type === 'video'">
-      <div class="peertube-radio-container">
-        <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
-        <label i18n for="download-direct">Direct download</label>
-      </div>
-
-      <div class="peertube-radio-container">
-        <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
-        <label i18n for="download-torrent">Torrent (.torrent file)</label>
-      </div>
-    </div>
-  </div>
-
-  <div class="modal-footer inputs">
-    <input
-      type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-      (click)="hide()" (key.enter)="hide()"
-    >
-
-    <input
-      type="submit" i18n-value value="Download" class="action-button-submit"
-      (click)="download()"
-    >
-  </div>
-</ng-template>
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
deleted file mode 100644 (file)
index b09078b..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-.peertube-select-container {
-  @include peertube-select-container(100px);
-
-  border-top-right-radius: 0;
-  border-bottom-right-radius: 0;
-  border-right: none;
-
-  select {
-    height: inherit;
-  }
-}
-
-#dropdownDownloadType {
-  cursor: pointer;
-}
-
-.download-type {
-  margin-top: 30px;
-
-  .peertube-radio-container {
-    @include peertube-radio-container;
-
-    display: inline-block;
-    margin-right: 30px;
-  }
-}
-
-.file-metadata {
-  padding: 1rem;
-}
-
-.file-metadata .metadata-attribute {
-  font-size: 13px;
-  display: block;
-  margin-bottom: 12px;
-
-  .metadata-attribute-label {
-    min-width: 142px;
-    padding-right: 5px;
-    display: inline-block;
-    color: pvar(--greyForegroundColor);
-    font-weight: $font-bold;
-  }
-
-  a.metadata-attribute-value {
-    @include disable-default-a-behaviour;
-    color: pvar(--mainForegroundColor);
-
-    &:hover {
-      opacity: 0.9;
-    }
-  }
-
-  &.metadata-attribute-tags {
-    .metadata-attribute-value:not(:nth-child(2)) {
-      &::before {
-        content: ', '
-      }
-    }
-  }
-}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
deleted file mode 100644 (file)
index d771878..0000000
+++ /dev/null
@@ -1,208 +0,0 @@
-import { Component, ElementRef, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
-import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AuthService, Notifier } from '@app/core'
-import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
-import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
-import { mapValues, pick } from 'lodash-es'
-import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
-import { BytesPipe } from 'ngx-pipes'
-import { VideoService } from '../video.service'
-
-type DownloadType = 'video' | 'subtitles'
-type FileMetadata = { [key: string]: { label: string, value: string }}
-
-@Component({
-  selector: 'my-video-download',
-  templateUrl: './video-download.component.html',
-  styleUrls: [ './video-download.component.scss' ]
-})
-export class VideoDownloadComponent {
-  @ViewChild('modal', { static: true }) modal: ElementRef
-
-  downloadType: 'direct' | 'torrent' = 'torrent'
-  resolutionId: number | string = -1
-  subtitleLanguageId: string
-
-  video: VideoDetails
-  videoFile: VideoFile
-  videoFileMetadataFormat: FileMetadata
-  videoFileMetadataVideoStream: FileMetadata | undefined
-  videoFileMetadataAudioStream: FileMetadata | undefined
-  videoCaptions: VideoCaption[]
-  activeModal: NgbActiveModal
-
-  type: DownloadType = 'video'
-
-  private bytesPipe: BytesPipe
-  private numbersPipe: NumberFormatterPipe
-
-  constructor (
-    private notifier: Notifier,
-    private modalService: NgbModal,
-    private videoService: VideoService,
-    private auth: AuthService,
-    private i18n: I18n
-  ) {
-    this.bytesPipe = new BytesPipe()
-    this.numbersPipe = new NumberFormatterPipe()
-  }
-
-  get typeText () {
-    return this.type === 'video'
-      ? this.i18n('video')
-      : this.i18n('subtitles')
-  }
-
-  getVideoFiles () {
-    if (!this.video) return []
-
-    return this.video.getFiles()
-  }
-
-  show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
-    this.video = video
-    this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
-
-    this.activeModal = this.modalService.open(this.modal, { centered: true })
-
-    this.resolutionId = this.getVideoFiles()[0].resolution.id
-    this.onResolutionIdChange()
-    if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
-  }
-
-  onClose () {
-    this.video = undefined
-    this.videoCaptions = undefined
-  }
-
-  download () {
-    window.location.assign(this.getLink())
-    this.activeModal.close()
-  }
-
-  getLink () {
-    return this.type === 'subtitles' && this.videoCaptions
-      ? this.getSubtitlesLink()
-      : this.getVideoFileLink()
-  }
-
-  async onResolutionIdChange () {
-    this.videoFile = this.getVideoFile()
-    if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
-
-    await this.hydrateMetadataFromMetadataUrl(this.videoFile)
-
-    this.videoFileMetadataFormat = this.videoFile
-      ? this.getMetadataFormat(this.videoFile.metadata.format)
-      : undefined
-    this.videoFileMetadataVideoStream = this.videoFile
-      ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
-      : undefined
-    this.videoFileMetadataAudioStream = this.videoFile
-      ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
-      : undefined
-  }
-
-  getVideoFile () {
-    // HTML select send us a string, so convert it to a number
-    this.resolutionId = parseInt(this.resolutionId.toString(), 10)
-
-    const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId)
-    if (!file) {
-      console.error('Could not find file with resolution %d.', this.resolutionId)
-      return
-    }
-    return file
-  }
-
-  getVideoFileLink () {
-    const file = this.videoFile
-    if (!file) return
-
-    const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
-      ? '?access_token=' + this.auth.getAccessToken()
-      : ''
-
-    switch (this.downloadType) {
-      case 'direct':
-        return file.fileDownloadUrl + suffix
-
-      case 'torrent':
-        return file.torrentDownloadUrl + suffix
-    }
-  }
-
-  getSubtitlesLink () {
-    return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath
-  }
-
-  activateCopiedMessage () {
-    this.notifier.success(this.i18n('Copied'))
-  }
-
-  switchToType (type: DownloadType) {
-    this.type = type
-  }
-
-  getMetadataFormat (format: FfprobeFormat) {
-    const keyToTranslateFunction = {
-      'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
-      'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
-      'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
-      'bit_rate': (value: number) => ({
-        label: this.i18n('Bitrate'),
-        value: `${this.numbersPipe.transform(value)}bps`
-      })
-    }
-
-    // flattening format
-    const sanitizedFormat = Object.assign(format, format.tags)
-    delete sanitizedFormat.tags
-
-    return mapValues(
-      pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
-      (val, key) => keyToTranslateFunction[key](val)
-    )
-  }
-
-  getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
-    const stream = streams.find(s => s.codec_type === type)
-    if (!stream) return undefined
-
-    let keyToTranslateFunction = {
-      'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
-      'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
-      'bit_rate': (value: number) => ({
-        label: this.i18n('Bitrate'),
-        value: `${this.numbersPipe.transform(value)}bps`
-      })
-    }
-
-    if (type === 'video') {
-      keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
-        'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
-        'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
-        'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
-        'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
-      })
-    } else {
-      keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
-        'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
-        'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
-      })
-    }
-
-    return mapValues(
-      pick(stream, Object.keys(keyToTranslateFunction)),
-      (val, key) => keyToTranslateFunction[key](val)
-    )
-  }
-
-  private hydrateMetadataFromMetadataUrl (file: VideoFile) {
-    const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
-    observable.subscribe(res => file.metadata = res)
-    return observable.toPromise()
-  }
-}
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
deleted file mode 100644 (file)
index d6beb6d..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-<ng-template #modal>
-  <div class="modal-header">
-    <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
-    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
-  </div>
-
-  <div class="modal-body">
-    <form novalidate [formGroup]="form" (ngSubmit)="report()">
-
-    <div class="row">
-      <div class="col-5 form-group">
-
-          <label i18n for="reportPredefinedReasons">What is the issue?</label>
-
-          <div class="ml-2 mt-2 d-flex flex-column">
-            <ng-container formGroupName="predefinedReasons">
-              <div class="form-group" *ngFor="let reason of predefinedReasons">
-                <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
-                  <ng-template *ngIf="reason.help" ptTemplate="help">
-                    <div [innerHTML]="reason.help"></div>
-                  </ng-template>
-                  <ng-container *ngIf="reason.description" ngProjectAs="description">
-                    <div [innerHTML]="reason.description"></div>
-                  </ng-container>
-                </my-peertube-checkbox>
-              </div>
-            </ng-container>
-          </div>
-
-      </div>
-
-      <div class="col-7">
-        <div class="row justify-content-center">
-          <div class="col-12 col-lg-9 mb-2">
-            <div class="screenratio">
-              <div [innerHTML]="embedHtml"></div>
-            </div>
-          </div>
-        </div>
-
-        <div class="mb-1 start-at" formGroupName="timestamp">
-          <my-peertube-checkbox
-            formControlName="hasStart"
-            i18n-labelText labelText="Start at"
-          ></my-peertube-checkbox>
-
-          <my-timestamp-input
-            [timestamp]="timestamp.startAt"
-            [maxTimestamp]="video.duration"
-            formControlName="startAt"
-            inputName="startAt"
-          >
-          </my-timestamp-input>
-        </div>
-
-        <div class="mb-3 stop-at"  formGroupName="timestamp" *ngIf="timestamp.hasStart">
-          <my-peertube-checkbox
-            formControlName="hasEnd"
-            i18n-labelText labelText="Stop at"
-          ></my-peertube-checkbox>
-
-          <my-timestamp-input
-            [timestamp]="timestamp.endAt"
-            [maxTimestamp]="video.duration"
-            formControlName="endAt"
-            inputName="endAt"
-          >
-          </my-timestamp-input>
-        </div>
-
-        <div i18n class="information">
-          Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
-        </div>
-
-        <div class="form-group">
-          <textarea 
-            i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
-            [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
-          ></textarea>
-          <div *ngIf="formErrors.reason" class="form-error">
-            {{ formErrors.reason }}
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <div class="form-group inputs">
-      <input
-        type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
-        (click)="hide()" (key.enter)="hide()"
-      >
-      <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
-    </div>
-
-    </form>
-  </div>
-</ng-template>
diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss
deleted file mode 100644 (file)
index b2606cb..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-.information {
-  margin-bottom: 20px;
-}
-
-textarea {
-  @include peertube-textarea(100%, 100px);
-}
-
-.start-at,
-.stop-at {
-  width: 300px;
-  display: flex;
-  align-items: center;
-
-  my-timestamp-input {
-    margin-left: 10px;
-  }
-}
-
-.screenratio {
-  @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
-    left: 0;
-  };
-}
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
deleted file mode 100644 (file)
index c2d441b..0000000
+++ /dev/null
@@ -1,163 +0,0 @@
-import { Component, Input, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { FormReactive } from '../../../shared/forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { VideoAbuseService } from '@app/shared/video-abuse'
-import { Video } from '@app/shared/video/video.model'
-import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
-import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
-import { mapValues, pickBy } from 'lodash-es'
-
-@Component({
-  selector: 'my-video-report',
-  templateUrl: './video-report.component.html',
-  styleUrls: [ './video-report.component.scss' ]
-})
-export class VideoReportComponent extends FormReactive implements OnInit {
-  @Input() video: Video = null
-
-  @ViewChild('modal', { static: true }) modal: NgbModal
-
-  error: string = null
-  predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
-  embedHtml: SafeHtml
-
-  private openedModal: NgbModalRef
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private modalService: NgbModal,
-    private videoAbuseValidatorsService: VideoAbuseValidatorsService,
-    private videoAbuseService: VideoAbuseService,
-    private notifier: Notifier,
-    private sanitizer: DomSanitizer,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  get currentHost () {
-    return window.location.host
-  }
-
-  get originHost () {
-    if (this.isRemoteVideo()) {
-      return this.video.account.host
-    }
-
-    return ''
-  }
-
-  get timestamp () {
-    return this.form.get('timestamp').value
-  }
-
-  getVideoEmbed () {
-    return this.sanitizer.bypassSecurityTrustHtml(
-      buildVideoEmbed(
-        buildVideoLink({
-          baseUrl: this.video.embedUrl,
-          title: false,
-          warningTitle: false
-        })
-      )
-    )
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
-      predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
-      timestamp: {
-        hasStart: null,
-        startAt: null,
-        hasEnd: null,
-        endAt: null
-      }
-    })
-
-    this.predefinedReasons = [
-      {
-        id: 'violentOrRepulsive',
-        label: this.i18n('Violent or repulsive'),
-        help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
-      },
-      {
-        id: 'hatefulOrAbusive',
-        label: this.i18n('Hateful or abusive'),
-        help: this.i18n('Contains abusive, racist or sexist language or iconography.')
-      },
-      {
-        id: 'spamOrMisleading',
-        label: this.i18n('Spam, ad or false news'),
-        help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
-      },
-      {
-        id: 'privacy',
-        label: this.i18n('Privacy breach or doxxing'),
-        help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
-      },
-      {
-        id: 'rights',
-        label: this.i18n('Intellectual property violation'),
-        help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
-      },
-      {
-        id: 'serverRules',
-        label: this.i18n('Breaks server rules'),
-        description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
-      },
-      {
-        id: 'thumbnails',
-        label: this.i18n('Thumbnails'),
-        help: this.i18n('The above can only be seen in thumbnails.')
-      },
-      {
-        id: 'captions',
-        label: this.i18n('Captions'),
-        help: this.i18n('The above can only be seen in captions (please describe which).')
-      }
-    ]
-
-    this.embedHtml = this.getVideoEmbed()
-  }
-
-  show () {
-    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
-  }
-
-  hide () {
-    this.openedModal.close()
-    this.openedModal = null
-  }
-
-  report () {
-    const reason = this.form.get('reason').value
-    const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
-    const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
-
-    this.videoAbuseService.reportVideo({
-      id: this.video.id,
-      reason,
-      predefinedReasons,
-      startAt: hasStart && startAt ? startAt : undefined,
-      endAt: hasEnd && endAt ? endAt : undefined
-    }).subscribe(
-      () => {
-        this.notifier.success(this.i18n('Video reported.'))
-        this.hide()
-      },
-
-      err => this.notifier.error(err.message)
-    )
-  }
-
-  isRemoteVideo () {
-    return !this.video.isLocal
-  }
-}
diff --git a/client/src/app/shared/video/recommendation-info.model.ts b/client/src/app/shared/video/recommendation-info.model.ts
deleted file mode 100644 (file)
index 0233563..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface RecommendationInfo {
-  uuid: string
-  tags?: string[]
-}
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
deleted file mode 100644 (file)
index fb918d7..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-import { catchError, map, toArray } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
-import { SortMeta } from 'primeng/api'
-import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
-import { concat, Observable } from 'rxjs'
-import { environment } from '../../../environments/environment'
-
-@Injectable()
-export class RedundancyService {
-  static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor
-  ) { }
-
-  updateRedundancy (host: string, redundancyAllowed: boolean) {
-    const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
-
-    const body = { redundancyAllowed }
-
-    return this.authHttp.put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  listVideoRedundancies (options: {
-    pagination: RestPagination,
-    sort: SortMeta,
-    target?: VideoRedundanciesTarget
-  }): Observable<ResultList<VideoRedundancy>> {
-    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<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
-               .pipe(
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  addVideoRedundancy (video: Video) {
-    return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
-      .pipe(
-        catchError(res => this.restExtractor.handleError(res))
-      )
-  }
-
-  removeVideoRedundancies (redundancy: VideoRedundancy) {
-    const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
-      .concat(redundancy.redundancies.files.map(r => r.id))
-      .map(id => this.removeRedundancy(id))
-
-    return concat(...observables)
-      .pipe(toArray())
-  }
-
-  private removeRedundancy (redundancyId: number) {
-    return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-}
diff --git a/client/src/app/shared/video/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts
deleted file mode 100644 (file)
index 65b24d9..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-export type VideoSortField = 'name' | '-name'
-                      | 'duration' | '-duration'
-                      | 'publishedAt' | '-publishedAt'
-                      | 'createdAt' | '-createdAt'
-                      | 'views' | '-views'
-                      | 'likes' | '-likes'
-                      | 'trending' | '-trending'
-
-export type CommentSortField = 'createdAt' | '-createdAt'
-                          | 'totalReplies' | '-totalReplies'
diff --git a/client/src/app/shared/video/syndication.model.ts b/client/src/app/shared/video/syndication.model.ts
deleted file mode 100644 (file)
index c59ab01..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
-
-export interface Syndication {
-  format: FeedFormat,
-  label: string,
-  url: string
-}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.html b/client/src/app/shared/video/video-actions-dropdown.component.html
deleted file mode 100644 (file)
index 3c8271b..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<ng-container *ngIf="videoActions.length !== 0">
-
-  <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
-    *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
-  >
-    <span class="anchor" ngbDropdownAnchor></span>
-
-    <div ngbDropdownMenu>
-      <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
-    </div>
-  </div>
-
-  <my-action-dropdown
-    [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
-    [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
-  ></my-action-dropdown>
-
-  <my-video-download #videoDownloadModal></my-video-download>
-  <my-video-report #videoReportModal [video]="video"></my-video-report>
-  <my-video-block #videoBlockModal [video]="video" (videoBlocked)="onVideoBlocked()"></my-video-block>
-</ng-container>
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.scss b/client/src/app/shared/video/video-actions-dropdown.component.scss
deleted file mode 100644 (file)
index 67d7ee8..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-.playlist-dropdown {
-  position: absolute;
-
-  .anchor {
-    display: block;
-    opacity: 0;
-  }
-}
-
-::ng-deep .icon-playlist-add {
-  left: 2px;
-}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
deleted file mode 100644 (file)
index 1f57636..0000000
+++ /dev/null
@@ -1,276 +0,0 @@
-import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
-import { AuthService, ConfirmService, Notifier } from '@app/core'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoDetails } from '@app/shared/video/video-details.model'
-import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
-import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
-import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
-import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component'
-import { VideoBlockService } from '@app/shared/video-block'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { VideoCaption } from '@shared/models'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
-
-export type VideoActionsDisplayType = {
-  playlist?: boolean
-  download?: boolean
-  update?: boolean
-  blacklist?: boolean
-  delete?: boolean
-  report?: boolean
-  duplicate?: boolean
-}
-
-@Component({
-  selector: 'my-video-actions-dropdown',
-  templateUrl: './video-actions-dropdown.component.html',
-  styleUrls: [ './video-actions-dropdown.component.scss' ]
-})
-export class VideoActionsDropdownComponent implements OnChanges {
-  @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
-  @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
-
-  @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
-  @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
-  @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
-
-  @Input() video: Video | VideoDetails
-  @Input() videoCaptions: VideoCaption[] = []
-
-  @Input() displayOptions: VideoActionsDisplayType = {
-    playlist: false,
-    download: true,
-    update: true,
-    blacklist: true,
-    delete: true,
-    report: true,
-    duplicate: true
-  }
-  @Input() placement = 'left'
-
-  @Input() label: string
-
-  @Input() buttonStyled = false
-  @Input() buttonSize: DropdownButtonSize = 'normal'
-  @Input() buttonDirection: DropdownDirection = 'vertical'
-
-  @Output() videoRemoved = new EventEmitter()
-  @Output() videoUnblocked = new EventEmitter()
-  @Output() videoBlocked = new EventEmitter()
-  @Output() modalOpened = new EventEmitter()
-
-  videoActions: DropdownAction<{ video: Video }>[][] = []
-
-  private loaded = false
-
-  constructor (
-    private authService: AuthService,
-    private notifier: Notifier,
-    private confirmService: ConfirmService,
-    private videoBlocklistService: VideoBlockService,
-    private screenService: ScreenService,
-    private videoService: VideoService,
-    private redundancyService: RedundancyService,
-    private i18n: I18n
-  ) { }
-
-  get user () {
-    return this.authService.getUser()
-  }
-
-  ngOnChanges () {
-    if (this.loaded) {
-      this.loaded = false
-      this.playlistAdd.reload()
-    }
-
-    this.buildActions()
-  }
-
-  isUserLoggedIn () {
-    return this.authService.isLoggedIn()
-  }
-
-  loadDropdownInformation () {
-    if (!this.isUserLoggedIn() || this.loaded === true) return
-
-    this.loaded = true
-
-    if (this.displayOptions.playlist) this.playlistAdd.load()
-  }
-
-  /* Show modals */
-
-  showDownloadModal () {
-    this.modalOpened.emit()
-
-    this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions)
-  }
-
-  showReportModal () {
-    this.modalOpened.emit()
-
-    this.videoReportModal.show()
-  }
-
-  showBlockModal () {
-    this.modalOpened.emit()
-
-    this.videoBlockModal.show()
-  }
-
-  /* Actions checker */
-
-  isVideoUpdatable () {
-    return this.video.isUpdatableBy(this.user)
-  }
-
-  isVideoRemovable () {
-    return this.video.isRemovableBy(this.user)
-  }
-
-  isVideoBlockable () {
-    return this.video.isBlockableBy(this.user)
-  }
-
-  isVideoUnblockable () {
-    return this.video.isUnblockableBy(this.user)
-  }
-
-  isVideoDownloadable () {
-    return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
-  }
-
-  canVideoBeDuplicated () {
-    return this.video.canBeDuplicatedBy(this.user)
-  }
-
-  /* Action handlers */
-
-  async unblockVideo () {
-    const confirmMessage = this.i18n(
-      'Do you really want to unblock this video? It will be available again in the videos list.'
-    )
-
-    const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock'))
-    if (res === false) return
-
-    this.videoBlocklistService.unblockVideo(this.video.id).subscribe(
-      () => {
-        this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name }))
-
-        this.video.blacklisted = false
-        this.video.blockedReason = null
-
-        this.videoUnblocked.emit()
-      },
-
-      err => this.notifier.error(err.message)
-    )
-  }
-
-  async removeVideo () {
-    this.modalOpened.emit()
-
-    const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
-    if (res === false) return
-
-    this.videoService.removeVideo(this.video.id)
-        .subscribe(
-          () => {
-            this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
-
-            this.videoRemoved.emit()
-          },
-
-          error => this.notifier.error(error.message)
-        )
-  }
-
-  duplicateVideo () {
-    this.redundancyService.addVideoRedundancy(this.video)
-      .subscribe(
-        () => {
-          const message = this.i18n('This video will be duplicated by your instance.')
-          this.notifier.success(message)
-        },
-
-        err => this.notifier.error(err.message)
-      )
-  }
-
-  onVideoBlocked () {
-    this.videoBlocked.emit()
-  }
-
-  getPlaylistDropdownPlacement () {
-    if (this.screenService.isInSmallView()) {
-      return 'bottom-right'
-    }
-
-    return 'bottom-left bottom-right'
-  }
-
-  private buildActions () {
-    this.videoActions = [
-      [
-        {
-          label: this.i18n('Save to playlist'),
-          handler: () => this.playlistDropdown.toggle(),
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist,
-          iconName: 'playlist-add'
-        }
-      ],
-      [
-        {
-          label: this.i18n('Download'),
-          handler: () => this.showDownloadModal(),
-          isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
-          iconName: 'download'
-        },
-        {
-          label: this.i18n('Update'),
-          linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
-          iconName: 'edit',
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
-        },
-        {
-          label: this.i18n('Block'),
-          handler: () => this.showBlockModal(),
-          iconName: 'no',
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable()
-        },
-        {
-          label: this.i18n('Unblock'),
-          handler: () => this.unblockVideo(),
-          iconName: 'undo',
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable()
-        },
-        {
-          label: this.i18n('Mirror'),
-          handler: () => this.duplicateVideo(),
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
-          iconName: 'cloud-download'
-        },
-        {
-          label: this.i18n('Delete'),
-          handler: () => this.removeVideo(),
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
-          iconName: 'delete'
-        }
-      ],
-      [
-        {
-          label: this.i18n('Report'),
-          handler: () => this.showReportModal(),
-          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report,
-          iconName: 'alert'
-        }
-      ]
-    ]
-  }
-}
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts
deleted file mode 100644 (file)
index 14347a1..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared'
-import { Video } from '../../shared/video/video.model'
-import { Account } from '@app/shared/account/account.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
-import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
-
-export class VideoDetails extends Video implements VideoDetailsServerModel {
-  descriptionPath: string
-  support: string
-  channel: VideoChannel
-  tags: string[]
-  files: VideoFile[]
-  account: Account
-  commentsEnabled: boolean
-  downloadEnabled: boolean
-
-  waitTranscoding: boolean
-  state: VideoConstant<VideoState>
-
-  likesPercent: number
-  dislikesPercent: number
-
-  trackerUrls: string[]
-
-  streamingPlaylists: VideoStreamingPlaylist[]
-
-  constructor (hash: VideoDetailsServerModel, translations = {}) {
-    super(hash, translations)
-
-    this.descriptionPath = hash.descriptionPath
-    this.files = hash.files
-    this.channel = new VideoChannel(hash.channel)
-    this.account = new Account(hash.account)
-    this.tags = hash.tags
-    this.support = hash.support
-    this.commentsEnabled = hash.commentsEnabled
-    this.downloadEnabled = hash.downloadEnabled
-
-    this.trackerUrls = hash.trackerUrls
-    this.streamingPlaylists = hash.streamingPlaylists
-
-    this.buildLikeAndDislikePercents()
-  }
-
-  buildLikeAndDislikePercents () {
-    this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
-    this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
-  }
-
-  getHlsPlaylist () {
-    return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
-  }
-
-  hasHlsPlaylist () {
-    return !!this.getHlsPlaylist()
-  }
-
-  getFiles () {
-    if (this.files.length === 0) return this.getHlsPlaylist().files
-
-    return this.files
-  }
-}
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
deleted file mode 100644 (file)
index 67d8e77..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
-import { VideoUpdate } from '../../../../../shared/models/videos'
-import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
-import { Video } from '../../../../../shared/models/videos/video.model'
-
-export class VideoEdit implements VideoUpdate {
-  static readonly SPECIAL_SCHEDULED_PRIVACY = -1
-
-  category: number
-  licence: number
-  language: string
-  description: string
-  name: string
-  tags: string[]
-  nsfw: boolean
-  commentsEnabled: boolean
-  downloadEnabled: boolean
-  waitTranscoding: boolean
-  channelId: number
-  privacy: VideoPrivacy
-  support: string
-  thumbnailfile?: any
-  previewfile?: any
-  thumbnailUrl: string
-  previewUrl: string
-  uuid?: string
-  id?: number
-  scheduleUpdate?: VideoScheduleUpdate
-  originallyPublishedAt?: Date | string
-
-  constructor (
-    video?: Video & {
-      tags: string[],
-      commentsEnabled: boolean,
-      downloadEnabled: boolean,
-      support: string,
-      thumbnailUrl: string,
-      previewUrl: string
-    }) {
-    if (video) {
-      this.id = video.id
-      this.uuid = video.uuid
-      this.category = video.category.id
-      this.licence = video.licence.id
-      this.language = video.language.id
-      this.description = video.description
-      this.name = video.name
-      this.tags = video.tags
-      this.nsfw = video.nsfw
-      this.commentsEnabled = video.commentsEnabled
-      this.downloadEnabled = video.downloadEnabled
-      this.waitTranscoding = video.waitTranscoding
-      this.channelId = video.channel.id
-      this.privacy = video.privacy.id
-      this.support = video.support
-      this.thumbnailUrl = video.thumbnailUrl
-      this.previewUrl = video.previewUrl
-
-      this.scheduleUpdate = video.scheduledUpdate
-      this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
-    }
-  }
-
-  patch (values: { [ id: string ]: string }) {
-    Object.keys(values).forEach((key) => {
-      this[ key ] = values[ key ]
-    })
-
-    // If schedule publication, the video is private and will be changed to public privacy
-    if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) {
-      const updateAt = new Date(values['schedulePublicationAt'])
-      updateAt.setSeconds(0)
-
-      this.privacy = VideoPrivacy.PRIVATE
-      this.scheduleUpdate = {
-        updateAt: updateAt.toISOString(),
-        privacy: VideoPrivacy.PUBLIC
-      }
-    } else {
-      this.scheduleUpdate = null
-    }
-
-    // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
-    if (this.originallyPublishedAt) {
-      const originallyPublishedAt = new Date(values['originallyPublishedAt'])
-      this.originallyPublishedAt = originallyPublishedAt.toISOString()
-    }
-
-    // Use the same file than the preview for the thumbnail
-    if (this.previewfile) {
-      this.thumbnailfile = this.previewfile
-    }
-  }
-
-  toFormPatch () {
-    const json = {
-      category: this.category,
-      licence: this.licence,
-      language: this.language,
-      description: this.description,
-      support: this.support,
-      name: this.name,
-      tags: this.tags,
-      nsfw: this.nsfw,
-      commentsEnabled: this.commentsEnabled,
-      downloadEnabled: this.downloadEnabled,
-      waitTranscoding: this.waitTranscoding,
-      channelId: this.channelId,
-      privacy: this.privacy,
-      originallyPublishedAt: this.originallyPublishedAt
-    }
-
-    // Special case if we scheduled an update
-    if (this.scheduleUpdate) {
-      Object.assign(json, {
-        privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
-        schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
-      })
-    }
-
-    return json
-  }
-}
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
deleted file mode 100644 (file)
index 82afc86..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
-  <my-video-thumbnail
-    [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
-    [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
-  >
-    <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
-    <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
-  </my-video-thumbnail>
-
-  <div class="video-bottom">
-    <div class="video-miniature-information">
-      <div class="d-inline-flex video-miniature-meta">
-        <a *ngIf="displayOptions.avatar" class="avatar" [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
-          <img [src]="getAvatarUrl()" alt="" />
-        </a>
-
-        <div class="w-100 d-flex flex-column">
-          <a
-            tabindex="-1"
-            class="video-miniature-name"
-            [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
-          >{{ video.name }}</a>
-
-          <span class="video-miniature-created-at-views">
-            <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
-
-            <span class="views">
-              <ng-container *ngIf="displayOptions.date && displayOptions.views"> â€¢ </ng-container>
-              <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
-            </span>
-          </span>
-
-          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
-            {{ video.byAccount }}
-          </a>
-          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
-            {{ video.byVideoChannel }}
-          </a>
-
-          <div class="video-info-privacy">
-            <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
-            <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
-            <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
-          </div>
-        </div>
-      </div>
-
-      <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blocked">
-        <span class="blocked-label" i18n>Blocked</span>
-        <span class="blocked-reason" *ngIf="video.blockedReason">{{ video.blockedReason }}</span>
-      </div>
-
-      <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
-        Sensitive
-      </div>
-    </div>
-
-    <div class="video-actions">
-      <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
-      <my-video-actions-dropdown
-        *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
-        (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()"
-      ></my-video-actions-dropdown>
-    </div>
-  </div>
-</div>
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
deleted file mode 100644 (file)
index 38cac5b..0000000
+++ /dev/null
@@ -1,200 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-$more-button-width: 40px;
-$more-margin-right: 15px;
-
-.video-miniature {
-  display: inline-flex;
-  flex-direction: column;
-  padding-bottom: $video-miniature-margin-bottom;
-  vertical-align: top;
-
-  .video-bottom {
-    display: flex;
-
-    .video-miniature-information {
-      width: $video-miniature-width - $more-button-width - $more-margin-right;
-      line-height: normal;
-
-      .avatar {
-        margin: 10px 10px 0 0;
-
-        img {
-          @include avatar(40px);
-        }
-      }
-
-      .video-miniature-name {
-        @include miniature-name;
-        width: calc(100% - #{$more-button-width});
-      }
-
-      .video-miniature-meta {
-        width: calc(100% + #{$more-button-width});
-        overflow: hidden;
-      }
-
-      .video-miniature-created-at-views {
-        display: block;
-        font-size: 13px;
-      }
-
-      .video-miniature-account,
-      .video-miniature-channel {
-        @include disable-default-a-behaviour;
-        @include ellipsis;
-
-        display: block;
-        font-size: 13px;
-        color: pvar(--greyForegroundColor);
-
-        &:hover {
-          color: $grey-foreground-hover-color;
-        }
-      }
-
-      .video-info-privacy,
-      .video-info-blocked .blocked-label,
-      .video-info-nsfw {
-        font-weight: $font-semibold;
-      }
-
-      .video-info-blocked {
-        color: red;
-
-        .blocked-reason::before {
-          content: ' - ';
-        }
-      }
-
-      .video-info-nsfw {
-        color: red;
-      }
-    }
-
-    .video-actions {
-      margin-top: 3px;
-      width: $more-button-width;
-      height: 30px;
-
-      ::ng-deep .dropdown-root:not(.show) {
-        opacity: 0;
-      }
-
-      ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root {
-        opacity: 1;
-      }
-
-      ::ng-deep .more-icon {
-        opacity: .6;
-
-        &:hover {
-          opacity: 1;
-        }
-      }
-    }
-
-    @media screen and (max-width: $small-view) {
-      .video-miniature-information {
-        margin: 0 10px;
-      }
-
-      .video-actions {
-        margin: 0;
-        top: -3px;
-
-        ::ng-deep .dropdown-root {
-          opacity: 1 !important;
-        }
-      }
-    }
-  }
-
-  &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay,
-  &:hover .video-bottom .video-actions ::ng-deep .dropdown-root {
-    opacity: 1;
-  }
-
-  &.fit-width {
-    width: 100%;
-
-    .video-bottom {
-      width: 100% !important;
-
-      .video-miniature-information {
-        width: calc(100% - #{$more-button-width}) !important;
-      }
-    }
-
-    my-video-thumbnail {
-      @include large-screen-ratio($selector: '::ng-deep .video-thumbnail');
-    }
-  }
-
-  &.display-as-row {
-    flex-direction: row;
-    padding-bottom: 0;
-    height: auto;
-    display: flex;
-    flex-grow: 1;
-
-    my-video-thumbnail {
-      margin-right: 10px;
-    }
-
-    .video-bottom {
-      .video-miniature-information {
-        @media screen and (min-width: $small-view) {
-          width: auto;
-          min-width: 500px;
-        }
-
-        .video-miniature-name {
-          @include ellipsis-multiline(1.3em, 2);
-
-          margin-top: 2px;
-          margin-bottom: 5px;
-        }
-
-        .video-miniature-created-at-views,
-        .video-miniature-account,
-        .video-miniature-channel {
-          font-size: 95%;
-          width: fit-content;
-        }
-
-        .video-miniature-created-at-views + .video-miniature-channel {
-          margin-top: 5px;
-        }
-
-        .video-info-privacy {
-          margin-top: 5px;
-        }
-
-        .video-info-blocked {
-          margin-top: 3px;
-        }
-      }
-
-      .video-actions {
-        margin: 0;
-        top: -3px;
-      }
-    }
-
-    @media screen and (max-width: $small-view) {
-      flex-direction: column;
-      height: auto;
-
-      my-video-thumbnail {
-        margin-right: 0;
-      }
-
-      .video-miniature-information {
-        min-width: initial;
-      }
-    }
-  }
-}
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
deleted file mode 100644 (file)
index a08c3fc..0000000
+++ /dev/null
@@ -1,285 +0,0 @@
-import { switchMap } from 'rxjs/operators'
-import {
-  ChangeDetectionStrategy,
-  ChangeDetectorRef,
-  Component,
-  EventEmitter,
-  Inject,
-  Input,
-  LOCALE_ID,
-  OnInit,
-  Output
-} from '@angular/core'
-import { AuthService, ServerService } from '@app/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
-import { User } from '../users'
-import { Video } from './video.model'
-
-export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
-export type MiniatureDisplayOptions = {
-  date?: boolean
-  views?: boolean
-  by?: boolean
-  avatar?: boolean
-  privacyLabel?: boolean
-  privacyText?: boolean
-  state?: boolean
-  blacklistInfo?: boolean
-  nsfw?: boolean
-}
-
-@Component({
-  selector: 'my-video-miniature',
-  styleUrls: [ './video-miniature.component.scss' ],
-  templateUrl: './video-miniature.component.html',
-  changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoMiniatureComponent implements OnInit {
-  @Input() user: User
-  @Input() video: Video
-
-  @Input() ownerDisplayType: OwnerDisplayType = 'account'
-  @Input() displayOptions: MiniatureDisplayOptions = {
-    date: true,
-    views: true,
-    by: true,
-    avatar: false,
-    privacyLabel: false,
-    privacyText: false,
-    state: false,
-    blacklistInfo: false
-  }
-  @Input() displayAsRow = false
-  @Input() displayVideoActions = true
-  @Input() fitWidth = false
-
-  @Input() useLazyLoadUrl = false
-
-  @Output() videoBlocked = new EventEmitter()
-  @Output() videoUnblocked = new EventEmitter()
-  @Output() videoRemoved = new EventEmitter()
-
-  videoActionsDisplayOptions: VideoActionsDisplayType = {
-    playlist: true,
-    download: false,
-    update: true,
-    blacklist: true,
-    delete: true,
-    report: true,
-    duplicate: true
-  }
-  showActions = false
-  serverConfig: ServerConfig
-
-  addToWatchLaterText: string
-  addedToWatchLaterText: string
-  inWatchLaterPlaylist: boolean
-  channelLinkTitle = ''
-
-  watchLaterPlaylist: {
-    id: number
-    playlistElementId?: number
-  }
-
-  videoLink: any[] = []
-
-  private ownerDisplayTypeChosen: 'account' | 'videoChannel'
-
-  constructor (
-    private screenService: ScreenService,
-    private serverService: ServerService,
-    private i18n: I18n,
-    private authService: AuthService,
-    private videoPlaylistService: VideoPlaylistService,
-    private cd: ChangeDetectorRef,
-    @Inject(LOCALE_ID) private localeId: string
-  ) {}
-
-  get isVideoBlur () {
-    return this.video.isVideoNSFWForUser(this.user, this.serverConfig)
-  }
-
-  ngOnInit () {
-    this.serverConfig = this.serverService.getTmpConfig()
-    this.serverService.getConfig()
-        .subscribe(config => {
-          this.serverConfig = config
-          this.buildVideoLink()
-        })
-
-    this.setUpBy()
-
-    this.channelLinkTitle = this.i18n(
-      '{{name}} (channel page)',
-      { name: this.video.channel.name, handle: this.video.byVideoChannel }
-    )
-
-    // We rely on mouseenter to lazy load actions
-    if (this.screenService.isInTouchScreen()) {
-      this.loadActions()
-    }
-  }
-
-  buildVideoLink () {
-    if (this.useLazyLoadUrl && this.video.url) {
-      const remoteUriConfig = this.serverConfig.search.remoteUri
-
-      // Redirect on the external instance if not allowed to fetch remote data
-      const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
-      const fromPath = window.location.pathname + window.location.search
-
-      this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
-      return
-    }
-
-    this.videoLink = [ '/videos/watch', this.video.uuid ]
-  }
-
-  displayOwnerAccount () {
-    return this.ownerDisplayTypeChosen === 'account'
-  }
-
-  displayOwnerVideoChannel () {
-    return this.ownerDisplayTypeChosen === 'videoChannel'
-  }
-
-  isUnlistedVideo () {
-    return this.video.privacy.id === VideoPrivacy.UNLISTED
-  }
-
-  isPrivateVideo () {
-    return this.video.privacy.id === VideoPrivacy.PRIVATE
-  }
-
-  getStateLabel (video: Video) {
-    if (!video.state) return ''
-
-    if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
-      return this.i18n('Published')
-    }
-
-    if (video.scheduledUpdate) {
-      const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
-      return this.i18n('Publication scheduled on ') + updateAt
-    }
-
-    if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
-      return this.i18n('Waiting transcoding')
-    }
-
-    if (video.state.id === VideoState.TO_TRANSCODE) {
-      return this.i18n('To transcode')
-    }
-
-    if (video.state.id === VideoState.TO_IMPORT) {
-      return this.i18n('To import')
-    }
-
-    return ''
-  }
-
-  getAvatarUrl () {
-    if (this.ownerDisplayTypeChosen === 'account') {
-      return this.video.accountAvatarUrl
-    }
-
-    return this.video.videoChannelAvatarUrl
-  }
-
-  loadActions () {
-    if (this.displayVideoActions) this.showActions = true
-
-    this.loadWatchLater()
-  }
-
-  onVideoBlocked () {
-    this.videoBlocked.emit()
-  }
-
-  onVideoUnblocked () {
-    this.videoUnblocked.emit()
-  }
-
-  onVideoRemoved () {
-    this.videoRemoved.emit()
-  }
-
-  isUserLoggedIn () {
-    return this.authService.isLoggedIn()
-  }
-
-  onWatchLaterClick (currentState: boolean) {
-    if (currentState === true) this.removeFromWatchLater()
-    else this.addToWatchLater()
-
-    this.inWatchLaterPlaylist = !currentState
-  }
-
-  addToWatchLater () {
-    const body = { videoId: this.video.id }
-
-    this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe(
-      res => {
-        this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id
-      }
-    )
-  }
-
-  removeFromWatchLater () {
-    this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id)
-        .subscribe(
-          _ => { /* empty */ }
-        )
-  }
-
-  isWatchLaterPlaylistDisplayed () {
-    return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
-  }
-
-  private setUpBy () {
-    if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
-      this.ownerDisplayTypeChosen = this.ownerDisplayType
-      return
-    }
-
-    // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
-    // -> Use the account name
-    if (
-      this.video.channel.name === `${this.video.account.name}_channel` ||
-      this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
-    ) {
-      this.ownerDisplayTypeChosen = 'account'
-    } else {
-      this.ownerDisplayTypeChosen = 'videoChannel'
-    }
-  }
-
-  private loadWatchLater () {
-    if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return
-
-    this.authService.userInformationLoaded
-        .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)))
-        .subscribe(existResult => {
-          const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER)
-          const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id)
-          this.inWatchLaterPlaylist = false
-
-          this.watchLaterPlaylist = {
-            id: watchLaterPlaylist.id
-          }
-
-          if (existsInWatchLater) {
-            this.inWatchLaterPlaylist = true
-            this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId
-          }
-
-          this.cd.markForCheck()
-        })
-
-    this.videoPlaylistService.runPlaylistCheck(this.video.id)
-  }
-}
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
deleted file mode 100644 (file)
index fe5510c..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<a
-  [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
-  class="video-thumbnail"
->
-  <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
-
-  <div *ngIf="displayWatchLaterPlaylist" class="video-thumbnail-actions-overlay">
-    <ng-container *ngIf="inWatchLaterPlaylist !== true">
-      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
-        <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
-      </div>
-    </ng-container>
-
-    <ng-container *ngIf="inWatchLaterPlaylist === true">
-      <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
-        <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
-      </div>
-    </ng-container>
-  </div>
-
-  <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
-  <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
-
-  <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
-
-  <div class="play-overlay">
-    <div class="icon"></div>
-  </div>
-
-  <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
-    <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
-  </div>
-</a>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
deleted file mode 100644 (file)
index feff78a..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.video-thumbnail {
-  @include miniature-thumbnail;
-
-  .progress-bar {
-    height: 3px;
-    width: 100%;
-    position: absolute;
-    bottom: 0;
-    background-color: rgba(0, 0, 0, 0.20);
-
-    div {
-      height: 100%;
-      background-color: pvar(--mainColor);
-    }
-  }
-
-  .video-thumbnail-watch-later-overlay,
-  .video-thumbnail-label-overlay,
-  .video-thumbnail-duration-overlay {
-    @include static-thumbnail-overlay;
-
-    border-radius: 3px;
-    font-size: 12px;
-    font-weight: $font-semibold;
-    line-height: 1.2;
-    z-index: z(miniature);
-  }
-
-  .video-thumbnail-label-overlay {
-    position: absolute;
-    padding: 0 5px;
-    left: 5px;
-    top: 5px;
-    font-weight: $font-bold;
-
-    &.warning { background-color: orange; }
-    &.danger { background-color: red; }
-  }
-
-  .video-thumbnail-duration-overlay {
-    position: absolute;
-    padding: 0 3px;
-    right: 5px;
-    bottom: 5px;
-  }
-
-  .video-thumbnail-actions-overlay {
-    position: absolute;
-    display: flex;
-    flex-direction: column;
-    right: 5px;
-    top: 5px;
-    opacity: 0;
-
-    div:not(:first-child) {
-      margin-top: 2px;
-    }
-
-    .video-thumbnail-watch-later-overlay {
-      padding: 3px;
-
-      my-global-icon {
-        width: 22px;
-        height: 22px;
-
-        @include apply-svg-color(#fff);
-      }
-    }
-  }
-}
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
deleted file mode 100644 (file)
index 111b4c8..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Component, EventEmitter, Input, Output } from '@angular/core'
-import { Video } from './video.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
-  selector: 'my-video-thumbnail',
-  styleUrls: [ './video-thumbnail.component.scss' ],
-  templateUrl: './video-thumbnail.component.html'
-})
-export class VideoThumbnailComponent {
-  @Input() video: Video
-  @Input() nsfw = false
-  @Input() routerLink: any[]
-  @Input() queryParams: { [ p: string ]: any }
-
-  @Input() displayWatchLaterPlaylist: boolean
-  @Input() inWatchLaterPlaylist: boolean
-
-  @Output() watchLaterClick = new EventEmitter<boolean>()
-
-  addToWatchLaterText: string
-  addedToWatchLaterText: string
-
-  constructor (
-    private screenService: ScreenService,
-    private i18n: I18n
-  ) {
-    this.addToWatchLaterText = this.i18n('Add to watch later')
-    this.addedToWatchLaterText = this.i18n('Remove from watch later')
-  }
-
-  getImageUrl () {
-    if (!this.video) return ''
-
-    if (this.screenService.isInMobileView()) {
-      return this.video.previewUrl
-    }
-
-    return this.video.thumbnailUrl
-  }
-
-  getProgressPercent () {
-    if (!this.video.userHistory) return 0
-
-    const currentTime = this.video.userHistory.currentTime
-
-    return (currentTime / this.video.duration) * 100
-  }
-
-  getVideoRouterLink () {
-    if (this.routerLink) return this.routerLink
-
-    return [ '/videos/watch', this.video.uuid ]
-  }
-
-  onWatchLaterClick (event: Event) {
-    this.watchLaterClick.emit(this.inWatchLaterPlaylist)
-
-    event.stopPropagation()
-    return false
-  }
-}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
deleted file mode 100644 (file)
index dc5f456..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-import { User } from '../'
-import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
-import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
-import { peertubeTranslate, ServerConfig } from '../../../../../shared/models'
-import { Actor } from '@app/shared/actor/actor.model'
-import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
-import { AuthUser } from '@app/core'
-import { environment } from '../../../environments/environment'
-
-export class Video implements VideoServerModel {
-  byVideoChannel: string
-  byAccount: string
-
-  accountAvatarUrl: string
-  videoChannelAvatarUrl: string
-
-  createdAt: Date
-  updatedAt: Date
-  publishedAt: Date
-  originallyPublishedAt: Date | string
-  category: VideoConstant<number>
-  licence: VideoConstant<number>
-  language: VideoConstant<string>
-  privacy: VideoConstant<VideoPrivacy>
-  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<VideoState>
-  scheduledUpdate?: VideoScheduleUpdate
-  blacklisted?: boolean
-  blockedReason?: string
-
-  account: {
-    id: number
-    name: string
-    displayName: string
-    url: string
-    host: string
-    avatar?: Avatar
-  }
-
-  channel: {
-    id: number
-    name: string
-    displayName: string
-    url: string
-    host: string
-    avatar?: Avatar
-  }
-
-  userHistory?: {
-    currentTime: number
-  }
-
-  static buildClientUrl (videoUUID: string) {
-    return '/videos/watch/' + videoUUID
-  }
-
-  constructor (hash: VideoServerModel, translations = {}) {
-    const absoluteAPIUrl = getAbsoluteAPIUrl()
-
-    this.createdAt = new Date(hash.createdAt.toString())
-    this.publishedAt = new Date(hash.publishedAt.toString())
-    this.category = hash.category
-    this.licence = hash.licence
-    this.language = hash.language
-    this.privacy = hash.privacy
-    this.waitTranscoding = hash.waitTranscoding
-    this.state = hash.state
-    this.description = hash.description
-
-    this.duration = hash.duration
-    this.durationLabel = durationToString(hash.duration)
-
-    this.id = hash.id
-    this.uuid = hash.uuid
-
-    this.isLocal = hash.isLocal
-    this.name = hash.name
-
-    this.thumbnailPath = hash.thumbnailPath
-    this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
-
-    this.previewPath = hash.previewPath
-    this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
-
-    this.embedPath = hash.embedPath
-    this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
-
-    this.url = hash.url
-
-    this.views = hash.views
-    this.likes = hash.likes
-    this.dislikes = hash.dislikes
-
-    this.nsfw = hash.nsfw
-
-    this.account = hash.account
-    this.channel = hash.channel
-
-    this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
-    this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host)
-    this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
-    this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
-
-    this.category.label = peertubeTranslate(this.category.label, translations)
-    this.licence.label = peertubeTranslate(this.licence.label, translations)
-    this.language.label = peertubeTranslate(this.language.label, translations)
-    this.privacy.label = peertubeTranslate(this.privacy.label, translations)
-
-    this.scheduledUpdate = hash.scheduledUpdate
-    this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
-
-    if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
-
-    this.blacklisted = hash.blacklisted
-    this.blockedReason = hash.blacklistedReason
-
-    this.userHistory = hash.userHistory
-
-    this.originInstanceHost = this.account.host
-    this.originInstanceUrl = 'https://' + this.originInstanceHost
-  }
-
-  isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
-    // Video is not NSFW, skip
-    if (this.nsfw === false) return false
-
-    // Return user setting if logged in
-    if (user) return user.nsfwPolicy !== 'display'
-
-    // Return default instance config
-    return serverConfig.instance.defaultNSFWPolicy !== 'display'
-  }
-
-  isRemovableBy (user: AuthUser) {
-    return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
-  }
-
-  isBlockableBy (user: AuthUser) {
-    return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
-  }
-
-  isUnblockableBy (user: AuthUser) {
-    return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
-  }
-
-  isUpdatableBy (user: AuthUser) {
-    return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
-  }
-
-  canBeDuplicatedBy (user: AuthUser) {
-    return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
-  }
-}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
deleted file mode 100644 (file)
index d66a1f8..0000000
+++ /dev/null
@@ -1,409 +0,0 @@
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared'
-import { ResultList } from '../../../../../shared/models/result-list.model'
-import {
-  UserVideoRate,
-  UserVideoRateType,
-  UserVideoRateUpdate,
-  VideoConstant,
-  VideoFilter,
-  VideoPrivacy,
-  VideoUpdate
-} from '../../../../../shared/models/videos'
-import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
-import { environment } from '../../../environments/environment'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { RestService } from '../rest/rest.service'
-import { UserService } from '../users/user.service'
-import { VideoSortField } from './sort-field.type'
-import { VideoDetails } from './video-details.model'
-import { VideoEdit } from './video-edit.model'
-import { Video } from './video.model'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { ServerService, AuthService } from '@app/core'
-import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { FfprobeData } from 'fluent-ffmpeg'
-
-export interface VideosProvider {
-  getVideos (parameters: {
-    videoPagination: ComponentPaginationLight,
-    sort: VideoSortField,
-    filter?: VideoFilter,
-    categoryOneOf?: number[],
-    languageOneOf?: string[]
-    nsfwPolicy: NSFWPolicyType
-  }): Observable<ResultList<Video>>
-}
-
-@Injectable()
-export class VideoService implements VideosProvider {
-  static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
-  static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
-
-  constructor (
-    private authHttp: HttpClient,
-    private authService: AuthService,
-    private userService: UserService,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private serverService: ServerService,
-    private i18n: I18n
-  ) {}
-
-  getVideoViewUrl (uuid: string) {
-    return VideoService.BASE_VIDEO_URL + uuid + '/views'
-  }
-
-  getUserWatchingVideoUrl (uuid: string) {
-    return VideoService.BASE_VIDEO_URL + uuid + '/watching'
-  }
-
-  getVideo (options: { videoId: string }): Observable<VideoDetails> {
-    return this.serverService.getServerLocale()
-               .pipe(
-                 switchMap(translations => {
-                   return this.authHttp.get<VideoDetailsServerModel>(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<ResultList<Video>> {
-    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<ResultList<Video>>(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<ResultList<Video>> {
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    return this.authHttp
-               .get<ResultList<Video>>(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<ResultList<Video>> {
-    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<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
-               .pipe(
-                 switchMap(res => this.extractVideos(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getUserSubscriptionVideos (parameters: {
-    videoPagination: ComponentPaginationLight,
-    sort: VideoSortField,
-    skipCount?: boolean
-  }): Observable<ResultList<Video>> {
-    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<ResultList<Video>>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params })
-               .pipe(
-                 switchMap(res => this.extractVideos(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getVideos (parameters: {
-    videoPagination: ComponentPaginationLight,
-    sort: VideoSortField,
-    filter?: VideoFilter,
-    categoryOneOf?: number[],
-    languageOneOf?: string[],
-    skipCount?: boolean,
-    nsfwPolicy?: NSFWPolicyType
-  }): Observable<ResultList<Video>> {
-    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<ResultList<Video>>(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<FfprobeData>(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<UserVideoRate>(url)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
-  }
-
-  extractVideos (result: ResultList<VideoServerModel>) {
-    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<VideoPrivacy>[]) {
-    const base = [
-      {
-        id: VideoPrivacy.PRIVATE,
-        label: this.i18n('Only I can see this video')
-      },
-      {
-        id: VideoPrivacy.UNLISTED,
-        label: this.i18n('Only people with the private link can see this video')
-      },
-      {
-        id: VideoPrivacy.PUBLIC,
-        label: this.i18n('Anyone can see this video')
-      },
-      {
-        id: VideoPrivacy.INTERNAL,
-        label: this.i18n('Only users of this instance can see this video')
-      }
-    ]
-
-    return base.filter(o => !!privacies.find(p => p.id === o.id))
-  }
-
-  nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
-    return nsfwPolicy === 'do_not_list'
-      ? 'false'
-      : 'both'
-  }
-
-  private setVideoRate (id: number, rateType: UserVideoRateType) {
-    const url = VideoService.BASE_VIDEO_URL + id + '/rate'
-    const body: UserVideoRateUpdate = {
-      rating: rateType
-    }
-
-    return this.authHttp
-               .put(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-}
diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html
deleted file mode 100644 (file)
index 44aa567..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
-
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
-  <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
-
-    <div class="checkbox-container">
-      <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
-    </div>
-
-    <my-video-miniature
-      [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
-      [displayVideoActions]="false" [ownerDisplayType]="ownerDisplayType"
-    ></my-video-miniature>
-
-    <!-- Display only once -->
-    <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
-      <div class="action-selection-mode-child">
-        <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
-          Cancel
-        </span>
-
-        <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
-      </div>
-    </div>
-
-    <ng-container  *ngIf="isInSelectionMode() === false">
-      <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
-    </ng-container>
-  </div>
-</div>
diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss
deleted file mode 100644 (file)
index d3cbabf..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.action-selection-mode {
-  display: flex;
-  justify-content: flex-end;
-  flex-grow: 1;
-
-  .action-selection-mode-child {
-    position: fixed;
-
-    .action-button {
-      display: inline-block;
-    }
-
-    .action-button-cancel-selection {
-      @include peertube-button;
-      @include grey-button;
-
-      margin-right: 10px;
-    }
-  }
-}
-
-.video {
-  @include row-blocks;
-
-  &:first-child {
-    margin-top: 47px;
-  }
-
-  .checkbox-container {
-    display: flex;
-    align-items: center;
-    margin-right: 20px;
-    margin-left: 12px;
-  }
-
-  my-video-miniature {
-    flex-grow: 1;
-  }
-}
-
-@media screen and (max-width: $small-view) {
-  .video {
-    flex-direction: column;
-    height: auto;
-
-    .checkbox-container {
-      display: none;
-    }
-
-    my-button {
-      margin-top: 10px;
-    }
-  }
-}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
deleted file mode 100644 (file)
index 9453664..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-import {
-  AfterContentInit,
-  Component,
-  ContentChildren,
-  EventEmitter,
-  Input,
-  OnDestroy,
-  OnInit,
-  Output,
-  QueryList,
-  TemplateRef
-} from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { Observable } from 'rxjs'
-import { Video } from '@app/shared/video/video.model'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ResultList } from '@shared/models'
-import { UserService } from '../users'
-import { LocalStorageService } from '../misc/storage.service'
-
-export type SelectionType = { [ id: number ]: boolean }
-
-@Component({
-  selector: 'my-videos-selection',
-  templateUrl: './videos-selection.component.html',
-  styleUrls: [ './videos-selection.component.scss' ]
-})
-export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
-  @Input() pagination: ComponentPagination
-  @Input() titlePage: string
-  @Input() miniatureDisplayOptions: MiniatureDisplayOptions
-  @Input() ownerDisplayType: OwnerDisplayType
-
-  @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
-
-  @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
-
-  @Output() selectionChange = new EventEmitter<SelectionType>()
-  @Output() videosModelChange = new EventEmitter<Video[]>()
-
-  _selection: SelectionType = {}
-
-  rowButtonsTemplate: TemplateRef<any>
-  globalButtonsTemplate: TemplateRef<any>
-
-  constructor (
-    protected i18n: I18n,
-    protected router: Router,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected serverService: ServerService
-  ) {
-    super()
-  }
-
-  @Input() get selection () {
-    return this._selection
-  }
-
-  set selection (selection: SelectionType) {
-    this._selection = selection
-    this.selectionChange.emit(this._selection)
-  }
-
-  @Input() get videosModel () {
-    return this.videos
-  }
-
-  set videosModel (videos: Video[]) {
-    this.videos = videos
-    this.videosModelChange.emit(this.videos)
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-  }
-
-  ngAfterContentInit () {
-    {
-      const t = this.templates.find(t => t.name === 'rowButtons')
-      if (t) this.rowButtonsTemplate = t.template
-    }
-
-    {
-      const t = this.templates.find(t => t.name === 'globalButtons')
-      if (t) this.globalButtonsTemplate = t.template
-    }
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    return this.getVideosObservableFunction(page, this.sort)
-  }
-
-  abortSelectionMode () {
-    this._selection = {}
-  }
-
-  isInSelectionMode () {
-    return Object.keys(this._selection).some(k => this._selection[ k ] === true)
-  }
-
-  generateSyndicationList () {
-    throw new Error('Method not implemented.')
-  }
-
-  protected onMoreVideos () {
-    this.videosModel = this.videos
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts b/client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts
new file mode 100644 (file)
index 0000000..b05852f
--- /dev/null
@@ -0,0 +1,94 @@
+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.'
+    })
+  }
+}
index 9856aac9eb4857947da2546460a1fcc24964f61e..a90d04ce82fe39ee92165cb4283c838b554aac73 100644 (file)
@@ -1,11 +1,9 @@
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { FormReactive } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
 import { ServerService } from '@app/core'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit } from '@app/shared/shared-main'
 import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { ServerConfig, VideoConstant } from '../../../../../../shared'
+import { ServerConfig, VideoConstant } from '@shared/models'
 
 @Component({
   selector: 'my-video-caption-add-modal',
index 15073fc44dc4cbf71d61bc887aba44cefd30fda8..239e453ada3de5e0d1db5e32307ba87f4704643a 100644 (file)
@@ -1,19 +1,13 @@
+import { map } from 'rxjs/operators'
 import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
-import { ActivatedRoute, Router } from '@angular/router'
-import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
-import { Notifier } from '@app/core'
-import { ServerService } from '../../../core/server'
-import { VideoEdit } from '../../../shared/video/video-edit.model'
-import { map } from 'rxjs/operators'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { removeElementFromArray } from '@app/shared/misc/utils'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '../../../../../../shared'
-import { VideoService } from '@app/shared/video/video.service'
+import { ServerService } from '@app/core'
+import { removeElementFromArray } from '@app/helpers'
+import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
+import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
+import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 
 @Component({
   selector: 'my-video-edit',
@@ -60,11 +54,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   constructor (
     private formValidatorService: FormValidatorService,
     private videoValidatorsService: VideoValidatorsService,
-    private videoCaptionService: VideoCaptionService,
     private videoService: VideoService,
-    private route: ActivatedRoute,
-    private router: Router,
-    private notifier: Notifier,
     private serverService: ServerService,
     private i18nPrimengCalendarService: I18nPrimengCalendarService,
     private ngZone: NgZone
index 1357d607ce2d02fe073c6914ef5d078dc8f80215..96061a300be8a9e996a925182bab970dbcbdedee 100644 (file)
@@ -1,16 +1,20 @@
-import { NgModule } from '@angular/core'
 import { TagInputModule } from 'ngx-chips'
-import { SharedModule } from '../../../shared/'
-import { VideoEditComponent } from './video-edit.component'
 import { CalendarModule } from 'primeng/calendar'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
+import { VideoEditComponent } from './video-edit.component'
 
 @NgModule({
   imports: [
     TagInputModule,
     CalendarModule,
 
-    SharedModule
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
@@ -22,6 +26,10 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone
     TagInputModule,
     CalendarModule,
 
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule,
+
     VideoEditComponent
   ],
 
index 4d0b0b08087f7765b927f0a155b5dc31e2fd177e..5b453a1d99f575729268e44c6e211ef1d5c60e4b 100644 (file)
@@ -1,17 +1,13 @@
 import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
-import { VideoPrivacy, VideoUpdate } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoService } from '../../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LoadingBarService } from '@ngx-loading-bar/core'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { FormValidatorService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoImportService } from '@app/shared/video-import'
-import { scrollToTop } from '@app/shared/misc/utils'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
 
 @Component({
   selector: 'my-video-import-torrent',
index 213c42333dd1a6ae38cf0248c7cd8f5587689a1c..d0bd1f54df19a325083ed8e937bec20856746d64 100644 (file)
@@ -1,18 +1,14 @@
+import { map, switchMap } from 'rxjs/operators'
 import { Component, EventEmitter, OnInit, Output } from '@angular/core'
 import { Router } from '@angular/router'
-import { VideoPrivacy, VideoUpdate } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoService } from '../../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LoadingBarService } from '@ngx-loading-bar/core'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { FormValidatorService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoImportService } from '@app/shared/video-import'
-import { scrollToTop, getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-import { switchMap, map } from 'rxjs/operators'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
 
 @Component({
   selector: 'my-video-import-url',
index b32f16950908a8f05f72557b38336ef0f3bd7622..94479321d42ca9746601839f2e3164c45b9980b4 100644 (file)
@@ -1,15 +1,11 @@
+import { catchError, switchMap, tap } from 'rxjs/operators'
 import { EventEmitter, OnInit } from '@angular/core'
+import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
+import { FormReactive } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { catchError, switchMap, tap } from 'rxjs/operators'
-import { FormReactive } from '@app/shared'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '../../../../../../shared'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
-import { CanComponentDeactivateResult } from '@app/shared/guards/can-deactivate-guard.service'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
 
 export abstract class VideoSend extends FormReactive implements OnInit {
   userVideoChannels: { id: number, label: string, support: string }[] = []
index 9ce3fbc6d1392ef29ae95900be146f049ef5e016..eb7ac32ae510b99da40f370d84559c6cdf405551 100644 (file)
@@ -1,19 +1,16 @@
+import { BytesPipe } from 'ngx-pipes'
+import { Subscription } from 'rxjs'
 import { HttpEventType, HttpResponse } from '@angular/common/http'
 import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
 import { LoadingBarService } from '@ngx-loading-bar/core'
-import { BytesPipe } from 'ngx-pipes'
-import { Subscription } from 'rxjs'
-import { VideoPrivacy } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoEdit } from '../../../shared/video/video-edit.model'
-import { VideoService } from '../../../shared/video/video.service'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { FormValidatorService, UserService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { scrollToTop } from '@app/shared/misc/utils'
+import { VideoPrivacy } from '@shared/models'
 
 @Component({
   selector: 'my-video-upload',
index e0fef7158dfa6e30360fa6f22e9b3b158a0e82fd..9ff66bea00306c5fc7af7e370da0edbd5740a64d 100644 (file)
@@ -1,10 +1,7 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
-
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
 import { MetaGuard } from '@ngx-meta/core'
-
-import { LoginGuard } from '../../core'
-import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
 import { VideoAddComponent } from './video-add.component'
 
 const videoAddRoutes: Routes = [
index 30ab08ea0e67b8bf031fa6bca6610a6a39ea3dc9..5bd7688092020553001626b29f52321d9d606749 100644 (file)
@@ -1,10 +1,9 @@
 import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component'
-import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component'
-import { AuthService, ServerService } from '@app/core'
-import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component'
+import { AuthService, CanComponentDeactivate, ServerService } from '@app/core'
 import { ServerConfig } from '@shared/models'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
 
 @Component({
   selector: 'my-videos-add',
index b8f5a9a476cb78d36c92da9f1d39d50a03289ccb..477c1cf5e73a49eb8e2ead4f6775ec7ebc1ff682 100644 (file)
@@ -1,20 +1,20 @@
 import { NgModule } from '@angular/core'
-import { SharedModule } from '../../shared'
+import { CanDeactivateGuard } from '@app/core'
 import { VideoEditModule } from './shared/video-edit.module'
+import { DragDropDirective } from './video-add-components/drag-drop.directive'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
 import { VideoAddRoutingModule } from './video-add-routing.module'
 import { VideoAddComponent } from './video-add.component'
-import { DragDropDirective } from './video-add-components/drag-drop.directive'
-import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
-import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component'
-import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component'
-import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component'
 
 @NgModule({
   imports: [
     VideoAddRoutingModule,
-    VideoEditModule,
-    SharedModule
+
+    VideoEditModule
   ],
+
   declarations: [
     VideoAddComponent,
     VideoUploadComponent,
@@ -22,10 +22,9 @@ import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-c
     VideoImportTorrentComponent,
     DragDropDirective
   ],
-  exports: [
-    VideoAddComponent,
-    DragDropDirective
-  ],
+
+  exports: [ ],
+
   providers: [
     CanDeactivateGuard
   ]
index 564b8fb45b89f7e2a773e6251e9d25ba2c34d608..a04351b05867bbd4c1fa70cf75c2d751478e3edc 100644 (file)
@@ -1,12 +1,9 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
-
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
 import { MetaGuard } from '@ngx-meta/core'
-
-import { LoginGuard } from '../../core'
 import { VideoUpdateComponent } from './video-update.component'
-import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
-import { CanDeactivateGuard } from '@app/shared/guards/can-deactivate-guard.service'
+import { VideoUpdateResolver } from './video-update.resolver'
 
 const videoUpdateRoutes: Routes = [
   {
index 20a6071349c763c9923912fc8ba5da260fe01a7c..7bd6eb553fd4ff4eeed9ef403bed2d75a933b325 100644 (file)
@@ -1,17 +1,11 @@
 import { map, switchMap } from 'rxjs/operators'
 import { Component, HostListener, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { LoadingBarService } from '@ngx-loading-bar/core'
 import { Notifier } from '@app/core'
-import { ServerService } from '../../core'
-import { FormReactive } from '../../shared'
-import { VideoEdit } from '../../shared/video/video-edit.model'
-import { VideoService } from '../../shared/video/video.service'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoDetails } from '@app/shared/video/video-details.model'
 import { VideoPrivacy } from '@shared/models'
 
 @Component({
index d60aa699f8ab0e22da4f4896215e9afa5c4b8c01..322c69629f592bc81e51d903dc12ddf881282996 100644 (file)
@@ -1,25 +1,22 @@
 import { NgModule } from '@angular/core'
-import { SharedModule } from '../../shared'
+import { CanDeactivateGuard } from '@app/core'
+import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
 import { VideoEditModule } from './shared/video-edit.module'
 import { VideoUpdateRoutingModule } from './video-update-routing.module'
 import { VideoUpdateComponent } from './video-update.component'
-import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
-import { CanDeactivateGuard } from '@app/shared/guards/can-deactivate-guard.service'
 
 @NgModule({
   imports: [
     VideoUpdateRoutingModule,
-    VideoEditModule,
-    SharedModule
+
+    VideoEditModule
   ],
 
   declarations: [
     VideoUpdateComponent
   ],
 
-  exports: [
-    VideoUpdateComponent
-  ],
+  exports: [ ],
 
   providers: [
     VideoUpdateResolver,
index 4ac517d961e613edaf692be3b03487972708d17b..30bcf4d74535ca0dd520d7eac3c83d26b2125cc5 100644 (file)
@@ -1,10 +1,8 @@
+import { forkJoin } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
-import { VideoService } from '@app/shared/video/video.service'
 import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
-import { map, switchMap } from 'rxjs/operators'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { forkJoin } from 'rxjs'
+import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
 
 @Injectable()
 export class VideoUpdateResolver implements Resolve<any> {
index e1a8f6260d75ef0a2cc5c122fc9a90bf601822bf..79505c779acddbbf0fc4b18f9edbc4ea050c668a 100644 (file)
@@ -1,16 +1,13 @@
+import { Observable } from 'rxjs'
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
-import { Notifier } from '@app/core'
-import { Observable } from 'rxjs'
-import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
-import { FormReactive } from '../../../shared'
-import { User } from '../../../shared/users'
-import { Video } from '../../../shared/video/video.model'
+import { Notifier, User } from '@app/core'
+import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
+import { Video } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCommentCreate } from '@shared/models'
 import { VideoComment } from './video-comment.model'
 import { VideoCommentService } from './video-comment.service'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
 @Component({
   selector: 'my-video-comment-add',
index 1566d7369255b74310286113beae77fcca7dc82e..7c2aaeadde86ce62174f7b9f56ef6cd803864455 100644 (file)
@@ -1,5 +1,5 @@
-import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '../../../../../../shared/models/videos/video-comment.model'
-import { VideoComment } from '@app/videos/+video-watch/comment/video-comment.model'
+import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
+import { VideoComment } from './video-comment.model'
 
 export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
   comment: VideoComment
index 868addd5894cad98f1ad06fd9a4aa0b87140c703..27846c1ad213dfc00ddcb4ed7f0d37b2176cb689 100644 (file)
@@ -1,15 +1,10 @@
 import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { User, UserRight } from '../../../../../../shared/models/users'
+import { MarkdownService, Notifier, UserService } from '@app/core'
 import { AuthService } from '@app/core/auth'
-import { AccountService } from '@app/shared/account/account.service'
-import { Video } from '@app/shared/video/video.model'
+import { Account, Actor, Video } from '@app/shared/shared-main'
+import { User, UserRight } from '@shared/models'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
 import { VideoComment } from './video-comment.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
-import { UserService } from '@app/shared'
-import { Actor } from '@app/shared/actor/actor.model'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
 
 @Component({
   selector: 'my-video-comment',
@@ -40,7 +35,6 @@ export class VideoCommentComponent implements OnInit, OnChanges {
   constructor (
     private markdownService: MarkdownService,
     private authService: AuthService,
-    private accountService: AccountService,
     private userService: UserService,
     private notifier: Notifier
   ) {}
index 171fc4acccc3ba65e2dd6293225a829b9d17f90b..e854431968b6058b6ceaa2dcc611af7a3d6e5289 100644 (file)
@@ -1,7 +1,6 @@
-import { Account as AccountInterface } from '../../../../../../shared/models/actors'
-import { VideoComment as VideoCommentServerModel, VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
-import { Actor } from '@app/shared/actor/actor.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
+import { getAbsoluteAPIUrl } from '@app/helpers'
+import { Actor } from '@app/shared/shared-main'
+import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
 
 export class VideoComment implements VideoCommentServerModel {
   id: number
index 0b071539075aa43b648fcc642251462145a19d9a..a73fb9ca853525031dfbd42298942b4113d350a8 100644 (file)
@@ -1,20 +1,19 @@
+import { Observable } from 'rxjs'
 import { catchError, map } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { objectLineFeedToHtml } from '@app/shared/misc/utils'
-import { Observable } from 'rxjs'
-import { FeedFormat, ResultList } from '../../../../../../shared/models'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { objectLineFeedToHtml } from '@app/helpers'
 import {
+  FeedFormat,
+  ResultList,
   VideoComment as VideoCommentServerModel,
   VideoCommentCreate,
   VideoCommentThreadTree as VideoCommentThreadTreeServerModel
-} from '../../../../../../shared/models/videos/video-comment.model'
+} from '@shared/models'
 import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared/rest'
-import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model'
-import { CommentSortField } from '../../../shared/video/sort-field.type'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
 import { VideoComment } from './video-comment.model'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
 
 @Injectable()
 export class VideoCommentService {
@@ -52,7 +51,7 @@ export class VideoCommentService {
   getVideoCommentThreads (parameters: {
     videoId: number | string,
     componentPagination: ComponentPaginationLight,
-    sort: CommentSortField
+    sort: string
   }): Observable<ResultList<VideoComment>> {
     const { videoId, componentPagination, sort } = parameters
 
index bba9f13721f52cc7c24497d6911f4bcf39840b52..df0018ec65214936f9225fa1a4c2ccace6386d15 100644 (file)
@@ -1,18 +1,13 @@
+import { Subject, Subscription } from 'rxjs'
 import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { ConfirmService, Notifier } from '@app/core'
-import { Subject, Subscription } from 'rxjs'
-import { AuthService } from '../../../core/auth'
-import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
-import { User } from '../../../shared/users'
-import { CommentSortField } from '../../../shared/video/sort-field.type'
-import { VideoDetails } from '../../../shared/video/video-details.model'
+import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { Syndication, VideoDetails } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
 import { VideoComment } from './video-comment.model'
 import { VideoCommentService } from './video-comment.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Syndication } from '@app/shared/video/syndication.model'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
 
 @Component({
   selector: 'my-video-comments',
@@ -28,7 +23,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
 
   comments: VideoComment[] = []
   highlightedThread: VideoComment
-  sort: CommentSortField = '-createdAt'
+  sort = '-createdAt'
   componentPagination: ComponentPagination = {
     currentPage: 1,
     itemsPerPage: 10,
@@ -154,7 +149,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
     this.viewReplies(commentTree.comment.id)
   }
 
-  handleSortChange (sort: CommentSortField) {
+  handleSortChange (sort: string) {
     if (this.sort === sort) return
 
     this.sort = sort
index 3550556a0273ff483b2acf3dcea767004f091a47..b42b775c135cff238e1d814914e55d5a4e5893a4 100644 (file)
@@ -1,9 +1,9 @@
 import { Component, ElementRef, Input, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
 import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { VideoCaption } from '@shared/models'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { VideoDetails } from '@app/shared/shared-main'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
 
 type Customizations = {
   startAtCheckbox: boolean
@@ -40,13 +40,9 @@ export class VideoShareComponent {
   isAdvancedCustomizationCollapsed = true
   includeVideoInPlaylist = false
 
-  private currentVideoTimestamp: number
-
   constructor (private modalService: NgbModal) { }
 
   show (currentVideoTimestamp?: number) {
-    this.currentVideoTimestamp = currentVideoTimestamp
-
     let subtitle: string
     if (this.videoCaptions.length !== 0) {
       subtitle = this.videoCaptions[0].language.id
index 0058172f21f10fe355709d29b2f1bb5851b98a24..48d5f2948640d6a670494697f5a95c9fa810d4dc 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, Input, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
+import { MarkdownService } from '@app/core'
+import { VideoDetails } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { MarkdownService } from '@app/shared/renderer'
 
 @Component({
   selector: 'my-video-support',
diff --git a/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts
new file mode 100644 (file)
index 0000000..45e0236
--- /dev/null
@@ -0,0 +1,39 @@
+import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
+
+@Directive({
+  selector: '[timestampRouteTransformer]'
+})
+export class TimestampRouteTransformerDirective {
+  @Output() timestampClicked = new EventEmitter<number>()
+
+  @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/videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts
new file mode 100644 (file)
index 0000000..4b67674
--- /dev/null
@@ -0,0 +1,28 @@
+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 })
+  }
+}
index 827c34d414d90361d65716d1d38adb4493b5d5a4..2c21be6439a6241b4b31ef3c5c2aa9c15867a461 100644 (file)
@@ -1,15 +1,10 @@
 import { Component, Input } from '@angular/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
 import { Router } from '@angular/router'
-import { UserService } from '@app/shared'
-import { AuthService, Notifier } from '@app/core'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage'
+import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage'
+import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { SessionStorageService, LocalStorageService } from '@app/shared/misc/storage.service'
+import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
 
 @Component({
   selector: 'my-video-watch-playlist',
index ce9250bdc756c8d80ba72a7e935c6e39372769b2..d8fecb87da9c546fea5ee78488aa7e4b6a0fd178 100644 (file)
@@ -1,8 +1,6 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
-
 import { MetaGuard } from '@ngx-meta/core'
-
 import { VideoWatchComponent } from './video-watch.component'
 
 const videoWatchRoutes: Routes = [
index df0c1058ce10296e961f7f0f71055ccbfb36dff7..5b0b34c803264b16a834fb44ae1913a8df589d4a 100644 (file)
@@ -1,41 +1,33 @@
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { forkJoin, Observable, Subscription } from 'rxjs'
 import { catchError } from 'rxjs/operators'
+import { PlatformLocation } from '@angular/common'
 import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
 import { RedirectService } from '@app/core/routing/redirect.service'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
+import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers'
+import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
+import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { MetaService } from '@ngx-meta/core'
-import { AuthUser, Notifier, ServerService } from '@app/core'
-import { forkJoin, Observable, Subscription } from 'rxjs'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
-import { AuthService, ConfirmService } from '../../core'
-import { RestExtractor, UserService } from '../../shared'
-import { VideoDetails } from '../../shared/video/video-details.model'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoShareComponent } from './modal/video-share.component'
-import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { environment } from '../../../environments/environment'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { MarkdownService } from '@app/shared/renderer'
+import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
+import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
 import {
-  videojs,
   CustomizationOptions,
   P2PMediaLoaderOptions,
   PeertubePlayerManager,
   PeertubePlayerManagerOptions,
-  PlayerMode
+  PlayerMode,
+  videojs
 } from '../../../assets/player/peertube-player-manager'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { Video } from '@app/shared/video/video.model'
 import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
-import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
-import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { PlatformLocation } from '@angular/common'
-import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils'
+import { environment } from '../../../environments/environment'
+import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
 
 @Component({
   selector: 'my-video-watch',
index 9b445269d3634a513a22a1c9b3c3dca66ce3158e..a1c54f065364b868d57b8e06640eba1c9f707997 100644 (file)
@@ -1,26 +1,40 @@
+import { QRCodeModule } from 'angularx-qrcode'
 import { NgModule } from '@angular/core'
-import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
-import { SharedModule } from '../../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
 import { VideoCommentAddComponent } from './comment/video-comment-add.component'
 import { VideoCommentComponent } from './comment/video-comment.component'
 import { VideoCommentService } from './comment/video-comment.service'
 import { VideoCommentsComponent } from './comment/video-comments.component'
 import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
+import { VideoDurationPipe } from './video-duration-formatter.pipe'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
 import { VideoWatchRoutingModule } from './video-watch-routing.module'
 import { VideoWatchComponent } from './video-watch.component'
-import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
-import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
-import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
-import { QRCodeModule } from 'angularx-qrcode'
-import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive'
 
 @NgModule({
   imports: [
     VideoWatchRoutingModule,
-    SharedModule,
     NgbTooltipModule,
     QRCodeModule,
-    RecommendationsModule
+    RecommendationsModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedVideoMiniatureModule,
+    SharedVideoPlaylistModule,
+    SharedUserSubscriptionModule,
+    SharedModerationModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
@@ -33,6 +47,8 @@ import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestam
     VideoCommentAddComponent,
     VideoCommentComponent,
 
+    TimestampRouteTransformerDirective,
+    VideoDurationPipe,
     TimestampRouteTransformerDirective
   ],
 
index 0abf938b73a0eed64f75026c78bc6e554b3a4215..a376453bf949ee743e38399340d05affdd20a519 100644 (file)
@@ -1,15 +1,13 @@
 import { Observable, of } from 'rxjs'
 import { map, switchMap } from 'rxjs/operators'
 import { Injectable } from '@angular/core'
-import { ServerService } from '@app/core'
+import { ServerService, UserService } from '@app/core'
 import { AdvancedSearch } from '@app/search/advanced-search.model'
 import { SearchService } from '@app/search/search.service'
-import { UserService } from '@app/shared'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
+import { Video, VideoService } from '@app/shared/shared-main'
 import { ServerConfig } from '@shared/models'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
 
 /**
  * Provides "recommendations" by providing the most recently uploaded videos.
diff --git a/client/src/app/videos/recommendations/recommendation-info.model.ts b/client/src/app/videos/recommendations/recommendation-info.model.ts
new file mode 100644 (file)
index 0000000..0233563
--- /dev/null
@@ -0,0 +1,4 @@
+export interface RecommendationInfo {
+  uuid: string
+  tags?: string[]
+}
index 3e279cc296b18c24862c0b2e39a18fa8fbcab350..03cc272cac6bce25507bc32be24371331a5cac00 100644 (file)
@@ -1,16 +1,21 @@
-import { NgModule } from '@angular/core'
 import { InputSwitchModule } from 'primeng/inputswitch'
-import { RecommendedVideosComponent } from '@app/videos/recommendations/recommended-videos.component'
-import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store'
 import { CommonModule } from '@angular/common'
-import { SharedModule } from '@app/shared'
-import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendedVideosComponent } from './recommended-videos.component'
+import { RecommendedVideosStore } from './recommended-videos.store'
 
 @NgModule({
   imports: [
+    CommonModule,
     InputSwitchModule,
-    SharedModule,
-    CommonModule
+
+    SharedMainModule,
+    SharedVideoPlaylistModule,
+    SharedVideoMiniatureModule
   ],
   declarations: [
     RecommendedVideosComponent
index a547e289d6aace7c14f2d9f4235047aa8974364e..1d79d35f609f427049460324ea39cb2bc6d8b497 100644 (file)
@@ -1,6 +1,6 @@
-import { Video } from '@app/shared/video/video.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
 import { Observable } from 'rxjs'
+import { Video } from '@app/shared/shared-main'
+import { RecommendationInfo } from './recommendation-info.model'
 
 export interface RecommendationService {
   getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
index a6f3bce3d36b01cbe066a218b4a91a6bc864fd79..0169753414c1fb15b151dcb37bd3fdea0541d2eb 100644 (file)
@@ -1,15 +1,12 @@
 import { Observable } from 'rxjs'
 import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
-import { User } from '@app/shared'
-import { SessionStorageService } from '@app/shared/misc/storage.service'
-import { UserService } from '@app/shared/users/user.service'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
-import { Video } from '@app/shared/video/video.model'
-import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store'
+import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendedVideosStore } from './recommended-videos.store'
 
 @Component({
   selector: 'my-recommended-videos',
index 858ec3a2789e37a1ddeea131a3488bff8aca2525..8c3fb648042f7cbd84268beb119df8d530669f15 100644 (file)
@@ -1,10 +1,10 @@
-import { Inject, Injectable } from '@angular/core'
 import { Observable, ReplaySubject } from 'rxjs'
-import { Video } from '@app/shared/video/video.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
-import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
 import { map, shareReplay, switchMap, take } from 'rxjs/operators'
+import { Inject, Injectable } from '@angular/core'
+import { Video } from '@app/shared/shared-main'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
 
 /**
  * This store is intended to provide data for the RecommendedVideosComponent.
@@ -20,7 +20,7 @@ export class RecommendedVideosStore {
   ) {
     this.recommendations$ = this.requestsForLoad$$.pipe(
       switchMap(requestedRecommendation => {
-        return recommendations.getRecommendations(requestedRecommendation)
+        return this.recommendations.getRecommendations(requestedRecommendation)
                               .pipe(take(1))
       }),
       shareReplay()
index b367110ae5bad85764af7c72ca36fc850cca6ce8..af1bd58b70ce555068901276b7087de5aa5cc3cb 100644 (file)
@@ -1,3 +1,4 @@
+export * from './overview'
 export * from './video-local.component'
 export * from './video-recently-added.component'
 export * from './video-trending.component'
diff --git a/client/src/app/videos/video-list/overview/index.ts b/client/src/app/videos/video-list/overview/index.ts
new file mode 100644 (file)
index 0000000..e6cfa48
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './overview.service'
+export * from './video-overview.component'
+export * from './videos-overview.model'
diff --git a/client/src/app/videos/video-list/overview/overview.service.ts b/client/src/app/videos/video-list/overview/overview.service.ts
new file mode 100644 (file)
index 0000000..4458454
--- /dev/null
@@ -0,0 +1,78 @@
+import { forkJoin, Observable, of } from 'rxjs'
+import { catchError, map, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, ServerService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { peertubeTranslate, VideosOverview as VideosOverviewServer } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { VideosOverview } from './videos-overview.model'
+
+@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<VideosOverview> {
+    let params = new HttpParams()
+    params = params.append('page', page + '')
+
+    return this.authHttp
+               .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
+               .pipe(
+                 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
+    const observables: Observable<any>[] = []
+    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/videos/video-list/overview/video-overview.component.html b/client/src/app/videos/video-list/overview/video-overview.component.html
new file mode 100644 (file)
index 0000000..ca986c6
--- /dev/null
@@ -0,0 +1,52 @@
+<h1 class="sr-only" i18n>Discover</h1>
+<div class="margin-content">
+
+  <div class="no-results" i18n *ngIf="notResults">No results.</div>
+
+  <div
+    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+  >
+    <ng-container *ngFor="let overview of overviews">
+
+      <div class="section videos" *ngFor="let object of overview.categories">
+        <h1 class="section-title">
+          <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
+        </h1>
+
+        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+          </my-video-miniature>
+        </div>
+      </div>
+
+      <div class="section videos" *ngFor="let object of overview.tags">
+        <h2 class="section-title">
+          <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
+        </h2>
+
+        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+          </my-video-miniature>
+        </div>
+      </div>
+
+      <div class="section channel videos" *ngFor="let object of overview.channels">
+        <div class="section-title">
+          <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
+            <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
+
+            <h2 class="section-title">{{ object.channel.displayName }}</h2>
+          </a>
+        </div>
+
+        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+          </my-video-miniature>
+        </div>
+      </div>
+
+    </ng-container>
+
+  </div>
+
+</div>
diff --git a/client/src/app/videos/video-list/overview/video-overview.component.scss b/client/src/app/videos/video-list/overview/video-overview.component.scss
new file mode 100644 (file)
index 0000000..c1d1018
--- /dev/null
@@ -0,0 +1,16 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.section-title {
+  // make the element span a full grid row within .videos grid
+  grid-column: 1 / -1;
+}
+
+.margin-content {
+  @include fluid-videos-miniature-layout;
+}
+
+.section {
+  @include miniature-rows;
+}
diff --git a/client/src/app/videos/video-list/overview/video-overview.component.ts b/client/src/app/videos/video-list/overview/video-overview.component.ts
new file mode 100644 (file)
index 0000000..b3be1d7
--- /dev/null
@@ -0,0 +1,94 @@
+import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, ScreenService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { OverviewService } from './overview.service'
+import { VideosOverview } from './videos-overview.model'
+
+@Component({
+  selector: 'my-video-overview',
+  templateUrl: './video-overview.component.html',
+  styleUrls: [ './video-overview.component.scss' ]
+})
+export class VideoOverviewComponent implements OnInit {
+  onDataSubject = new Subject<any>()
+
+  overviews: VideosOverview[] = []
+  notResults = false
+
+  userMiniature: User
+
+  private loaded = false
+  private currentPage = 1
+  private maxPage = 20
+  private lastWasEmpty = false
+  private isLoading = false
+
+  constructor (
+    private notifier: Notifier,
+    private userService: UserService,
+    private overviewService: OverviewService,
+    private screenService: ScreenService
+  ) { }
+
+  ngOnInit () {
+    this.loadMoreResults()
+
+    this.userService.getAnonymousOrLoggedUser()
+      .subscribe(user => this.userMiniature = user)
+
+    this.userService.listenAnonymousUpdate()
+      .subscribe(user => this.userMiniature = user)
+  }
+
+  buildVideoChannelBy (object: { videos: Video[] }) {
+    return object.videos[0].byVideoChannel
+  }
+
+  buildVideoChannelAvatarUrl (object: { videos: Video[] }) {
+    return object.videos[0].videoChannelAvatarUrl
+  }
+
+  buildVideos (videos: Video[]) {
+    const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures()
+
+    return videos.slice(0, numberOfVideos * 2)
+  }
+
+  onNearOfBottom () {
+    if (this.currentPage >= this.maxPage) return
+    if (this.lastWasEmpty) return
+    if (this.isLoading) return
+
+    this.currentPage++
+    this.loadMoreResults()
+  }
+
+  private loadMoreResults () {
+    this.isLoading = true
+
+    this.overviewService.getVideosOverview(this.currentPage)
+        .subscribe(
+          overview => {
+            this.isLoading = false
+
+            if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) {
+              this.lastWasEmpty = true
+              if (this.loaded === false) this.notResults = true
+
+              return
+            }
+
+            this.loaded = true
+            this.onDataSubject.next(overview)
+
+            this.overviews.push(overview)
+          },
+
+          err => {
+            this.notifier.error(err.message)
+            this.isLoading = false
+          }
+        )
+  }
+}
diff --git a/client/src/app/videos/video-list/overview/videos-overview.model.ts b/client/src/app/videos/video-list/overview/videos-overview.model.ts
new file mode 100644 (file)
index 0000000..6765ad9
--- /dev/null
@@ -0,0 +1,20 @@
+import { Video } from '@app/shared/shared-main'
+import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models'
+
+export class VideosOverview implements VideosOverviewServer {
+  channels: {
+    channel: VideoChannelSummary
+    videos: Video[]
+  }[]
+
+  categories: {
+    category: VideoConstant<number>
+    videos: Video[]
+  }[]
+
+  tags: {
+    tag: string
+    videos: Video[]
+  }[]
+  [key: string]: any
+}
index 960523cd709e1a7c4c1f72afcc47852986e49417..b4c71ac4984e99954fe735b73cceaeb6521f6ea2 100644 (file)
@@ -1,23 +1,17 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { UserRight } from '../../../../../shared/models/users'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRight, VideoFilter, VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-videos-local',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
 })
 export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
   titlePage: string
index cc91a2330204eba13b0b06dbcfb8b69333b8b52e..ca14851bbd39e174cccae0724852a108dcc08382 100644 (file)
@@ -1,21 +1,17 @@
 import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-videos-most-liked',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
 })
 export class VideoMostLikedComponent extends AbstractVideoList implements OnInit {
   titlePage: string
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html
deleted file mode 100644 (file)
index ca986c6..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<h1 class="sr-only" i18n>Discover</h1>
-<div class="margin-content">
-
-  <div class="no-results" i18n *ngIf="notResults">No results.</div>
-
-  <div
-    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
-  >
-    <ng-container *ngFor="let overview of overviews">
-
-      <div class="section videos" *ngFor="let object of overview.categories">
-        <h1 class="section-title">
-          <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
-        </h1>
-
-        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
-          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
-          </my-video-miniature>
-        </div>
-      </div>
-
-      <div class="section videos" *ngFor="let object of overview.tags">
-        <h2 class="section-title">
-          <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
-        </h2>
-
-        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
-          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
-          </my-video-miniature>
-        </div>
-      </div>
-
-      <div class="section channel videos" *ngFor="let object of overview.channels">
-        <div class="section-title">
-          <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
-            <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
-
-            <h2 class="section-title">{{ object.channel.displayName }}</h2>
-          </a>
-        </div>
-
-        <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
-          <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
-          </my-video-miniature>
-        </div>
-      </div>
-
-    </ng-container>
-
-  </div>
-
-</div>
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss
deleted file mode 100644 (file)
index c1d1018..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.section-title {
-  // make the element span a full grid row within .videos grid
-  grid-column: 1 / -1;
-}
-
-.margin-content {
-  @include fluid-videos-miniature-layout;
-}
-
-.section {
-  @include miniature-rows;
-}
diff --git a/client/src/app/videos/video-list/video-overview.component.ts b/client/src/app/videos/video-list/video-overview.component.ts
deleted file mode 100644 (file)
index 8ff8400..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Subject } from 'rxjs'
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { User, UserService } from '@app/shared'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { OverviewService } from '@app/shared/overview'
-import { VideosOverview } from '@app/shared/overview/videos-overview.model'
-import { Video } from '@app/shared/video/video.model'
-
-@Component({
-  selector: 'my-video-overview',
-  templateUrl: './video-overview.component.html',
-  styleUrls: [ './video-overview.component.scss' ]
-})
-export class VideoOverviewComponent implements OnInit {
-  onDataSubject = new Subject<any>()
-
-  overviews: VideosOverview[] = []
-  notResults = false
-
-  userMiniature: User
-
-  private loaded = false
-  private currentPage = 1
-  private maxPage = 20
-  private lastWasEmpty = false
-  private isLoading = false
-
-  constructor (
-    private notifier: Notifier,
-    private userService: UserService,
-    private overviewService: OverviewService,
-    private screenService: ScreenService
-  ) { }
-
-  ngOnInit () {
-    this.loadMoreResults()
-
-    this.userService.getAnonymousOrLoggedUser()
-      .subscribe(user => this.userMiniature = user)
-
-    this.userService.listenAnonymousUpdate()
-      .subscribe(user => this.userMiniature = user)
-  }
-
-  buildVideoChannelBy (object: { videos: Video[] }) {
-    return object.videos[0].byVideoChannel
-  }
-
-  buildVideoChannelAvatarUrl (object: { videos: Video[] }) {
-    return object.videos[0].videoChannelAvatarUrl
-  }
-
-  buildVideos (videos: Video[]) {
-    const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures()
-
-    return videos.slice(0, numberOfVideos * 2)
-  }
-
-  onNearOfBottom () {
-    if (this.currentPage >= this.maxPage) return
-    if (this.lastWasEmpty) return
-    if (this.isLoading) return
-
-    this.currentPage++
-    this.loadMoreResults()
-  }
-
-  private loadMoreResults () {
-    this.isLoading = true
-
-    this.overviewService.getVideosOverview(this.currentPage)
-        .subscribe(
-          overview => {
-            this.isLoading = false
-
-            if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) {
-              this.lastWasEmpty = true
-              if (this.loaded === false) this.notResults = true
-
-              return
-            }
-
-            this.loaded = true
-            this.onDataSubject.next(overview)
-
-            this.overviews.push(overview)
-          },
-
-          err => {
-            this.notifier.error(err.message)
-            this.isLoading = false
-          }
-        )
-  }
-}
index 9f57a61e344bce45422711ef8cb0c6964ed49bb4..c9395133f61da8a4de8dc537bc6a2bb86cfc8f71 100644 (file)
@@ -1,21 +1,17 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-videos-recently-added',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
 })
 export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
   titlePage: string
index 62e0f4e690c64037b1ac16d574426fe2850b9833..10eab18de0b2d6eac97ebf09a49db3ad61c22f29 100644 (file)
@@ -1,21 +1,17 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-videos-trending',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
 })
 export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
   titlePage: string
index 036fd8dcbb1a6a2812f3cb5513bc239ac47093a1..41ad9b27791d082012ae1fedef72cbc74d42c4ce 100644 (file)
@@ -1,22 +1,18 @@
 import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
+import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-videos-user-subscriptions',
-  styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
-  templateUrl: '../../shared/video/abstract-video-list.html'
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
 })
 export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
   titlePage: string
@@ -34,6 +30,7 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
     protected userService: UserService,
     protected screenService: ScreenService,
     protected storageService: LocalStorageService,
+    private userSubscription: UserSubscriptionService,
     private videoService: VideoService,
     private hooks: HooksService
   ) {
@@ -64,7 +61,7 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
     }
 
     return this.hooks.wrapObsFun(
-      this.videoService.getUserSubscriptionVideos.bind(this.videoService),
+      this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription),
       params,
       'common',
       'filter:api.user-subscriptions-videos.videos.list.params',
index 11a087d0af38567642cdb95d2d9c77fc0e215e48..16b65be6363b49c05e0d80579b301b0121344310 100644 (file)
@@ -6,8 +6,8 @@ import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.c
 import { VideoTrendingComponent } from './video-list/video-trending.component'
 import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
 import { VideosComponent } from './videos.component'
-import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
-import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
+import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
 
 const videosRoutes: Routes = [
   {
index 95078a7341b7e66722a65faaf1bb523d7b12f18f..217e5bb50d24d09a70c5499bae4d9b9b068ef34d 100644 (file)
@@ -1,18 +1,27 @@
 import { NgModule } from '@angular/core'
-import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
+import { VideoLocalComponent } from './video-list/video-local.component'
+import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
 import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
 import { VideoTrendingComponent } from './video-list/video-trending.component'
-import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
 import { VideosRoutingModule } from './videos-routing.module'
 import { VideosComponent } from './videos.component'
-import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
-import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
 
 @NgModule({
   imports: [
     VideosRoutingModule,
-    SharedModule
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedVideoMiniatureModule,
+    SharedUserSubscriptionModule,
+    SharedGlobalIconModule
   ],
 
   declarations: [
index 23890e20ce72c5e5e3c8ce664433ecf1704ec326..914c52e27add2ca0033f37a962001711abaafba9 100644 (file)
@@ -1,9 +1,15 @@
-import * as express from 'express'
 import 'multer'
-import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
+import * as express from 'express'
+import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate, VideoSortField } from '../../../../shared'
+import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
+import { createReqFiles } from '../../../helpers/express-utils'
 import { getFormattedObjects } from '../../../helpers/utils'
+import { CONFIG } from '../../../initializers/config'
 import { MIMETYPES } from '../../../initializers/constants'
+import { sequelizeTypescript } from '../../../initializers/database'
 import { sendUpdateActor } from '../../../lib/activitypub/send'
+import { updateActorAvatarFile } from '../../../lib/avatar'
+import { sendVerifyUserEmail } from '../../../lib/user'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -15,19 +21,12 @@ import {
   usersVideoRatingValidator
 } from '../../../middlewares'
 import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators'
+import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
+import { AccountModel } from '../../../models/account/account'
 import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
 import { UserModel } from '../../../models/account/user'
 import { VideoModel } from '../../../models/video/video'
-import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
-import { createReqFiles } from '../../../helpers/express-utils'
-import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
-import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
-import { updateActorAvatarFile } from '../../../lib/avatar'
 import { VideoImportModel } from '../../../models/video/video-import'
-import { AccountModel } from '../../../models/account/account'
-import { CONFIG } from '../../../initializers/config'
-import { sequelizeTypescript } from '../../../initializers/database'
-import { sendVerifyUserEmail } from '../../../lib/user'
 
 const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
 
index 8579bdbb44401e0f6fe0b1d4d5e82b54284a0ff8..3f8e186334bef7d6b01eb79b6a015ab6cad7bfe7 100644 (file)
@@ -10,7 +10,7 @@ import {
   RegisterServerAuthenticatedResult,
   RegisterServerAuthPassOptions,
   RegisterServerExternalAuthenticatedResult
-} from '@shared/models/plugins/register-server-auth.model'
+} from '@server/types/plugins/register-server-auth.model'
 import * as express from 'express'
 import * as OAuthServer from 'express-oauth-server'
 
index de82b4918866185b30cd0ee92af7e467ce4a6186..39773f693757799c380f640dc4ad29a16f929087 100644 (file)
@@ -1,4 +1,4 @@
-import { PeerTubeHelpers } from '@server/typings/plugins'
+import { PeerTubeHelpers } from '@server/types/plugins'
 import { sequelizeTypescript } from '@server/initializers/database'
 import { buildLogger } from '@server/helpers/logger'
 import { VideoModel } from '@server/models/video/video'
index 7fda5d9a4beadf93796d8f6131807b1438f572de..94b5ecc4114039b5c1bbe26d4efa20e98166e33b 100644 (file)
@@ -1,28 +1,26 @@
-import { PluginModel } from '../../models/server/plugin'
-import { logger } from '../../helpers/logger'
+import { createReadStream, createWriteStream } from 'fs'
+import { outputFile, readJSON } from 'fs-extra'
 import { basename, join } from 'path'
-import { CONFIG } from '../../initializers/config'
-import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
+import { MOAuthTokenUser, MUser } from '@server/types/models'
+import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
+import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
 import {
   ClientScript,
   PluginPackageJson,
   PluginTranslationPaths as PackagePluginTranslations
 } from '../../../shared/models/plugins/plugin-package-json.model'
-import { createReadStream, createWriteStream } from 'fs'
-import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
+import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
 import { PluginType } from '../../../shared/models/plugins/plugin.type'
-import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
-import { outputFile, readJSON } from 'fs-extra'
 import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server-hook.model'
-import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
-import { RegisterServerOptions } from '../../typings/plugins/register-server-option.model'
-import { PluginLibrary } from '../../typings/plugins'
+import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
+import { logger } from '../../helpers/logger'
+import { CONFIG } from '../../initializers/config'
+import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
+import { PluginModel } from '../../models/server/plugin'
+import { PluginLibrary, RegisterServerAuthExternalOptions, RegisterServerAuthPassOptions, RegisterServerOptions } from '../../types/plugins'
 import { ClientHtml } from '../client-html'
-import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
 import { RegisterHelpersStore } from './register-helpers-store'
-import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
-import { MOAuthTokenUser, MUser } from '@server/types/models'
-import { RegisterServerAuthPassOptions, RegisterServerAuthExternalOptions } from '@shared/models/plugins/register-server-auth.model'
+import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
 
 export interface RegisteredPlugin {
   npmName: string
index e337b1cb09853a4ce48c3cbb11a28f21691cb97f..c73079302157c573aa982d4c1109d9e239c67fd4 100644 (file)
@@ -9,22 +9,24 @@ import {
 } from '@server/initializers/constants'
 import { onExternalUserAuthenticated } from '@server/lib/auth'
 import { PluginModel } from '@server/models/server/plugin'
-import { RegisterServerOptions } from '@server/typings/plugins'
-import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model'
-import { PluginSettingsManager } from '@shared/models/plugins/plugin-settings-manager.model'
-import { PluginStorageManager } from '@shared/models/plugins/plugin-storage-manager.model'
-import { PluginVideoCategoryManager } from '@shared/models/plugins/plugin-video-category-manager.model'
-import { PluginVideoLanguageManager } from '@shared/models/plugins/plugin-video-language-manager.model'
-import { PluginVideoLicenceManager } from '@shared/models/plugins/plugin-video-licence-manager.model'
-import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model'
 import {
   RegisterServerAuthExternalOptions,
   RegisterServerAuthExternalResult,
   RegisterServerAuthPassOptions,
-  RegisterServerExternalAuthenticatedResult
-} from '@shared/models/plugins/register-server-auth.model'
-import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
-import { RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+  RegisterServerExternalAuthenticatedResult,
+  RegisterServerOptions
+} from '@server/types/plugins'
+import {
+  PluginPlaylistPrivacyManager,
+  PluginSettingsManager,
+  PluginStorageManager,
+  PluginVideoCategoryManager,
+  PluginVideoLanguageManager,
+  PluginVideoLicenceManager,
+  PluginVideoPrivacyManager,
+  RegisterServerHookOptions,
+  RegisterServerSettingOptions
+} from '@shared/models'
 import { serverHookObject } from '@shared/models/plugins/server-hook.model'
 import { buildPluginHelpers } from './plugin-helpers'
 
index 88c9b4adb94b43182d729af2c6c604947b1c7d09..d706d9ea834566099e3cb34764758682e25eca4f 100644 (file)
@@ -3,23 +3,6 @@ import validator from 'validator'
 import { Col } from 'sequelize/types/lib/utils'
 import { literal, OrderItem, Op } from 'sequelize'
 
-type Primitive = string | Function | number | boolean | Symbol | undefined | null
-type DeepOmitHelper<T, K extends keyof T> = {
-  [P in K]: // extra level of indirection needed to trigger homomorhic behavior
-  T[P] extends infer TP // distribute over unions
-    ? TP extends Primitive
-      ? TP // leave primitives and functions alone
-      : TP extends any[]
-        ? DeepOmitArray<TP, K> // Array special handling
-        : DeepOmit<TP, K>
-    : never
-}
-type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
-
-type DeepOmitArray<T extends any[], K> = {
-  [P in keyof T]: DeepOmit<T[P], K>
-}
-
 type SortType = { sortModel: string, sortValue: string }
 
 // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
@@ -217,7 +200,6 @@ function searchAttribute (sourceField?: string, targetField?: string) {
 // ---------------------------------------------------------------------------
 
 export {
-  DeepOmit,
   buildBlockedAccountSQL,
   buildLocalActorIdsIn,
   SortType,
diff --git a/server/types/index.ts b/server/types/index.ts
new file mode 100644 (file)
index 0000000..18d3827
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './plugins'
+export * from './activitypub-processor.model'
+export * from './sequelize'
index 0d8bf11bd98b5838f5aab96af8896f5efa53b892..3126fd0ab499bbcd99baefa0eccb4cc99123ab22 100644 (file)
@@ -1,5 +1,5 @@
 import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
 import { MAccountDefault, MAccountFormattable } from './account'
 
 type Use<K extends keyof AccountBlocklistModel, M> = PickWith<AccountBlocklistModel, K, M>
index 7b826ee046be0db57aa86e42ab7fb39b500c21aa..d2add98103d69defdd832bd7bfa0d0005f0fb16b 100644 (file)
@@ -13,7 +13,7 @@ import {
   MActorSummaryFormattable,
   MActorUrl
 } from './actor'
-import { FunctionProperties, PickWith } from '../../utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
 import { MAccountBlocklistId } from './account-blocklist'
 import { MChannelDefault } from '../video/video-channels'
 
index 5d0c03c8d537eeaaf8b59e6a287a0eb1371b0958..8c213d09c774b8614265f9cd0c10fb9c4986acf2 100644 (file)
@@ -8,7 +8,7 @@ import {
   MActorHost,
   MActorUsername
 } from './actor'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
 import { ActorModel } from '@server/models/activitypub/actor'
 import { MChannelDefault } from '../video/video-channels'
 
index 1160e84cb7f076401ff2bf108fbbed80506c6dbc..ee0d05f4ec4e84475e4b127166c388b68873047e 100644 (file)
@@ -1,5 +1,5 @@
 import { ActorModel } from '../../../models/activitypub/actor'
-import { FunctionProperties, PickWith, PickWithOpt } from '../../utils'
+import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
 import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from './account'
 import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server'
 import { MAvatar, MAvatarFormattable } from './avatar'
index 6eba59ee4e6996cd8cbd5059e84ecee032cf8ccf..0489a85992059dd39c60d7a0e8adc5b2c2e75643 100644 (file)
@@ -1,5 +1,5 @@
 import { AvatarModel } from '../../../models/avatar/avatar'
-import { FunctionProperties } from '@server/types/utils'
+import { FunctionProperties } from '@shared/core-utils'
 
 export type MAvatar = AvatarModel
 
index 396cf642960787c01bc3a993feeeb6c1d426a8a1..8399af8f1795948235ab4f11bf930609c2571835 100644 (file)
@@ -1,5 +1,5 @@
 import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MUserAccountUrl } from '../user/user'
 
 type Use<K extends keyof OAuthTokenModel, M> = PickWith<OAuthTokenModel, K, M>
index cfbf3b73ac751b4783587741cc25c6317bed7ac5..801f179fd7cd6640c473fb6c65ac51abb04b67fa 100644 (file)
@@ -1,5 +1,5 @@
 import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MAccountDefault, MAccountFormattable } from '../account/account'
 import { MServer, MServerFormattable } from './server'
 
index b35e55aeb556d1e139f41438487239217adc788c..f8b053e3b9cc2c2479f371b305f3b60677448b92 100644 (file)
@@ -1,5 +1,5 @@
 import { ServerModel } from '../../../models/server/server'
-import { FunctionProperties, PickWith } from '../../utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
 import { MAccountBlocklistId } from '../account'
 
 type Use<K extends keyof ServerModel, M> = PickWith<ServerModel, K, M>
index 2080360e12e1d49c806513ce687416a1632bbd87..dd3de423b978d5d89084a1581552bae241a99a6d 100644 (file)
@@ -1,5 +1,5 @@
 import { UserNotificationModel } from '../../../models/account/user-notification'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { VideoModel } from '../../../models/video/video'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ServerModel } from '../../../models/server/server'
index 7938ea2885f2a765a46ea803dab0e315fcb8e2d6..12a68accf682e5dc1de6e4322995d05b79747700 100644 (file)
@@ -1,5 +1,5 @@
 import { UserModel } from '../../../models/account/user'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import {
   MAccount,
   MAccountDefault,
index 6336fdabe9c51f755e334801670574ecf591707b..5d2936000529ce8ef77add5bc21588a5dc8b6cb7 100644 (file)
@@ -1,5 +1,5 @@
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MVideoAPWithoutCaption, MVideoWithBlacklistLight } from './video'
 
 type Use<K extends keyof ScheduleVideoUpdateModel, M> = PickWith<ScheduleVideoUpdateModel, K, M>
index d60f05e4cd21413b1e1e06749d0856f2147a2afd..279a87cf3ac189971731982a07f4c21e57b8bc6a 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
 import { MVideoAccountLightBlacklistAllFiles, MVideo } from './video'
 import { MAccountDefault, MAccountFormattable } from '../account'
 
index 08e59284a85ba5446320010318dd55f9e705faf4..2ac912405e169fd8d7d5d03180f0b6133cc1bbe0 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MVideo, MVideoFormattable } from './video'
 
 type Use<K extends keyof VideoBlacklistModel, M> = PickWith<VideoBlacklistModel, K, M>
index 9bb067001ecc9704d399c42e79e50c4b0e66f26f..ab80ff8305c9bb73cc92cad918e68d3669fb8692 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoCaptionModel } from '../../../models/video/video-caption'
-import { FunctionProperties, PickWith } from '@server/types/utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
 import { MVideo, MVideoUUID } from './video'
 
 type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M>
index 7421e081e4bc848c46041258b6474764e24edd8b..244d1a6713aaf9a68030865d9d600821dbf265c4 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MAccountDefault, MAccountFormattable } from '../account/account'
 import { MVideo, MVideoWithAllFiles } from './video'
 
index 50f7c2d8a053c49647fb8d225f2d54889547a11a..2e05d8753c2886f5732f96064bc2742a2a322734 100644 (file)
@@ -1,4 +1,4 @@
-import { FunctionProperties, PickWith, PickWithOpt } from '../../utils'
+import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
 import { VideoChannelModel } from '../../../models/video/video-channel'
 import {
   MAccountActor,
index d6e0b66f545aeaadf5aa05db03bd039887b046b3..f1c50c753a0dcaeac7e48dde987697ddecaa89a8 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoCommentModel } from '../../../models/video/video-comment'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { MAccountDefault, MAccountFormattable, MAccountUrl } from '../account'
 import { MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoUrl } from './video'
 
index 3fcaca78f6f427db09f7fe1b72d0fdb9663f36a0..327a148ce9fdb783fcfaee00fd4b3baac6eb8a5d 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoFileModel } from '../../../models/video/video-file'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { MVideo, MVideoUUID } from './video'
 import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy'
 import { MStreamingPlaylistVideo, MStreamingPlaylist } from './video-streaming-playlist'
index f1385877e031e621390f951b32d0a7b39cae5005..759b13c6ef2d28dbbc4a2923806e804affb640fb 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoImportModel } from '@server/models/video/video-import'
-import { PickWith, PickWithOpt } from '@server/types/utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { MVideo, MVideoAccountLight, MVideoFormattable, MVideoTag, MVideoThumbnail, MVideoWithFile } from './video'
 import { MUser } from '../user/user'
 
index c50992da7eae5a5ffd1910b92d977db2323a4c86..f46ff4d49bb82a575373fa245c963420b0e55ba3 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MVideoFormattable, MVideoThumbnail, MVideoUrl } from './video'
 import { MVideoPlaylistPrivacy } from './video-playlist'
 
index b504d16647bfeda46505dcb2b3ba0859a4ce6964..79e2daebf5ea9ea034da73421eba3f3f703e7a33 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoPlaylistModel } from '../../../models/video/video-playlist'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
 import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account'
 import { MThumbnail } from './thumbnail'
 import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels'
index a7682ef31d8ab5aa37968f6d0162ffe86a1d5bf9..7bd54f7b017b4ca03655d57572ba11fa554db4cf 100644 (file)
@@ -1,5 +1,5 @@
 import { AccountVideoRateModel } from '@server/models/account/account-video-rate'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
 import { MAccountAudience, MAccountUrl } from '../account/account'
 import { MVideo, MVideoFormattable } from './video'
 
index 7c7d5203528e5767530dfbbdbf3f52a77e42d880..411375c81e723f4d39a147bc6540d4bd792e2d27 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
-import { PickWith, PickWithOpt } from '@server/types/utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
 import { VideoFileModel } from '@server/models/video/video-file'
 import { MVideoFile, MVideoFileVideo } from './video-file'
index 50ca75d269b8858b8a3609fcec1d72db7b95006b..b7a783bb6e7b812a21f7222b5c3a1e7f9f594400 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoShareModel } from '../../../models/video/video-share'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
 import { MActorDefault } from '../account'
 import { MVideo } from './video'
 
index 3f54aa560b7ac3b2354bf61438f1ffc3ab1f9f92..8b3ef51fc625b6d7fffa21e93bb050617fcebcbe 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import { MVideoRedundancyFileUrl, MVideoRedundancy } from './video-redundancy'
 import { MVideo } from './video'
 import { MVideoFile } from './video-file'
index 022a9566d50d3f8267df2af29566c504f8cf5a54..3d8f85b3de456c190200762034bb664a861cf271 100644 (file)
@@ -1,5 +1,5 @@
 import { VideoModel } from '../../../models/video/video'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
 import {
   MChannelAccountDefault,
   MChannelAccountLight,
diff --git a/server/types/plugins/index.ts b/server/types/plugins/index.ts
new file mode 100644 (file)
index 0000000..de30ff2
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './plugin-library.model'
+export * from './register-server-auth.model'
+export * from './register-server-option.model'
diff --git a/server/types/plugins/plugin-library.model.ts b/server/types/plugins/plugin-library.model.ts
new file mode 100644 (file)
index 0000000..5b517ee
--- /dev/null
@@ -0,0 +1,7 @@
+import { RegisterServerOptions } from './register-server-option.model'
+
+export interface PluginLibrary {
+  register: (options: RegisterServerOptions) => Promise<any>
+
+  unregister: () => Promise<any>
+}
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
new file mode 100644 (file)
index 0000000..31c71b0
--- /dev/null
@@ -0,0 +1,52 @@
+import * as express from 'express'
+import { UserRole } from '@shared/models'
+import { MOAuthToken, MUser } from '../models'
+
+export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
+
+export interface RegisterServerAuthenticatedResult {
+  username: string
+  email: string
+  role?: UserRole
+  displayName?: string
+}
+
+export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
+  req: express.Request
+  res: express.Response
+}
+
+interface RegisterServerAuthBase {
+  // Authentication name (a plugin can register multiple auth strategies)
+  authName: string
+
+  // Called by PeerTube when a user from your plugin logged out
+  onLogout?(user: MUser): void
+
+  // Your plugin can hook PeerTube access/refresh token validity
+  // So you can control for your plugin the user session lifetime
+  hookTokenValidity?(options: { token: MOAuthToken, type: 'access' | 'refresh' }): Promise<{ valid: boolean }>
+}
+
+export interface RegisterServerAuthPassOptions extends RegisterServerAuthBase {
+  // Weight of this authentication so PeerTube tries the auth methods in DESC weight order
+  getWeight(): number
+
+  // Used by PeerTube to login a user
+  // Returns null if the login failed, or { username, email } on success
+  login(body: {
+    id: string
+    password: string
+  }): Promise<RegisterServerAuthenticatedResult | null>
+}
+
+export interface RegisterServerAuthExternalOptions extends RegisterServerAuthBase {
+  // Will be displayed in a block next to the login form
+  authDisplayName: () => string
+
+  onAuthRequest: (req: express.Request, res: express.Response) => void
+}
+
+export interface RegisterServerAuthExternalResult {
+  userAuthenticated (options: RegisterServerExternalAuthenticatedResult): void
+}
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
new file mode 100644 (file)
index 0000000..74303d3
--- /dev/null
@@ -0,0 +1,84 @@
+import * as Bluebird from 'bluebird'
+import { Router } from 'express'
+import { Logger } from 'winston'
+import { ActorModel } from '@server/models/activitypub/actor'
+import {
+  PluginPlaylistPrivacyManager,
+  PluginSettingsManager,
+  PluginStorageManager,
+  PluginVideoCategoryManager,
+  PluginVideoLanguageManager,
+  PluginVideoLicenceManager,
+  PluginVideoPrivacyManager,
+  RegisterServerHookOptions,
+  RegisterServerSettingOptions,
+  VideoBlacklistCreate
+} from '@shared/models'
+import { MVideoThumbnail } from '../models'
+import {
+  RegisterServerAuthExternalOptions,
+  RegisterServerAuthExternalResult,
+  RegisterServerAuthPassOptions
+} from './register-server-auth.model'
+
+export type PeerTubeHelpers = {
+  logger: Logger
+
+  database: {
+    query: Function
+  }
+
+  videos: {
+    loadByUrl: (url: string) => Bluebird<MVideoThumbnail>
+
+    removeVideo: (videoId: number) => Promise<void>
+  }
+
+  config: {
+    getWebserverUrl: () => string
+  }
+
+  moderation: {
+    blockServer: (options: { byAccountId: number, hostToBlock: string }) => Promise<void>
+    unblockServer: (options: { byAccountId: number, hostToUnblock: string }) => Promise<void>
+    blockAccount: (options: { byAccountId: number, handleToBlock: string }) => Promise<void>
+    unblockAccount: (options: { byAccountId: number, handleToUnblock: string }) => Promise<void>
+
+    blacklistVideo: (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => Promise<void>
+    unblacklistVideo: (options: { videoIdOrUUID: number | string }) => Promise<void>
+  }
+
+  server: {
+    getServerActor: () => Promise<ActorModel>
+  }
+}
+
+export type RegisterServerOptions = {
+  registerHook: (options: RegisterServerHookOptions) => void
+
+  registerSetting: (options: RegisterServerSettingOptions) => void
+
+  settingsManager: PluginSettingsManager
+
+  storageManager: PluginStorageManager
+
+  videoCategoryManager: PluginVideoCategoryManager
+  videoLanguageManager: PluginVideoLanguageManager
+  videoLicenceManager: PluginVideoLicenceManager
+
+  videoPrivacyManager: PluginVideoPrivacyManager
+  playlistPrivacyManager: PluginPlaylistPrivacyManager
+
+  registerIdAndPassAuth: (options: RegisterServerAuthPassOptions) => void
+  registerExternalAuth: (options: RegisterServerAuthExternalOptions) => RegisterServerAuthExternalResult
+  unregisterIdAndPassAuth: (authName: string) => void
+  unregisterExternalAuth: (authName: string) => void
+
+  // Get plugin router to create custom routes
+  // Base routes of this router are
+  //  * /plugins/:pluginName/:pluginVersion/router/...
+  //  * /plugins/:pluginName/router/...
+  getRouter(): Router
+
+  peertubeHelpers: PeerTubeHelpers
+}
diff --git a/server/types/utils.ts b/server/types/utils.ts
deleted file mode 100644 (file)
index 55500d8..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-/* eslint-disable @typescript-eslint/array-type */
-
-export type FunctionPropertyNames<T> = {
-  [K in keyof T]: T[K] extends Function ? K : never
-}[keyof T]
-
-export type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>
-
-export type PickWith<T, KT extends keyof T, V> = {
-  [P in KT]: T[P] extends V ? V : never
-}
-
-export type PickWithOpt<T, KT extends keyof T, V> = {
-  [P in KT]?: T[P] extends V ? V : never
-}
-
-// https://github.com/krzkaczor/ts-essentials Rocks!
-export type DeepPartial<T> = {
-  [P in keyof T]?: T[P] extends Array<infer U>
-    ? Array<DeepPartial<U>>
-    : T[P] extends ReadonlyArray<infer U>
-      ? ReadonlyArray<DeepPartial<U>>
-      : DeepPartial<T[P]>
-}
index ad3212340c79625f4dbdbf10864799bf723fbdc9..cac801e55229c8a39f104f574116185fe5073bbc 100644 (file)
@@ -1,3 +1,20 @@
+import { RegisterServerAuthExternalOptions } from '@server/types'
+import {
+  MAccountBlocklist,
+  MActorUrl,
+  MStreamingPlaylist,
+  MVideoChangeOwnershipFull,
+  MVideoFile,
+  MVideoImmutable,
+  MVideoPlaylistFull,
+  MVideoPlaylistFullSummary
+} from '@server/types/models'
+import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
+import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
+import { MVideoImportDefault } from '@server/types/models/video/video-import'
+import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
+import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
+import { UserRole } from '@shared/models'
 import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
 import {
   MAccountDefault,
@@ -19,17 +36,6 @@ import {
   MVideoThumbnail,
   MVideoWithRights
 } from '../../types/models'
-import { MVideoPlaylistFull, MVideoPlaylistFullSummary } from '../../types/models/video/video-playlist'
-import { MVideoImportDefault } from '@server/types/models/video/video-import'
-import { MAccountBlocklist, MActorUrl, MStreamingPlaylist, MVideoFile, MVideoImmutable } from '@server/types/models'
-import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
-import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
-import { MVideoChangeOwnershipFull } from '../../types/models/video/video-change-ownership'
-import { MPlugin, MServer } from '@server/types/models/server'
-import { MServerBlocklist } from '../../types/models/server/server-blocklist'
-import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
-import { UserRole } from '@shared/models'
-import { RegisterServerAuthExternalOptions } from '@shared/models/plugins/register-server-auth.model'
 
 declare module 'express' {
   export interface Request {
diff --git a/server/typings/plugins/index.d.ts b/server/typings/plugins/index.d.ts
deleted file mode 100644 (file)
index 9570579..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './register-server-option.model'
-export * from './plugin-library.model'
diff --git a/server/typings/plugins/plugin-library.model.ts b/server/typings/plugins/plugin-library.model.ts
deleted file mode 100644 (file)
index 5b517ee..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { RegisterServerOptions } from './register-server-option.model'
-
-export interface PluginLibrary {
-  register: (options: RegisterServerOptions) => Promise<any>
-
-  unregister: () => Promise<any>
-}
diff --git a/server/typings/plugins/register-server-option.model.ts b/server/typings/plugins/register-server-option.model.ts
deleted file mode 100644 (file)
index b4594c6..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as Bluebird from 'bluebird'
-import { Router } from 'express'
-import { Logger } from 'winston'
-import { ActorModel } from '@server/models/activitypub/actor'
-import { VideoBlacklistCreate } from '@shared/models'
-import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model'
-import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model'
-import {
-  RegisterServerAuthExternalOptions,
-  RegisterServerAuthExternalResult,
-  RegisterServerAuthPassOptions
-} from '@shared/models/plugins/register-server-auth.model'
-import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
-import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model'
-import { PluginVideoCategoryManager } from '../../../shared/models/plugins/plugin-video-category-manager.model'
-import { PluginVideoLanguageManager } from '../../../shared/models/plugins/plugin-video-language-manager.model'
-import { PluginVideoLicenceManager } from '../../../shared/models/plugins/plugin-video-licence-manager.model'
-import { RegisterServerHookOptions } from '../../../shared/models/plugins/register-server-hook.model'
-import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
-import { MVideoThumbnail } from '../../types/models'
-
-export type PeerTubeHelpers = {
-  logger: Logger
-
-  database: {
-    query: Function
-  }
-
-  videos: {
-    loadByUrl: (url: string) => Bluebird<MVideoThumbnail>
-
-    removeVideo: (videoId: number) => Promise<void>
-  }
-
-  config: {
-    getWebserverUrl: () => string
-  }
-
-  moderation: {
-    blockServer: (options: { byAccountId: number, hostToBlock: string }) => Promise<void>
-    unblockServer: (options: { byAccountId: number, hostToUnblock: string }) => Promise<void>
-    blockAccount: (options: { byAccountId: number, handleToBlock: string }) => Promise<void>
-    unblockAccount: (options: { byAccountId: number, handleToUnblock: string }) => Promise<void>
-
-    blacklistVideo: (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => Promise<void>
-    unblacklistVideo: (options: { videoIdOrUUID: number | string }) => Promise<void>
-  }
-
-  server: {
-    getServerActor: () => Promise<ActorModel>
-  }
-}
-
-export type RegisterServerOptions = {
-  registerHook: (options: RegisterServerHookOptions) => void
-
-  registerSetting: (options: RegisterServerSettingOptions) => void
-
-  settingsManager: PluginSettingsManager
-
-  storageManager: PluginStorageManager
-
-  videoCategoryManager: PluginVideoCategoryManager
-  videoLanguageManager: PluginVideoLanguageManager
-  videoLicenceManager: PluginVideoLicenceManager
-
-  videoPrivacyManager: PluginVideoPrivacyManager
-  playlistPrivacyManager: PluginPlaylistPrivacyManager
-
-  registerIdAndPassAuth: (options: RegisterServerAuthPassOptions) => void
-  registerExternalAuth: (options: RegisterServerAuthExternalOptions) => RegisterServerAuthExternalResult
-  unregisterIdAndPassAuth: (authName: string) => void
-  unregisterExternalAuth: (authName: string) => void
-
-  // Get plugin router to create custom routes
-  // Base routes of this router are
-  //  * /plugins/:pluginName/:pluginVersion/router/...
-  //  * /plugins/:pluginName/router/...
-  getRouter(): Router
-
-  peertubeHelpers: PeerTubeHelpers
-}
diff --git a/shared/core-utils/index.ts b/shared/core-utils/index.ts
new file mode 100644 (file)
index 0000000..54e2335
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './logs'
+export * from './miscs'
+export * from './plugins'
diff --git a/shared/core-utils/logs/index.ts b/shared/core-utils/logs/index.ts
new file mode 100644 (file)
index 0000000..ceb5d7a
--- /dev/null
@@ -0,0 +1 @@
+export * from './logs'
diff --git a/shared/core-utils/miscs/index.ts b/shared/core-utils/miscs/index.ts
new file mode 100644 (file)
index 0000000..afd147f
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './date'
+export * from './miscs'
+export * from './types'
diff --git a/shared/core-utils/miscs/types.ts b/shared/core-utils/miscs/types.ts
new file mode 100644 (file)
index 0000000..bb64dc8
--- /dev/null
@@ -0,0 +1,41 @@
+/* eslint-disable @typescript-eslint/array-type */
+
+export type FunctionPropertyNames<T> = {
+  [K in keyof T]: T[K] extends Function ? K : never
+}[keyof T]
+
+export type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>
+
+export type PickWith<T, KT extends keyof T, V> = {
+  [P in KT]: T[P] extends V ? V : never
+}
+
+export type PickWithOpt<T, KT extends keyof T, V> = {
+  [P in KT]?: T[P] extends V ? V : never
+}
+
+// https://github.com/krzkaczor/ts-essentials Rocks!
+export type DeepPartial<T> = {
+  [P in keyof T]?: T[P] extends Array<infer U>
+    ? Array<DeepPartial<U>>
+    : T[P] extends ReadonlyArray<infer U>
+      ? ReadonlyArray<DeepPartial<U>>
+      : DeepPartial<T[P]>
+}
+
+type Primitive = string | Function | number | boolean | Symbol | undefined | null
+export type DeepOmitHelper<T, K extends keyof T> = {
+  [P in K]: // extra level of indirection needed to trigger homomorhic behavior
+  T[P] extends infer TP // distribute over unions
+    ? TP extends Primitive
+      ? TP // leave primitives and functions alone
+      : TP extends any[]
+        ? DeepOmitArray<TP, K> // Array special handling
+        : DeepOmit<TP, K>
+    : never
+}
+export type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
+
+export type DeepOmitArray<T extends any[], K> = {
+  [P in keyof T]: DeepOmit<T[P], K>
+}
diff --git a/shared/core-utils/plugins/index.ts b/shared/core-utils/plugins/index.ts
new file mode 100644 (file)
index 0000000..fc78d35
--- /dev/null
@@ -0,0 +1 @@
+export * from './hooks'
index 2b04a03969d19f83bcc33e10deb735651d0b2ab5..3ae1c6e67d8cf238fa1093393e888d9ee3eb4805 100644 (file)
@@ -1,6 +1,6 @@
 import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
 import { CustomConfig } from '../../models/server/custom-config.model'
-import { DeepPartial } from '@server/types/utils'
+import { DeepPartial } from '@shared/core-utils'
 import { merge } from 'lodash'
 
 function getConfig (url: string) {
index 8fe437b81c37f8285e2afc67988db5856dab0691..c7a92399d45c9c71e66d55871836376d5aac34bd 100644 (file)
@@ -1,2 +1,3 @@
 export * from './account.model'
+export * from './actor.model'
 export * from './follow.model'
index b562e04a33e18bce997e67353a1fc58fc559c261..3d4bdedde40ab4780f8d3212e74a11599f9b6b40 100644 (file)
@@ -9,8 +9,9 @@ export * from './videos'
 export * from './feeds'
 export * from './i18n'
 export * from './overviews'
+export * from './plugins'
 export * from './search'
-export * from './server/job.model'
+export * from './server'
 export * from './oauth-client-local.model'
 export * from './result-list.model'
 export * from './server/server-config.model'
diff --git a/shared/models/plugins/index.ts b/shared/models/plugins/index.ts
new file mode 100644 (file)
index 0000000..209fca7
--- /dev/null
@@ -0,0 +1,24 @@
+export * from './client-hook.model'
+export * from './hook-type.enum'
+export * from './install-plugin.model'
+export * from './manage-plugin.model'
+export * from './peertube-plugin-index-list.model'
+export * from './peertube-plugin-index.model'
+export * from './peertube-plugin-latest-version.model'
+export * from './peertube-plugin.model'
+export * from './plugin-client-scope.type'
+export * from './plugin-package-json.model'
+export * from './plugin-playlist-privacy-manager.model'
+export * from './plugin-settings-manager.model'
+export * from './plugin-storage-manager.model'
+export * from './plugin-translation.model'
+export * from './plugin-video-category-manager.model'
+export * from './plugin-video-language-manager.model'
+export * from './plugin-video-licence-manager.model'
+export * from './plugin-video-privacy-manager.model'
+export * from './plugin.type'
+export * from './public-server.setting'
+export * from './register-client-hook.model'
+export * from './register-server-hook.model'
+export * from './register-server-setting.model'
+export * from './server-hook.model'
index f9630c77f5733dce598dc50c0682739ac6740cbb..d1823ef4e6d1179b5cb3ae8456a0c21feac36e2d 100644 (file)
@@ -1,4 +1,4 @@
-import { VideoPlaylistPrivacy } from '@shared/models'
+import { VideoPlaylistPrivacy } from '../videos/playlist/video-playlist-privacy.model'
 
 export interface PluginPlaylistPrivacyManager {
   // PUBLIC = 1,
index d602ba297d83b3acf85a71abd90058e76bf1d13b..c0e8117b5211ab3097ce90a5f86e4d470708d5b4 100644 (file)
@@ -1,4 +1,4 @@
-import { VideoPrivacy } from '@shared/models'
+import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum'
 
 export interface PluginVideoPrivacyManager {
   // PUBLIC = 1
diff --git a/shared/models/plugins/register-server-auth.model.ts b/shared/models/plugins/register-server-auth.model.ts
deleted file mode 100644 (file)
index fc6f0f2..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-import { UserRole } from '@shared/models'
-import { MOAuthToken, MUser } from '@server/types/models'
-import * as express from 'express'
-
-export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
-
-export interface RegisterServerAuthenticatedResult {
-  username: string
-  email: string
-  role?: UserRole
-  displayName?: string
-}
-
-export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
-  req: express.Request
-  res: express.Response
-}
-
-interface RegisterServerAuthBase {
-  // Authentication name (a plugin can register multiple auth strategies)
-  authName: string
-
-  // Called by PeerTube when a user from your plugin logged out
-  onLogout?(user: MUser): void
-
-  // Your plugin can hook PeerTube access/refresh token validity
-  // So you can control for your plugin the user session lifetime
-  hookTokenValidity?(options: { token: MOAuthToken, type: 'access' | 'refresh' }): Promise<{ valid: boolean }>
-}
-
-export interface RegisterServerAuthPassOptions extends RegisterServerAuthBase {
-  // Weight of this authentication so PeerTube tries the auth methods in DESC weight order
-  getWeight(): number
-
-  // Used by PeerTube to login a user
-  // Returns null if the login failed, or { username, email } on success
-  login(body: {
-    id: string
-    password: string
-  }): Promise<RegisterServerAuthenticatedResult | null>
-}
-
-export interface RegisterServerAuthExternalOptions extends RegisterServerAuthBase {
-  // Will be displayed in a block next to the login form
-  authDisplayName: () => string
-
-  onAuthRequest: (req: express.Request, res: express.Response) => void
-}
-
-export interface RegisterServerAuthExternalResult {
-  userAuthenticated (options: RegisterServerExternalAuthenticatedResult): void
-}
index 28dd954437825e0b131f14a67ff160ce1753be56..e2d0ab62056d67998f8659130157b867209b5b3c 100644 (file)
@@ -1,3 +1,4 @@
 export * from './nsfw-query.model'
+export * from './search-target-query.model'
 export * from './videos-search-query.model'
 export * from './video-channels-search-query.model'
index 2bb443d46a950a00a308109de5b8eebdb3fe40f2..b079bc9a03475af538e833c21ff6ede5448abbec 100644 (file)
@@ -5,5 +5,6 @@ export * from './custom-config.model'
 export * from './debug.model'
 export * from './emailer.model'
 export * from './job.model'
+export * from './log-level.type'
 export * from './server-config.model'
 export * from './server-stats.model'
index cd07cf320021bda49f747f5f18821a96d0d689b4..a9d5780540413e1fe41eb895540eaa01be8a4d57 100644 (file)
@@ -1,12 +1,14 @@
-export * from './user.model'
 export * from './user-create.model'
+export * from './user-flag.model'
 export * from './user-login.model'
-export * from './user-notification.model'
 export * from './user-notification-setting.model'
+export * from './user-notification.model'
 export * from './user-refresh-token.model'
-export * from './user-update.model'
-export * from './user-update-me.model'
+export * from './user-register.model'
 export * from './user-right.enum'
 export * from './user-role'
+export * from './user-update-me.model'
+export * from './user-update.model'
 export * from './user-video-quota.model'
 export * from './user-watching-video.model'
+export * from './user.model'
diff --git a/shared/models/videos/abuse/index.ts b/shared/models/videos/abuse/index.ts
new file mode 100644 (file)
index 0000000..bdeef1d
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './video-abuse-create.model'
+export * from './video-abuse-state.model'
+export * from './video-abuse-update.model'
+export * from './video-abuse-video-is.type'
+export * from './video-abuse.model'
diff --git a/shared/models/videos/blacklist/index.ts b/shared/models/videos/blacklist/index.ts
new file mode 100644 (file)
index 0000000..66082be
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './video-blacklist.model'
+export * from './video-blacklist-create.model'
+export * from './video-blacklist-update.model'
diff --git a/shared/models/videos/caption/index.ts b/shared/models/videos/caption/index.ts
new file mode 100644 (file)
index 0000000..2a5ff51
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './video-caption.model'
+export * from './video-caption-update.model'
diff --git a/shared/models/videos/channel/index.ts b/shared/models/videos/channel/index.ts
new file mode 100644 (file)
index 0000000..9dbaa42
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './video-channel-create.model'
+export * from './video-channel-update.model'
+export * from './video-channel.model'
diff --git a/shared/models/videos/import/index.ts b/shared/models/videos/import/index.ts
new file mode 100644 (file)
index 0000000..8884ee8
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './video-import-create.model'
+export * from './video-import-state.enum'
+export * from './video-import.model'
index 58bd1ebd7df59fc0cc40bd44948181e772fde9de..e1d96b40ad22699db24a25045005b29e24ad6515 100644 (file)
@@ -1,41 +1,38 @@
-export * from './rate/user-video-rate-update.model'
-export * from './rate/user-video-rate.model'
-export * from './rate/account-video-rate.model'
-export * from './rate/user-video-rate.type'
-export * from './abuse/video-abuse-state.model'
-export * from './abuse/video-abuse-create.model'
-export * from './abuse/video-abuse-reason.model'
-export * from './abuse/video-abuse.model'
-export * from './abuse/video-abuse-update.model'
-export * from './blacklist/video-blacklist.model'
-export * from './blacklist/video-blacklist-create.model'
-export * from './blacklist/video-blacklist-update.model'
-export * from './channel/video-channel-create.model'
-export * from './channel/video-channel-update.model'
-export * from './channel/video-channel.model'
-export * from './playlist/video-playlist-create.model'
-export * from './playlist/video-playlist-element-create.model'
-export * from './playlist/video-playlist-element-update.model'
-export * from './playlist/video-playlist-privacy.model'
-export * from './playlist/video-playlist-type.model'
-export * from './playlist/video-playlist-update.model'
-export * from './playlist/video-playlist.model'
-export * from './playlist/video-playlist-element.model'
-export * from './video-change-ownership.model'
+export * from './abuse'
+export * from './blacklist'
+export * from './caption'
+export * from './channel'
+export * from './import'
+export * from './playlist'
+export * from './rate'
+
+export * from './nsfw-policy.type'
+
+export * from './thumbnail.type'
+
+export * from './video-change-ownership-accept.model'
 export * from './video-change-ownership-create.model'
+export * from './video-change-ownership.model'
+
+export * from './video-comment.model'
+export * from './video-constant.model'
 export * from './video-create.model'
+export * from './video-file-metadata'
 export * from './video-file.model'
+
 export * from './video-privacy.enum'
+export * from './video-query.type'
 export * from './video-rate.type'
 export * from './video-resolution.enum'
-export * from './video-update.model'
-export * from './video.model'
-export * from './video-query.type'
+
+export * from './video-schedule-update.model'
+export * from './video-sort-field.type'
 export * from './video-state.enum'
+
+export * from './video-streaming-playlist.model'
+export * from './video-streaming-playlist.type'
+
 export * from './video-transcoding-fps.model'
-export * from './caption/video-caption.model'
-export * from './caption/video-caption-update.model'
-export * from './import/video-import-create.model'
-export * from './import/video-import-state.enum'
-export * from './import/video-import.model'
-export * from './video-constant.model'
+
+export * from './video-update.model'
+export * from './video.model'
diff --git a/shared/models/videos/playlist/index.ts b/shared/models/videos/playlist/index.ts
new file mode 100644 (file)
index 0000000..99f7e9b
--- /dev/null
@@ -0,0 +1,10 @@
+export * from './video-exist-in-playlist.model'
+export * from './video-playlist-create.model'
+export * from './video-playlist-element-create.model'
+export * from './video-playlist-element-update.model'
+export * from './video-playlist-element.model'
+export * from './video-playlist-privacy.model'
+export * from './video-playlist-reorder.model'
+export * from './video-playlist-type.model'
+export * from './video-playlist-update.model'
+export * from './video-playlist.model'
diff --git a/shared/models/videos/rate/index.ts b/shared/models/videos/rate/index.ts
new file mode 100644 (file)
index 0000000..06aa691
--- /dev/null
@@ -0,0 +1,5 @@
+
+export * from './user-video-rate-update.model'
+export * from './user-video-rate.model'
+export * from './account-video-rate.model'
+export * from './user-video-rate.type'
index 15683cacfb233321a3a7c06c802a5ad3e022d690..010bf7cf59c4a25f5c160974d73d62134194fa27 100644 (file)
@@ -1,5 +1,5 @@
-import { FfprobeData } from "fluent-ffmpeg"
-import { DeepOmit } from "@server/models/utils"
+import { FfprobeData } from 'fluent-ffmpeg'
+import { DeepOmit } from '../../core-utils'
 
 export type VideoFileMetadataModel = DeepOmit<FfprobeData, 'filename'>
 
diff --git a/shared/models/videos/video-sort-field.type.ts b/shared/models/videos/video-sort-field.type.ts
new file mode 100644 (file)
index 0000000..f2e70f5
--- /dev/null
@@ -0,0 +1,8 @@
+export type VideoSortField =
+  'name' | '-name' |
+  'duration' | '-duration' |
+  'publishedAt' | '-publishedAt' |
+  'createdAt' | '-createdAt' |
+  'views' | '-views' |
+  'likes' | '-likes' |
+  'trending' | '-trending'