aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app')
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.html13
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.ts60
-rw-r--r--client/src/app/+about/about-instance/about-instance.resolver.ts27
-rw-r--r--client/src/app/+about/about-instance/contact-admin-modal.component.html15
-rw-r--r--client/src/app/+about/about-instance/contact-admin-modal.component.ts2
-rw-r--r--client/src/app/+about/about-peertube/about-peertube.component.html9
-rw-r--r--client/src/app/+about/about-peertube/about-peertube.component.ts12
-rw-r--r--client/src/app/+about/about-routing.module.ts4
-rw-r--r--client/src/app/+about/about.component.html6
-rw-r--r--client/src/app/+about/about.module.ts2
-rw-r--r--client/src/app/+accounts/account-about/account-about.component.html4
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html2
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.scss9
-rw-r--r--client/src/app/+accounts/account-videos/account-videos.component.ts4
-rw-r--r--client/src/app/+accounts/accounts.component.html27
-rw-r--r--client/src/app/+accounts/accounts.component.scss46
-rw-r--r--client/src/app/+accounts/accounts.component.ts20
-rw-r--r--client/src/app/+admin/admin.component.html26
-rw-r--r--client/src/app/+admin/admin.component.ts22
-rw-r--r--client/src/app/+admin/admin.module.ts21
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html1203
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss41
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts30
-rw-r--r--client/src/app/+admin/config/shared/batch-domains-modal.component.html43
-rw-r--r--client/src/app/+admin/config/shared/batch-domains-modal.component.scss3
-rw-r--r--client/src/app/+admin/config/shared/batch-domains-modal.component.ts54
-rw-r--r--client/src/app/+admin/config/shared/batch-domains-validators.service.ts68
-rw-r--r--client/src/app/+admin/config/shared/config.service.ts12
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html46
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.scss14
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts9
-rw-r--r--client/src/app/+admin/follows/following-add/following-add.component.html22
-rw-r--r--client/src/app/+admin/follows/following-add/following-add.component.scss18
-rw-r--r--client/src/app/+admin/follows/following-add/following-add.component.ts85
-rw-r--r--client/src/app/+admin/follows/following-add/index.ts1
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html52
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.scss20
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts33
-rw-r--r--client/src/app/+admin/follows/follows.component.html8
-rw-r--r--client/src/app/+admin/follows/follows.component.scss6
-rw-r--r--client/src/app/+admin/follows/follows.routes.ts13
-rw-r--r--client/src/app/+admin/follows/index.ts2
-rw-r--r--client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts2
-rw-r--r--client/src/app/+admin/follows/shared/redundancy.service.ts28
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/index.ts1
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html100
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss51
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts187
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html19
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss8
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts11
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html54
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts20
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html50
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss20
-rw-r--r--client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts37
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss148
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html9
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts4
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html77
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts17
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html136
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss23
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts283
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html83
-rw-r--r--client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts18
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html23
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts4
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.html25
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss2
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts6
-rw-r--r--client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html18
-rw-r--r--client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss8
-rw-r--r--client/src/app/+admin/plugins/plugins.component.scss26
-rw-r--r--client/src/app/+admin/plugins/shared/plugin-list.component.scss29
-rw-r--r--client/src/app/+admin/plugins/shared/toggle-plugin-type.scss15
-rw-r--r--client/src/app/+admin/system/debug/debug.component.scss7
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.html19
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.scss3
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts20
-rw-r--r--client/src/app/+admin/system/logs/logs.component.html8
-rw-r--r--client/src/app/+admin/system/logs/logs.component.scss37
-rw-r--r--client/src/app/+admin/users/user-edit/user-create.component.ts12
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html261
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.scss35
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.ts51
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.html2
-rw-r--r--client/src/app/+admin/users/user-edit/user-password.component.ts2
-rw-r--r--client/src/app/+admin/users/user-edit/user-update.component.ts37
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.html49
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.scss9
-rw-r--r--client/src/app/+admin/users/user-list/user-list.component.ts12
-rw-r--r--client/src/app/+admin/users/users.routes.ts4
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html3
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts7
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html4
-rw-r--r--client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts7
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.html2
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.scss19
-rw-r--r--client/src/app/+my-account/my-account-history/my-account-history.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss10
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html7
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html2
-rw-r--r--client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts7
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts28
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss7
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html6
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss5
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html5
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss5
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts40
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss14
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html6
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss17
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html99
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.scss18
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.ts14
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html39
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss12
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts64
-rw-r--r--client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss23
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.html149
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss44
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts2
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts41
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html13
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss24
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts94
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts25
-rw-r--r--client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html20
-rw-r--r--client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts11
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html129
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss13
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss11
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss21
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.scss44
-rw-r--r--client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html7
-rw-r--r--client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts2
-rw-r--r--client/src/app/+my-account/my-account.component.html2
-rw-r--r--client/src/app/+my-account/my-account.component.scss5
-rw-r--r--client/src/app/+my-account/my-account.module.ts14
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.html18
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.scss48
-rw-r--r--client/src/app/+my-account/shared/actor-avatar-info.component.ts21
-rw-r--r--client/src/app/+signup/+register/register-step-channel.component.html2
-rw-r--r--client/src/app/+signup/+register/register-step-user.component.html8
-rw-r--r--client/src/app/+signup/+register/register.component.scss2
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html2
-rw-r--r--client/src/app/+video-channels/video-channel-about/video-channel-about.component.html6
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts6
-rw-r--r--client/src/app/+video-channels/video-channels.component.html38
-rw-r--r--client/src/app/+video-channels/video-channels.component.scss50
-rw-r--r--client/src/app/+video-channels/video-channels.component.ts18
-rw-r--r--client/src/app/app-routing.module.ts4
-rw-r--r--client/src/app/app.component.html23
-rw-r--r--client/src/app/app.component.scss19
-rw-r--r--client/src/app/app.component.ts46
-rw-r--r--client/src/app/app.module.ts30
-rw-r--r--client/src/app/core/auth/auth-user.model.ts15
-rw-r--r--client/src/app/core/auth/auth.service.ts16
-rw-r--r--client/src/app/core/core.module.ts2
-rw-r--r--client/src/app/core/hotkeys/hotkeys.component.scss7
-rw-r--r--client/src/app/core/menu/index.ts1
-rw-r--r--client/src/app/core/menu/menu.service.ts32
-rw-r--r--client/src/app/core/plugins/hooks.service.ts10
-rw-r--r--client/src/app/core/plugins/plugin.service.ts40
-rw-r--r--client/src/app/core/routing/custom-reuse-strategy.ts4
-rw-r--r--client/src/app/core/routing/index.ts1
-rw-r--r--client/src/app/core/routing/menu-guard.service.ts48
-rw-r--r--client/src/app/core/routing/preload-selected-modules-list.ts4
-rw-r--r--client/src/app/core/server/server.service.ts41
-rw-r--r--client/src/app/core/theme/theme.service.ts30
-rw-r--r--client/src/app/header/header.component.html6
-rw-r--r--client/src/app/header/header.component.scss49
-rw-r--r--client/src/app/header/header.component.ts60
-rw-r--r--client/src/app/header/index.ts3
-rw-r--r--client/src/app/header/search-typeahead.component.html53
-rw-r--r--client/src/app/header/search-typeahead.component.scss145
-rw-r--r--client/src/app/header/search-typeahead.component.ts179
-rw-r--r--client/src/app/header/suggestion.component.html22
-rw-r--r--client/src/app/header/suggestion.component.scss32
-rw-r--r--client/src/app/header/suggestion.component.ts37
-rw-r--r--client/src/app/header/suggestions.component.html6
-rw-r--r--client/src/app/header/suggestions.component.ts24
-rw-r--r--client/src/app/login/login.component.html109
-rw-r--r--client/src/app/login/login.component.scss39
-rw-r--r--client/src/app/login/login.component.ts71
-rw-r--r--client/src/app/menu/avatar-notification.component.html2
-rw-r--r--client/src/app/menu/avatar-notification.component.ts1
-rw-r--r--client/src/app/menu/language-chooser.component.scss7
-rw-r--r--client/src/app/menu/language-chooser.component.ts18
-rw-r--r--client/src/app/menu/menu.component.html103
-rw-r--r--client/src/app/menu/menu.component.scss207
-rw-r--r--client/src/app/menu/menu.component.ts100
-rw-r--r--client/src/app/modal/custom-modal.component.html20
-rw-r--r--client/src/app/modal/custom-modal.component.scss20
-rw-r--r--client/src/app/modal/custom-modal.component.ts93
-rw-r--r--client/src/app/modal/instance-config-warning-modal.component.html7
-rw-r--r--client/src/app/modal/instance-config-warning-modal.component.ts2
-rw-r--r--client/src/app/modal/quick-settings-modal.component.html20
-rw-r--r--client/src/app/modal/quick-settings-modal.component.scss39
-rw-r--r--client/src/app/modal/quick-settings-modal.component.ts62
-rw-r--r--client/src/app/modal/welcome-modal.component.html12
-rw-r--r--client/src/app/modal/welcome-modal.component.ts3
-rw-r--r--client/src/app/search/search-filters.component.html18
-rw-r--r--client/src/app/search/search-filters.component.scss63
-rw-r--r--client/src/app/search/search.component.scss48
-rw-r--r--client/src/app/search/search.component.ts3
-rw-r--r--client/src/app/search/search.service.ts7
-rw-r--r--client/src/app/shared/angular/from-now.pipe.ts7
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts54
-rw-r--r--client/src/app/shared/angular/timestamp-route-transformer.directive.ts51
-rw-r--r--client/src/app/shared/angular/video-duration-formatter.pipe.ts21
-rw-r--r--client/src/app/shared/blocklist/blocklist.service.ts12
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.html18
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.scss4
-rw-r--r--client/src/app/shared/buttons/action-dropdown.component.ts8
-rw-r--r--client/src/app/shared/buttons/button.component.scss23
-rw-r--r--client/src/app/shared/buttons/edit-button.component.ts2
-rw-r--r--client/src/app/shared/confirm/confirm.component.html8
-rw-r--r--client/src/app/shared/confirm/confirm.component.ts3
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts2
-rw-r--r--client/src/app/shared/forms/form-validators/user-validators.service.ts12
-rw-r--r--client/src/app/shared/forms/input-readonly-copy.component.html4
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.html41
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.scss246
-rw-r--r--client/src/app/shared/forms/markdown-textarea.component.ts39
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.html2
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.scss15
-rw-r--r--client/src/app/shared/forms/peertube-checkbox.component.ts1
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.scss7
-rw-r--r--client/src/app/shared/images/global-icon.component.ts105
-rw-r--r--client/src/app/shared/images/preview-upload.component.html6
-rw-r--r--client/src/app/shared/images/preview-upload.component.scss6
-rw-r--r--client/src/app/shared/images/preview-upload.component.ts20
-rw-r--r--client/src/app/shared/instance/instance-features-table.component.html15
-rw-r--r--client/src/app/shared/instance/instance-statistics.component.ts23
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.html37
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.scss29
-rw-r--r--client/src/app/shared/menu/top-menu-dropdown.component.ts51
-rw-r--r--client/src/app/shared/misc/help.component.scss3
-rw-r--r--client/src/app/shared/misc/list-overflow.component.html35
-rw-r--r--client/src/app/shared/misc/list-overflow.component.scss61
-rw-r--r--client/src/app/shared/misc/list-overflow.component.ts120
-rw-r--r--client/src/app/shared/misc/screen.service.ts4
-rw-r--r--client/src/app/shared/misc/storage.service.ts40
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.html11
-rw-r--r--client/src/app/shared/moderation/user-ban-modal.component.ts2
-rw-r--r--client/src/app/shared/moderation/user-moderation-dropdown.component.ts4
-rw-r--r--client/src/app/shared/overview/overview.service.ts9
-rw-r--r--client/src/app/shared/renderer/html-renderer.service.ts7
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts4
-rw-r--r--client/src/app/shared/rest/rest-table.ts51
-rw-r--r--client/src/app/shared/rest/rest.service.ts63
-rw-r--r--client/src/app/shared/shared.module.ts42
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.html2
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.scss12
-rw-r--r--client/src/app/shared/users/user.model.ts46
-rw-r--r--client/src/app/shared/users/user.service.ts82
-rw-r--r--client/src/app/shared/video-abuse/video-abuse.service.ts41
-rw-r--r--client/src/app/shared/video-blacklist/video-blacklist.service.ts16
-rw-r--r--client/src/app/shared/video-channel/video-channel.model.ts9
-rw-r--r--client/src/app/shared/video-channel/video-channel.service.ts7
-rw-r--r--client/src/app/shared/video-import/video-import.service.ts2
-rw-r--r--client/src/app/shared/video-ownership/video-ownership.service.ts2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss5
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts2
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss17
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts26
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts4
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.html21
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts6
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html60
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss35
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts111
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html16
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts6
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts45
-rw-r--r--client/src/app/shared/video/video-miniature.component.html14
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss8
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts2
-rw-r--r--client/src/app/shared/video/video.model.ts19
-rw-r--r--client/src/app/shared/video/video.service.ts32
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts4
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html10
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss5
-rw-r--r--client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts2
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html51
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.scss93
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.module.ts2
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts30
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html13
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss6
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts7
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html6
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts95
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-send.scss10
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.html23
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss45
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts13
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.html38
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.scss93
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.ts6
-rw-r--r--client/src/app/videos/+video-edit/video-add.module.ts11
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.html22
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.scss24
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.ts9
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts7
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts12
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.model.ts2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.service.ts13
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.html5
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.scss21
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts16
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html82
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss10
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts12
-rw-r--r--client/src/app/videos/+video-watch/modal/video-support.component.html5
-rw-r--r--client/src/app/videos/+video-watch/modal/video-support.component.ts2
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.ts7
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html25
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss17
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts29
-rw-r--r--client/src/app/videos/+video-watch/video-watch.module.ts4
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.component.html4
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.component.scss22
-rw-r--r--client/src/app/videos/recommendations/recommended-videos.component.ts20
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts4
-rw-r--r--client/src/app/videos/video-list/video-most-liked.component.ts4
-rw-r--r--client/src/app/videos/video-list/video-overview.component.html55
-rw-r--r--client/src/app/videos/video-list/video-overview.component.ts67
-rw-r--r--client/src/app/videos/video-list/video-recently-added.component.ts4
-rw-r--r--client/src/app/videos/video-list/video-trending.component.ts4
-rw-r--r--client/src/app/videos/video-list/video-user-subscriptions.component.ts4
345 files changed, 8017 insertions, 2796 deletions
diff --git a/client/src/app/+about/about-instance/about-instance.component.html b/client/src/app/+about/about-instance/about-instance.component.html
index fc5736aba..1df1ef2ad 100644
--- a/client/src/app/+about/about-instance/about-instance.component.html
+++ b/client/src/app/+about/about-instance/about-instance.component.html
@@ -20,7 +20,7 @@
20 </div> 20 </div>
21 21
22 <div i18n class="middle-title" *ngIf="html.administrator || maintenanceLifetime || businessModel"> 22 <div i18n class="middle-title" *ngIf="html.administrator || maintenanceLifetime || businessModel">
23 Administrators & sustainability 23 ADMINISTRATORS & SUSTAINABILITY
24 </div> 24 </div>
25 25
26 <div class="block administrator" *ngIf="html.administrator"> 26 <div class="block administrator" *ngIf="html.administrator">
@@ -48,7 +48,7 @@
48 </div> 48 </div>
49 49
50 <div i18n class="middle-title" *ngIf="html.description"> 50 <div i18n class="middle-title" *ngIf="html.description">
51 Information 51 INFORMATION
52 </div> 52 </div>
53 53
54 <div class="block description"> 54 <div class="block description">
@@ -58,7 +58,7 @@
58 </div> 58 </div>
59 59
60 <div i18n class="middle-title" *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"> 60 <div i18n class="middle-title" *ngIf="html.moderationInformation || html.codeOfConduct || html.terms">
61 Moderation 61 MODERATION
62 </div> 62 </div>
63 63
64 <div class="block moderation-information" *ngIf="html.moderationInformation"> 64 <div class="block moderation-information" *ngIf="html.moderationInformation">
@@ -80,7 +80,7 @@
80 </div> 80 </div>
81 81
82 <div i18n class="middle-title" *ngIf="html.hardwareInformation"> 82 <div i18n class="middle-title" *ngIf="html.hardwareInformation">
83 Other information 83 OTHER INFORMATION
84 </div> 84 </div>
85 85
86 <div class="block hardware-information" *ngIf="html.hardwareInformation"> 86 <div class="block hardware-information" *ngIf="html.hardwareInformation">
@@ -96,9 +96,8 @@
96 </div> 96 </div>
97 97
98 <div class="col"> 98 <div class="col">
99 <div i18n class="middle-title"> 99 <div class="anchor" id="statistics"></div>
100 Statistics 100 <div i18n class="middle-title">STATISTICS</div>
101 </div>
102 <my-instance-statistics></my-instance-statistics> 101 <my-instance-statistics></my-instance-statistics>
103 </div> 102 </div>
104</div> 103</div>
diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts
index 87beb13da..e1809d7b7 100644
--- a/client/src/app/+about/about-instance/about-instance.component.ts
+++ b/client/src/app/+about/about-instance/about-instance.component.ts
@@ -1,19 +1,18 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild, AfterViewChecked } from '@angular/core'
2import { Notifier, ServerService } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' 3import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
5import { InstanceService } from '@app/shared/instance/instance.service' 4import { InstanceService } from '@app/shared/instance/instance.service'
6import { MarkdownService } from '@app/shared/renderer'
7import { forkJoin } from 'rxjs'
8import { map, switchMap } from 'rxjs/operators'
9import { ServerConfig } from '@shared/models' 5import { ServerConfig } from '@shared/models'
6import { ActivatedRoute } from '@angular/router'
7import { ResolverData } from './about-instance.resolver'
8import { ViewportScroller } from '@angular/common'
10 9
11@Component({ 10@Component({
12 selector: 'my-about-instance', 11 selector: 'my-about-instance',
13 templateUrl: './about-instance.component.html', 12 templateUrl: './about-instance.component.html',
14 styleUrls: [ './about-instance.component.scss' ] 13 styleUrls: [ './about-instance.component.scss' ]
15}) 14})
16export class AboutInstanceComponent implements OnInit { 15export class AboutInstanceComponent implements OnInit, AfterViewChecked {
17 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent 16 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
18 17
19 shortDescription = '' 18 shortDescription = ''
@@ -37,11 +36,10 @@ export class AboutInstanceComponent implements OnInit {
37 serverConfig: ServerConfig 36 serverConfig: ServerConfig
38 37
39 constructor ( 38 constructor (
40 private notifier: Notifier, 39 private viewportScroller: ViewportScroller,
40 private route: ActivatedRoute,
41 private serverService: ServerService, 41 private serverService: ServerService,
42 private instanceService: InstanceService, 42 private instanceService: InstanceService
43 private markdownService: MarkdownService,
44 private i18n: I18n
45 ) {} 43 ) {}
46 44
47 get instanceName () { 45 get instanceName () {
@@ -56,35 +54,27 @@ export class AboutInstanceComponent implements OnInit {
56 return this.serverConfig.instance.isNSFW 54 return this.serverConfig.instance.isNSFW
57 } 55 }
58 56
59 ngOnInit () { 57 async ngOnInit () {
60 this.serverConfig = this.serverService.getTmpConfig() 58 this.serverConfig = this.serverService.getTmpConfig()
61 this.serverService.getConfig() 59 this.serverService.getConfig()
62 .subscribe(config => this.serverConfig = config) 60 .subscribe(config => this.serverConfig = config)
63 61
64 this.instanceService.getAbout() 62 const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData
65 .pipe( 63
66 switchMap(about => { 64 this.languages = languages
67 return forkJoin([ 65 this.categories = categories
68 this.instanceService.buildTranslatedLanguages(about), 66
69 this.instanceService.buildTranslatedCategories(about) 67 this.shortDescription = about.instance.shortDescription
70 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }))) 68
71 }) 69 this.creationReason = about.instance.creationReason
72 ).subscribe( 70 this.maintenanceLifetime = about.instance.maintenanceLifetime
73 async ({ about, languages, categories }) => { 71 this.businessModel = about.instance.businessModel
74 this.languages = languages 72
75 this.categories = categories 73 this.html = await this.instanceService.buildHtml(about)
76 74 }
77 this.shortDescription = about.instance.shortDescription 75
78 76 ngAfterViewChecked () {
79 this.creationReason = about.instance.creationReason 77 if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
80 this.maintenanceLifetime = about.instance.maintenanceLifetime
81 this.businessModel = about.instance.businessModel
82
83 this.html = await this.instanceService.buildHtml(about)
84 },
85
86 () => this.notifier.error(this.i18n('Cannot get about information from server'))
87 )
88 } 78 }
89 79
90 openContactModal () { 80 openContactModal () {
diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts
new file mode 100644
index 000000000..94c6abe5a
--- /dev/null
+++ b/client/src/app/+about/about-instance/about-instance.resolver.ts
@@ -0,0 +1,27 @@
1import { Injectable } from '@angular/core'
2import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
3import { map, switchMap } from 'rxjs/operators'
4import { forkJoin } from 'rxjs'
5import { InstanceService } from '@app/shared/instance/instance.service'
6import { About } from '@shared/models/server'
7
8export type ResolverData = { about: About, languages: string[], categories: string[] }
9
10@Injectable()
11export class AboutInstanceResolver implements Resolve<any> {
12 constructor (
13 private instanceService: InstanceService
14 ) {}
15
16 resolve (route: ActivatedRouteSnapshot) {
17 return this.instanceService.getAbout()
18 .pipe(
19 switchMap(about => {
20 return forkJoin([
21 this.instanceService.buildTranslatedLanguages(about),
22 this.instanceService.buildTranslatedCategories(about)
23 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories })))
24 })
25 )
26 }
27}
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.html b/client/src/app/+about/about-instance/contact-admin-modal.component.html
index c3c71bdee..7d93796ec 100644
--- a/client/src/app/+about/about-instance/contact-admin-modal.component.html
+++ b/client/src/app/+about/about-instance/contact-admin-modal.component.html
@@ -10,7 +10,7 @@
10 <div class="form-group"> 10 <div class="form-group">
11 <label i18n for="fromName">Your name</label> 11 <label i18n for="fromName">Your name</label>
12 <input 12 <input
13 type="text" id="fromName" 13 type="text" id="fromName" class="form-control"
14 formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }" 14 formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
15 > 15 >
16 <div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div> 16 <div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div>
@@ -19,7 +19,7 @@
19 <div class="form-group"> 19 <div class="form-group">
20 <label i18n for="fromEmail">Your email</label> 20 <label i18n for="fromEmail">Your email</label>
21 <input 21 <input
22 type="text" id="fromEmail" 22 type="text" id="fromEmail" class="form-control"
23 formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }" 23 formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
24 > 24 >
25 <div *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div> 25 <div *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div>
@@ -28,7 +28,7 @@
28 <div class="form-group"> 28 <div class="form-group">
29 <label i18n for="subject">Subject</label> 29 <label i18n for="subject">Subject</label>
30 <input 30 <input
31 type="text" id="subject" 31 type="text" id="subject" class="form-control"
32 formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }" 32 formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
33 > 33 >
34 <div *ngIf="formErrors.subject" class="form-error">{{ formErrors.subject }}</div> 34 <div *ngIf="formErrors.subject" class="form-error">{{ formErrors.subject }}</div>
@@ -36,7 +36,7 @@
36 36
37 <div class="form-group"> 37 <div class="form-group">
38 <label i18n for="body">Your message</label> 38 <label i18n for="body">Your message</label>
39 <textarea id="body" formControlName="body" [ngClass]="{ 'input-error': formErrors['body'] }"> 39 <textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
40 </textarea> 40 </textarea>
41 <div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div> 41 <div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div>
42 </div> 42 </div>
@@ -44,9 +44,10 @@
44 <div *ngIf="error" class="alert alert-danger">{{ error }}</div> 44 <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
45 45
46 <div class="form-group inputs"> 46 <div class="form-group inputs">
47 <span i18n class="action-button action-button-cancel" (click)="hide()"> 47 <input
48 Cancel 48 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
49 </span> 49 (click)="hide()" (key.enter)="hide()"
50 >
50 51
51 <input 52 <input
52 type="submit" i18n-value value="Submit" class="action-button-submit" 53 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.ts b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
index 2ed41e741..d5e146b82 100644
--- a/client/src/app/+about/about-instance/contact-admin-modal.component.ts
+++ b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
@@ -51,7 +51,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
51 } 51 }
52 52
53 show () { 53 show () {
54 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 54 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
55 } 55 }
56 56
57 hide () { 57 hide () {
diff --git a/client/src/app/+about/about-peertube/about-peertube.component.html b/client/src/app/+about/about-peertube/about-peertube.component.html
index 26a3d4554..e5a8b2097 100644
--- a/client/src/app/+about/about-peertube/about-peertube.component.html
+++ b/client/src/app/+about/about-peertube/about-peertube.component.html
@@ -65,8 +65,11 @@
65 <div class="privacy-contributors"> 65 <div class="privacy-contributors">
66 <my-about-peertube-contributors></my-about-peertube-contributors> 66 <my-about-peertube-contributors></my-about-peertube-contributors>
67 67
68 <div class="p2p-privacy"> 68 <div class="p2p-privacy">
69 <h3 i18n class="section-title">P2P & Privacy</h3> 69 <h3 class="section-title">
70 <div class="anchor" id="privacy"></div> <!-- privacy anchor -->
71 <ng-container i18n>P2P & Privacy</ng-container>
72 </h3>
70 73
71 <p i18n> 74 <p i18n>
72 PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server, 75 PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server,
@@ -95,7 +98,7 @@
95 <li i18n> 98 <li i18n>
96 For each request sent, the tracker returns random peers at a limited number. 99 For each request sent, the tracker returns random peers at a limited number.
97 For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50 100 For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50
98 requests sent to know every peers in the swarm 101 requests sent to know every peer in the swarm
99 </li> 102 </li>
100 103
101 <li i18n> 104 <li i18n>
diff --git a/client/src/app/+about/about-peertube/about-peertube.component.ts b/client/src/app/+about/about-peertube/about-peertube.component.ts
index 64fd30837..98c5f93c3 100644
--- a/client/src/app/+about/about-peertube/about-peertube.component.ts
+++ b/client/src/app/+about/about-peertube/about-peertube.component.ts
@@ -1,4 +1,5 @@
1import { Component } from '@angular/core' 1import { Component, AfterViewChecked } from '@angular/core'
2import { ViewportScroller } from '@angular/common'
2 3
3@Component({ 4@Component({
4 selector: 'my-about-peertube', 5 selector: 'my-about-peertube',
@@ -6,5 +7,12 @@ import { Component } from '@angular/core'
6 styleUrls: [ './about-peertube.component.scss' ] 7 styleUrls: [ './about-peertube.component.scss' ]
7}) 8})
8 9
9export class AboutPeertubeComponent { 10export class AboutPeertubeComponent implements AfterViewChecked {
11 constructor (
12 private viewportScroller: ViewportScroller
13 ) {}
14
15 ngAfterViewChecked () {
16 if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
17 }
10} 18}
diff --git a/client/src/app/+about/about-routing.module.ts b/client/src/app/+about/about-routing.module.ts
index 33e5070cb..91ccb846f 100644
--- a/client/src/app/+about/about-routing.module.ts
+++ b/client/src/app/+about/about-routing.module.ts
@@ -5,6 +5,7 @@ import { AboutComponent } from './about.component'
5import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' 5import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
6import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' 6import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
7import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' 7import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
8import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
8 9
9const aboutRoutes: Routes = [ 10const aboutRoutes: Routes = [
10 { 11 {
@@ -24,6 +25,9 @@ const aboutRoutes: Routes = [
24 meta: { 25 meta: {
25 title: 'About this instance' 26 title: 'About this instance'
26 } 27 }
28 },
29 resolve: {
30 instanceData: AboutInstanceResolver
27 } 31 }
28 }, 32 },
29 { 33 {
diff --git a/client/src/app/+about/about.component.html b/client/src/app/+about/about.component.html
index 0c4a5156d..da16b933d 100644
--- a/client/src/app/+about/about.component.html
+++ b/client/src/app/+about/about.component.html
@@ -2,11 +2,11 @@
2 <div class="sub-menu"> 2 <div class="sub-menu">
3 3
4 <div class="links"> 4 <div class="links">
5 <a i18n routerLink="instance" routerLinkActive="active" class="title-page">Instance</a> 5 <a i18n routerLink="instance" routerLinkActive="active" class="title-page title-page-about">Instance</a>
6 6
7 <a i18n routerLink="peertube" routerLinkActive="active" class="title-page">PeerTube</a> 7 <a i18n routerLink="peertube" routerLinkActive="active" class="title-page title-page-about">PeerTube</a>
8 8
9 <a i18n routerLink="follows" routerLinkActive="active" class="title-page">Follows</a> 9 <a i18n routerLink="follows" routerLinkActive="active" class="title-page title-page-about">Follows</a>
10 </div> 10 </div>
11 </div> 11 </div>
12 12
diff --git a/client/src/app/+about/about.module.ts b/client/src/app/+about/about.module.ts
index 14bf76e55..84d697540 100644
--- a/client/src/app/+about/about.module.ts
+++ b/client/src/app/+about/about.module.ts
@@ -7,6 +7,7 @@ import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertub
7import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' 7import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
8import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' 8import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
9import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component' 9import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component'
10import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
10 11
11@NgModule({ 12@NgModule({
12 imports: [ 13 imports: [
@@ -28,6 +29,7 @@ import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/a
28 ], 29 ],
29 30
30 providers: [ 31 providers: [
32 AboutInstanceResolver
31 ] 33 ]
32}) 34})
33export class AboutModule { } 35export class AboutModule { }
diff --git a/client/src/app/+accounts/account-about/account-about.component.html b/client/src/app/+accounts/account-about/account-about.component.html
index f857e5a52..3ae11b49c 100644
--- a/client/src/app/+accounts/account-about/account-about.component.html
+++ b/client/src/app/+accounts/account-about/account-about.component.html
@@ -1,11 +1,11 @@
1<div *ngIf="account" class="row"> 1<div *ngIf="account" class="row">
2 <div class="block col-md-6 col-sm-12"> 2 <div class="block col-md-6 col-sm-12">
3 <div i18n class="small-title">Description</div> 3 <div i18n class="small-title">DESCRIPTION</div>
4 <div class="content" [innerHtml]="getAccountDescription()"></div> 4 <div class="content" [innerHtml]="getAccountDescription()"></div>
5 </div> 5 </div>
6 6
7 <div class="block col-md-6 col-sm-12"> 7 <div class="block col-md-6 col-sm-12">
8 <div i18n class="small-title">Stats</div> 8 <div i18n class="small-title">STATS</div>
9 9
10 <div i18n class="content">Joined {{ account.createdAt | date }}</div> 10 <div i18n class="content">Joined {{ account.createdAt | date }}</div>
11 </div> 11 </div>
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
index 781156840..8f1ff21a5 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
@@ -25,7 +25,7 @@
25 </div> 25 </div>
26 26
27 <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)"> 27 <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)">
28 Show this channel 28 SHOW THIS CHANNEL
29 </a> 29 </a>
30 </div> 30 </div>
31 </div> 31 </div>
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
index a258c7b86..042290809 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
@@ -29,4 +29,11 @@
29 } 29 }
30} 30}
31 31
32 32@media screen and (max-width: $mobile-view) {
33 .section {
34 .section-title {
35 flex-direction: column;
36 align-items: normal;
37 }
38 }
39}
diff --git a/client/src/app/+accounts/account-videos/account-videos.component.ts b/client/src/app/+accounts/account-videos/account-videos.component.ts
index ac4477c18..41b27b541 100644
--- a/client/src/app/+accounts/account-videos/account-videos.component.ts
+++ b/client/src/app/+accounts/account-videos/account-videos.component.ts
@@ -12,6 +12,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
12import { Subscription } from 'rxjs' 12import { Subscription } from 'rxjs'
13import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
14import { Notifier, ServerService } from '@app/core' 14import { Notifier, ServerService } from '@app/core'
15import { UserService } from '@app/shared'
16import { LocalStorageService } from '@app/shared/misc/storage.service'
15 17
16@Component({ 18@Component({
17 selector: 'my-account-videos', 19 selector: 'my-account-videos',
@@ -34,9 +36,11 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
34 protected serverService: ServerService, 36 protected serverService: ServerService,
35 protected route: ActivatedRoute, 37 protected route: ActivatedRoute,
36 protected authService: AuthService, 38 protected authService: AuthService,
39 protected userService: UserService,
37 protected notifier: Notifier, 40 protected notifier: Notifier,
38 protected confirmService: ConfirmService, 41 protected confirmService: ConfirmService,
39 protected screenService: ScreenService, 42 protected screenService: ScreenService,
43 protected storageService: LocalStorageService,
40 private accountService: AccountService, 44 private accountService: AccountService,
41 private videoService: VideoService 45 private videoService: VideoService
42 ) { 46 ) {
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index 85f7dd30c..af80337ce 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -7,14 +7,13 @@
7 <div class="actor-info"> 7 <div class="actor-info">
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ account.displayName }}</div> 9 <div class="actor-display-name">{{ account.displayName }}</div>
10 <div class="actor-name">{{ account.nameWithHost }} 10 <div class="actor-name">
11 11 <span>{{ account.nameWithHost }}</span>
12 <button ngxClipboard [cbContent]="account.nameWithHostForced" (click)="activateCopiedMessage()" 12 <button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
13 class="btn btn-outline-secondary btn-sm copy-button" 13 class="btn btn-outline-secondary btn-sm copy-button"
14 > 14 >
15 <span class="glyphicon glyphicon-copy"></span> 15 <span class="glyphicon glyphicon-copy"></span>
16 </button> 16 </button>
17
18 </div> 17 </div>
19 <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span> 18 <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span>
20 <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span> 19 <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>
@@ -33,17 +32,17 @@
33 </div> 32 </div>
34 33
35 <div class="right-buttons"> 34 <div class="right-buttons">
36 <a *ngIf="isAccountManageable" routerLink="/my-account" class="btn btn-outline-tertiary mr-2" i18n>Manage</a> 35 <a *ngIf="isAccountManageable && !isInSmallView" routerLink="/my-account" class="btn btn-outline-tertiary mr-2" i18n>Manage account</a>
37 <my-subscribe-button *ngIf="videoChannels" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button> 36 <my-subscribe-button *ngIf="videoChannels" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button>
38 </div> 37 </div>
39 </div> 38 </div>
40 39
41 <div class="links"> 40 <div class="links w-100">
42 <a i18n routerLink="video-channels" routerLinkActive="active" class="title-page">Video channels</a> 41 <ng-template #linkTemplate let-item="item">
43 42 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
44 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> 43 </ng-template>
45 44
46 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 45 <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
47 </div> 46 </div>
48 </div> 47 </div>
49 48
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss
index 96484c9d3..12170e371 100644
--- a/client/src/app/+accounts/accounts.component.scss
+++ b/client/src/app/+accounts/accounts.component.scss
@@ -1,3 +1,9 @@
1// Bootstrap grid utilities require functions, variables and mixins
2@import 'node_modules/bootstrap/scss/functions';
3@import 'node_modules/bootstrap/scss/variables';
4@import 'node_modules/bootstrap/scss/mixins';
5@import 'node_modules/bootstrap/scss/grid';
6
1@import '_variables'; 7@import '_variables';
2@import '_mixins'; 8@import '_mixins';
3 9
@@ -13,7 +19,16 @@
13 display: flex; 19 display: flex;
14 height: max-content; 20 height: max-content;
15 margin-left: auto; 21 margin-left: auto;
16 margin-top: 20px; 22 margin-top: 10px;
23
24 @include media-breakpoint-down(lg) {
25 flex-flow: column-reverse;
26
27 a {
28 margin-top: 0.25rem;
29 margin-right: 0 !important;
30 }
31 }
17 32
18 a { 33 a {
19 @include peertube-button-outline; 34 @include peertube-button-outline;
@@ -41,3 +56,32 @@ my-user-moderation-dropdown,
41 padding: 5px; 56 padding: 5px;
42 margin-top: -2px; 57 margin-top: -2px;
43} 58}
59
60@media screen and (max-width: $mobile-view) {
61 .sub-menu {
62 .actor {
63 flex-direction: column;
64 align-items: center;
65
66 img,
67 .actor-info .actor-names .actor-display-name {
68 margin-right: 0;
69 }
70
71 .actor-info {
72 .actor-names {
73 flex-direction: column;
74 align-items: center;
75 }
76
77 my-user-moderation-dropdown {
78 margin-left: 0;
79 }
80
81 .actor-followers {
82 text-align: center;
83 }
84 }
85 }
86 }
87}
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index ad611f221..bf71179f3 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -3,13 +3,15 @@ import { ActivatedRoute } from '@angular/router'
3import { AccountService } from '@app/shared/account/account.service' 3import { AccountService } from '@app/shared/account/account.service'
4import { Account } from '@app/shared/account/account.model' 4import { Account } from '@app/shared/account/account.model'
5import { RestExtractor, UserService } from '@app/shared' 5import { RestExtractor, UserService } from '@app/shared'
6import { catchError, distinctUntilChanged, first, map, switchMap, tap } from 'rxjs/operators' 6import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
7import { forkJoin, Subscription } from 'rxjs' 7import { Subscription } from 'rxjs'
8import { AuthService, Notifier, RedirectService } from '@app/core' 8import { AuthService, Notifier, RedirectService } from '@app/core'
9import { User, UserRight } from '../../../../shared' 9import { User, UserRight } from '../../../../shared'
10import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 11import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 12import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
14import { ScreenService } from '@app/shared/misc/screen.service'
13 15
14@Component({ 16@Component({
15 templateUrl: './accounts.component.html', 17 templateUrl: './accounts.component.html',
@@ -19,6 +21,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
19 account: Account 21 account: Account
20 accountUser: User 22 accountUser: User
21 videoChannels: VideoChannel[] = [] 23 videoChannels: VideoChannel[] = []
24 links: ListOverflowItem[] = []
22 25
23 isAccountManageable = false 26 isAccountManageable = false
24 accountFollowerTitle = '' 27 accountFollowerTitle = ''
@@ -34,6 +37,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
34 private restExtractor: RestExtractor, 37 private restExtractor: RestExtractor,
35 private redirectService: RedirectService, 38 private redirectService: RedirectService,
36 private authService: AuthService, 39 private authService: AuthService,
40 private screenService: ScreenService,
37 private i18n: I18n 41 private i18n: I18n
38 ) { 42 ) {
39 } 43 }
@@ -70,6 +74,12 @@ export class AccountsComponent implements OnInit, OnDestroy {
70 74
71 err => this.notifier.error(err.message) 75 err => this.notifier.error(err.message)
72 ) 76 )
77
78 this.links = [
79 { label: this.i18n('VIDEO CHANNELS'), routerLink: 'video-channels' },
80 { label: this.i18n('VIDEOS'), routerLink: 'videos' },
81 { label: this.i18n('ABOUT'), routerLink: 'about' }
82 ]
73 } 83 }
74 84
75 ngOnDestroy () { 85 ngOnDestroy () {
@@ -83,6 +93,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
83 ) 93 )
84 } 94 }
85 95
96 get isInSmallView () {
97 return this.screenService.isInSmallView()
98 }
99
86 onUserChanged () { 100 onUserChanged () {
87 this.getUserIfNeeded(this.account) 101 this.getUserIfNeeded(this.account)
88 } 102 }
@@ -96,7 +110,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
96 } 110 }
97 111
98 subscribersDisplayFor (count: number) { 112 subscribersDisplayFor (count: number) {
99 return this.i18n(`{count, plural, =1 {1 subscriber} other {${count} subscribers}}`, { count }) 113 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count })
100 } 114 }
101 115
102 private getUserIfNeeded (account: Account) { 116 private getUserIfNeeded (account: Account) {
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html
index 9a3d90c18..76d297c52 100644
--- a/client/src/app/+admin/admin.component.html
+++ b/client/src/app/+admin/admin.component.html
@@ -1,28 +1,10 @@
1<div class="row"> 1<div class="row">
2 <div class="sub-menu"> 2 <div class="sub-menu">
3 <a i18n *ngIf="hasUsersRight()" routerLink="/admin/users" routerLinkActive="active" class="title-page"> 3 <ng-template #linkTemplate let-item="item">
4 Users 4 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ item.label }}</a>
5 </a> 5 </ng-template>
6 6
7 <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page"> 7 <list-overflow [items]="items" [itemTemplate]="linkTemplate"></list-overflow>
8 Manage follows
9 </a>
10
11 <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page">
12 Moderation
13 </a>
14
15 <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page">
16 Configuration
17 </a>
18
19 <a i18n *ngIf="hasPluginsRight()" routerLink="/admin/plugins" routerLinkActive="active" class="title-page">
20 Plugins/Themes
21 </a>
22
23 <a i18n *ngIf="hasJobsRight() || hasLogsRight() || hasDebugRight()" routerLink="/admin/system" routerLinkActive="active" class="title-page">
24 System
25 </a>
26 </div> 8 </div>
27 9
28 <div class="margin-content"> 10 <div class="margin-content">
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index b23999d40..9662522dc 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -1,12 +1,28 @@
1import { Component } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { UserRight } from '../../../../shared' 2import { UserRight } from '../../../../shared'
3import { AuthService } from '../core/auth/auth.service' 3import { AuthService } from '../core/auth/auth.service'
4import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
5import { I18n } from '@ngx-translate/i18n-polyfill'
4 6
5@Component({ 7@Component({
6 templateUrl: './admin.component.html' 8 templateUrl: './admin.component.html'
7}) 9})
8export class AdminComponent { 10export class AdminComponent implements OnInit {
9 constructor (private auth: AuthService) {} 11 items: ListOverflowItem[] = []
12
13 constructor (
14 private auth: AuthService,
15 private i18n: I18n
16 ) {}
17
18 ngOnInit () {
19 if (this.hasUsersRight()) this.items.push({ label: this.i18n('Users'), routerLink: '/admin/users' })
20 if (this.hasServerFollowRight()) this.items.push({ label: this.i18n('Follows & redundancies'), routerLink: '/admin/follows' })
21 if (this.hasVideoAbusesRight() || this.hasVideoBlacklistRight()) this.items.push({ label: this.i18n('Moderation'), routerLink: '/admin/moderation' })
22 if (this.hasConfigRight()) this.items.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' })
23 if (this.hasPluginsRight()) this.items.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' })
24 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.items.push({ label: this.i18n('System'), routerLink: '/admin/system' })
25 }
10 26
11 hasUsersRight () { 27 hasUsersRight () {
12 return this.auth.getUser().hasRight(UserRight.MANAGE_USERS) 28 return this.auth.getUser().hasRight(UserRight.MANAGE_USERS)
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 9c56b5750..d04313c0a 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -5,7 +5,7 @@ import { TableModule } from 'primeng/table'
5import { SharedModule } from '../shared' 5import { SharedModule } from '../shared'
6import { AdminRoutingModule } from './admin-routing.module' 6import { AdminRoutingModule } from './admin-routing.module'
7import { AdminComponent } from './admin.component' 7import { AdminComponent } from './admin.component'
8import { FollowersListComponent, FollowingAddComponent, FollowsComponent } from './follows' 8import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
9import { FollowingListComponent } from './follows/following-list/following-list.component' 9import { FollowingListComponent } from './follows/following-list/following-list.component'
10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' 10import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
11import { 11import {
@@ -16,7 +16,6 @@ import {
16} from './moderation' 16} from './moderation'
17import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 17import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' 18import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
19import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service'
20import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 19import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
21import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' 20import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
22import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' 21import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
@@ -27,23 +26,31 @@ import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-
27import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component' 26import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
28import { SelectButtonModule } from 'primeng/selectbutton' 27import { SelectButtonModule } from 'primeng/selectbutton'
29import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' 28import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
29import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
30import { ChartModule } from 'primeng/chart'
31import { BatchDomainsModalComponent } from './config/shared/batch-domains-modal.component'
32import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component'
30 33
31@NgModule({ 34@NgModule({
32 imports: [ 35 imports: [
33 AdminRoutingModule, 36 AdminRoutingModule,
37
38 SharedModule,
39
34 TableModule, 40 TableModule,
35 SelectButtonModule, 41 SelectButtonModule,
36 SharedModule 42 ChartModule
37 ], 43 ],
38 44
39 declarations: [ 45 declarations: [
40 AdminComponent, 46 AdminComponent,
41 47
42 FollowsComponent, 48 FollowsComponent,
43 FollowingAddComponent,
44 FollowersListComponent, 49 FollowersListComponent,
45 FollowingListComponent, 50 FollowingListComponent,
46 RedundancyCheckboxComponent, 51 RedundancyCheckboxComponent,
52 VideoRedundanciesListComponent,
53 VideoRedundancyInformationComponent,
47 54
48 UsersComponent, 55 UsersComponent,
49 UserCreateComponent, 56 UserCreateComponent,
@@ -54,6 +61,7 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
54 ModerationComponent, 61 ModerationComponent,
55 VideoBlacklistListComponent, 62 VideoBlacklistListComponent,
56 VideoAbuseListComponent, 63 VideoAbuseListComponent,
64 VideoAbuseDetailsComponent,
57 VideoAutoBlacklistListComponent, 65 VideoAutoBlacklistListComponent,
58 ModerationCommentModalComponent, 66 ModerationCommentModalComponent,
59 InstanceServerBlocklistComponent, 67 InstanceServerBlocklistComponent,
@@ -70,7 +78,9 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
70 DebugComponent, 78 DebugComponent,
71 79
72 ConfigComponent, 80 ConfigComponent,
73 EditCustomConfigComponent 81 EditCustomConfigComponent,
82
83 BatchDomainsModalComponent
74 ], 84 ],
75 85
76 exports: [ 86 exports: [
@@ -78,7 +88,6 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
78 ], 88 ],
79 89
80 providers: [ 90 providers: [
81 RedundancyService,
82 JobService, 91 JobService,
83 LogsService, 92 LogsService,
84 DebugService, 93 DebugService,
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 915d60090..5703d5a2e 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -1,671 +1,808 @@
1<form role="form" [formGroup]="form"> 1<form role="form" [formGroup]="form">
2 2
3 <ngb-tabset class="root-tabset bootstrap"> 3 <div ngbNav #nav="ngbNav" class="nav-tabs">
4 4
5 <ngb-tab i18n-title title="Instance information"> 5 <ng-container ngbNavItem="instance-information">
6 <ng-template ngbTabContent> 6 <a ngbNavLink i18n>Instance information</a>
7
8 <ng-template ngbNavContent>
7 9
8 <ng-container formGroupName="instance"> 10 <ng-container formGroupName="instance">
9 11
10 <div i18n class="inner-form-title">Instance</div> 12 <div class="form-row mt-5"> <!-- instance grid -->
13 <div class="form-group col-12 col-lg-4 col-xl-3">
14 <div i18n class="inner-form-title">INSTANCE</div>
15 </div>
11 16
12 <div class="form-group"> 17 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
13 <label i18n for="instanceName">Name</label>
14 <input
15 type="text" id="instanceName"
16 formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
17 >
18 <div *ngIf="formErrors.instance.name" class="form-error">{{ formErrors.instance.name }}</div>
19 </div>
20 18
21 <div class="form-group"> 19 <div class="form-group">
22 <label i18n for="instanceShortDescription">Short description</label> 20 <label i18n for="instanceName">Name</label>
23 <textarea 21 <input
24 id="instanceShortDescription" formControlName="shortDescription" class="small" 22 type="text" id="instanceName" class="form-control"
25 [ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }" 23 formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
26 ></textarea> 24 >
27 <div *ngIf="formErrors.instance.shortDescription" class="form-error">{{ formErrors.instance.shortDescription }}</div> 25 <div *ngIf="formErrors.instance.name" class="form-error">{{ formErrors.instance.name }}</div>
28 </div> 26 </div>
29 27
30 <div class="form-group"> 28 <div class="form-group">
31 <label i18n for="instanceDescription">Description</label><my-help helpType="markdownText"></my-help> 29 <label i18n for="instanceShortDescription">Short description</label>
32 <my-markdown-textarea 30 <textarea
33 name="instanceDescription" formControlName="description" textareaWidth="500px" [previewColumn]="true" 31 id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
34 [classes]="{ 'input-error': formErrors['instance.description'] }" 32 [ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }"
35 ></my-markdown-textarea> 33 ></textarea>
36 <div *ngIf="formErrors.instance.description" class="form-error">{{ formErrors.instance.description }}</div> 34 <div *ngIf="formErrors.instance.shortDescription" class="form-error">{{ formErrors.instance.shortDescription }}</div>
37 </div> 35 </div>
38 36
39 <div class="form-group"> 37 <div class="form-group">
40 <label i18n for="instanceCategories">Main instance categories</label> 38 <label i18n for="instanceDescription">Description</label><my-help helpType="markdownText"></my-help>
39 <my-markdown-textarea
40 name="instanceDescription" formControlName="description" textareaMaxWidth="500px"
41 [classes]="{ 'input-error': formErrors['instance.description'] }"
42 ></my-markdown-textarea>
43 <div *ngIf="formErrors.instance.description" class="form-error">{{ formErrors.instance.description }}</div>
44 </div>
41 45
42 <div> 46 <div class="form-group">
43 <p-multiSelect 47 <label i18n for="instanceCategories">Main instance categories</label>
44 inputId="instanceCategories" [options]="categoryItems" formControlName="categories" showToggleAll="false" 48
45 [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()" 49 <div>
46 emptyFilterMessage="No results found" i18n-emptyFilterMessage 50 <p-multiSelect
47 ></p-multiSelect> 51 inputId="instanceCategories" [options]="categoryItems" formControlName="categories" [showToggleAll]="false"
48 </div> 52 [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()"
49 </div> 53 emptyFilterMessage="No results found" i18n-emptyFilterMessage
54 ></p-multiSelect>
55 </div>
56 </div>
50 57
51 <div class="form-group"> 58 <div class="form-group">
52 <label i18n for="instanceLanguages">Main languages you/your moderators speak</label> 59 <label i18n for="instanceLanguages">Main languages you/your moderators speak</label>
60
61 <div>
62 <p-multiSelect
63 inputId="instanceLanguages" [options]="languageItems" formControlName="languages" [showToggleAll]="false"
64 [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()"
65 emptyFilterMessage="No results found" i18n-emptyFilterMessage
66 ></p-multiSelect>
67 </div>
68 </div>
53 69
54 <div>
55 <p-multiSelect
56 inputId="instanceLanguages" [options]="languageItems" formControlName="languages" showToggleAll="false"
57 [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()"
58 emptyFilterMessage="No results found" i18n-emptyFilterMessage
59 ></p-multiSelect>
60 </div> 70 </div>
61 </div> 71 </div>
62 72
63 <div i18n class="inner-form-title">Moderation & NSFW</div> 73 <div class="form-row mt-4"> <!-- moderation & nsfw grid -->
74 <div class="form-group col-12 col-lg-4 col-xl-3">
75 <div i18n class="inner-form-title">MODERATION & NSFW</div>
76 <div i18n class="inner-for-description">
77 Manage <a routerLink="/admin/users">users</a> to build a moderation team.
78 </div>
79 </div>
64 80
65 <div class="form-group"> 81 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
66 <my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
67 <ng-template ptTemplate="label">
68 <ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container>
69 </ng-template>
70 82
71 <ng-template ptTemplate="help"> 83 <div class="form-group">
72 <ng-container i18n> 84 <my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
73 Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br /> 85 <ng-template ptTemplate="label">
74 Moreover, the NSFW checkbox on video upload will be automatically checked by default. 86 <ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container>
75 </ng-container> 87 </ng-template>
76 </ng-template>
77 </my-peertube-checkbox>
78 </div>
79 88
80 <div class="form-group"> 89 <ng-template ptTemplate="help">
81 <label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label> 90 <ng-container i18n>
91 Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br />
92 Moreover, the NSFW checkbox on video upload will be automatically checked by default.
93 </ng-container>
94 </ng-template>
95 </my-peertube-checkbox>
96 </div>
82 97
83 <my-help> 98 <div class="form-group">
84 <ng-template ptTemplate="customHtml"> 99 <label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label>
85 <ng-container i18n>
86 With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
87 </ng-container>
88 </ng-template>
89 </my-help>
90
91 <div class="peertube-select-container">
92 <select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy">
93 <option i18n value="do_not_list">Do not list</option>
94 <option i18n value="blur">Blur thumbnails</option>
95 <option i18n value="display">Display</option>
96 </select>
97 </div>
98 <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error">{{ formErrors.instance.defaultNSFWPolicy }}</div>
99 </div>
100 100
101 <div class="form-group"> 101 <my-help>
102 <label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help> 102 <ng-template ptTemplate="customHtml">
103 <my-markdown-textarea 103 <ng-container i18n>
104 name="instanceTerms" formControlName="terms" textareaWidth="500px" [previewColumn]="true" 104 With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
105 [ngClass]="{ 'input-error': formErrors['instance.terms'] }" 105 </ng-container>
106 ></my-markdown-textarea> 106 </ng-template>
107 <div *ngIf="formErrors.instance.terms" class="form-error">{{ formErrors.instance.terms }}</div> 107 </my-help>
108 </div> 108
109 <div class="peertube-select-container">
110 <select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy" class="form-control">
111 <option i18n value="undefined" disabled>Policy for sensitive videos</option>
112 <option i18n value="do_not_list">Do not list</option>
113 <option i18n value="blur">Blur thumbnails</option>
114 <option i18n value="display">Display</option>
115 </select>
116 </div>
117 <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error">{{ formErrors.instance.defaultNSFWPolicy }}</div>
118 </div>
109 119
110 <div class="form-group"> 120 <div class="form-group">
111 <label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help> 121 <label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help>
112 <my-markdown-textarea 122 <my-markdown-textarea
113 name="instanceCodeOfConduct" formControlName="codeOfConduct" textareaWidth="500px" [previewColumn]="true" 123 name="instanceTerms" formControlName="terms" textareaMaxWidth="500px"
114 [ngClass]="{ 'input-error': formErrors['instance.codeOfConduct'] }" 124 [ngClass]="{ 'input-error': formErrors['instance.terms'] }"
115 ></my-markdown-textarea> 125 ></my-markdown-textarea>
116 <div *ngIf="formErrors.instance.codeOfConduct" class="form-error">{{ formErrors.instance.codeOfConduct }}</div> 126 <div *ngIf="formErrors.instance.terms" class="form-error">{{ formErrors.instance.terms }}</div>
117 </div> 127 </div>
118 128
119 <div class="form-group"> 129 <div class="form-group">
120 <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> 130 <label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help>
121 <div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div> 131 <my-markdown-textarea
132 name="instanceCodeOfConduct" formControlName="codeOfConduct" textareaMaxWidth="500px"
133 [ngClass]="{ 'input-error': formErrors['instance.codeOfConduct'] }"
134 ></my-markdown-textarea>
135 <div *ngIf="formErrors.instance.codeOfConduct" class="form-error">{{ formErrors.instance.codeOfConduct }}</div>
136 </div>
122 137
123 <my-markdown-textarea 138 <div class="form-group">
124 name="instanceModerationInformation" formControlName="moderationInformation" textareaWidth="500px" [previewColumn]="true" 139 <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
125 [ngClass]="{ 'input-error': formErrors['instance.moderationInformation'] }" 140 <div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div>
126 ></my-markdown-textarea> 141
127 <div *ngIf="formErrors.instance.moderationInformation" class="form-error">{{ formErrors.instance.moderationInformation }}</div> 142 <my-markdown-textarea
143 name="instanceModerationInformation" formControlName="moderationInformation" textareaMaxWidth="500px"
144 [ngClass]="{ 'input-error': formErrors['instance.moderationInformation'] }"
145 ></my-markdown-textarea>
146 <div *ngIf="formErrors.instance.moderationInformation" class="form-error">{{ formErrors.instance.moderationInformation }}</div>
147 </div>
148
149 </div>
128 </div> 150 </div>
129 151
130 <div i18n class="inner-form-title">You and your instance</div> 152 <div class="form-row mt-4"> <!-- you and your instance grid -->
153 <div class="form-group col-12 col-lg-4 col-xl-3">
154 <div i18n class="inner-form-title">YOU AND YOUR INSTANCE</div>
155 </div>
131 156
132 <div class="form-group"> 157 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
133 <label i18n for="instanceAdministrator">Who is behind the instance?</label>
134 <div i18n class="label-small-info">A single person? A non-profit? A company?</div>
135 158
136 <my-markdown-textarea 159 <div class="form-group">
137 name="instanceAdministrator" formControlName="administrator" textareaWidth="500px" textareaHeight="75px" [previewColumn]="true" 160 <label i18n for="instanceAdministrator">Who is behind the instance?</label>
138 [classes]="{ 'input-error': formErrors['instance.administrator'] }" 161 <div i18n class="label-small-info">A single person? A non-profit? A company?</div>
139 ></my-markdown-textarea>
140 162
141 <div *ngIf="formErrors.instance.administrator" class="form-error">{{ formErrors.instance.administrator }}</div> 163 <my-markdown-textarea
142 </div> 164 name="instanceAdministrator" formControlName="administrator" textareaMaxWidth="500px" textareaHeight="75px"
165 [classes]="{ 'input-error': formErrors['instance.administrator'] }"
166 ></my-markdown-textarea>
143 167
144 <div class="form-group"> 168 <div *ngIf="formErrors.instance.administrator" class="form-error">{{ formErrors.instance.administrator }}</div>
145 <label i18n for="instanceCreationReason">Why did you create this instance?</label> 169 </div>
146 <div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div>
147 170
148 <textarea 171 <div class="form-group">
149 id="instanceCreationReason" formControlName="creationReason" class="small" 172 <label i18n for="instanceCreationReason">Why did you create this instance?</label>
150 [ngClass]="{ 'input-error': formErrors['instance.creationReason'] }" 173 <div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div>
151 ></textarea> 174
152 <div *ngIf="formErrors.instance.creationReason" class="form-error">{{ formErrors.instance.creationReason }}</div> 175 <textarea
153 </div> 176 id="instanceCreationReason" formControlName="creationReason" class="small" class="form-control"
177 [ngClass]="{ 'input-error': formErrors['instance.creationReason'] }"
178 ></textarea>
179 <div *ngIf="formErrors.instance.creationReason" class="form-error">{{ formErrors.instance.creationReason }}</div>
180 </div>
154 181
155 <div class="form-group"> 182 <div class="form-group">
156 <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label> 183 <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label>
157 <div i18n class="label-small-info">It's important to know for users who want to register on your instance</div> 184 <div i18n class="label-small-info">It's important to know for users who want to register on your instance</div>
185
186 <textarea
187 id="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" class="form-control small"
188 [ngClass]="{ 'input-error': formErrors['instance.maintenanceLifetime'] }"
189 ></textarea>
190 <div *ngIf="formErrors.instance.maintenanceLifetime" class="form-error">{{ formErrors.instance.maintenanceLifetime }}</div>
191 </div>
158 192
159 <textarea 193 <div class="form-group">
160 id="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" class="small" 194 <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label>
161 [ngClass]="{ 'input-error': formErrors['instance.maintenanceLifetime'] }" 195 <div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div>
162 ></textarea> 196
163 <div *ngIf="formErrors.instance.maintenanceLifetime" class="form-error">{{ formErrors.instance.maintenanceLifetime }}</div> 197 <textarea
198 id="instanceBusinessModel" formControlName="businessModel" class="form-control small"
199 [ngClass]="{ 'input-error': formErrors['instance.businessModel'] }"
200 ></textarea>
201 <div *ngIf="formErrors.instance.businessModel" class="form-error">{{ formErrors.instance.businessModel }}</div>
202 </div>
203
204 </div>
164 </div> 205 </div>
165 206
166 <div class="form-group"> 207 <div class="form-row mt-4"> <!-- other information grid -->
167 <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label> 208 <div class="form-group col-12 col-lg-4 col-xl-3">
168 <div i18n class="label-small-info">With your own funds? With users donations? Advertising?</div> 209 <div i18n class="inner-form-title">OTHER INFORMATION</div>
210 </div>
169 211
170 <textarea 212 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
171 id="instanceBusinessModel" formControlName="businessModel" class="small"
172 [ngClass]="{ 'input-error': formErrors['instance.businessModel'] }"
173 ></textarea>
174 <div *ngIf="formErrors.instance.businessModel" class="form-error">{{ formErrors.instance.businessModel }}</div>
175 </div>
176 213
177 <div i18n class="inner-form-title">Other information</div> 214 <div class="form-group">
215 <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label>
216 <div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div>
178 217
179 <div class="form-group"> 218 <my-markdown-textarea
180 <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label> 219 name="instanceHardwareInformation" formControlName="hardwareInformation" textareaMaxWidth="500px" textareaHeight="75px"
181 <div i18n class="label-small-info">2vCore 2GB RAM/or directly the link to the server you rent etc</div> 220 [classes]="{ 'input-error': formErrors['instance.hardwareInformation'] }"
221 ></my-markdown-textarea>
182 222
183 <my-markdown-textarea 223 <div *ngIf="formErrors.instance.hardwareInformation" class="form-error">{{ formErrors.instance.hardwareInformation }}</div>
184 name="instanceHardwareInformation" formControlName="hardwareInformation" textareaWidth="500px" textareaHeight="75px" [previewColumn]="true" 224 </div>
185 [classes]="{ 'input-error': formErrors['instance.hardwareInformation'] }"
186 ></my-markdown-textarea>
187 225
188 <div *ngIf="formErrors.instance.hardwareInformation" class="form-error">{{ formErrors.instance.hardwareInformation }}</div> 226 </div>
189 </div> 227 </div>
190 228
191 </ng-container> 229 </ng-container>
192 </ng-template> 230 </ng-template>
193 </ngb-tab> 231 </ng-container>
194
195 <ngb-tab i18n-title title="Basic configuration">
196 <ng-template ngbTabContent>
197 232
198 <div i18n class="inner-form-title">Theme & Default route</div> 233 <ng-container ngbNavItem="basic-configuration">
234 <a ngbNavLink i18n>Basic configuration</a>
199 235
200 <ng-container formGroupName="theme"> 236 <ng-template ngbNavContent>
201 <div class="form-group">
202 <label i18n for="themeDefault">Global theme</label>
203 237
204 <div class="peertube-select-container"> 238 <div class="form-row mt-5"> <!-- appearance grid -->
205 <select formControlName="default" id="themeDefault"> 239 <div class="form-group col-12 col-lg-4 col-xl-3">
206 <option i18n value="default">default</option> 240 <div i18n class="inner-form-title">APPEARANCE</div>
207 241 <div i18n class="inner-for-description">
208 <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option> 242 Use <a routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or <a routerLink="/admin/config/edit-custom" fragment="customizations" (click)="gotoAnchor()">add slight customizations</a>.
209 </select>
210 </div> 243 </div>
211 </div> 244 </div>
212 </ng-container>
213
214 245
215 <div class="form-group" formGroupName="instance"> 246 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
216 <label i18n for="instanceDefaultClientRoute">Default client route</label>
217 <div class="peertube-select-container">
218 <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute">
219 <option i18n value="/videos/overview">Discover videos</option>
220 <option i18n value="/videos/trending">Trending videos</option>
221 <option i18n value="/videos/most-liked">Most liked videos</option>
222 <option i18n value="/videos/recently-added">Recently added videos</option>
223 <option i18n value="/videos/local">Local videos</option>
224 </select>
225 </div>
226 <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
227 </div>
228 247
229 <div i18n class="inner-form-title">Signup</div> 248 <ng-container formGroupName="theme">
230 249 <div class="form-group">
231 <ng-container formGroupName="signup"> 250 <label i18n for="themeDefault">Theme</label>
232 <div class="form-group">
233 <my-peertube-checkbox
234 inputName="signupEnabled" formControlName="enabled"
235 i18n-labelText labelText="Signup enabled"
236 >
237 <ng-container ngProjectAs="extra">
238 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }"
239 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
240 i18n-labelText labelText="Signup requires email verification"
241 ></my-peertube-checkbox>
242
243 <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3">
244 <label i18n for="signupLimit">Signup limit</label>
245 <input
246 type="text" id="signupLimit"
247 formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
248 >
249 <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div>
250 </div>
251 </ng-container>
252 </my-peertube-checkbox>
253 </div>
254 </ng-container>
255 251
252 <div class="peertube-select-container">
253 <select formControlName="default" id="themeDefault" class="form-control">
254 <option i18n value="default">default</option>
256 255
257 <div i18n class="inner-form-title">Users</div> 256 <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
257 </select>
258 </div>
259 </div>
260 </ng-container>
258 261
259 <ng-container formGroupName="user"> 262 <div class="form-group" formGroupName="instance">
260 <div class="form-group"> 263 <label i18n for="instanceDefaultClientRoute">Landing page</label>
261 <label i18n for="userVideoQuota">Default video quota per user</label> 264 <div class="peertube-select-container">
262 <div class="peertube-select-container"> 265 <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
263 <select id="userVideoQuota" formControlName="videoQuota"> 266 <option i18n value="/videos/overview">Discover videos</option>
264 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value"> 267 <option i18n value="/videos/trending">Trending videos</option>
265 {{ videoQuotaOption.label }} 268 <option i18n value="/videos/most-liked">Most liked videos</option>
266 </option> 269 <option i18n value="/videos/recently-added">Recently added videos</option>
267 </select> 270 <option i18n value="/videos/local">Local videos</option>
271 </select>
272 </div>
273 <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
268 </div> 274 </div>
269 <div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div> 275
270 </div> 276 </div>
277 </div>
271 278
272 <div class="form-group"> 279 <div class="form-row mt-4"> <!-- new users grid -->
273 <label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label> 280 <div class="form-group col-12 col-lg-4 col-xl-3">
274 <div class="peertube-select-container"> 281 <div i18n class="inner-form-title">NEW USERS</div>
275 <select id="userVideoQuotaDaily" formControlName="videoQuotaDaily"> 282 <div i18n class="inner-for-description">
276 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value"> 283 Manage <a routerLink="/admin/users">users</a> to set their quota individually.
277 {{ videoQuotaDailyOption.label }}
278 </option>
279 </select>
280 </div> 284 </div>
281 <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error">{{ formErrors.user.videoQuotaDaily }}</div>
282 </div> 285 </div>
283 </ng-container>
284 286
287 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
285 288
286 <div i18n class="inner-form-title">Import</div> 289 <ng-container formGroupName="signup">
290 <div class="form-group">
291 <my-peertube-checkbox
292 inputName="signupEnabled" formControlName="enabled"
293 i18n-labelText labelText="Signup enabled"
294 >
295 <ng-container ngProjectAs="description">
296 <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
297 </ng-container>
298 <ng-container ngProjectAs="extra">
299 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }"
300 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
301 i18n-labelText labelText="Signup requires email verification"
302 ></my-peertube-checkbox>
303
304 <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3">
305 <label i18n for="signupLimit">Signup limit</label>
306 <input
307 type="number" min="-1" id="signupLimit" class="form-control"
308 formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
309 >
310 <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div>
311 <small *ngIf="form.value['signup']['limit'] === -1" class="text-muted">Signup won't be limited to a fixed number of users.</small>
312 </div>
313 </ng-container>
314 </my-peertube-checkbox>
315 </div>
316 </ng-container>
287 317
288 <ng-container formGroupName="import"> 318 <ng-container formGroupName="user">
289 <ng-container formGroupName="videos"> 319 <div class="form-group">
320 <label i18n for="userVideoQuota">Default video quota per user</label>
321 <div class="peertube-select-container">
322 <select id="userVideoQuota" formControlName="videoQuota" class="form-control">
323 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value" [disabled]="videoQuotaOption.disabled">
324 {{ videoQuotaOption.label }}
325 </option>
326 </select>
327 </div>
328 <div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div>
329 </div>
290 330
291 <div class="form-group" formGroupName="http"> 331 <div class="form-group">
292 <my-peertube-checkbox 332 <label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label>
293 inputName="importVideosHttpEnabled" formControlName="enabled" 333 <div class="peertube-select-container">
294 i18n-labelText labelText="Allow import with HTTP URL (i.e. YouTube)" 334 <select id="userVideoQuotaDaily" formControlName="videoQuotaDaily" class="form-control">
295 ></my-peertube-checkbox> 335 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value" [disabled]="videoQuotaDailyOption.disabled">
296 </div> 336 {{ videoQuotaDailyOption.label }}
337 </option>
338 </select>
339 </div>
340 <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error">{{ formErrors.user.videoQuotaDaily }}</div>
341 </div>
342 </ng-container>
297 343
298 <div class="form-group" formGroupName="torrent"> 344 </div>
299 <my-peertube-checkbox 345 </div>
300 inputName="importVideosTorrentEnabled" formControlName="enabled"
301 i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
302 ></my-peertube-checkbox>
303 </div>
304 346
305 </ng-container> 347 <div class="form-row mt-4"> <!-- new videos grid -->
306 </ng-container> 348 <div class="form-group col-12 col-lg-4 col-xl-3">
349 <div i18n class="inner-form-title">NEW VIDEOS</div>
350 </div>
307 351
352 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
308 353
309 <div i18n class="inner-form-title">Auto-blacklist</div> 354 <ng-container formGroupName="import">
355 <ng-container formGroupName="videos">
310 356
311 <ng-container formGroupName="autoBlacklist"> 357 <div class="form-group" formGroupName="http">
312 <ng-container formGroupName="videos"> 358 <my-peertube-checkbox
313 <ng-container formGroupName="ofUsers"> 359 inputName="importVideosHttpEnabled" formControlName="enabled"
360 i18n-labelText labelText="Allow import with HTTP URL (i.e. YouTube)"
361 ></my-peertube-checkbox>
362 </div>
314 363
315 <div class="form-group"> 364 <div class="form-group" formGroupName="torrent">
316 <my-peertube-checkbox 365 <my-peertube-checkbox
317 inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" 366 inputName="importVideosTorrentEnabled" formControlName="enabled"
318 i18n-labelText labelText="Blacklist new videos automatically" 367 i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
319 > 368 ></my-peertube-checkbox>
320 <ng-container ngProjectAs="description"> 369 </div>
321 <span i18n>Videos of regular users will stay private until a moderator reviews them. Can be overriden per user.</span>
322 </ng-container>
323 </my-peertube-checkbox>
324 </div>
325 370
371 </ng-container>
326 </ng-container> 372 </ng-container>
327 </ng-container>
328 </ng-container>
329 373
374 <ng-container formGroupName="autoBlacklist">
375 <ng-container formGroupName="videos">
376 <ng-container formGroupName="ofUsers">
377
378 <div class="form-group">
379 <my-peertube-checkbox
380 inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
381 i18n-labelText labelText="Blacklist new videos automatically"
382 >
383 <ng-container ngProjectAs="description">
384 <span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
385 </ng-container>
386 </my-peertube-checkbox>
387 </div>
330 388
331 <div i18n class="inner-form-title">Instance followers</div> 389 </ng-container>
390 </ng-container>
391 </ng-container>
332 392
333 <ng-container formGroupName="followers"> 393 </div>
334 <ng-container formGroupName="instance"> 394 </div>
335 395
336 <div class="form-group"> 396 <div class="form-row mt-4"> <!-- federation grid -->
337 <my-peertube-checkbox 397 <div class="form-group col-12 col-lg-4 col-xl-3">
338 inputName="followersInstanceEnabled" formControlName="enabled" 398 <div i18n class="inner-form-title">FEDERATION</div>
339 i18n-labelText labelText="Other instances can follow your instance" 399 <div i18n class="inner-form-description">
340 ></my-peertube-checkbox> 400 Manage <a routerLink="/admin/follows">relations</a> with other instances.
341 </div> 401 </div>
402 </div>
342 403
343 <div class="form-group"> 404 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
344 <my-peertube-checkbox
345 inputName="followersInstanceManualApproval" formControlName="manualApproval"
346 i18n-labelText labelText="Manually approve new instance followers"
347 ></my-peertube-checkbox>
348 </div>
349 </ng-container>
350 </ng-container>
351 405
352 <div i18n class="inner-form-title">Instance followings</div> 406 <ng-container formGroupName="followers">
407 <ng-container formGroupName="instance">
353 408
354 <ng-container formGroupName="followings"> 409 <div class="form-group">
355 <ng-container formGroupName="instance"> 410 <my-peertube-checkbox
411 inputName="followersInstanceEnabled" formControlName="enabled"
412 i18n-labelText labelText="Other instances can follow yours"
413 ></my-peertube-checkbox>
414 </div>
356 415
357 <ng-container formGroupName="autoFollowBack"> 416 <div class="form-group">
358 <div class="form-group"> 417 <my-peertube-checkbox
359 <my-peertube-checkbox 418 inputName="followersInstanceManualApproval" formControlName="manualApproval"
360 inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" 419 i18n-labelText labelText="Manually approve new instance followers"
361 i18n-labelText labelText="Automatically follow other instances that follow you" 420 ></my-peertube-checkbox>
362 > 421 </div>
363 <ng-container ngProjectAs="description"> 422 </ng-container>
364 <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
365 </ng-container>
366 </my-peertube-checkbox>
367 </div>
368 </ng-container> 423 </ng-container>
369 424
370 <ng-container formGroupName="autoFollowIndex"> 425 <ng-container formGroupName="followings">
371 <div class="form-group"> 426 <ng-container formGroupName="instance">
372 <my-peertube-checkbox 427
373 inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" 428 <ng-container formGroupName="autoFollowBack">
374 i18n-labelText labelText="Automatically follow instances of the public index" 429 <div class="form-group">
375 > 430 <my-peertube-checkbox
376 <ng-container ngProjectAs="description"> 431 inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
377 <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> 432 i18n-labelText labelText="Automatically follow back instances"
378 </ng-container> 433 >
434 <ng-container ngProjectAs="description">
435 <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
436 </ng-container>
437 </my-peertube-checkbox>
438 </div>
439 </ng-container>
379 440
380 <ng-container ngProjectAs="extra"> 441 <ng-container formGroupName="autoFollowIndex">
381 <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }"> 442 <div class="form-group">
382 <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> 443 <my-peertube-checkbox
383 <input 444 inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
384 type="text" id="followingsInstanceAutoFollowIndexUrl" 445 i18n-labelText labelText="Automatically follow instances of a public index"
385 formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }" 446 >
386 > 447 <ng-container ngProjectAs="description">
387 <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div> 448 <p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p>
388 </div> 449
389 </ng-container> 450 <span i18n>
390 </my-peertube-checkbox> 451 You should only follow indexes you trust, or <a href="https://framagit.org/framasoft/peertube/instances-peertube#peertube-auto-follow">host your own</a>.
391 </div> 452 </span>
453 </ng-container>
454
455 <ng-container ngProjectAs="extra">
456 <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
457 <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
458 <input
459 type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
460 formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }"
461 >
462 <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
463 </div>
464 </ng-container>
465 </my-peertube-checkbox>
466 </div>
392 467
468 </ng-container>
469 </ng-container>
393 </ng-container> 470 </ng-container>
394 </ng-container>
395 </ng-container>
396 471
472 </div>
473 </div>
474
475 <div class="form-row mt-4"> <!-- administrators grid -->
476 <div class="form-group col-12 col-lg-4 col-xl-3">
477 <div i18n class="inner-form-title">ADMINISTRATORS</div>
478 </div>
397 479
398 <div i18n class="inner-form-title">Administrator</div> 480 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
399 481
400 <div class="form-group" formGroupName="admin"> 482 <div class="form-group" formGroupName="admin">
401 <label i18n for="adminEmail">Admin email</label> 483 <label i18n for="adminEmail">Admin email</label>
402 <input 484 <input
403 type="text" id="adminEmail" 485 type="text" id="adminEmail" class="form-control"
404 formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }" 486 formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }"
405 > 487 >
406 <div *ngIf="formErrors.admin.email" class="form-error">{{ formErrors.admin.email }}</div> 488 <div *ngIf="formErrors.admin.email" class="form-error">{{ formErrors.admin.email }}</div>
407 </div> 489 </div>
490
491 <div class="form-group" formGroupName="contactForm">
492 <my-peertube-checkbox
493 inputName="enableContactForm" formControlName="enabled"
494 i18n-labelText labelText="Enable contact form"
495 ></my-peertube-checkbox>
496 </div>
408 497
409 <div class="form-group" formGroupName="contactForm"> 498 </div>
410 <my-peertube-checkbox
411 inputName="enableContactForm" formControlName="enabled"
412 i18n-labelText labelText="Enable contact form"
413 ></my-peertube-checkbox>
414 </div> 499 </div>
415 500
416 </ng-template> 501 </ng-template>
417 </ngb-tab> 502 </ng-container>
418
419 <ngb-tab i18n-title title="Services">
420 <ng-template ngbTabContent>
421 <div i18n class="inner-form-title">Twitter</div>
422 503
423 <ng-container formGroupName="services"> 504 <ng-container ngbNavItem="services">
424 <ng-container formGroupName="twitter"> 505 <a ngbNavLink i18n>Services</a>
425 506
426 <div class="form-group"> 507 <ng-template ngbNavContent>
427 <label i18n for="signupLimit">Your Twitter username</label>
428 508
429 <my-help> 509 <div class="form-row mt-5"> <!-- twitter grid -->
430 <ng-template ptTemplate="customHtml"> 510 <div class="form-group col-12 col-lg-4 col-xl-3">
431 <ng-container i18n>Indicates the Twitter account for the website or platform on which the content was published.</ng-container> 511 <div i18n class="inner-form-title">TWITTER</div>
432 </ng-template> 512 <div i18n class="inner-form-description">
433 </my-help> 513 Optional. If any, provide the Twitter account representing your instance to improve link previews.
434
435 <input
436 type="text" id="servicesTwitterUsername"
437 formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }"
438 >
439 <div *ngIf="formErrors.services.twitter.username" class="form-error">{{ formErrors.services.twitter.username }}</div>
440 </div> 514 </div>
515 </div>
441 516
442 <div class="form-group"> 517 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
443 <my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted">
444 <ng-template ptTemplate="label">
445 <ng-container i18n>Instance whitelisted by Twitter</ng-container>
446 </ng-template>
447
448 <ng-template ptTemplate="help">
449 <ng-container i18n>
450 If your instance is whitelisted by Twitter, a video player will be embedded in the Twitter feed on PeerTube video share.<br />
451 If the instance is not whitelisted, we use an image link card that will redirect on your PeerTube instance.<br /><br />
452 Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/videos/watch/blabla) on
453 <a target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a>
454 to see if you instance is whitelisted.
455 </ng-container>
456 </ng-template>
457 </my-peertube-checkbox>
458 </div>
459 518
460 </ng-container> 519 <ng-container formGroupName="services">
461 </ng-container> 520 <ng-container formGroupName="twitter">
462 521
463 </ng-template> 522 <div class="form-group">
464 </ngb-tab> 523 <label i18n for="signupLimit">Your Twitter username</label>
465 524
466 <ngb-tab i18n-title title="Advanced configuration"> 525 <input
467 <ng-template ngbTabContent> 526 type="text" id="servicesTwitterUsername" class="form-control"
527 formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }"
528 >
529 <div *ngIf="formErrors.services.twitter.username" class="form-error">{{ formErrors.services.twitter.username }}</div>
530 </div>
468 531
469 <div i18n class="inner-form-title">Transcoding</div> 532 <div class="form-group">
533 <my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted">
534 <ng-template ptTemplate="label">
535 <ng-container i18n>Instance whitelisted by Twitter</ng-container>
536 </ng-template>
537
538 <ng-template ptTemplate="help">
539 <ng-container i18n>
540 If your instance is whitelisted by Twitter, a video player will be embedded in the Twitter feed on PeerTube video share.<br />
541 If the instance is not whitelisted, we use an image link card that will redirect on your PeerTube instance.<br /><br />
542 Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/videos/watch/blabla) on
543 <a target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a>
544 to see if you instance is whitelisted.
545 </ng-container>
546 </ng-template>
547 </my-peertube-checkbox>
548 </div>
470 549
471 <ng-container formGroupName="transcoding"> 550 </ng-container>
472 <div class="form-group"> 551 </ng-container>
473 <my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled">
474 <ng-template ptTemplate="label">
475 <ng-container i18n>Transcoding enabled</ng-container>
476 </ng-template>
477 552
478 <ng-template ptTemplate="help">
479 <ng-container i18n>If you disable transcoding, many videos from your users will not work!</ng-container>
480 </ng-template>
481 </my-peertube-checkbox>
482 </div> 553 </div>
554 </div>
555 </ng-template>
556 </ng-container>
483 557
484 <ng-container *ngIf="isTranscodingEnabled()"> 558 <ng-container ngbNavItem="advanced-configuration">
559 <a ngbNavLink i18n>Advanced configuration</a>
485 560
486 <div class="form-group"> 561 <ng-template ngbNavContent>
487 <my-peertube-checkbox
488 inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions"
489 i18n-labelText labelText="Allow additional extensions"
490 >
491 <ng-template ptTemplate="help">
492 <ng-container i18n>Allow your users to upload .mkv, .mov, .avi and .flv videos</ng-container>
493 </ng-template>
494 </my-peertube-checkbox>
495 </div>
496 562
497 <div class="form-group"> 563 <div class="form-row mt-5"> <!-- transcoding grid -->
498 <my-peertube-checkbox 564 <div class="form-group col-12 col-lg-4 col-xl-3">
499 inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles" 565 <div i18n class="inner-form-title">TRANSCODING</div>
500 i18n-labelText labelText="Allow audio files upload" 566 <div i18n class="inner-form-description">
501 > 567 Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
502 <ng-template ptTemplate="help"> 568 resources, this is a critical part of PeerTube, so tread carefully.
503 <ng-container i18n>Allow your users to upload audio files that will be merged with the preview file on upload</ng-container>
504 </ng-template>
505 </my-peertube-checkbox>
506 </div> 569 </div>
570 </div>
507 571
508 <ng-container formGroupName="webtorrent"> 572 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
509 <div class="form-group" >
510 <my-peertube-checkbox
511 inputName="transcodingWebTorrentEnabled" formControlName="enabled"
512 i18n-labelText labelText="WebTorrent support enabled"
513 >
514 <ng-template ptTemplate="help">
515 <ng-container i18n>
516 <strong>Experimental, we suggest you to not disable webtorrent support for now</strong>
517
518 <p>If you also enabled HLS support, it will multiply videos storage by 2</p>
519 573
520 <br /> 574 <ng-container formGroupName="transcoding">
521 575
522 <strong>If disabled, breaks federation with PeerTube instances < 2.1</strong> 576 <div class="form-group">
523 </ng-container> 577 <my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled">
578 <ng-template ptTemplate="label">
579 <ng-container i18n>Transcoding enabled</ng-container>
524 </ng-template> 580 </ng-template>
525 </my-peertube-checkbox>
526 </div>
527 </ng-container>
528 581
529 <ng-container formGroupName="hls">
530 <div class="form-group" >
531 <my-peertube-checkbox
532 inputName="transcodingHlsEnabled" formControlName="enabled"
533 i18n-labelText labelText="HLS support enabled"
534 >
535 <ng-template ptTemplate="help"> 582 <ng-template ptTemplate="help">
536 <ng-container i18n> 583 <ng-container i18n>If you disable transcoding, many videos from your users will not work!</ng-container>
537 <strong>Requires ffmpeg >= 4.1</strong> 584 </ng-template>
538 585
539 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with the current default player:</p> 586 <ng-container ngProjectAs="extra">
540 <ul>
541 <li>Resolution change is smoother</li>
542 <li>Faster playback in particular with long videos</li>
543 <li>More stable playback (less bugs/infinite loading)</li>
544 </ul>
545 587
546 <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p> 588 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
589 <my-peertube-checkbox
590 inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions"
591 i18n-labelText labelText="Allow additional extensions"
592 >
593 <ng-container ngProjectAs="description">
594 <span i18n>Allows users to upload .mkv, .mov, .avi and .flv videos.</span>
595 </ng-container>
596 </my-peertube-checkbox>
597 </div>
598
599 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
600 <my-peertube-checkbox
601 inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
602 i18n-labelText labelText="Allow audio files upload"
603 >
604 <ng-container ngProjectAs="description">
605 <span i18n>Allows users to upload audio files that will be merged with the preview file on upload.</span>
606 </ng-container>
607 </my-peertube-checkbox>
608 </div>
609
610 <ng-container formGroupName="webtorrent">
611 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
612 <my-peertube-checkbox
613 inputName="transcodingWebTorrentEnabled" formControlName="enabled"
614 i18n-labelText labelText="WebTorrent support enabled"
615 >
616 <ng-template ptTemplate="help">
617 <ng-container i18n>
618 <strong>Experimental, we suggest you to not disable webtorrent support for now</strong>
619
620 <p>If you also enabled HLS support, it will multiply videos storage by 2</p>
621
622 <br />
623
624 <strong>If disabled, breaks federation with PeerTube instances < 2.1</strong>
625 </ng-container>
626 </ng-template>
627 </my-peertube-checkbox>
628 </div>
547 </ng-container> 629 </ng-container>
548 </ng-template> 630
631 <ng-container formGroupName="hls">
632 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
633 <my-peertube-checkbox
634 inputName="transcodingHlsEnabled" formControlName="enabled"
635 i18n-labelText labelText="HLS support enabled"
636 >
637 <ng-template ptTemplate="help">
638 <ng-container i18n>
639 <strong>Requires ffmpeg >= 4.1</strong>
640
641 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with the current default player:</p>
642 <ul>
643 <li>Resolution change is smoother</li>
644 <li>Faster playback in particular with long videos</li>
645 <li>More stable playback (less bugs/infinite loading)</li>
646 </ul>
647
648 <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p>
649 </ng-container>
650 </ng-template>
651 </my-peertube-checkbox>
652 </div>
653 </ng-container>
654
655 </ng-container>
549 </my-peertube-checkbox> 656 </my-peertube-checkbox>
550 </div> 657 </div>
551 </ng-container>
552 658
553 <div class="form-group"> 659 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
554 <label i18n for="transcodingThreads">Transcoding threads</label> 660 <label i18n for="transcodingThreads">Transcoding threads</label>
555 <div class="peertube-select-container"> 661 <div class="peertube-select-container">
556 <select id="transcodingThreads" formControlName="threads"> 662 <select id="transcodingThreads" formControlName="threads" class="form-control">
557 <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value"> 663 <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value">
558 {{ transcodingThreadOption.label }} 664 {{ transcodingThreadOption.label }}
559 </option> 665 </option>
560 </select> 666 </select>
667 </div>
668 <div *ngIf="formErrors.transcoding.threads" class="form-error">{{ formErrors.transcoding.threads }}</div>
561 </div> 669 </div>
562 <div *ngIf="formErrors.transcoding.threads" class="form-error">{{ formErrors.transcoding.threads }}</div>
563 </div>
564 670
565 <ng-container formGroupName="resolutions"> 671 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
566 <div class="form-group" *ngFor="let resolution of resolutions">
567 <my-peertube-checkbox
568 [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
569 i18n-labelText labelText="Resolution {{resolution.label}} enabled"
570 >
571 <ng-template *ngIf="resolution.description" ptTemplate="help">
572 <div [innerHTML]="resolution.description"></div>
573 </ng-template>
574 </my-peertube-checkbox>
575 </div>
576 </ng-container>
577 672
578 </ng-container> 673 <label i18n for="transcodingThreads">Resolutions to generate</label>
579 </ng-container>
580 674
581 <div class="inner-form-title"> 675 <div class="ml-2 mt-2 d-flex flex-column">
582 <ng-container i18n>Cache</ng-container> 676 <ng-container formGroupName="resolutions">
677 <div class="form-group" *ngFor="let resolution of resolutions">
678 <my-peertube-checkbox
679 [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
680 labelText="{{resolution.label}}"
681 >
682 <ng-template *ngIf="resolution.description" ptTemplate="help">
683 <div [innerHTML]="resolution.description"></div>
684 </ng-template>
685 </my-peertube-checkbox>
686 </div>
687 </ng-container>
688 </div>
583 689
584 <my-help> 690 </div>
585 <ng-template ptTemplate="customHtml"> 691
586 <ng-container i18n>Some files are not federated (previews, captions). We fetch them directly from the origin instance and cache them.</ng-container> 692 </ng-container>
587 </ng-template>
588 </my-help>
589 </div>
590 693
591 <ng-container formGroupName="cache">
592 <div class="form-group" formGroupName="previews">
593 <label i18n for="cachePreviewsSize">Previews cache size</label>
594 <input
595 type="text" id="cachePreviewsSize"
596 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }"
597 >
598 <div *ngIf="formErrors.cache.previews.size" class="form-error">{{ formErrors.cache.previews.size }}</div>
599 </div> 694 </div>
695 </div>
600 696
601 <div class="form-group" formGroupName="captions"> 697 <div class="form-row mt-4"> <!-- cache grid -->
602 <label i18n for="cacheCaptionsSize">Video captions cache size</label> 698 <div class="form-group col-12 col-lg-4 col-xl-3">
603 <input 699 <div i18n class="inner-form-title">CACHE</div>
604 type="text" id="cacheCaptionsSize" 700 <div i18n class="inner-form-description">
605 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }" 701 Some files are not federated, and fetched when necessary. Define their caching policies.
606 > 702 </div>
607 <div *ngIf="formErrors.cache.captions.size" class="form-error">{{ formErrors.cache.captions.size }}</div>
608 </div> 703 </div>
609 </ng-container>
610 704
611 <div i18n class="inner-form-title">Customizations</div> 705 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
612 706
613 <ng-container formGroupName="instance"> 707 <ng-container formGroupName="cache">
614 <ng-container formGroupName="customizations"> 708 <div class="form-group" formGroupName="previews">
615 <div class="form-group"> 709 <label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
616 <label i18n for="customizationJavascript">JavaScript</label> 710 <input
617 <my-help> 711 type="number" min="0" id="cachePreviewsSize" class="form-control"
618 <ng-template ptTemplate="customHtml"> 712 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }"
619 <ng-container i18n> 713 >
620 Write JavaScript code directly.<br />Example: <pre>console.log('my instance is amazing');</pre> 714 <div *ngIf="formErrors.cache.previews.size" class="form-error">{{ formErrors.cache.previews.size }}</div>
621 </ng-container> 715 </div>
622 </ng-template>
623 </my-help>
624 716
625 <textarea 717 <div class="form-group" formGroupName="captions">
626 id="customizationJavascript" formControlName="javascript" 718 <label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
627 [ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }" 719 <input
628 ></textarea> 720 type="number" min="0" id="cacheCaptionsSize" class="form-control"
721 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }"
722 >
723 <div *ngIf="formErrors.cache.captions.size" class="form-error">{{ formErrors.cache.captions.size }}</div>
724 </div>
725 </ng-container>
629 726
630 <div *ngIf="formErrors.instance.customizations.javascript" class="form-error">{{ formErrors.instance.customizations.javascript }}</div> 727 </div>
631 </div> 728 </div>
632 729
633 <div class="form-group"> 730 <div class="form-row mt-4"> <!-- cache grid -->
634 <label for="customizationCSS">CSS</label> 731 <div class="form-group col-12 col-lg-4 col-xl-3">
635 732 <div class="anchor" id="customizations"></div> <!-- customizations anchor -->
636 <my-help> 733 <div i18n class="inner-form-title">CUSTOMIZATIONS</div>
637 <ng-template ptTemplate="customHtml"> 734 <div i18n class="inner-form-description">
638 <ng-container i18n> 735 Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
639 Write CSS code directly. Example:<br /><br />
640<pre>
641#custom-css {{ '{' }}
642 color: red;
643{{ '}' }}
644</pre>
645
646 Prepend with <em>#custom-css</em> to override styles. Example:<br /><br />
647<pre>
648#custom-css .logged-in-email {{ '{' }}
649 color: red;
650{{ '}' }}
651</pre>
652 </ng-container>
653 </ng-template>
654 </my-help>
655
656 <textarea
657 id="customizationCSS" formControlName="css"
658 [ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
659 ></textarea>
660 <div *ngIf="formErrors.instance.customizations.css" class="form-error">{{ formErrors.instance.customizations.css }}</div>
661 </div> 736 </div>
662 </ng-container> 737 </div>
663 </ng-container>
664 738
665 </ng-template> 739 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
666 </ngb-tab> 740
667 </ngb-tabset> 741 <ng-container formGroupName="instance">
742 <ng-container formGroupName="customizations">
743 <div class="form-group">
744 <label i18n for="customizationJavascript">JavaScript</label>
745 <my-help>
746 <ng-template ptTemplate="customHtml">
747 <ng-container i18n>
748 Write JavaScript code directly.<br />Example: <pre>console.log('my instance is amazing');</pre>
749 </ng-container>
750 </ng-template>
751 </my-help>
752
753 <textarea
754 id="customizationJavascript" formControlName="javascript" class="form-control"
755 [ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }"
756 ></textarea>
757
758 <div *ngIf="formErrors.instance.customizations.javascript" class="form-error">{{ formErrors.instance.customizations.javascript }}</div>
759 </div>
668 760
669 <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> 761 <div class="form-group">
670 <span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span> 762 <label for="customizationCSS">CSS</label>
763
764 <my-help>
765 <ng-template ptTemplate="customHtml">
766 <ng-container i18n>
767 Write CSS code directly. Example:<br /><br />
768 <pre>
769 #custom-css {{ '{' }}
770 color: red;
771 {{ '}' }}
772 </pre>
773 Prepend with <em>#custom-css</em> to override styles. Example:<br /><br />
774 <pre>
775 #custom-css .logged-in-email {{ '{' }}
776 color: red;
777 {{ '}' }}
778 </pre>
779 </ng-container>
780 </ng-template>
781 </my-help>
782
783 <textarea
784 id="customizationCSS" formControlName="css" class="form-control"
785 [ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
786 ></textarea>
787 <div *ngIf="formErrors.instance.customizations.css" class="form-error">{{ formErrors.instance.customizations.css }}</div>
788 </div>
789 </ng-container>
790 </ng-container>
791
792 </div>
793 </div>
794
795 </ng-template>
796 </ng-container>
797 </div>
798
799 <div [ngbNavOutlet]="nav"></div>
800
801 <div class="form-row mt-4"> <!-- submit placement block -->
802 <div class="col-md-7 col-xl-5"></div>
803 <div class="col-md-5 col-xl-5">
804 <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid">
805 <span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
806 </div>
807 </div>
671</form> 808</form>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
index 60d608028..9ee960ad6 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
@@ -1,12 +1,24 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-group { 4$form-base-input-width: 340px;
5 margin-bottom: 25px; 5
6label {
7 font-weight: $font-regular;
8 font-size: 100%;
9}
10
11form {
12 padding-bottom: 1.5rem;
6} 13}
7 14
8input[type=text] { 15input[type=text] {
9 @include peertube-input-text(340px); 16 @include peertube-input-text($form-base-input-width);
17 display: block;
18}
19
20input[type=number] {
21 @include peertube-input-text(315px);
10 display: block; 22 display: block;
11} 23}
12 24
@@ -15,14 +27,15 @@ input[type=checkbox] {
15} 27}
16 28
17.peertube-select-container { 29.peertube-select-container {
18 @include peertube-select-container(340px); 30 @include peertube-select-container($form-base-input-width);
19} 31}
20 32
21input[type=submit] { 33input[type=submit] {
22 @include peertube-button; 34 @include peertube-button;
23 @include orange-button; 35 @include orange-button;
24 36
25 margin-top: 20px; 37 display: flex;
38 margin-left: auto;
26 39
27 & + .form-error { 40 & + .form-error {
28 display: inline; 41 display: inline;
@@ -31,17 +44,13 @@ input[type=submit] {
31} 44}
32 45
33.inner-form-title { 46.inner-form-title {
34 text-transform: uppercase; 47 @include settings-big-title;
35 color: var(--mainColor);
36 font-weight: $font-bold;
37 font-size: 13px;
38 margin-top: 30px;
39 margin-bottom: 10px;
40} 48}
41 49
42textarea { 50textarea {
43 @include peertube-textarea(500px, 150px); 51 @include peertube-textarea(500px, 150px);
44 52
53 max-width: 100%;
45 display: block; 54 display: block;
46 55
47 &.small { 56 &.small {
@@ -58,3 +67,13 @@ textarea {
58 opacity: .5; 67 opacity: .5;
59 pointer-events: none; 68 pointer-events: none;
60} 69}
70
71.form-group-right {
72 padding-top: 2px;
73}
74
75ngb-tabset:not(.previews) ::ng-deep {
76 .nav-link {
77 font-size: 105%;
78 }
79} \ No newline at end of file
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index c88e81c01..cea314cea 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -1,4 +1,4 @@
1import { Component, OnInit } from '@angular/core' 1import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
2import { ConfigService } from '@app/+admin/config/shared/config.service' 2import { ConfigService } from '@app/+admin/config/shared/config.service'
3import { ServerService } from '@app/core/server/server.service' 3import { ServerService } from '@app/core/server/server.service'
4import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' 4import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared'
@@ -9,13 +9,19 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
9import { SelectItem } from 'primeng/api' 9import { SelectItem } from 'primeng/api'
10import { forkJoin } from 'rxjs' 10import { forkJoin } from 'rxjs'
11import { ServerConfig } from '@shared/models' 11import { ServerConfig } from '@shared/models'
12import { ViewportScroller } from '@angular/common'
13import { NgbNav } from '@ng-bootstrap/ng-bootstrap'
12 14
13@Component({ 15@Component({
14 selector: 'my-edit-custom-config', 16 selector: 'my-edit-custom-config',
15 templateUrl: './edit-custom-config.component.html', 17 templateUrl: './edit-custom-config.component.html',
16 styleUrls: [ './edit-custom-config.component.scss' ] 18 styleUrls: [ './edit-custom-config.component.scss' ]
17}) 19})
18export class EditCustomConfigComponent extends FormReactive implements OnInit { 20export class EditCustomConfigComponent extends FormReactive implements OnInit, AfterViewChecked {
21 // FIXME: use built-in router
22 @ViewChild('nav') nav: NgbNav
23
24 initDone = false
19 customConfig: CustomConfig 25 customConfig: CustomConfig
20 26
21 resolutions: { id: string, label: string, description?: string }[] = [] 27 resolutions: { id: string, label: string, description?: string }[] = []
@@ -27,6 +33,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
27 private serverConfig: ServerConfig 33 private serverConfig: ServerConfig
28 34
29 constructor ( 35 constructor (
36 private viewportScroller: ViewportScroller,
30 protected formValidatorService: FormValidatorService, 37 protected formValidatorService: FormValidatorService,
31 private customConfigValidatorsService: CustomConfigValidatorsService, 38 private customConfigValidatorsService: CustomConfigValidatorsService,
32 private userValidatorsService: UserValidatorsService, 39 private userValidatorsService: UserValidatorsService,
@@ -226,6 +233,13 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
226 this.checkTranscodingFields() 233 this.checkTranscodingFields()
227 } 234 }
228 235
236 ngAfterViewChecked () {
237 if (!this.initDone) {
238 this.initDone = true
239 this.gotoAnchor()
240 }
241 }
242
229 isTranscodingEnabled () { 243 isTranscodingEnabled () {
230 return this.form.value['transcoding']['enabled'] === true 244 return this.form.value['transcoding']['enabled'] === true
231 } 245 }
@@ -272,6 +286,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
272 return this.i18n('No category') 286 return this.i18n('No category')
273 } 287 }
274 288
289 gotoAnchor () {
290 const hashToNav = {
291 'customizations': 'advanced-configuration'
292 }
293 const hash = window.location.hash.replace('#', '')
294
295 if (hash && Object.keys(hashToNav).includes(hash)) {
296 this.nav.select(hashToNav[hash])
297 setTimeout(() => this.viewportScroller.scrollToAnchor(hash), 100)
298 }
299 }
300
275 private updateForm () { 301 private updateForm () {
276 this.form.patchValue(this.customConfig) 302 this.form.patchValue(this.customConfig)
277 } 303 }
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
new file mode 100644
index 000000000..1b85c8f48
--- /dev/null
+++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.html
@@ -0,0 +1,43 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">{{ action }}</h4>
4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div>
7
8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="submit()">
10 <div class="form-group">
11 <label i18n for="hosts">1 host (without "http://") per line</label>
12
13 <textarea
14 [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
15 class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
16 ></textarea>
17
18 <div *ngIf="formErrors.domains" class="form-error">
19 {{ formErrors.domains }}
20
21 <div *ngIf="form.controls['domains'].errors.validDomains">
22 {{ form.controls['domains'].errors.validDomains.value }}
23 </div>
24 </div>
25 </div>
26
27 <ng-content select="warning"></ng-content>
28
29 <div class="form-group inputs">
30 <input
31 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
32 (click)="hide()" (key.enter)="hide()"
33 >
34
35 <input
36 type="submit" [value]="action" class="action-button-submit"
37 [disabled]="!form.valid"
38 >
39 </div>
40 </form>
41 </div>
42
43</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
new file mode 100644
index 000000000..9621a566f
--- /dev/null
+++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.scss
@@ -0,0 +1,3 @@
1textarea {
2 height: 200px;
3}
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
new file mode 100644
index 000000000..620f2726b
--- /dev/null
+++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.ts
@@ -0,0 +1,54 @@
1import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
6import { FormReactive } from '@app/shared/forms'
7import { BatchDomainsValidatorsService } from './batch-domains-validators.service'
8
9@Component({
10 selector: 'my-batch-domains-modal',
11 templateUrl: './batch-domains-modal.component.html',
12 styleUrls: [ './batch-domains-modal.component.scss' ]
13})
14export class BatchDomainsModalComponent extends FormReactive implements OnInit {
15 @ViewChild('modal', { static: true }) modal: NgbModal
16 @Input() placeholder = 'example.com'
17 @Input() action: string
18 @Output() domains = new EventEmitter<string[]>()
19
20 private openedModal: NgbModalRef
21
22 constructor (
23 protected formValidatorService: FormValidatorService,
24 private modalService: NgbModal,
25 private batchDomainsValidatorsService: BatchDomainsValidatorsService,
26 private i18n: I18n
27 ) {
28 super()
29 }
30
31 ngOnInit () {
32 if (!this.action) this.action = this.i18n('Process domains')
33
34 this.buildForm({
35 domains: this.batchDomainsValidatorsService.DOMAINS
36 })
37 }
38
39 openModal () {
40 this.openedModal = this.modalService.open(this.modal, { centered: true })
41 }
42
43 hide () {
44 this.openedModal.close()
45 }
46
47 submit () {
48 this.domains.emit(
49 this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value)
50 )
51 this.form.reset()
52 this.hide()
53 }
54}
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
new file mode 100644
index 000000000..46fa6514d
--- /dev/null
+++ b/client/src/app/+admin/config/shared/batch-domains-validators.service.ts
@@ -0,0 +1,68 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators, ValidatorFn } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator, validateHost } from '@app/shared/forms/form-validators'
5
6@Injectable()
7export class BatchDomainsValidatorsService {
8 readonly DOMAINS: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.DOMAINS = {
12 VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ],
13 MESSAGES: {
14 'required': this.i18n('Domain is required.'),
15 'validDomains': this.i18n('Domains entered are invalid.'),
16 'uniqueDomains': this.i18n('Domains entered contain duplicates.')
17 }
18 }
19 }
20
21 getNotEmptyHosts (hosts: string) {
22 return hosts
23 .split('\n')
24 .filter((host: string) => host && host.length !== 0) // Eject empty hosts
25 }
26
27 private validDomains: ValidatorFn = (control) => {
28 if (!control.value) return null
29
30 const newHostsErrors = []
31 const hosts = this.getNotEmptyHosts(control.value)
32
33 for (const host of hosts) {
34 if (validateHost(host) === false) {
35 newHostsErrors.push(this.i18n('{{host}} is not valid', { host }))
36 }
37 }
38
39 /* Is not valid. */
40 if (newHostsErrors.length !== 0) {
41 return {
42 'validDomains': {
43 reason: 'invalid',
44 value: newHostsErrors.join('. ') + '.'
45 }
46 }
47 }
48
49 /* Is valid. */
50 return null
51 }
52
53 private isHostsUnique: ValidatorFn = (control) => {
54 if (!control.value) return null
55
56 const hosts = this.getNotEmptyHosts(control.value)
57
58 if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
59 return null
60 } else {
61 return {
62 'uniqueDomains': {
63 reason: 'invalid'
64 }
65 }
66 }
67 }
68}
diff --git a/client/src/app/+admin/config/shared/config.service.ts b/client/src/app/+admin/config/shared/config.service.ts
index 28a3d67d6..874b8094d 100644
--- a/client/src/app/+admin/config/shared/config.service.ts
+++ b/client/src/app/+admin/config/shared/config.service.ts
@@ -10,8 +10,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
10export class ConfigService { 10export class ConfigService {
11 private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' 11 private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
12 12
13 videoQuotaOptions: { value: number, label: string }[] = [] 13 videoQuotaOptions: { value: number, label: string, disabled?: boolean }[] = []
14 videoQuotaDailyOptions: { value: number, label: string }[] = [] 14 videoQuotaDailyOptions: { value: number, label: string, disabled?: boolean }[] = []
15 15
16 constructor ( 16 constructor (
17 private authHttp: HttpClient, 17 private authHttp: HttpClient,
@@ -19,8 +19,10 @@ export class ConfigService {
19 private i18n: I18n 19 private i18n: I18n
20 ) { 20 ) {
21 this.videoQuotaOptions = [ 21 this.videoQuotaOptions = [
22 { value: undefined, label: 'Default quota', disabled: true },
22 { value: -1, label: this.i18n('Unlimited') }, 23 { value: -1, label: this.i18n('Unlimited') },
23 { value: 0, label: '0' }, 24 { value: undefined, label: '─────', disabled: true },
25 { value: 0, label: this.i18n('None - no upload possible') },
24 { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, 26 { value: 100 * 1024 * 1024, label: this.i18n('100MB') },
25 { value: 500 * 1024 * 1024, label: this.i18n('500MB') }, 27 { value: 500 * 1024 * 1024, label: this.i18n('500MB') },
26 { value: 1024 * 1024 * 1024, label: this.i18n('1GB') }, 28 { value: 1024 * 1024 * 1024, label: this.i18n('1GB') },
@@ -30,8 +32,10 @@ export class ConfigService {
30 ] 32 ]
31 33
32 this.videoQuotaDailyOptions = [ 34 this.videoQuotaDailyOptions = [
35 { value: undefined, label: 'Default daily upload limit', disabled: true },
33 { value: -1, label: this.i18n('Unlimited') }, 36 { value: -1, label: this.i18n('Unlimited') },
34 { value: 0, label: '0' }, 37 { value: undefined, label: '─────', disabled: true },
38 { value: 0, label: this.i18n('None - no upload possible') },
35 { value: 10 * 1024 * 1024, label: this.i18n('10MB') }, 39 { value: 10 * 1024 * 1024, label: this.i18n('10MB') },
36 { value: 50 * 1024 * 1024, label: this.i18n('50MB') }, 40 { value: 50 * 1024 * 1024, label: this.i18n('50MB') },
37 { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, 41 { value: 100 * 1024 * 1024, label: this.i18n('100MB') },
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index 6b5f3b450..93378a533 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -1,35 +1,46 @@
1<p-table 1<p-table
2 [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="followers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
4> 6>
5 <ng-template pTemplate="caption"> 7 <ng-template pTemplate="caption">
6 <div class="caption"> 8 <div class="caption">
7 <input 9 <div class="ml-auto has-feedback has-clear">
8 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 10 <input
9 (keyup)="onSearch($event.target.value)" 11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
10 > 12 (keyup)="onSearch($event)"
13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
16 </div>
11 </div> 17 </div>
12 </ng-template> 18 </ng-template>
13 19
14 <ng-template pTemplate="header"> 20 <ng-template pTemplate="header">
15 <tr> 21 <tr>
16 <th i18n>Follower handle</th> 22 <th i18n>Follower handle</th>
17 <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> 23 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
18 <th i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> 24 <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
19 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 25 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
20 <th></th> 26 <th style="width: 100px;"></th>
21 </tr> 27 </tr>
22 </ng-template> 28 </ng-template>
23 29
24 <ng-template pTemplate="body" let-follow> 30 <ng-template pTemplate="body" let-follow>
25 <tr> 31 <tr>
26 <td><a [href]="follow.follower.url" target="_blank" rel="noopener noreferrer">{{ follow.follower.name + '@' + follow.follower.host }}</a></td> 32 <td>
33 <a [href]="follow.follower.url" i18n-title title="Open actor page in a new tab" target="_blank" rel="noopener noreferrer">
34 {{ follow.follower.name + '@' + follow.follower.host }}
35 <span class="glyphicon glyphicon-new-window"></span>
36 </a>
37 </td>
27 38
28 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> 39 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td>
29 <td *ngIf="follow.state === 'pending'" i18n>Pending</td> 40 <td *ngIf="follow.state === 'pending'" i18n>Pending</td>
30 41
31 <td>{{ follow.score }}</td> 42 <td>{{ follow.score }}</td>
32 <td>{{ follow.createdAt }}</td> 43 <td>{{ follow.createdAt | date: 'short' }}</td>
33 44
34 <td class="action-cell"> 45 <td class="action-cell">
35 <ng-container *ngIf="follow.state === 'pending'"> 46 <ng-container *ngIf="follow.state === 'pending'">
@@ -41,4 +52,15 @@
41 </td> 52 </td>
42 </tr> 53 </tr>
43 </ng-template> 54 </ng-template>
55
56 <ng-template pTemplate="emptymessage">
57 <tr>
58 <td colspan="6">
59 <div class="empty-table-message">
60 <ng-container *ngIf="search" i18n>No follower found matching current filters.</ng-container>
61 <ng-container *ngIf="!search" i18n>Your instance doesn't have any follower.</ng-container>
62 </div>
63 </td>
64 </tr>
65 </ng-template>
44</p-table> 66</p-table>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.scss b/client/src/app/+admin/follows/followers-list/followers-list.component.scss
index 964b3f99b..14189ff11 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.scss
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.scss
@@ -9,6 +9,20 @@
9 } 9 }
10} 10}
11 11
12a {
13 @include disable-default-a-behaviour;
14 display: inline-block;
15
16 &, &:hover {
17 color: var(--mainForegroundColor);
18 }
19
20 span {
21 font-size: 80%;
22 color: var(--inputPlaceholderColor);
23 }
24}
25
12.action-cell { 26.action-cell {
13 my-button:first-child { 27 my-button:first-child {
14 margin-right: 10px; 28 margin-right: 10px;
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index 707daef84..17352a601 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -9,13 +9,12 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9@Component({ 9@Component({
10 selector: 'my-followers-list', 10 selector: 'my-followers-list',
11 templateUrl: './followers-list.component.html', 11 templateUrl: './followers-list.component.html',
12 styleUrls: [ './followers-list.component.scss' ] 12 styleUrls: [ '../follows.component.scss', './followers-list.component.scss' ]
13}) 13})
14export class FollowersListComponent extends RestTable implements OnInit { 14export class FollowersListComponent extends RestTable implements OnInit {
15 followers: ActorFollow[] = [] 15 followers: ActorFollow[] = []
16 totalRecords = 0 16 totalRecords = 0
17 rowsPerPage = 10 17 sort: SortMeta = { field: 'createdAt', order: -1 }
18 sort: SortMeta = { field: 'createdAt', order: 1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
20 19
21 constructor ( 20 constructor (
@@ -31,6 +30,10 @@ export class FollowersListComponent extends RestTable implements OnInit {
31 this.initialize() 30 this.initialize()
32 } 31 }
33 32
33 getIdentifier () {
34 return 'FollowersListComponent'
35 }
36
34 acceptFollower (follow: ActorFollow) { 37 acceptFollower (follow: ActorFollow) {
35 follow.state = 'accepted' 38 follow.state = 'accepted'
36 39
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.html b/client/src/app/+admin/follows/following-add/following-add.component.html
deleted file mode 100644
index e08decb3f..000000000
--- a/client/src/app/+admin/follows/following-add/following-add.component.html
+++ /dev/null
@@ -1,22 +0,0 @@
1<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
2
3<form (ngSubmit)="addFollowing()">
4 <div class="form-group">
5 <label i18n for="hosts">1 host (without "http://") per line</label>
6
7 <textarea
8 type="text" class="form-control" placeholder="example.com" id="hosts" name="hosts"
9 [(ngModel)]="hostsString" (ngModelChange)="onHostsChanged()" [ngClass]="{ 'input-error': hostsError }"
10 ></textarea>
11
12 <div *ngIf="hostsError" class="form-error">
13 {{ hostsError }}
14 </div>
15 </div>
16
17 <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
18 It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
19 </div>
20
21 <input type="submit" i18n-value value="Add following" [disabled]="hostsError || !hostsString" class="btn btn-secondary">
22</form>
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.scss b/client/src/app/+admin/follows/following-add/following-add.component.scss
deleted file mode 100644
index 1baddc95f..000000000
--- a/client/src/app/+admin/follows/following-add/following-add.component.scss
+++ /dev/null
@@ -1,18 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4textarea {
5 height: 250px;
6}
7
8.form-control {
9 &, &:focus {
10 background-color: var(--inputColor);
11 color: var(--mainForegroundColor);
12 }
13}
14
15input[type=submit] {
16 @include peertube-button;
17 @include orange-button;
18}
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.ts b/client/src/app/+admin/follows/following-add/following-add.component.ts
deleted file mode 100644
index 308bbb0c5..000000000
--- a/client/src/app/+admin/follows/following-add/following-add.component.ts
+++ /dev/null
@@ -1,85 +0,0 @@
1import { Component } from '@angular/core'
2import { Router } from '@angular/router'
3import { Notifier } from '@app/core'
4import { ConfirmService } from '../../../core'
5import { validateHost } from '../../../shared'
6import { FollowService } from '@app/shared/instance/follow.service'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8
9@Component({
10 selector: 'my-following-add',
11 templateUrl: './following-add.component.html',
12 styleUrls: [ './following-add.component.scss' ]
13})
14export class FollowingAddComponent {
15 hostsString = ''
16 hostsError: string = null
17 error: string = null
18
19 constructor (
20 private router: Router,
21 private notifier: Notifier,
22 private confirmService: ConfirmService,
23 private followService: FollowService,
24 private i18n: I18n
25 ) {}
26
27 httpEnabled () {
28 return window.location.protocol === 'https:'
29 }
30
31 onHostsChanged () {
32 this.hostsError = null
33
34 const newHostsErrors = []
35 const hosts = this.getNotEmptyHosts()
36
37 for (const host of hosts) {
38 if (validateHost(host) === false) {
39 newHostsErrors.push(this.i18n('{{host}} is not valid', { host }))
40 }
41 }
42
43 if (newHostsErrors.length !== 0) {
44 this.hostsError = newHostsErrors.join('. ')
45 }
46 }
47
48 async addFollowing () {
49 this.error = ''
50
51 const hosts = this.getNotEmptyHosts()
52 if (hosts.length === 0) {
53 this.error = this.i18n('You need to specify hosts to follow.')
54 }
55
56 if (!this.isHostsUnique(hosts)) {
57 this.error = this.i18n('Hosts need to be unique.')
58 return
59 }
60
61 const confirmMessage = this.i18n('If you confirm, you will send a follow request to:<br /> - ') + hosts.join('<br /> - ')
62 const res = await this.confirmService.confirm(confirmMessage, this.i18n('Follow new server(s)'))
63 if (res === false) return
64
65 this.followService.follow(hosts).subscribe(
66 () => {
67 this.notifier.success(this.i18n('Follow request(s) sent!'))
68
69 setTimeout(() => this.router.navigate([ '/admin/follows/following-list' ]), 500)
70 },
71
72 err => this.notifier.error(err.message)
73 )
74 }
75
76 private isHostsUnique (hosts: string[]) {
77 return hosts.every(host => hosts.indexOf(host) === hosts.lastIndexOf(host))
78 }
79
80 private getNotEmptyHosts () {
81 return this.hostsString
82 .split('\n')
83 .filter(host => host && host.length !== 0) // Eject empty hosts
84 }
85}
diff --git a/client/src/app/+admin/follows/following-add/index.ts b/client/src/app/+admin/follows/following-add/index.ts
deleted file mode 100644
index 1b1897ffa..000000000
--- a/client/src/app/+admin/follows/following-add/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './following-add.component'
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index 5a252eda9..059c07295 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -1,36 +1,49 @@
1<p-table 1<p-table
2 [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="following" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
4> 6>
5 <ng-template pTemplate="caption"> 7 <ng-template pTemplate="caption">
6 <div class="caption"> 8 <div class="caption">
7 <div> 9 <div class="ml-auto has-feedback has-clear">
8 <input 10 <input
9 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
10 (keyup)="onSearch($event.target.value)" 12 (keyup)="onSearch($event)"
11 > 13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
12 </div> 16 </div>
17 <a class="ml-2 follow-button" (click)="addDomainsToFollow()" (key.enter)="addDomainsToFollow()">
18 <my-global-icon iconName="add"></my-global-icon>
19 <ng-container i18n>Follow domain</ng-container>
20 </a>
13 </div> 21 </div>
14 </ng-template> 22 </ng-template>
15 23
16 <ng-template pTemplate="header"> 24 <ng-template pTemplate="header">
17 <tr> 25 <tr>
18 <th i18n>Host</th> 26 <th i18n>Host</th>
19 <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> 27 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
20 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 28 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
21 <th i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> 29 <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
22 <th></th> 30 <th style="width: 100px;"></th>
23 </tr> 31 </tr>
24 </ng-template> 32 </ng-template>
25 33
26 <ng-template pTemplate="body" let-follow> 34 <ng-template pTemplate="body" let-follow>
27 <tr> 35 <tr>
28 <td>{{ follow.following.host }}</td> 36 <td>
37 <a [href]="'https://' + follow.following.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
38 {{ follow.following.host }}
39 <span class="glyphicon glyphicon-new-window"></span>
40 </a>
41 </td>
29 42
30 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> 43 <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td>
31 <td *ngIf="follow.state === 'pending'" i18n>Pending</td> 44 <td *ngIf="follow.state === 'pending'" i18n>Pending</td>
32 45
33 <td>{{ follow.createdAt }}</td> 46 <td>{{ follow.createdAt | date: 'short' }}</td>
34 <td> 47 <td>
35 <my-redundancy-checkbox 48 <my-redundancy-checkbox
36 [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed" 49 [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
@@ -41,4 +54,23 @@
41 </td> 54 </td>
42 </tr> 55 </tr>
43 </ng-template> 56 </ng-template>
57
58 <ng-template pTemplate="emptymessage">
59 <tr>
60 <td colspan="6">
61 <div class="empty-table-message">
62 <ng-container *ngIf="search" i18n>No host found matching current filters.</ng-container>
63 <ng-container *ngIf="!search" i18n>Your instance is not following anyone.</ng-container>
64 </div>
65 </td>
66 </tr>
67 </ng-template>
44</p-table> 68</p-table>
69
70<my-batch-domains-modal #batchDomainsModal i18n-action action="Follow domains" (domains)="addFollowing($event)">
71 <ng-container ngProjectAs="warning">
72 <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
73 It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
74 </div>
75 </ng-container>
76</my-batch-domains-modal>
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.scss b/client/src/app/+admin/follows/following-list/following-list.component.scss
index a6f0656b8..563f8d2bc 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.scss
+++ b/client/src/app/+admin/follows/following-list/following-list.component.scss
@@ -1,10 +1,28 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4a {
5 @include disable-default-a-behaviour;
6 display: inline-block;
7
8 &, &:hover {
9 color: var(--mainForegroundColor);
10 }
11
12 span {
13 font-size: 80%;
14 color: var(--inputPlaceholderColor);
15 }
16}
17
4.caption { 18.caption {
5 justify-content: flex-end; 19 justify-content: flex-end;
6 20
7 input { 21 input {
8 @include peertube-input-text(250px); 22 @include peertube-input-text(250px);
9 } 23 }
10} \ No newline at end of file 24}
25
26.follow-button {
27 @include create-button;
28}
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index 3d78c254f..6ddbf02d6 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -1,4 +1,4 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { SortMeta } from 'primeng/api' 3import { SortMeta } from 'primeng/api'
4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' 4import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
@@ -6,17 +6,19 @@ import { ConfirmService } from '../../../core/confirm/confirm.service'
6import { RestPagination, RestTable } from '../../../shared' 6import { RestPagination, RestTable } from '../../../shared'
7import { FollowService } from '@app/shared/instance/follow.service' 7import { FollowService } from '@app/shared/instance/follow.service'
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
9 10
10@Component({ 11@Component({
11 selector: 'my-followers-list', 12 selector: 'my-followers-list',
12 templateUrl: './following-list.component.html', 13 templateUrl: './following-list.component.html',
13 styleUrls: [ './following-list.component.scss' ] 14 styleUrls: [ '../follows.component.scss', './following-list.component.scss' ]
14}) 15})
15export class FollowingListComponent extends RestTable implements OnInit { 16export class FollowingListComponent extends RestTable implements OnInit {
17 @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
18
16 following: ActorFollow[] = [] 19 following: ActorFollow[] = []
17 totalRecords = 0 20 totalRecords = 0
18 rowsPerPage = 10 21 sort: SortMeta = { field: 'createdAt', order: -1 }
19 sort: SortMeta = { field: 'createdAt', order: 1 }
20 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 22 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
21 23
22 constructor ( 24 constructor (
@@ -32,6 +34,29 @@ export class FollowingListComponent extends RestTable implements OnInit {
32 this.initialize() 34 this.initialize()
33 } 35 }
34 36
37 getIdentifier () {
38 return 'FollowingListComponent'
39 }
40
41 addDomainsToFollow () {
42 this.batchDomainsModal.openModal()
43 }
44
45 httpEnabled () {
46 return window.location.protocol === 'https:'
47 }
48
49 async addFollowing (hosts: string[]) {
50 this.followService.follow(hosts).subscribe(
51 () => {
52 this.notifier.success(this.i18n('Follow request(s) sent!'))
53 this.loadData()
54 },
55
56 err => this.notifier.error(err.message)
57 )
58 }
59
35 async removeFollowing (follow: ActorFollow) { 60 async removeFollowing (follow: ActorFollow) {
36 const res = await this.confirmService.confirm( 61 const res = await this.confirmService.confirm(
37 this.i18n('Do you really want to unfollow {{host}}?', { host: follow.following.host }), 62 this.i18n('Do you really want to unfollow {{host}}?', { host: follow.following.host }),
diff --git a/client/src/app/+admin/follows/follows.component.html b/client/src/app/+admin/follows/follows.component.html
index 21d477132..7b5bcc2db 100644
--- a/client/src/app/+admin/follows/follows.component.html
+++ b/client/src/app/+admin/follows/follows.component.html
@@ -1,13 +1,13 @@
1<div class="admin-sub-header"> 1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Manage follows</div> 2 <div i18n class="form-sub-title">Follows & redundancies</div>
3 3
4 <div class="admin-sub-nav"> 4 <div class="admin-sub-nav">
5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a> 5 <a i18n routerLink="following-list" routerLinkActive="active">Following</a>
6 6
7 <a i18n routerLink="following-add" routerLinkActive="active">Follow</a>
8
9 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a> 7 <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a>
8
9 <a i18n routerLink="video-redundancies-list" routerLinkActive="active">Video redundancies</a>
10 </div> 10 </div>
11</div> 11</div>
12 12
13<router-outlet></router-outlet> \ No newline at end of file 13<router-outlet></router-outlet>
diff --git a/client/src/app/+admin/follows/follows.component.scss b/client/src/app/+admin/follows/follows.component.scss
index 766d7853b..32394f698 100644
--- a/client/src/app/+admin/follows/follows.component.scss
+++ b/client/src/app/+admin/follows/follows.component.scss
@@ -1,4 +1,10 @@
1@import "mixins";
2
1.form-sub-title { 3.form-sub-title {
2 flex-grow: 0; 4 flex-grow: 0;
3 margin-right: 30px; 5 margin-right: 30px;
4} 6}
7
8.empty-table-message {
9 @include empty-state;
10}
diff --git a/client/src/app/+admin/follows/follows.routes.ts b/client/src/app/+admin/follows/follows.routes.ts
index e84c79e82..8270ae444 100644
--- a/client/src/app/+admin/follows/follows.routes.ts
+++ b/client/src/app/+admin/follows/follows.routes.ts
@@ -2,10 +2,10 @@ import { Routes } from '@angular/router'
2 2
3import { UserRightGuard } from '../../core' 3import { UserRightGuard } from '../../core'
4import { FollowsComponent } from './follows.component' 4import { FollowsComponent } from './follows.component'
5import { FollowingAddComponent } from './following-add'
6import { FollowersListComponent } from './followers-list' 5import { FollowersListComponent } from './followers-list'
7import { UserRight } from '../../../../../shared' 6import { UserRight } from '../../../../../shared'
8import { FollowingListComponent } from './following-list/following-list.component' 7import { FollowingListComponent } from './following-list/following-list.component'
8import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
9 9
10export const FollowsRoutes: Routes = [ 10export const FollowsRoutes: Routes = [
11 { 11 {
@@ -41,12 +41,11 @@ export const FollowsRoutes: Routes = [
41 }, 41 },
42 { 42 {
43 path: 'following-add', 43 path: 'following-add',
44 component: FollowingAddComponent, 44 redirectTo: 'following-list'
45 data: { 45 },
46 meta: { 46 {
47 title: 'Add follow' 47 path: 'video-redundancies-list',
48 } 48 component: VideoRedundanciesListComponent
49 }
50 } 49 }
51 ] 50 ]
52 } 51 }
diff --git a/client/src/app/+admin/follows/index.ts b/client/src/app/+admin/follows/index.ts
index e94f33710..285955468 100644
--- a/client/src/app/+admin/follows/index.ts
+++ b/client/src/app/+admin/follows/index.ts
@@ -1,5 +1,5 @@
1export * from './following-add'
2export * from './followers-list' 1export * from './followers-list'
3export * from './following-list' 2export * from './following-list'
3export * from './video-redundancies-list'
4export * from './follows.component' 4export * from './follows.component'
5export * from './follows.routes' 5export * from './follows.routes'
diff --git a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
index fa1da26bf..9d7883d97 100644
--- a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
+++ b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts
@@ -1,7 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' 4import { RedundancyService } from '@app/shared/video/redundancy.service'
5 5
6@Component({ 6@Component({
7 selector: 'my-redundancy-checkbox', 7 selector: 'my-redundancy-checkbox',
diff --git a/client/src/app/+admin/follows/shared/redundancy.service.ts b/client/src/app/+admin/follows/shared/redundancy.service.ts
deleted file mode 100644
index 87ae01c04..000000000
--- a/client/src/app/+admin/follows/shared/redundancy.service.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { catchError, map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/shared'
5import { environment } from '../../../../environments/environment'
6
7@Injectable()
8export class RedundancyService {
9 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/server/redundancy'
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) { }
15
16 updateRedundancy (host: string, redundancyAllowed: boolean) {
17 const url = RedundancyService.BASE_USER_SUBSCRIPTIONS_URL + '/' + host
18
19 const body = { redundancyAllowed }
20
21 return this.authHttp.put(url, body)
22 .pipe(
23 map(this.restExtractor.extractDataBool),
24 catchError(err => this.restExtractor.handleError(err))
25 )
26 }
27
28}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/index.ts b/client/src/app/+admin/follows/video-redundancies-list/index.ts
new file mode 100644
index 000000000..6a7c7f483
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/index.ts
@@ -0,0 +1 @@
export * from './video-redundancies-list.component'
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
new file mode 100644
index 000000000..28d57f83c
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
@@ -0,0 +1,100 @@
1<div class="admin-sub-header">
2 <div class="select-filter-block">
3 <label for="displayType" i18n>Display</label>
4
5 <div class="peertube-select-container">
6 <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()" class="form-control">
7 <option value="my-videos" i18n>My videos duplicated by remote instances</option>
8 <option value="remote-videos" i18n>Remote videos duplicated by my instance</option>
9 </select>
10 </div>
11 </div>
12</div>
13
14<p-table
15 [value]="videoRedundancies" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
16 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
17 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
18>
19 <ng-template pTemplate="header">
20 <tr>
21 <th style="width: 40px;"></th>
22 <th style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
23 <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th >
24 <th style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
25 <th style="width: 80px;"></th>
26 </tr>
27 </ng-template>
28
29 <ng-template pTemplate="body" let-expanded="expanded" let-redundancy>
30 <tr>
31
32 <td>
33 <span class="expander" i18n-ngbTooltip ngbTooltip="List redundancies" [pRowToggler]="redundancy">
34 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
35 </span>
36 </td>
37
38 <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td>
39
40 <td>
41 <a [href]="redundancy.url" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer">
42 {{ redundancy.name }}
43 <span class="glyphicon glyphicon-new-window"></span>
44 </a>
45 </td>
46
47 <td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
48
49 <td class="action-cell">
50 <my-delete-button (click)="removeRedundancy(redundancy)"></my-delete-button>
51 </td>
52 </tr>
53 </ng-template>
54
55 <ng-template pTemplate="rowexpansion" let-redundancy>
56 <tr *ngIf="redundancy.redundancies.files.length !== 0">
57 <td class="expand-cell" [attr.colspan]="getColspan()">
58 <div *ngFor="let file of redundancy.redundancies.files" class="expansion-block">
59 <my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information>
60 </div>
61 </td>
62 </tr>
63
64 <tr *ngIf="redundancy.redundancies.streamingPlaylists.length !== 0">
65 <td class="expand-cell" [attr.colspan]="getColspan()">
66 <div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists">
67 <my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information>
68 </div>
69 </td>
70 </tr>
71 </ng-template>
72
73 <ng-template pTemplate="emptymessage">
74 <tr>
75 <td colspan="6">
76 <div class="empty-table-message">
77 <ng-container *ngIf="isDisplayingRemoteVideos()" i18n>Your instance doesn't mirror any video.</ng-container>
78 <ng-container *ngIf="!isDisplayingRemoteVideos()" i18n>Your instance has no mirrored videos.</ng-container>
79 </div>
80 </td>
81 </tr>
82 </ng-template>
83</p-table>
84
85
86<div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos()">
87 <div class="form-sub-title" i18n>Enabled strategies stats</div>
88
89 <div class="chart-blocks">
90
91 <div *ngIf="noRedundancies" i18n class="no-results">
92 No redundancy strategy is enabled on your instance.
93 </div>
94
95 <div class="chart-block" *ngFor="let r of redundanciesGraphsData">
96 <p-chart type="pie" [data]="r.graphData" [options]="r.options" width="300px" height="300px"></p-chart>
97 </div>
98
99 </div>
100</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
new file mode 100644
index 000000000..dc43e4007
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss
@@ -0,0 +1,51 @@
1@import '_variables';
2@import '_mixins';
3
4a {
5 @include disable-default-a-behaviour;
6 display: inline-block;
7
8 &, &:hover {
9 color: var(--mainForegroundColor);
10 }
11
12 span {
13 font-size: 80%;
14 color: var(--inputPlaceholderColor);
15 }
16}
17
18.expansion-block {
19 margin-bottom: 20px;
20}
21
22.admin-sub-header {
23 justify-content: flex-end;
24
25 .select-filter-block {
26 &:not(:last-child) {
27 margin-right: 10px;
28 }
29
30 label {
31 margin-bottom: 2px;
32 }
33
34 .peertube-select-container {
35 @include peertube-select-container(auto);
36 }
37 }
38}
39
40.redundancies-charts {
41 margin-top: 50px;
42
43 .chart-blocks {
44 display: flex;
45 justify-content: center;
46
47 .chart-block {
48 margin: 0 20px;
49 }
50 }
51}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
new file mode 100644
index 000000000..267a1f58e
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
@@ -0,0 +1,187 @@
1import { Component, OnInit } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { SortMeta } from 'primeng/api'
4import { ConfirmService } from '../../../core/confirm/confirm.service'
5import { RestPagination, RestTable } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
8import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
9import { VideosRedundancyStats } from '@shared/models/server'
10import { BytesPipe } from 'ngx-pipes'
11import { RedundancyService } from '@app/shared/video/redundancy.service'
12
13@Component({
14 selector: 'my-video-redundancies-list',
15 templateUrl: './video-redundancies-list.component.html',
16 styleUrls: [ '../follows.component.scss', './video-redundancies-list.component.scss' ]
17})
18export class VideoRedundanciesListComponent extends RestTable implements OnInit {
19 private static LOCAL_STORAGE_DISPLAY_TYPE = 'video-redundancies-list-display-type'
20
21 videoRedundancies: VideoRedundancy[] = []
22 totalRecords = 0
23
24 sort: SortMeta = { field: 'name', order: 1 }
25 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
26 displayType: VideoRedundanciesTarget = 'my-videos'
27
28 redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: object, options: object }[] = []
29
30 noRedundancies = false
31
32 private bytesPipe: BytesPipe
33
34 constructor (
35 private notifier: Notifier,
36 private confirmService: ConfirmService,
37 private redundancyService: RedundancyService,
38 private serverService: ServerService,
39 private i18n: I18n
40 ) {
41 super()
42
43 this.bytesPipe = new BytesPipe()
44 }
45
46 getIdentifier () {
47 return 'VideoRedundanciesListComponent'
48 }
49
50 ngOnInit () {
51 this.loadSelectLocalStorage()
52
53 this.initialize()
54
55 this.serverService.getServerStats()
56 .subscribe(res => {
57 const redundancies = res.videosRedundancy
58
59 if (redundancies.length === 0) this.noRedundancies = true
60
61 for (const r of redundancies) {
62 this.buildPieData(r)
63 }
64 })
65 }
66
67 getColspan () {
68 if (this.isDisplayingRemoteVideos()) return 5
69
70 return 4
71 }
72
73 isDisplayingRemoteVideos () {
74 return this.displayType === 'remote-videos'
75 }
76
77 getTotalSize (redundancy: VideoRedundancy) {
78 return redundancy.redundancies.files.reduce((a, b) => a + b.size, 0) +
79 redundancy.redundancies.streamingPlaylists.reduce((a, b) => a + b.size, 0)
80 }
81
82 onDisplayTypeChanged () {
83 this.pagination.start = 0
84 this.saveSelectLocalStorage()
85
86 this.loadData()
87 }
88
89 getRedundancyStrategy (redundancy: VideoRedundancy) {
90 if (redundancy.redundancies.files.length !== 0) return redundancy.redundancies.files[0].strategy
91 if (redundancy.redundancies.streamingPlaylists.length !== 0) return redundancy.redundancies.streamingPlaylists[0].strategy
92
93 return ''
94 }
95
96 buildPieData (stats: VideosRedundancyStats) {
97 const totalSize = stats.totalSize
98 ? stats.totalSize - stats.totalUsed
99 : stats.totalUsed
100
101 if (totalSize === 0) return
102
103 this.redundanciesGraphsData.push({
104 stats,
105 graphData: {
106 labels: [ this.i18n('Used'), this.i18n('Available') ],
107 datasets: [
108 {
109 data: [ stats.totalUsed, totalSize ],
110 backgroundColor: [
111 '#FF6384',
112 '#36A2EB'
113 ],
114 hoverBackgroundColor: [
115 '#FF6384',
116 '#36A2EB'
117 ]
118 }
119 ]
120 },
121 options: {
122 title: {
123 display: true,
124 text: stats.strategy
125 },
126
127 tooltips: {
128 callbacks: {
129 label: (tooltipItem: any, data: any) => {
130 const dataset = data.datasets[tooltipItem.datasetIndex]
131 let label = data.labels[tooltipItem.index]
132 if (label) label += ': '
133 else label = ''
134
135 label += this.bytesPipe.transform(dataset.data[tooltipItem.index], 1)
136 return label
137 }
138 }
139 }
140 }
141 })
142 }
143
144 async removeRedundancy (redundancy: VideoRedundancy) {
145 const message = this.i18n('Do you really want to remove this video redundancy?')
146 const res = await this.confirmService.confirm(message, this.i18n('Remove redundancy'))
147 if (res === false) return
148
149 this.redundancyService.removeVideoRedundancies(redundancy)
150 .subscribe(
151 () => {
152 this.notifier.success(this.i18n('Video redundancies removed!'))
153 this.loadData()
154 },
155
156 err => this.notifier.error(err.message)
157 )
158
159 }
160
161 protected loadData () {
162 const options = {
163 pagination: this.pagination,
164 sort: this.sort,
165 target: this.displayType
166 }
167
168 this.redundancyService.listVideoRedundancies(options)
169 .subscribe(
170 resultList => {
171 this.videoRedundancies = resultList.data
172 this.totalRecords = resultList.total
173 },
174
175 err => this.notifier.error(err.message)
176 )
177 }
178
179 private loadSelectLocalStorage () {
180 const displayType = peertubeLocalStorage.getItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE)
181 if (displayType) this.displayType = displayType as VideoRedundanciesTarget
182 }
183
184 private saveSelectLocalStorage () {
185 peertubeLocalStorage.setItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE, this.displayType)
186 }
187}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
new file mode 100644
index 000000000..9de6e4661
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html
@@ -0,0 +1,19 @@
1<div>
2 <span class="label">Url</span>
3 <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement.fileUrl">{{ redundancyElement.fileUrl }}</a>
4</div>
5
6<div>
7 <span class="label">Created on</span>
8 <span>{{ redundancyElement.createdAt | date: 'medium' }}</span>
9</div>
10
11<div>
12 <span class="label">Expires on</span>
13 <span>{{ redundancyElement.expiresOn | date: 'medium' }}</span>
14</div>
15
16<div>
17 <span class="label">Size</span>
18 <span>{{ redundancyElement.size | bytes: 1 }}</span>
19</div>
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
new file mode 100644
index 000000000..6b09fbb01
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss
@@ -0,0 +1,8 @@
1@import '_variables';
2@import '_mixins';
3
4.label {
5 display: inline-block;
6 min-width: 100px;
7 font-weight: $font-semibold;
8}
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
new file mode 100644
index 000000000..6f3090c08
--- /dev/null
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models'
3
4@Component({
5 selector: 'my-video-redundancy-information',
6 templateUrl: './video-redundancy-information.component.html',
7 styleUrls: [ './video-redundancy-information.component.scss' ]
8})
9export class VideoRedundancyInformationComponent {
10 @Input() redundancyElement: FileRedundancyInformation | StreamingPlaylistRedundancyInformation
11}
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
index 7797bc56e..a4ab2a58c 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
@@ -1,22 +1,64 @@
1<p-table 1<p-table
2 [value]="blockedAccounts" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
4> 6>
7 <ng-template pTemplate="caption">
8 <div class="caption">
9 <div class="ml-auto has-feedback has-clear">
10 <input
11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
12 (keyup)="onSearch($event)"
13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
16 </div>
17 </div>
18 </ng-template>
5 19
6 <ng-template pTemplate="header"> 20 <ng-template pTemplate="header">
7 <tr> 21 <tr>
8 <th i18n>Account</th> 22 <th style="width: 100%;" i18n>Account</th>
9 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> 23 <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
24 <th style="width: 100px;"></th> <!-- column for action buttons -->
10 </tr> 25 </tr>
11 </ng-template> 26 </ng-template>
12 27
13 <ng-template pTemplate="body" let-accountBlock> 28 <ng-template pTemplate="body" let-accountBlock>
14 <tr> 29 <tr>
15 <td>{{ accountBlock.blockedAccount.nameWithHost }}</td> 30 <td>
16 <td>{{ accountBlock.createdAt }}</td> 31 <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
32 <div class="chip two-lines">
33 <img
34 class="avatar"
35 [src]="accountBlock.blockedAccount.avatar?.path"
36 (error)="switchToDefaultAvatar($event)"
37 alt="Avatar"
38 >
39 <div>
40 {{ accountBlock.blockedAccount.displayName }}
41 <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
42 </div>
43 </div>
44 </a>
45 </td>
46
47 <td>{{ accountBlock.createdAt | date: 'short' }}</td>
17 <td class="action-cell"> 48 <td class="action-cell">
18 <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button> 49 <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
19 </td> 50 </td>
20 </tr> 51 </tr>
21 </ng-template> 52 </ng-template>
53
54 <ng-template pTemplate="emptymessage">
55 <tr>
56 <td colspan="6">
57 <div class="empty-table-message">
58 <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
59 <ng-container *ngIf="!search" i18n>No account found.</ng-container>
60 </div>
61 </td>
62 </tr>
63 </ng-template>
22</p-table> 64</p-table>
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
index 032bf745a..73a9ae75d 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts
@@ -2,18 +2,18 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { AccountBlock, BlocklistService } from '@app/shared/blocklist' 6import { AccountBlock, BlocklistService } from '@app/shared/blocklist'
7import { Actor } from '@app/shared/actor/actor.model'
7 8
8@Component({ 9@Component({
9 selector: 'my-instance-account-blocklist', 10 selector: 'my-instance-account-blocklist',
10 styleUrls: [ './instance-account-blocklist.component.scss' ], 11 styleUrls: [ '../moderation.component.scss', './instance-account-blocklist.component.scss' ],
11 templateUrl: './instance-account-blocklist.component.html' 12 templateUrl: './instance-account-blocklist.component.html'
12}) 13})
13export class InstanceAccountBlocklistComponent extends RestTable implements OnInit { 14export class InstanceAccountBlocklistComponent extends RestTable implements OnInit {
14 blockedAccounts: AccountBlock[] = [] 15 blockedAccounts: AccountBlock[] = []
15 totalRecords = 0 16 totalRecords = 0
16 rowsPerPage = 10
17 sort: SortMeta = { field: 'createdAt', order: -1 } 17 sort: SortMeta = { field: 'createdAt', order: -1 }
18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
19 19
@@ -29,6 +29,14 @@ export class InstanceAccountBlocklistComponent extends RestTable implements OnIn
29 this.initialize() 29 this.initialize()
30 } 30 }
31 31
32 getIdentifier () {
33 return 'InstanceAccountBlocklistComponent'
34 }
35
36 switchToDefaultAvatar ($event: Event) {
37 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
38 }
39
32 unblockAccount (accountBlock: AccountBlock) { 40 unblockAccount (accountBlock: AccountBlock) {
33 const blockedAccount = accountBlock.blockedAccount 41 const blockedAccount = accountBlock.blockedAccount
34 42
@@ -45,7 +53,11 @@ export class InstanceAccountBlocklistComponent extends RestTable implements OnIn
45 } 53 }
46 54
47 protected loadData () { 55 protected loadData () {
48 return this.blocklistService.getInstanceAccountBlocklist(this.pagination, this.sort) 56 return this.blocklistService.getInstanceAccountBlocklist({
57 pagination: this.pagination,
58 sort: this.sort,
59 search: this.search
60 })
49 .subscribe( 61 .subscribe(
50 resultList => { 62 resultList => {
51 this.blockedAccounts = resultList.data 63 this.blockedAccounts = resultList.data
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html
index f634ba834..dab068dd6 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html
@@ -1,23 +1,59 @@
1<p-table 1<p-table
2 [value]="blockedServers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
4> 6>
7 <ng-template pTemplate="caption">
8 <div class="caption">
9 <div class="ml-auto has-feedback has-clear">
10 <input
11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
12 (keyup)="onSearch($event)"
13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
16 </div>
17 <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
18 <my-global-icon iconName="add"></my-global-icon>
19 <ng-container i18n>Mute domain</ng-container>
20 </a>
21 </div>
22 </ng-template>
5 23
6 <ng-template pTemplate="header"> 24 <ng-template pTemplate="header">
7 <tr> 25 <tr>
8 <th i18n>Instance</th> 26 <th style="width: 100%;" i18n>Instance</th>
9 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> 27 <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
10 <th></th> 28 <th style="width: 100px;"></th> <!-- column for action buttons -->
11 </tr> 29 </tr>
12 </ng-template> 30 </ng-template>
13 31
14 <ng-template pTemplate="body" let-serverBlock> 32 <ng-template pTemplate="body" let-serverBlock>
15 <tr> 33 <tr>
16 <td>{{ serverBlock.blockedServer.host }}</td> 34 <td>
17 <td>{{ serverBlock.createdAt }}</td> 35 <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
36 {{ serverBlock.blockedServer.host }}
37 <span class="glyphicon glyphicon-new-window"></span>
38 </a>
39 </td>
40 <td>{{ serverBlock.createdAt | date: 'short' }}</td>
18 <td class="action-cell"> 41 <td class="action-cell">
19 <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button> 42 <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
20 </td> 43 </td>
21 </tr> 44 </tr>
22 </ng-template> 45 </ng-template>
46
47 <ng-template pTemplate="emptymessage">
48 <tr>
49 <td colspan="6">
50 <div class="empty-table-message">
51 <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
52 <ng-container *ngIf="!search" i18n>No server found.</ng-container>
53 </div>
54 </td>
55 </tr>
56 </ng-template>
23</p-table> 57</p-table>
58
59<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss
index 6028b75ea..c6c71587f 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss
@@ -1,7 +1,25 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4a {
5 @include disable-default-a-behaviour;
6 display: inline-block;
7
8 &, &:hover {
9 color: var(--mainForegroundColor);
10 }
11
12 span {
13 font-size: 80%;
14 color: var(--inputPlaceholderColor);
15 }
16}
17
4.unblock-button { 18.unblock-button {
5 @include peertube-button; 19 @include peertube-button;
6 @include grey-button; 20 @include grey-button;
7} \ No newline at end of file 21}
22
23.block-button {
24 @include create-button;
25}
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
index db3dfcd1c..559c9c0b0 100644
--- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
+++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts
@@ -1,20 +1,22 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { BlocklistService } from '@app/shared/blocklist' 6import { BlocklistService } from '@app/shared/blocklist'
7import { ServerBlock } from '../../../../../../shared' 7import { ServerBlock } from '../../../../../../shared'
8import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
8 9
9@Component({ 10@Component({
10 selector: 'my-instance-server-blocklist', 11 selector: 'my-instance-server-blocklist',
11 styleUrls: [ './instance-server-blocklist.component.scss' ], 12 styleUrls: [ '../moderation.component.scss', './instance-server-blocklist.component.scss' ],
12 templateUrl: './instance-server-blocklist.component.html' 13 templateUrl: './instance-server-blocklist.component.html'
13}) 14})
14export class InstanceServerBlocklistComponent extends RestTable implements OnInit { 15export class InstanceServerBlocklistComponent extends RestTable implements OnInit {
16 @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
17
15 blockedServers: ServerBlock[] = [] 18 blockedServers: ServerBlock[] = []
16 totalRecords = 0 19 totalRecords = 0
17 rowsPerPage = 10
18 sort: SortMeta = { field: 'createdAt', order: -1 } 20 sort: SortMeta = { field: 'createdAt', order: -1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 21 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
20 22
@@ -30,6 +32,10 @@ export class InstanceServerBlocklistComponent extends RestTable implements OnIni
30 this.initialize() 32 this.initialize()
31 } 33 }
32 34
35 getIdentifier () {
36 return 'InstanceServerBlocklistComponent'
37 }
38
33 unblockServer (serverBlock: ServerBlock) { 39 unblockServer (serverBlock: ServerBlock) {
34 const host = serverBlock.blockedServer.host 40 const host = serverBlock.blockedServer.host
35 41
@@ -43,8 +49,29 @@ export class InstanceServerBlocklistComponent extends RestTable implements OnIni
43 ) 49 )
44 } 50 }
45 51
52 addServersToBlock () {
53 this.batchDomainsModal.openModal()
54 }
55
56 onDomainsToBlock (domains: string[]) {
57 domains.forEach(domain => {
58 this.blocklistService.blockServerByInstance(domain)
59 .subscribe(
60 () => {
61 this.notifier.success(this.i18n('Instance {{domain}} muted by your instance.', { domain }))
62
63 this.loadData()
64 }
65 )
66 })
67 }
68
46 protected loadData () { 69 protected loadData () {
47 return this.blocklistService.getInstanceServerBlocklist(this.pagination, this.sort) 70 return this.blocklistService.getInstanceServerBlocklist({
71 pagination: this.pagination,
72 sort: this.sort,
73 search: this.search
74 })
48 .subscribe( 75 .subscribe(
49 resultList => { 76 resultList => {
50 this.blockedServers = resultList.data 77 this.blockedServers = resultList.data
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss
index 13b019c5b..26c2a30d4 100644
--- a/client/src/app/+admin/moderation/moderation.component.scss
+++ b/client/src/app/+admin/moderation/moderation.component.scss
@@ -1,25 +1,151 @@
1@import 'variables'; 1@import 'variables';
2@import 'mixins'; 2@import 'mixins';
3@import 'miniature';
3 4
4.form-sub-title { 5.form-sub-title {
5 flex-grow: 0; 6 flex-grow: 0;
6 margin-right: 30px; 7 margin-right: 30px;
7} 8}
8 9
9.moderation-expanded-label { 10.caption {
10 font-weight: $font-semibold; 11 justify-content: flex-end;
11 min-width: 200px; 12
12 display: inline-block; 13 input {
13 vertical-align: top; 14 @include peertube-input-text(250px);
15 flex-grow: 1;
16 }
14} 17}
15 18
16.moderation-expanded-text { 19.empty-table-message {
17 display: inline-block; 20 @include empty-state;
18} 21}
19 22
20.moderation-expanded { 23.moderation-expanded {
21 word-wrap: break-word; 24 font-size: 90%;
22 overflow: visible !important; 25
23 text-overflow: unset !important; 26 .moderation-expanded-label {
24 white-space: unset !important; 27 font-weight: $font-semibold;
28 display: inline-block;
29 vertical-align: top;
30 text-align: right;
31 }
32
33 .moderation-expanded-text {
34 display: inline-flex;
35 word-wrap: break-word;
36
37 ::ng-deep p:last-child {
38 margin-bottom: 0px !important;
39 }
40 }
41}
42
43.video-table-states {
44 & > :not(:first-child) {
45 margin-left: .4rem;
46 }
47}
48
49.screenratio {
50 position: relative;
51 width: 100%;
52 height: 0;
53 padding-bottom: 56%;
54
55 div {
56 @include miniature-thumbnail;
57 position: absolute;
58 height: 100%;
59 width: 100%;
60 display: inline-flex;
61 justify-content: center;
62 align-items: center;
63 color: var(--inputPlaceholderColor);
64 }
65
66 ::ng-deep iframe {
67 position: absolute;
68 width: 100% !important;
69 height: 100% !important;
70 left: 0;
71 top: 0;
72 }
73}
74
75.chip {
76 @include chip;
77}
78
79my-action-dropdown.show {
80 ::ng-deep .dropdown-root {
81 display: block !important;
82 }
83}
84
85
86.video-table-video-link {
87 @include disable-outline;
88 position: relative;
89 top: 3px;
90}
91
92.video-table-video {
93 display: inline-flex;
94
95 .video-table-video-image {
96 @include miniature-thumbnail;
97
98 $image-height: 45px;
99
100 height: $image-height;
101 width: #{(16/9) * $image-height};
102 margin-right: 0.5rem;
103 border-radius: 2px;
104 border: none;
105 background: transparent;
106 display: inline-flex;
107 justify-content: center;
108 align-items: center;
109 position: relative;
110
111 img {
112 height: 100%;
113 width: 100%;
114 border-radius: 2px;
115 }
116
117 span {
118 color: var(--inputPlaceholderColor);
119 }
120
121 .video-table-video-image-label {
122 @include static-thumbnail-overlay;
123 position: absolute;
124 border-radius: 3px;
125 font-size: 10px;
126 padding: 0 3px;
127 line-height: 1.3;
128 bottom: 2px;
129 right: 2px;
130 }
131 }
132
133 .video-table-video-text {
134 display: inline-flex;
135 flex-direction: column;
136 justify-content: center;
137 font-size: 90%;
138 color: var(--mainForegroundColor);
139 line-height: 1rem;
140
141 div .glyphicon {
142 font-size: 80%;
143 color: gray;
144 margin-left: 0.1rem;
145 }
146
147 div + div {
148 font-size: 80%;
149 }
150 }
25} 151}
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html
index 303a788d2..8082e93f4 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html
+++ b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html
@@ -8,7 +8,9 @@
8 <div class="modal-body"> 8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea formControlName="moderationComment" [ngClass]="{ 'input-error': formErrors['moderationComment'] }"> 11 <textarea
12 formControlName="moderationComment" ngbAutofocus i18-placeholder placeholder="Comment this report…"
13 [ngClass]="{ 'input-error': formErrors['moderationComment'] }" class="form-control">
12 </textarea> 14 </textarea>
13 <div *ngIf="formErrors.moderationComment" class="form-error"> 15 <div *ngIf="formErrors.moderationComment" class="form-error">
14 {{ formErrors.moderationComment }} 16 {{ formErrors.moderationComment }}
@@ -20,7 +22,10 @@
20 </div> 22 </div>
21 23
22 <div class="form-group inputs"> 24 <div class="form-group inputs">
23 <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span> 25 <input
26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
27 (click)="hide()" (key.enter)="hide()"
28 >
24 29
25 <input 30 <input
26 type="submit" i18n-value value="Update this comment" class="action-button-submit" 31 type="submit" i18n-value value="Update this comment" class="action-button-submit"
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
index f8a5ef8cb..a0471f2b0 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
@@ -32,13 +32,13 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
32 32
33 ngOnInit () { 33 ngOnInit () {
34 this.buildForm({ 34 this.buildForm({
35 moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON 35 moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_MODERATION_COMMENT
36 }) 36 })
37 } 37 }
38 38
39 openModal (abuseToComment: VideoAbuse) { 39 openModal (abuseToComment: VideoAbuse) {
40 this.abuseToComment = abuseToComment 40 this.abuseToComment = abuseToComment
41 this.openedModal = this.modalService.open(this.modal) 41 this.openedModal = this.modalService.open(this.modal, { centered: true })
42 42
43 this.form.patchValue({ 43 this.form.patchValue({
44 moderationComment: this.abuseToComment.moderationComment 44 moderationComment: this.abuseToComment.moderationComment
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
new file mode 100644
index 000000000..2abcc0669
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
@@ -0,0 +1,77 @@
1<div class="d-flex moderation-expanded">
2 <!-- report left part (report details) -->
3 <div class="col-8">
4
5 <!-- report metadata -->
6 <div class="d-flex">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8 <span class="col-9 moderation-expanded-text">
9 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
10 <img
11 class="avatar"
12 [src]="videoAbuse.reporterAccount.avatar?.path"
13 (error)="switchToDefaultAvatar($event)"
14 alt="Avatar"
15 >
16 <div>
17 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
18 </div>
19 </a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
21 {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
22 </a>
23 </span>
24 </div>
25
26 <div class="d-flex">
27 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
28 <span class="col-9 moderation-expanded-text">
29 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
30 <img
31 class="avatar"
32 [src]="videoAbuse.video.channel.ownerAccount?.avatar?.path"
33 (error)="switchToDefaultAvatar($event)"
34 alt="Avatar"
35 >
36 <div>
37 <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
38 </div>
39 </a>
40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
41 {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
42 </a>
43 </span>
44 </div>
45
46 <div class="d-flex" *ngIf="videoAbuse.updatedAt">
47 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
48 <time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time>
49 </div>
50
51 <!-- report text -->
52 <div class="mt-3 d-flex">
53 <span class="col-3 moderation-expanded-label">
54 <ng-container i18n>Report</ng-container>
55 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a>
56 </span>
57 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
58 </div>
59
60 <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
61 <span class="col-3 moderation-expanded-label" i18n>Note</span>
62 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
63 </div>
64
65 </div>
66
67 <!-- report right part (video details) -->
68 <div class="col-4">
69 <div class="screenratio">
70 <div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted">
71 <span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span>
72 <span i18n *ngIf="!videoAbuse.video.deleted">The video was blacklisted</span>
73 </div>
74 <div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div>
75 </div>
76 </div>
77</div> \ No newline at end of file
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
new file mode 100644
index 000000000..d9cb19845
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
@@ -0,0 +1,17 @@
1import { Component, Input } from '@angular/core'
2import { Account } from '@app/shared/account/account.model'
3import { Actor } from '@app/shared/actor/actor.model'
4import { ProcessedVideoAbuse } from './video-abuse-list.component'
5
6@Component({
7 selector: 'my-video-abuse-details',
8 templateUrl: './video-abuse-details.component.html',
9 styleUrls: [ '../moderation.component.scss' ]
10})
11export class VideoAbuseDetailsComponent {
12 @Input() videoAbuse: ProcessedVideoAbuse
13
14 switchToDefaultAvatar ($event: Event) {
15 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
16 }
17}
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
index 30eb2dbde..1c9530152 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
@@ -1,13 +1,45 @@
1<p-table 1<p-table
2 [value]="videoAbuses" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="videoAbuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
4> 7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <div class="input-group has-feedback has-clear">
12 <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
13 <div class="input-group-text" ngbDropdownToggle>
14 <span class="caret" aria-haspopup="menu" role="button"></span>
15 </div>
16
17 <div role="menu" ngbDropdownMenu>
18 <h6 class="dropdown-header" i18n>Advanced report filters</h6>
19 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
21 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
22 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blacklisted videos</a>
23 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
24 </div>
25 </div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onAbuseSearch($event)"
29 >
30 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
31 <span class="sr-only" i18n>Clear filters</span>
32 </div>
33 </div>
34 </div>
35 </ng-template>
36
5 <ng-template pTemplate="header"> 37 <ng-template pTemplate="header">
6 <tr> 38 <tr> <!-- header -->
7 <th style="width: 40px"></th> 39 <th style="width: 40px;"></th>
8 <th i18n>Reporter</th> 40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
9 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
10 <th i18n>Video</th> 41 <th i18n>Video</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
11 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th> 43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
12 <th style="width: 120px;"></th> 44 <th style="width: 120px;"></th>
13 </tr> 45 </tr>
@@ -15,51 +47,103 @@
15 47
16 <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse> 48 <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse>
17 <tr> 49 <tr>
18 <td class="expand-cell"> 50 <td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
19 <span class="expander" [pRowToggler]="videoAbuse"> 51 <span class="expander">
20 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> 52 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
21 </span> 53 </span>
22 </td> 54 </td>
23 55
24 <td> 56 <td>
25 <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Go to the account" target="_blank" rel="noopener noreferrer"> 57 <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
26 {{ createByString(videoAbuse.reporterAccount) }} 58 <div class="chip two-lines">
59 <img
60 class="avatar"
61 [src]="videoAbuse.reporterAccount.avatar?.path"
62 (error)="switchToDefaultAvatar($event)"
63 alt="Avatar"
64 >
65 <div>
66 {{ videoAbuse.reporterAccount.displayName }}
67 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
68 </div>
69 </div>
27 </a> 70 </a>
28 </td> 71 </td>
29 72
30 <td>{{ videoAbuse.createdAt }}</td> 73 <td *ngIf="!videoAbuse.video.deleted">
31 74 <a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer">
32 <td> 75 <div class="video-table-video">
33 <a [href]="getVideoUrl(videoAbuse)" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer"> 76 <div class="video-table-video-image">
34 {{ videoAbuse.video.name }} 77 <img [src]="videoAbuse.video.thumbnailPath">
78 <span
79 class="video-table-video-image-label" *ngIf="videoAbuse.count > 1"
80 i18n-title title="This video has been reported multiple times."
81 >
82 {{ videoAbuse.nth }}/{{ videoAbuse.count }}
83 </span>
84 </div>
85 <div class="video-table-video-text">
86 <div>
87 {{ videoAbuse.video.name }}
88 <span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
89 <span *ngIf="videoAbuse.video.blacklisted" i18n-title title="Video was blacklisted" class="glyphicon glyphicon-ban-circle"></span>
90 </div>
91 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
92 </div>
93 </div>
35 </a> 94 </a>
36 </td> 95 </td>
37 96
38 <td> 97 <td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse">
98 <div class="video-table-video" i18n-title title="Video was deleted">
99 <div class="video-table-video-image">
100 <span i18n>Deleted</span>
101 </div>
102 <div class="video-table-video-text">
103 <div>
104 {{ videoAbuse.video.name }}
105 <span class="glyphicon glyphicon-trash"></span>
106 </div>
107 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
108 </div>
109 </div>
110 </td>
111
112 <td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short' }}</td>
113
114 <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse">
39 <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span> 115 <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span>
40 <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span> 116 <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span>
117 <span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span>
41 </td> 118 </td>
42 119
43 <td class="action-cell"> 120 <td class="action-cell">
44 <my-action-dropdown placement="bottom-right" i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"></my-action-dropdown> 121 <my-action-dropdown
122 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
123 i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
124 ></my-action-dropdown>
45 </td> 125 </td>
46 </tr> 126 </tr>
47 </ng-template> 127 </ng-template>
48 128
49 <ng-template pTemplate="rowexpansion" let-videoAbuse> 129 <ng-template pTemplate="rowexpansion" let-videoAbuse>
50 <tr> 130 <tr>
51 <td class="moderation-expanded" colspan="6"> 131 <td class="expand-cell" colspan="6">
52 <div> 132 <my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details>
53 <span i18n class="moderation-expanded-label">Reason:</span>
54 <span class="moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
55 </div>
56 <div *ngIf="videoAbuse.moderationComment">
57 <span i18n class="moderation-expanded-label">Moderation comment:</span>
58 <span class="moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
59 </div>
60 </td> 133 </td>
61 </tr> 134 </tr>
62 </ng-template> 135 </ng-template>
136
137 <ng-template pTemplate="emptymessage">
138 <tr>
139 <td colspan="6">
140 <div class="empty-table-message">
141 <ng-container *ngIf="search" i18n>No video abuses found matching current filters.</ng-container>
142 <ng-container *ngIf="!search" i18n>No video abuses found.</ng-container>
143 </div>
144 </td>
145 </tr>
146 </ng-template>
63</p-table> 147</p-table>
64 148
65<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal> 149<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
new file mode 100644
index 000000000..8eee15b64
--- /dev/null
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
@@ -0,0 +1,23 @@
1@import 'mixins';
2@import 'miniature';
3
4.video-details-date-updated {
5 font-size: 90%;
6 margin-top: .1rem;
7}
8
9.video-details-links {
10 @include disable-default-a-behaviour;
11}
12
13.video-abuse-states .glyphicon-comment {
14 margin-left: 0.5rem;
15}
16
17.input-group {
18 @include peertube-input-group(300px);
19
20 .dropdown-toggle::after {
21 margin-left: 0;
22 }
23}
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
index 778f18d3d..39f619cc3 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
+++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
@@ -1,65 +1,222 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'
2import { Account } from '../../../shared/account/account.model' 2import { Account } from '@app/shared/account/account.model'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { VideoAbuse, VideoAbuseState } from '../../../../../../shared' 5import { VideoAbuse, VideoAbuseState } from '../../../../../../shared'
6import { RestPagination, RestTable, VideoAbuseService } from '../../../shared' 6import { RestPagination, RestTable, VideoAbuseService, VideoBlacklistService } from '../../../shared'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { DropdownAction } from '../../../shared/buttons/action-dropdown.component' 8import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
9import { ConfirmService } from '../../../core/index' 9import { ConfirmService } from '../../../core/index'
10import { ModerationCommentModalComponent } from './moderation-comment-modal.component' 10import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
11import { Video } from '../../../shared/video/video.model' 11import { Video } from '../../../shared/video/video.model'
12import { MarkdownService } from '@app/shared/renderer' 12import { MarkdownService } from '@app/shared/renderer'
13import { Actor } from '@app/shared/actor/actor.model'
14import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils'
15import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
16import { DomSanitizer } from '@angular/platform-browser'
17import { BlocklistService } from '@app/shared/blocklist'
18import { VideoService } from '@app/shared/video/video.service'
19import { ActivatedRoute, Params, Router } from '@angular/router'
20import { filter } from 'rxjs/operators'
21
22export type ProcessedVideoAbuse = VideoAbuse & {
23 moderationCommentHtml?: string,
24 reasonHtml?: string
25 embedHtml?: string
26 updatedAt?: Date
27 // override bare server-side definitions with rich client-side definitions
28 reporterAccount: Account
29 video: VideoAbuse['video'] & {
30 channel: VideoAbuse['video']['channel'] & {
31 ownerAccount: Account
32 }
33 }
34}
13 35
14@Component({ 36@Component({
15 selector: 'my-video-abuse-list', 37 selector: 'my-video-abuse-list',
16 templateUrl: './video-abuse-list.component.html', 38 templateUrl: './video-abuse-list.component.html',
17 styleUrls: [ '../moderation.component.scss'] 39 styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ]
18}) 40})
19export class VideoAbuseListComponent extends RestTable implements OnInit { 41export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit {
20 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent 42 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
21 43
22 videoAbuses: (VideoAbuse & { moderationCommentHtml?: string, reasonHtml?: string })[] = [] 44 videoAbuses: ProcessedVideoAbuse[] = []
23 totalRecords = 0 45 totalRecords = 0
24 rowsPerPage = 10
25 sort: SortMeta = { field: 'createdAt', order: 1 } 46 sort: SortMeta = { field: 'createdAt', order: 1 }
26 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 47 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
27 48
28 videoAbuseActions: DropdownAction<VideoAbuse>[] = [] 49 videoAbuseActions: DropdownAction<VideoAbuse>[][] = []
29 50
30 constructor ( 51 constructor (
31 private notifier: Notifier, 52 private notifier: Notifier,
32 private videoAbuseService: VideoAbuseService, 53 private videoAbuseService: VideoAbuseService,
54 private blocklistService: BlocklistService,
55 private videoService: VideoService,
56 private videoBlacklistService: VideoBlacklistService,
33 private confirmService: ConfirmService, 57 private confirmService: ConfirmService,
34 private i18n: I18n, 58 private i18n: I18n,
35 private markdownRenderer: MarkdownService 59 private markdownRenderer: MarkdownService,
60 private sanitizer: DomSanitizer,
61 private route: ActivatedRoute,
62 private router: Router
36 ) { 63 ) {
37 super() 64 super()
38 65
39 this.videoAbuseActions = [ 66 this.videoAbuseActions = [
40 { 67 [
41 label: this.i18n('Delete this report'), 68 {
42 handler: videoAbuse => this.removeVideoAbuse(videoAbuse) 69 label: this.i18n('Internal actions'),
43 }, 70 isHeader: true
44 { 71 },
45 label: this.i18n('Update moderation comment'), 72 {
46 handler: videoAbuse => this.openModerationCommentModal(videoAbuse) 73 label: this.i18n('Delete report'),
47 }, 74 handler: videoAbuse => this.removeVideoAbuse(videoAbuse)
48 { 75 },
49 label: this.i18n('Mark as accepted'), 76 {
50 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED), 77 label: this.i18n('Add note'),
51 isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse) 78 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
52 }, 79 isDisplayed: videoAbuse => !videoAbuse.moderationComment
53 { 80 },
54 label: this.i18n('Mark as rejected'), 81 {
55 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED), 82 label: this.i18n('Update note'),
56 isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse) 83 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
57 } 84 isDisplayed: videoAbuse => !!videoAbuse.moderationComment
85 },
86 {
87 label: this.i18n('Mark as accepted'),
88 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED),
89 isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse)
90 },
91 {
92 label: this.i18n('Mark as rejected'),
93 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED),
94 isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse)
95 }
96 ],
97 [
98 {
99 label: this.i18n('Actions for the video'),
100 isHeader: true,
101 isDisplayed: videoAbuse => !videoAbuse.video.deleted
102 },
103 {
104 label: this.i18n('Blacklist video'),
105 isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted,
106 handler: videoAbuse => {
107 this.videoBlacklistService.blacklistVideo(videoAbuse.video.id, undefined, true)
108 .subscribe(
109 () => {
110 this.notifier.success(this.i18n('Video blacklisted.'))
111
112 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
113 },
114
115 err => this.notifier.error(err.message)
116 )
117 }
118 },
119 {
120 label: this.i18n('Unblacklist video'),
121 isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted,
122 handler: videoAbuse => {
123 this.videoBlacklistService.removeVideoFromBlacklist(videoAbuse.video.id)
124 .subscribe(
125 () => {
126 this.notifier.success(this.i18n('Video unblacklisted.'))
127
128 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
129 },
130
131 err => this.notifier.error(err.message)
132 )
133 }
134 },
135 {
136 label: this.i18n('Delete video'),
137 isDisplayed: videoAbuse => !videoAbuse.video.deleted,
138 handler: async videoAbuse => {
139 const res = await this.confirmService.confirm(
140 this.i18n('Do you really want to delete this video?'),
141 this.i18n('Delete')
142 )
143 if (res === false) return
144
145 this.videoService.removeVideo(videoAbuse.video.id)
146 .subscribe(
147 () => {
148 this.notifier.success(this.i18n('Video deleted.'))
149
150 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
151 },
152
153 err => this.notifier.error(err.message)
154 )
155 }
156 }
157 ],
158 [
159 {
160 label: this.i18n('Actions for the reporter'),
161 isHeader: true
162 },
163 {
164 label: this.i18n('Mute reporter'),
165 handler: async videoAbuse => {
166 const account = videoAbuse.reporterAccount as Account
167
168 this.blocklistService.blockAccountByInstance(account)
169 .subscribe(
170 () => {
171 this.notifier.success(
172 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
173 )
174
175 account.mutedByInstance = true
176 },
177
178 err => this.notifier.error(err.message)
179 )
180 }
181 },
182 {
183 label: this.i18n('Mute server'),
184 isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId,
185 handler: async videoAbuse => {
186 this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host)
187 .subscribe(
188 () => {
189 this.notifier.success(
190 this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host })
191 )
192 },
193
194 err => this.notifier.error(err.message)
195 )
196 }
197 }
198 ]
58 ] 199 ]
59 } 200 }
60 201
61 ngOnInit () { 202 ngOnInit () {
62 this.initialize() 203 this.initialize()
204
205 this.route.queryParams
206 .pipe(filter(params => params.search !== undefined && params.search !== null))
207 .subscribe(params => {
208 this.search = params.search
209 this.setTableFilter(params.search)
210 this.loadData()
211 })
212 }
213
214 ngAfterViewInit () {
215 if (this.search) this.setTableFilter(this.search)
216 }
217
218 getIdentifier () {
219 return 'VideoAbuseListComponent'
63 } 220 }
64 221
65 openModerationCommentModal (videoAbuse: VideoAbuse) { 222 openModerationCommentModal (videoAbuse: VideoAbuse) {
@@ -70,10 +227,25 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
70 this.loadData() 227 this.loadData()
71 } 228 }
72 229
73 createByString (account: Account) { 230 /* Table filter functions */
74 return Account.CREATE_BY_STRING(account.name, account.host) 231 onAbuseSearch (event: Event) {
232 this.onSearch(event)
233 this.setQueryParams((event.target as HTMLInputElement).value)
234 }
235
236 setQueryParams (search: string) {
237 const queryParams: Params = {}
238 if (search) Object.assign(queryParams, { search })
239 this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams })
75 } 240 }
76 241
242 resetTableFilter () {
243 this.setTableFilter('')
244 this.setQueryParams('')
245 this.resetSearch()
246 }
247 /* END Table filter functions */
248
77 isVideoAbuseAccepted (videoAbuse: VideoAbuse) { 249 isVideoAbuseAccepted (videoAbuse: VideoAbuse) {
78 return videoAbuse.state.id === VideoAbuseState.ACCEPTED 250 return videoAbuse.state.id === VideoAbuseState.ACCEPTED
79 } 251 }
@@ -86,6 +258,19 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
86 return Video.buildClientUrl(videoAbuse.video.uuid) 258 return Video.buildClientUrl(videoAbuse.video.uuid)
87 } 259 }
88 260
261 getVideoEmbed (videoAbuse: VideoAbuse) {
262 const absoluteAPIUrl = getAbsoluteAPIUrl()
263 const embedUrl = buildVideoLink({
264 baseUrl: absoluteAPIUrl + '/videos/embed/' + videoAbuse.video.uuid,
265 warningTitle: false
266 })
267 return buildVideoEmbed(embedUrl)
268 }
269
270 switchToDefaultAvatar ($event: Event) {
271 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
272 }
273
89 async removeVideoAbuse (videoAbuse: VideoAbuse) { 274 async removeVideoAbuse (videoAbuse: VideoAbuse) {
90 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete')) 275 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
91 if (res === false) return 276 if (res === false) return
@@ -111,24 +296,34 @@ export class VideoAbuseListComponent extends RestTable implements OnInit {
111 } 296 }
112 297
113 protected loadData () { 298 protected loadData () {
114 return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) 299 return this.videoAbuseService.getVideoAbuses({
115 .subscribe( 300 pagination: this.pagination,
116 async resultList => { 301 sort: this.sort,
117 this.totalRecords = resultList.total 302 search: this.search
303 }).subscribe(
304 async resultList => {
305 this.totalRecords = resultList.total
306 const videoAbuses = []
307
308 for (const abuse of resultList.data) {
309 Object.assign(abuse, {
310 reasonHtml: await this.toHtml(abuse.reason),
311 moderationCommentHtml: await this.toHtml(abuse.moderationComment),
312 embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
313 reporterAccount: new Account(abuse.reporterAccount)
314 })
118 315
119 this.videoAbuses = resultList.data 316 if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
317 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
120 318
121 for (const abuse of this.videoAbuses) { 319 videoAbuses.push(abuse as ProcessedVideoAbuse)
122 Object.assign(abuse, { 320 }
123 reasonHtml: await this.toHtml(abuse.reason),
124 moderationCommentHtml: await this.toHtml(abuse.moderationComment)
125 })
126 }
127 321
128 }, 322 this.videoAbuses = videoAbuses
323 },
129 324
130 err => this.notifier.error(err.message) 325 err => this.notifier.error(err.message)
131 ) 326 )
132 } 327 }
133 328
134 private toHtml (text: string) { 329 private toHtml (text: string) {
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html
index a0b89acc6..c4c4e765a 100644
--- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html
+++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html
@@ -1,47 +1,98 @@
1<p-table 1<p-table
2 [value]="blacklist" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="blacklist" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} blacklisted videos"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
4> 7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto has-feedback has-clear">
11 <input
12 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
13 (keyup)="onSearch($event)"
14 >
15 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
16 <span class="sr-only" i18n>Clear filters</span>
17 </div>
18 </div>
19 </ng-template>
20
5 <ng-template pTemplate="header"> 21 <ng-template pTemplate="header">
6 <tr> 22 <tr>
7 <th style="width: 40px"></th> 23 <th style="width: 40px"></th>
8 <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th> 24 <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th>
9 <th i18n>Sensitive</th> 25 <th style="width: 100px;" i18n>Sensitive</th>
10 <th i18n>Unfederated</th> 26 <th style="width: 120px;" i18n>Unfederated</th>
11 <th i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> 27 <th style="width: 150px;" i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
12 <th style="width: 120px;"></th> 28 <th style="width: 120px;"></th>
13 </tr> 29 </tr>
14 </ng-template> 30 </ng-template>
15 31
16 <ng-template pTemplate="body" let-videoBlacklist let-expanded="expanded"> 32 <ng-template pTemplate="body" let-videoBlacklist let-expanded="expanded">
17 <tr> 33 <tr>
18 <td class="expand-cell"> 34 <td *ngIf="!videoBlacklist.reason"></td>
19 <span *ngIf="videoBlacklist.reason" class="expander" [pRowToggler]="videoBlacklist"> 35 <td *ngIf="videoBlacklist.reason" class="expand-cell c-hand" [pRowToggler]="videoBlacklist" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
36 <span class="expander">
20 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> 37 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
21 </span> 38 </span>
22 </td> 39 </td>
23 40
24 <td> 41 <td>
25 <a [href]="getVideoUrl(videoBlacklist)" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer"> 42 <a [href]="getVideoUrl(videoBlacklist)" class="video-table-video-link" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer">
26 {{ videoBlacklist.video.name }} 43 <div class="video-table-video">
44 <div class="video-table-video-image">
45 <img [src]="videoBlacklist.video.thumbnailPath">
46 </div>
47 <div class="video-table-video-text">
48 <div>
49 {{ videoBlacklist.video.name }}
50 <span i18n-title title="Video was blacklisted" class="glyphicon glyphicon-ban-circle"></span>
51 </div>
52 <div class="text-muted">by {{ videoBlacklist.video.channel?.displayName }} on {{ videoBlacklist.video.channel?.host }} </div>
53 </div>
54 </div>
27 </a> 55 </a>
28 </td> 56 </td>
29 57
30 <td>{{ booleanToText(videoBlacklist.video.nsfw) }}</td> 58 <ng-container *ngIf="videoBlacklist.reason">
31 <td>{{ booleanToText(videoBlacklist.unfederated) }}</td> 59 <td class="c-hand" [pRowToggler]="videoBlacklist">{{ booleanToText(videoBlacklist.video.nsfw) }}</td>
32 <td>{{ videoBlacklist.createdAt }}</td> 60 <td class="c-hand" [pRowToggler]="videoBlacklist">{{ booleanToText(videoBlacklist.unfederated) }}</td>
61 <td class="c-hand" [pRowToggler]="videoBlacklist">{{ videoBlacklist.createdAt | date: 'short' }}</td>
62 </ng-container>
63 <ng-container *ngIf="!videoBlacklist.reason">
64 <td>{{ booleanToText(videoBlacklist.video.nsfw) }}</td>
65 <td>{{ booleanToText(videoBlacklist.unfederated) }}</td>
66 <td>{{ videoBlacklist.createdAt | date: 'short' }}</td>
67 </ng-container>
33 68
34 <td class="action-cell"> 69 <td class="action-cell">
35 <my-action-dropdown i18n-label placement="bottom-right" label="Actions" [actions]="videoBlacklistActions" [entry]="videoBlacklist"></my-action-dropdown> 70 <my-action-dropdown
71 [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
72 i18n-label label="Actions" [actions]="videoBlacklistActions" [entry]="videoBlacklist"
73 ></my-action-dropdown>
36 </td> 74 </td>
37 </tr> 75 </tr>
38 </ng-template> 76 </ng-template>
39 77
40 <ng-template pTemplate="rowexpansion" let-videoBlacklist> 78 <ng-template pTemplate="rowexpansion" let-videoBlacklist>
41 <tr> 79 <tr>
42 <td class="moderation-expanded" colspan="6"> 80 <td class="expand-cell" colspan="6">
43 <span i18n class="moderation-expanded-label">Blacklist reason:</span> 81 <div class="d-flex moderation-expanded">
44 <span class="moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span> 82 <span class="col-2 moderation-expanded-label" i18n>Blacklist reason:</span>
83 <span class="col-9 moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span>
84 </div>
85 </td>
86 </tr>
87 </ng-template>
88
89 <ng-template pTemplate="emptymessage">
90 <tr>
91 <td colspan="6">
92 <div class="empty-table-message">
93 <ng-container *ngIf="search" i18n>No blacklisted video found matching current filters.</ng-container>
94 <ng-container *ngIf="!search" i18n>No blacklisted video found.</ng-container>
95 </div>
45 </td> 96 </td>
46 </tr> 97 </tr>
47 </ng-template> 98 </ng-template>
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
index 5876f658b..63ecdeb9f 100644
--- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
+++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts
@@ -1,5 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { SortMeta } from 'primeng/components/common/sortmeta' 2import { SortMeta } from 'primeng/api'
3import { Notifier, ServerService } from '@app/core' 3import { Notifier, ServerService } from '@app/core'
4import { ConfirmService } from '../../../core' 4import { ConfirmService } from '../../../core'
5import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared' 5import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared'
@@ -17,8 +17,7 @@ import { MarkdownService } from '@app/shared/renderer'
17export class VideoBlacklistListComponent extends RestTable implements OnInit { 17export class VideoBlacklistListComponent extends RestTable implements OnInit {
18 blacklist: (VideoBlacklist & { reasonHtml?: string })[] = [] 18 blacklist: (VideoBlacklist & { reasonHtml?: string })[] = []
19 totalRecords = 0 19 totalRecords = 0
20 rowsPerPage = 10 20 sort: SortMeta = { field: 'createdAt', order: -1 }
21 sort: SortMeta = { field: 'createdAt', order: 1 }
22 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 21 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
23 listBlacklistTypeFilter: VideoBlacklistType = undefined 22 listBlacklistTypeFilter: VideoBlacklistType = undefined
24 23
@@ -38,7 +37,7 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
38 ngOnInit () { 37 ngOnInit () {
39 this.serverService.getConfig() 38 this.serverService.getConfig()
40 .subscribe(config => { 39 .subscribe(config => {
41 // don't filter if auto-blacklist not enabled as this will be only list 40 // don't filter if auto-blacklist is not enabled as this will be the only list
42 if (config.autoBlacklist.videos.ofUsers.enabled) { 41 if (config.autoBlacklist.videos.ofUsers.enabled) {
43 this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL 42 this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL
44 } 43 }
@@ -54,6 +53,10 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
54 ] 53 ]
55 } 54 }
56 55
56 getIdentifier () {
57 return 'VideoBlacklistListComponent'
58 }
59
57 getVideoUrl (videoBlacklist: VideoBlacklist) { 60 getVideoUrl (videoBlacklist: VideoBlacklist) {
58 return Video.buildClientUrl(videoBlacklist.video.uuid) 61 return Video.buildClientUrl(videoBlacklist.video.uuid)
59 } 62 }
@@ -87,7 +90,12 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit {
87 } 90 }
88 91
89 protected loadData () { 92 protected loadData () {
90 this.videoBlacklistService.listBlacklist(this.pagination, this.sort, this.listBlacklistTypeFilter) 93 this.videoBlacklistService.listBlacklist({
94 pagination: this.pagination,
95 sort: this.sort,
96 search: this.search,
97 type: this.listBlacklistTypeFilter
98 })
91 .subscribe( 99 .subscribe(
92 async resultList => { 100 async resultList => {
93 this.totalRecords = resultList.total 101 this.totalRecords = resultList.total
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
index 4526aaf66..a2d0fde08 100644
--- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
+++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
@@ -10,22 +10,19 @@
10 <div class="card plugin" *ngFor="let plugin of plugins"> 10 <div class="card plugin" *ngFor="let plugin of plugins">
11 <div class="card-body"> 11 <div class="card-body">
12 <div class="first-row"> 12 <div class="first-row">
13 <a class="plugin-name" [routerLink]="getShowRouterLink(plugin)" title="Show plugin settings">{{ plugin.name }}</a> 13 <span class="plugin-name">{{ plugin.name }}</span>
14 14
15 <span class="plugin-version">{{ plugin.version }}</span> 15 <span class="plugin-version">{{ plugin.version }}</span>
16 </div>
17 16
18 <div class="second-row"> 17 <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="plugin.homepage" i18n-title title="Go to the plugin homepage">
19 <div class="description">{{ plugin.description }}</div> 18 <my-global-icon iconName="home"></my-global-icon>
19 </a>
20 20
21 <div class="buttons"> 21 <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="'https://www.npmjs.com/package/peertube-plugin-' + plugin.name" i18n-title title="Go to the plugin homepage">
22 <a class="action-button action-button-edit grey-button" target="_blank" rel="noopener noreferrer" 22 <my-global-icon iconName="npm"></my-global-icon>
23 [href]="plugin.homepage" i18n-title title="Go to the plugin homepage" 23 </a>
24 >
25 <my-global-icon iconName="go"></my-global-icon>
26 <span i18n class="button-label">Homepage</span>
27 </a>
28 24
25 <div class="buttons">
29 <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button> 26 <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button>
30 27
31 <my-button class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)" 28 <my-button class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
@@ -35,6 +32,10 @@
35 <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button> 32 <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button>
36 </div> 33 </div>
37 </div> 34 </div>
35
36 <div class="second-row">
37 <div class="description">{{ plugin.description }}</div>
38 </div>
38 </div> 39 </div>
39 </div> 40 </div>
40</div> 41</div>
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
index f18c2e6ca..a8973f2b2 100644
--- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
+++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts
@@ -89,10 +89,10 @@ export class PluginListInstalledComponent implements OnInit {
89 89
90 getNoResultMessage () { 90 getNoResultMessage () {
91 if (this.pluginType === PluginType.PLUGIN) { 91 if (this.pluginType === PluginType.PLUGIN) {
92 return this.i18n('You don\'t have plugins installed yet.') 92 return this.i18n("You don't have plugins installed yet.")
93 } 93 }
94 94
95 return this.i18n('You don\'t have themes installed yet.') 95 return this.i18n("You don't have themes installed yet.")
96 } 96 }
97 97
98 isUpdateAvailable (plugin: PeerTubePlugin) { 98 isUpdateAvailable (plugin: PeerTubePlugin) {
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
index 6ec6301b1..9f942c4b3 100644
--- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
+++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
@@ -3,7 +3,7 @@
3</div> 3</div>
4 4
5<div class="search-bar"> 5<div class="search-bar">
6 <input type="text" (input)="onSearchChange($event.target.value)" i18n-placeholder placeholder="Search..."/> 6 <input type="text" (input)="onSearchChange($event)" i18n-placeholder placeholder="Search..."/>
7</div> 7</div>
8 8
9<div class="alert alert-info" i18n *ngIf="pluginInstalled"> 9<div class="alert alert-info" i18n *ngIf="pluginInstalled">
@@ -37,25 +37,26 @@
37 37
38 <span class="plugin-version">{{ plugin.latestVersion }}</span> 38 <span class="plugin-version">{{ plugin.latestVersion }}</span>
39 39
40 <span *ngIf="plugin.installed" class="badge badge-success">Installed</span> 40 <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="plugin.homepage" i18n-title title="Go to the plugin homepage">
41 </div> 41 <my-global-icon iconName="home"></my-global-icon>
42 </a>
42 43
43 <div class="second-row"> 44 <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="'https://www.npmjs.com/package/peertube-plugin-' + plugin.name" i18n-title title="Go to the plugin npm package">
44 <div class="description">{{ plugin.description }}</div> 45 <my-global-icon iconName="npm"></my-global-icon>
46 </a>
45 47
46 <div class="buttons"> 48 <span *ngIf="plugin.installed" class="badge badge-success">Installed</span>
47 <a class="action-button action-button-edit grey-button" target="_blank" rel="noopener noreferrer"
48 [href]="plugin.homepage" i18n-title title="Go to the plugin homepage"
49 >
50 <my-global-icon iconName="go"></my-global-icon>
51 <span i18n class="button-label">Homepage</span>
52 </a>
53 49
50 <div class="buttons">
54 <my-button class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)" [loading]="isInstalling(plugin)" 51 <my-button class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)" [loading]="isInstalling(plugin)"
55 label="Install" icon="cloud-download" [attr.disabled]="isInstalling(plugin)" 52 label="Install" icon="cloud-download" [attr.disabled]="isInstalling(plugin)"
56 ></my-button> 53 ></my-button>
57 </div> 54 </div>
58 </div> 55 </div>
56
57 <div class="second-row">
58 <div class="description">{{ plugin.description }}</div>
59 </div>
59 </div> 60 </div>
60 </div> 61 </div>
61</div> 62</div>
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss
index ed06825c8..20f169e13 100644
--- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss
+++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss
@@ -25,5 +25,5 @@
25.badge { 25.badge {
26 font-size: 13px; 26 font-size: 13px;
27 font-weight: $font-semibold; 27 font-weight: $font-semibold;
28 margin-left: 5px; 28 margin-left: 15px;
29} 29}
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
index e08ded3f1..dc59e759b 100644
--- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
+++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts
@@ -70,8 +70,10 @@ export class PluginSearchComponent implements OnInit {
70 this.reloadPlugins() 70 this.reloadPlugins()
71 } 71 }
72 72
73 onSearchChange (search: string) { 73 onSearchChange (event: Event) {
74 this.searchSubject.next(search) 74 const target = event.target as HTMLInputElement
75
76 this.searchSubject.next(target.value)
75 } 77 }
76 78
77 reloadPlugins () { 79 reloadPlugins () {
diff --git a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html
index 95dd74d31..f3fc429ff 100644
--- a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html
+++ b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html
@@ -8,9 +8,27 @@
8 <form *ngIf="hasRegisteredSettings()" role="form" (ngSubmit)="formValidated()" [formGroup]="form"> 8 <form *ngIf="hasRegisteredSettings()" role="form" (ngSubmit)="formValidated()" [formGroup]="form">
9 <div class="form-group" *ngFor="let setting of registeredSettings"> 9 <div class="form-group" *ngFor="let setting of registeredSettings">
10 <label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label> 10 <label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label>
11
11 <input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" /> 12 <input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" />
13
12 <textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea> 14 <textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea>
13 15
16 <my-help *ngIf="setting.type === 'markdown-text'" helpType="markdownText"></my-help>
17
18 <my-help *ngIf="setting.type === 'markdown-enhanced'" helpType="markdownEnhanced"></my-help>
19
20 <my-markdown-textarea
21 *ngIf="setting.type === 'markdown-text'"
22 markdownType="text" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
23 [classes]="{ 'input-error': formErrors['settings.name'] }"
24 ></my-markdown-textarea>
25
26 <my-markdown-textarea
27 *ngIf="setting.type === 'markdown-enhanced'"
28 markdownType="enhanced" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
29 [classes]="{ 'input-error': formErrors['settings.name'] }"
30 ></my-markdown-textarea>
31
14 <my-peertube-checkbox 32 <my-peertube-checkbox
15 *ngIf="setting.type === 'input-checkbox'" 33 *ngIf="setting.type === 'input-checkbox'"
16 [id]="setting.name" 34 [id]="setting.name"
diff --git a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss
index 21c180a70..cc35aec57 100644
--- a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss
+++ b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss
@@ -5,9 +5,15 @@ h2 {
5 margin-bottom: 20px; 5 margin-bottom: 20px;
6} 6}
7 7
8textarea,
9input:not([type=submit]) { 8input:not([type=submit]) {
10 @include peertube-input-text(340px); 9 @include peertube-input-text(340px);
10
11 display: block;
12}
13
14textarea {
15 @include peertube-textarea(340px, 200px);
16
11 display: block; 17 display: block;
12} 18}
13 19
diff --git a/client/src/app/+admin/plugins/plugins.component.scss b/client/src/app/+admin/plugins/plugins.component.scss
index 9f61bcf7a..04ca8126a 100644
--- a/client/src/app/+admin/plugins/plugins.component.scss
+++ b/client/src/app/+admin/plugins/plugins.component.scss
@@ -5,3 +5,29 @@
5 flex-grow: 0; 5 flex-grow: 0;
6 margin-right: 30px; 6 margin-right: 30px;
7} 7}
8
9@media screen and (max-width: $small-view) {
10 ::ng-deep .plugins .plugin .first-row {
11 flex-wrap: wrap;
12
13 .plugin-name,
14 .plugin-version,
15 .plugin-icon {
16 margin-bottom: 10px;
17 }
18
19 .buttons {
20 my-edit-button,
21 my-delete-button,
22 my-button {
23 .action-button {
24 padding: 0 13px;
25 }
26
27 .button-label {
28 display: none;
29 }
30 }
31 }
32 }
33}
diff --git a/client/src/app/+admin/plugins/shared/plugin-list.component.scss b/client/src/app/+admin/plugins/shared/plugin-list.component.scss
index 87a709b00..3f4fad7b9 100644
--- a/client/src/app/+admin/plugins/shared/plugin-list.component.scss
+++ b/client/src/app/+admin/plugins/shared/plugin-list.component.scss
@@ -7,6 +7,8 @@
7} 7}
8 8
9.first-row { 9.first-row {
10 display: flex;
11 align-items: center;
10 margin-bottom: 10px; 12 margin-bottom: 10px;
11 13
12 .plugin-name { 14 .plugin-name {
@@ -18,6 +20,26 @@
18 .plugin-version { 20 .plugin-version {
19 opacity: 0.6; 21 opacity: 0.6;
20 } 22 }
23
24 .plugin-icon {
25 margin-left: 10px;
26
27 my-global-icon {
28 @include apply-svg-color($grey-foreground-color);
29
30 &[iconName="npm"] {
31 @include fill-svg-color($grey-foreground-color);
32 }
33 }
34 }
35
36 .buttons {
37 margin-left: auto;
38 width: max-content;
39 > *:not(:last-child) {
40 margin-right: 10px;
41 }
42 }
21} 43}
22 44
23.second-row { 45.second-row {
@@ -29,13 +51,6 @@
29 .description { 51 .description {
30 opacity: 0.8 52 opacity: 0.8
31 } 53 }
32
33 .buttons {
34 margin-left: 10px;
35 > *:not(:last-child) {
36 margin-right: 10px;
37 }
38 }
39} 54}
40 55
41.action-button { 56.action-button {
diff --git a/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss b/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss
index 7e2c40aae..56ea91d0b 100644
--- a/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss
+++ b/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss
@@ -10,11 +10,26 @@
10 ::ng-deep { 10 ::ng-deep {
11 .ui-button-text { 11 .ui-button-text {
12 font-size: 15px; 12 font-size: 15px;
13 font-weight: 600;
14 }
15
16 .ui-button.ui-state-default {
17 background-color: #f0f0f0;
18 border: 1px solid #f0f0f0;
13 } 19 }
14 20
15 .ui-button.ui-state-active { 21 .ui-button.ui-state-active {
16 background-color: var(--mainColor); 22 background-color: var(--mainColor);
17 border-color: var(--mainColor); 23 border-color: var(--mainColor);
24
25 &:hover {
26 background-color: var(--mainHoverColor);
27 border-color: var(--mainHoverColor);
28 }
29 }
30
31 .ui-button:not(.ui-state-active).ui-state-focus {
32 box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
18 } 33 }
19 } 34 }
20 } 35 }
diff --git a/client/src/app/+admin/system/debug/debug.component.scss b/client/src/app/+admin/system/debug/debug.component.scss
index 90addd284..7bc8fa946 100644
--- a/client/src/app/+admin/system/debug/debug.component.scss
+++ b/client/src/app/+admin/system/debug/debug.component.scss
@@ -2,5 +2,10 @@
2@import '_mixins'; 2@import '_mixins';
3 3
4.root { 4.root {
5 font-size: 14px; 5 font-size: 15px;
6
7 code {
8 font-size: 14px;
9 font-weight: $font-semibold;
10 }
6} 11}
diff --git a/client/src/app/+admin/system/jobs/jobs.component.html b/client/src/app/+admin/system/jobs/jobs.component.html
index b0f68eadd..038dfa522 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.html
+++ b/client/src/app/+admin/system/jobs/jobs.component.html
@@ -1,10 +1,8 @@
1<div class="admin-sub-header"> 1<div class="admin-sub-header">
2 <div i18n class="form-sub-title">Jobs list</div>
3
4 <div class="select-filter-block"> 2 <div class="select-filter-block">
5 <label for="jobType" i18n>Job type</label> 3 <label for="jobType" i18n>Job type</label>
6 <div class="peertube-select-container"> 4 <div class="peertube-select-container">
7 <select id="jobType" name="jobType" [(ngModel)]="jobType" (ngModelChange)="onJobStateOrTypeChanged()"> 5 <select id="jobType" name="jobType" [(ngModel)]="jobType" (ngModelChange)="onJobStateOrTypeChanged()" class="form-control">
8 <option *ngFor="let jobType of jobTypes" [value]="jobType">{{ jobType }}</option> 6 <option *ngFor="let jobType of jobTypes" [value]="jobType">{{ jobType }}</option>
9 </select> 7 </select>
10 </div> 8 </div>
@@ -13,7 +11,7 @@
13 <div class="select-filter-block"> 11 <div class="select-filter-block">
14 <label for="jobState" i18n>Job state</label> 12 <label for="jobState" i18n>Job state</label>
15 <div class="peertube-select-container"> 13 <div class="peertube-select-container">
16 <select id="jobState" name="jobState" [(ngModel)]="jobState" (ngModelChange)="onJobStateOrTypeChanged()"> 14 <select id="jobState" name="jobState" [(ngModel)]="jobState" (ngModelChange)="onJobStateOrTypeChanged()" class="form-control">
17 <option *ngFor="let state of jobStates" [value]="state">{{ state }}</option> 15 <option *ngFor="let state of jobStates" [value]="state">{{ state }}</option>
18 </select> 16 </select>
19 </div> 17 </div>
@@ -21,12 +19,13 @@
21</div> 19</div>
22 20
23<p-table 21<p-table
24 [value]="jobs" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" dataKey="uniqId" 22 [value]="jobs" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" dataKey="uniqId"
25 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [first]="pagination.start" 23 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [first]="pagination.start"
26 [tableStyle]="{'table-layout':'auto'}" 24 [tableStyle]="{'table-layout':'auto'}" (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
27> 25>
28 <ng-template pTemplate="header"> 26 <ng-template pTemplate="header">
29 <tr> 27 <tr>
28 <th style="width: 40px"></th>
30 <th class="job-id" i18n>ID</th> 29 <th class="job-id" i18n>ID</th>
31 <th class="job-type" i18n>Type</th> 30 <th class="job-type" i18n>Type</th>
32 <th class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 31 <th class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
@@ -35,7 +34,13 @@
35 </ng-template> 34 </ng-template>
36 35
37 <ng-template pTemplate="body" let-expanded="expanded" let-job> 36 <ng-template pTemplate="body" let-expanded="expanded" let-job>
38 <tr class="expander" [pRowToggler]="job"> 37 <tr>
38 <td class="expand-cell">
39 <span class="expander" [pRowToggler]="job" i18n-ngbTooltip ngbTooltip="More information">
40 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
41 </span>
42 </td>
43
39 <td class="job-id" [title]="job.id">{{ job.id }}</td> 44 <td class="job-id" [title]="job.id">{{ job.id }}</td>
40 <td class="job-type">{{ job.type }}</td> 45 <td class="job-type">{{ job.type }}</td>
41 <td class="job-date">{{ job.createdAt }}</td> 46 <td class="job-date">{{ job.createdAt }}</td>
diff --git a/client/src/app/+admin/system/jobs/jobs.component.scss b/client/src/app/+admin/system/jobs/jobs.component.scss
index 4cb706d2d..c33e14292 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.scss
+++ b/client/src/app/+admin/system/jobs/jobs.component.scss
@@ -18,7 +18,8 @@
18} 18}
19 19
20.admin-sub-header { 20.admin-sub-header {
21 align-items: flex-end; 21 flex-direction: row !important;
22 justify-content: flex-end;
22 23
23 .select-filter-block { 24 .select-filter-block {
24 &:not(:last-child) { 25 &:not(:last-child) {
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index 20c8ea71a..4f7f7c368 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -16,8 +16,8 @@ import { JobTypeClient } from '../../../../types/job-type-client.type'
16 styleUrls: [ './jobs.component.scss' ] 16 styleUrls: [ './jobs.component.scss' ]
17}) 17})
18export class JobsComponent extends RestTable implements OnInit { 18export class JobsComponent extends RestTable implements OnInit {
19 private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' 19 private static LOCAL_STORAGE_STATE = 'jobs-list-state'
20 private static JOB_STATE_LOCAL_STORAGE_TYPE = 'jobs-list-type' 20 private static LOCAL_STORAGE_TYPE = 'jobs-list-type'
21 21
22 jobState: JobStateClient = 'waiting' 22 jobState: JobStateClient = 'waiting'
23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ] 23 jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ]
@@ -34,12 +34,12 @@ export class JobsComponent extends RestTable implements OnInit {
34 'video-file-import', 34 'video-file-import',
35 'video-import', 35 'video-import',
36 'videos-views', 36 'videos-views',
37 'activitypub-refresher' 37 'activitypub-refresher',
38 'video-redundancy'
38 ] 39 ]
39 40
40 jobs: Job[] = [] 41 jobs: Job[] = []
41 totalRecords: number 42 totalRecords: number
42 rowsPerPage = 10
43 sort: SortMeta = { field: 'createdAt', order: -1 } 43 sort: SortMeta = { field: 'createdAt', order: -1 }
44 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 44 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
45 45
@@ -56,6 +56,10 @@ export class JobsComponent extends RestTable implements OnInit {
56 this.initialize() 56 this.initialize()
57 } 57 }
58 58
59 getIdentifier () {
60 return 'JobsComponent'
61 }
62
59 onJobStateOrTypeChanged () { 63 onJobStateOrTypeChanged () {
60 this.pagination.start = 0 64 this.pagination.start = 0
61 65
@@ -77,15 +81,15 @@ export class JobsComponent extends RestTable implements OnInit {
77 } 81 }
78 82
79 private loadJobStateAndType () { 83 private loadJobStateAndType () {
80 const state = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE) 84 const state = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_STATE)
81 if (state) this.jobState = state as JobState 85 if (state) this.jobState = state as JobState
82 86
83 const type = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE) 87 const type = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_TYPE)
84 if (type) this.jobType = type as JobType 88 if (type) this.jobType = type as JobType
85 } 89 }
86 90
87 private saveJobStateAndType () { 91 private saveJobStateAndType () {
88 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) 92 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_STATE, this.jobState)
89 peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE, this.jobType) 93 peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_TYPE, this.jobType)
90 } 94 }
91} 95}
diff --git a/client/src/app/+admin/system/logs/logs.component.html b/client/src/app/+admin/system/logs/logs.component.html
index ddad1314f..ae1b0c601 100644
--- a/client/src/app/+admin/system/logs/logs.component.html
+++ b/client/src/app/+admin/system/logs/logs.component.html
@@ -1,18 +1,18 @@
1<div class="header"> 1<div class="header">
2 <div class="peertube-select-container"> 2 <div class="peertube-select-container">
3 <select [(ngModel)]="logType" (ngModelChange)="refresh()"> 3 <select [(ngModel)]="logType" (ngModelChange)="refresh()" class="form-control">
4 <option *ngFor="let logTypeChoice of logTypeChoices" [value]="logTypeChoice.id">{{ logTypeChoice.label }}</option> 4 <option *ngFor="let logTypeChoice of logTypeChoices" [value]="logTypeChoice.id">{{ logTypeChoice.label }}</option>
5 </select> 5 </select>
6 </div> 6 </div>
7 7
8 <div class="peertube-select-container"> 8 <div class="peertube-select-container">
9 <select [(ngModel)]="startDate" (ngModelChange)="refresh()"> 9 <select [(ngModel)]="startDate" (ngModelChange)="refresh()" class="form-control">
10 <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option> 10 <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option>
11 </select> 11 </select>
12 </div> 12 </div>
13 13
14 <div class="peertube-select-container" *ngIf="!isAuditLog()"> 14 <div class="peertube-select-container" *ngIf="!isAuditLog()">
15 <select [(ngModel)]="level" (ngModelChange)="refresh()"> 15 <select [(ngModel)]="level" (ngModelChange)="refresh()" class="form-control">
16 <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option> 16 <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option>
17 </select> 17 </select>
18 </div> 18 </div>
@@ -21,7 +21,7 @@
21</div> 21</div>
22 22
23<div class="logs"> 23<div class="logs">
24 <div *ngIf="loading">Loading...</div> 24 <div *ngIf="loading" i18n>Loading...</div>
25 25
26 <div #logsElement> 26 <div #logsElement>
27 <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }"> 27 <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }">
diff --git a/client/src/app/+admin/system/logs/logs.component.scss b/client/src/app/+admin/system/logs/logs.component.scss
index dae8b21c7..087155254 100644
--- a/client/src/app/+admin/system/logs/logs.component.scss
+++ b/client/src/app/+admin/system/logs/logs.component.scss
@@ -28,7 +28,7 @@
28 } 28 }
29 29
30 .warn { 30 .warn {
31 color: $orange-color; 31 color: var(--mainColor);
32 } 32 }
33 33
34 .error { 34 .error {
@@ -57,3 +57,38 @@
57 } 57 }
58} 58}
59 59
60@media screen and (max-width: $small-view) {
61 .header {
62 flex-direction: column;
63
64 .peertube-select-container,
65 my-button {
66 width: 100% !important;
67 margin-left: 0px !important;
68 margin-bottom: 10px !important;
69 }
70
71 my-button {
72 text-align: center;
73 }
74 }
75}
76
77@media screen and (max-width: #{$small-view + $menu-width}) {
78 :host-context(.main-col:not(.expanded)) {
79 .header {
80 flex-direction: column;
81
82 .peertube-select-container,
83 my-button {
84 width: 100% !important;
85 margin-left: 0px !important;
86 margin-bottom: 10px !important;
87 }
88
89 my-button {
90 text-align: center;
91 }
92 }
93 }
94}
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts
index e726ec4d7..a394418cb 100644
--- a/client/src/app/+admin/users/user-edit/user-create.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-create.component.ts
@@ -1,5 +1,5 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router' 2import { Router, ActivatedRoute } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core' 3import { AuthService, Notifier, ServerService } from '@app/core'
4import { UserCreate, UserRole } from '../../../../../../shared' 4import { UserCreate, UserRole } from '../../../../../../shared'
5import { UserEdit } from './user-edit' 5import { UserEdit } from './user-edit'
@@ -8,6 +8,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
9import { ConfigService } from '@app/+admin/config/shared/config.service' 9import { ConfigService } from '@app/+admin/config/shared/config.service'
10import { UserService } from '@app/shared' 10import { UserService } from '@app/shared'
11import { ScreenService } from '@app/shared/misc/screen.service'
11 12
12@Component({ 13@Component({
13 selector: 'my-user-create', 14 selector: 'my-user-create',
@@ -21,8 +22,10 @@ export class UserCreateComponent extends UserEdit implements OnInit {
21 protected serverService: ServerService, 22 protected serverService: ServerService,
22 protected formValidatorService: FormValidatorService, 23 protected formValidatorService: FormValidatorService,
23 protected configService: ConfigService, 24 protected configService: ConfigService,
25 protected screenService: ScreenService,
24 protected auth: AuthService, 26 protected auth: AuthService,
25 private userValidatorsService: UserValidatorsService, 27 private userValidatorsService: UserValidatorsService,
28 private route: ActivatedRoute,
26 private router: Router, 29 private router: Router,
27 private notifier: Notifier, 30 private notifier: Notifier,
28 private userService: UserService, 31 private userService: UserService,
@@ -45,7 +48,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
45 this.buildForm({ 48 this.buildForm({
46 username: this.userValidatorsService.USER_USERNAME, 49 username: this.userValidatorsService.USER_USERNAME,
47 email: this.userValidatorsService.USER_EMAIL, 50 email: this.userValidatorsService.USER_EMAIL,
48 password: this.userValidatorsService.USER_PASSWORD, 51 password: this.isPasswordOptional() ? this.userValidatorsService.USER_PASSWORD_OPTIONAL : this.userValidatorsService.USER_PASSWORD,
49 role: this.userValidatorsService.USER_ROLE, 52 role: this.userValidatorsService.USER_ROLE,
50 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, 53 videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
51 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, 54 videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
@@ -78,6 +81,11 @@ export class UserCreateComponent extends UserEdit implements OnInit {
78 return true 81 return true
79 } 82 }
80 83
84 isPasswordOptional () {
85 const serverConfig = this.route.snapshot.data.serverConfig
86 return serverConfig.email.enabled
87 }
88
81 getFormButtonTitle () { 89 getFormButtonTitle () {
82 return this.i18n('Create user') 90 return this.i18n('Create user')
83 } 91 }
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html
index 4ff4d0d12..d30a606d6 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.html
@@ -1,105 +1,204 @@
1<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create user</div> 1<nav aria-label="breadcrumb">
2<div i18n class="form-sub-title" *ngIf="isCreation() === false">Edit user {{ username }}</div> 2 <ol class="breadcrumb">
3 <li class="breadcrumb-item">
4 <a routerLink="/admin/users" i18n>Users</a>
5 </li>
3 6
4<div *ngIf="error" class="alert alert-danger">{{ error }}</div> 7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="user" [routerLink]="[ '/accounts', user?.username ]">{{ user?.username }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
5 18
6<form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> 19<ng-template #dashboard>
7 <div class="form-group" *ngIf="isCreation()"> 20 <div *ngIf="!isCreation() && user" class="dashboard">
8 <label i18n for="username">Username</label> 21 <div>
9 <input 22 <a>
10 type="text" id="username" i18n-placeholder placeholder="john" 23 <div class="dashboard-num">{{ user.videosCount }} ({{ user.videoQuotaUsed | bytes: 0 }})</div>
11 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" 24 <div class="dashboard-label" i18n>{user.videosCount, plural, =1 {Video} other {Videos}}</div>
12 > 25 </a>
13 <div *ngIf="formErrors.username" class="form-error">
14 {{ formErrors.username }}
15 </div> 26 </div>
16 </div> 27 <div>
17 28 <a>
18 <div class="form-group"> 29 <div class="dashboard-num">{{ user.videoChannels.length || 0 }}</div>
19 <label i18n for="email">Email</label> 30 <div class="dashboard-label" i18n>{user.videoChannels.length, plural, =1 {Channel} other {Channels}}</div>
20 <input 31 </a>
21 type="text" id="email" i18n-placeholder placeholder="mail@example.com"
22 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
23 autocomplete="off"
24 >
25 <div *ngIf="formErrors.email" class="form-error">
26 {{ formErrors.email }}
27 </div> 32 </div>
28 </div> 33 <div>
29 34 <a>
30 <div class="form-group" *ngIf="isCreation()"> 35 <div class="dashboard-num">{{ subscribersCount }}</div>
31 <label i18n for="password">Password</label> 36 <div class="dashboard-label" i18n>{subscribersCount, plural, =1 {Subscriber} other {Subscribers}}</div>
32 <input 37 </a>
33 type="password" id="password" autocomplete="new-password" 38 </div>
34 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 39 <div>
35 > 40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:' + user?.account.displayName }">
36 <div *ngIf="formErrors.password" class="form-error"> 41 <div class="dashboard-num">{{ user.videoAbusesCount }}</div>
37 {{ formErrors.password }} 42 <div class="dashboard-label" i18n>Incriminated in reports</div>
43 </a>
44 </div>
45 <div>
46 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:' + user?.account.displayName + ' state:accepted' }">
47 <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div>
48 <div class="dashboard-label" i18n>Authored reports accepted</div>
49 </a>
50 </div>
51 <div>
52 <a>
53 <div class="dashboard-num">{{ user.videoCommentsCount }}</div>
54 <div class="dashboard-label" i18n>{user.videoCommentsCount, plural, =1 {Comment} other {Comments}}</div>
55 </a>
38 </div> 56 </div>
39 </div> 57 </div>
58</ng-template>
40 59
41 <div class="form-group"> 60<div class="form-row" *ngIf="!isInBigView()"> <!-- hidden on large screens, as it is then displayed on the right side of the form -->
42 <label i18n for="role">Role</label> 61 <div class="col-12 col-xl-3"></div>
43 <div class="peertube-select-container">
44 <select id="role" formControlName="role">
45 <option *ngFor="let role of getRoles()" [value]="role.value">
46 {{ role.label }}
47 </option>
48 </select>
49 </div>
50 62
51 <div *ngIf="formErrors.role" class="form-error"> 63 <div class="form-group-right col-12 col-xl-9">
52 {{ formErrors.role }} 64 <ng-template *ngTemplateOutlet="dashboard"></ng-template>
53 </div>
54 </div> 65 </div>
66</div>
55 67
56 <div class="form-group"> 68<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
57 <label i18n for="videoQuota">Video quota</label>
58 <div class="peertube-select-container">
59 <select id="videoQuota" formControlName="videoQuota">
60 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value">
61 {{ videoQuotaOption.label }}
62 </option>
63 </select>
64 </div>
65 69
66 <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()"> 70<div class="form-row mt-4"> <!-- user grid -->
67 Transcoding is enabled on server. The video quota only take in account <strong>original</strong> video. <br /> 71 <div class="form-group col-12 col-lg-4 col-xl-3">
68 At most, this user could use ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. 72 <div class="anchor" id="user"></div> <!-- user anchor -->
73 <div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div>
74 <div *ngIf="!isCreation() && user" class="account-title">
75 <my-actor-avatar-info [actor]="user.account"></my-actor-avatar-info>
69 </div> 76 </div>
70 </div> 77 </div>
71 78
72 <div class="form-group"> 79 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
73 <label i18n for="videoQuotaDaily">Daily video quota</label> 80
74 <div class="peertube-select-container"> 81 <form role="form" (ngSubmit)="formValidated()" [formGroup]="form" [ngClass]="{ 'col-5': isInBigView() }">
75 <select id="videoQuotaDaily" formControlName="videoQuotaDaily"> 82 <div class="form-group" *ngIf="isCreation()">
76 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value"> 83 <label i18n for="username">Username</label>
77 {{ videoQuotaDailyOption.label }} 84 <input
78 </option> 85 type="text" id="username" i18n-placeholder placeholder="john" class="form-control"
79 </select> 86 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }"
87 >
88 <div *ngIf="formErrors.username" class="form-error">
89 {{ formErrors.username }}
90 </div>
91 </div>
92
93 <div class="form-group">
94 <label i18n for="email">Email</label>
95 <input
96 type="text" id="email" i18n-placeholder placeholder="mail@example.com" class="form-control"
97 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }"
98 autocomplete="off"
99 >
100 <div *ngIf="formErrors.email" class="form-error">
101 {{ formErrors.email }}
102 </div>
103 </div>
104
105 <div class="form-group" *ngIf="isCreation()">
106 <label i18n for="password">Password</label>
107 <my-help *ngIf="isPasswordOptional()">
108 <ng-template ptTemplate="customHtml">
109 <ng-container i18n>
110 If you leave the password empty, an email will be sent to the user.
111 </ng-container>
112 </ng-template>
113 </my-help>
114 <input
115 type="password" id="password" autocomplete="new-password" class="form-control"
116 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
117 >
118 <div *ngIf="formErrors.password" class="form-error">
119 {{ formErrors.password }}
120 </div>
121 </div>
122
123 <div class="form-group">
124 <label i18n for="role">Role</label>
125 <div class="peertube-select-container">
126 <select id="role" formControlName="role" class="form-control">
127 <option *ngFor="let role of roles" [value]="role.value">
128 {{ role.label }}
129 </option>
130 </select>
131 </div>
132
133 <div *ngIf="formErrors.role" class="form-error">
134 {{ formErrors.role }}
135 </div>
136 </div>
137
138 <div class="form-group">
139 <label i18n for="videoQuota">Video quota</label>
140 <div class="peertube-select-container">
141 <select id="videoQuota" formControlName="videoQuota" class="form-control">
142 <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value" [disabled]="videoQuotaOption.disabled">
143 {{ videoQuotaOption.label }}
144 </option>
145 </select>
146 </div>
147
148 <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
149 Transcoding is enabled. The video quota only takes into account <strong>original</strong> video size. <br />
150 At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
151 </div>
152 </div>
153
154 <div class="form-group">
155 <label i18n for="videoQuotaDaily">Daily video quota</label>
156 <div class="peertube-select-container">
157 <select id="videoQuotaDaily" formControlName="videoQuotaDaily" class="form-control">
158 <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value" [disabled]="videoQuotaDailyOption.disabled">
159 {{ videoQuotaDailyOption.label }}
160 </option>
161 </select>
162 </div>
163 </div>
164
165 <div class="form-group">
166 <my-peertube-checkbox
167 inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist"
168 i18n-labelText labelText="Doesn't need review before a video goes public"
169 ></my-peertube-checkbox>
170 </div>
171
172 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
173 </form>
174
175 <div *ngIf="isInBigView()" class="col-7">
176 <ng-template *ngTemplateOutlet="dashboard"></ng-template>
80 </div> 177 </div>
178
81 </div> 179 </div>
180</div>
181
82 182
83 <div class="form-group"> 183<div *ngIf="!isCreation() && user" class="form-row mt-4"> <!-- danger zone grid -->
84 <my-peertube-checkbox 184 <div class="form-group col-12 col-lg-4 col-xl-3">
85 inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist" 185 <div class="anchor" id="danger"></div> <!-- danger zone anchor -->
86 i18n-labelText labelText="Bypass video auto blacklist" 186 <div i18n class="account-title">DANGER ZONE</div>
87 ></my-peertube-checkbox>
88 </div> 187 </div>
89 188
90 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 189 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }">
91</form>
92 190
93<div *ngIf="!isCreation()" class="danger-zone"> 191 <div class="danger-zone">
94 <div class="account-title" i18n>Danger Zone</div> 192 <div class="form-group reset-password-email">
193 <label i18n>Send a link to reset the password by email to the user</label>
194 <button (click)="resetPassword()" i18n>Ask for new password</button>
195 </div>
95 196
96 <div class="form-group reset-password-email"> 197 <div class="form-group">
97 <label i18n>Send a link to reset the password by email to the user</label> 198 <label i18n>Manually set the user password</label>
98 <button (click)="resetPassword()" i18n>Ask for new password</button> 199 <my-user-password [userId]="user.id"></my-user-password>
99 </div> 200 </div>
201 </div>
100 202
101 <div class="form-group">
102 <label i18n>Manually set the user password</label>
103 <my-user-password [userId]="userId"></my-user-password>
104 </div> 203 </div>
105</div> 204</div>
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.scss b/client/src/app/+admin/users/user-edit/user-edit.component.scss
index c1cc4ca45..d4c1b600e 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.scss
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.scss
@@ -1,8 +1,13 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-sub-title { 4label {
5 margin-bottom: 30px; 5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.account-title {
10 @include settings-big-title;
6} 11}
7 12
8input:not([type=submit]) { 13input:not([type=submit]) {
@@ -26,18 +31,9 @@ input[type=submit], button {
26 font-size: 11px; 31 font-size: 11px;
27} 32}
28 33
29.account-title {
30 @include in-content-small-title;
31
32 margin-top: 55px;
33 margin-bottom: 30px;
34}
35
36.danger-zone { 34.danger-zone {
37 .reset-password-email { 35 .reset-password-email {
38 margin-bottom: 30px; 36 margin-bottom: 30px;
39 padding-bottom: 30px;
40 border-bottom: 1px solid rgba(0, 0, 0, 0.1);
41 37
42 button { 38 button {
43 display: block; 39 display: block;
@@ -45,3 +41,20 @@ input[type=submit], button {
45 } 41 }
46 } 42 }
47} 43}
44
45.breadcrumb {
46 @include breadcrumb;
47}
48
49.dashboard {
50 @include dashboard;
51 max-width: 900px;
52}
53
54my-actor-avatar-info ::ng-deep {
55 .actor-img-edit-container,
56 .actor-info-followers,
57 .actor-info-username {
58 display: none;
59 }
60}
diff --git a/client/src/app/+admin/users/user-edit/user-edit.ts b/client/src/app/+admin/users/user-edit/user-edit.ts
index 02f1dcd42..6e2952c44 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.ts
+++ b/client/src/app/+admin/users/user-edit/user-edit.ts
@@ -4,17 +4,22 @@ import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../..
4import { ConfigService } from '@app/+admin/config/shared/config.service' 4import { ConfigService } from '@app/+admin/config/shared/config.service'
5import { UserAdminFlag } from '@shared/models/users/user-flag.model' 5import { UserAdminFlag } from '@shared/models/users/user-flag.model'
6import { OnInit } from '@angular/core' 6import { OnInit } from '@angular/core'
7import { User } from '@app/shared/users/user.model'
8import { ScreenService } from '@app/shared/misc/screen.service'
7 9
8export abstract class UserEdit extends FormReactive implements OnInit { 10export abstract class UserEdit extends FormReactive implements OnInit {
9 videoQuotaOptions: { value: string, label: string }[] = [] 11 videoQuotaOptions: { value: string, label: string, disabled?: boolean }[] = []
10 videoQuotaDailyOptions: { value: string, label: string }[] = [] 12 videoQuotaDailyOptions: { value: string, label: string, disabled?: boolean }[] = []
11 username: string 13 username: string
12 userId: number 14 user: User
15
16 roles: { value: string, label: string }[] = []
13 17
14 protected serverConfig: ServerConfig 18 protected serverConfig: ServerConfig
15 19
16 protected abstract serverService: ServerService 20 protected abstract serverService: ServerService
17 protected abstract configService: ConfigService 21 protected abstract configService: ConfigService
22 protected abstract screenService: ScreenService
18 protected abstract auth: AuthService 23 protected abstract auth: AuthService
19 abstract isCreation (): boolean 24 abstract isCreation (): boolean
20 abstract getFormButtonTitle (): string 25 abstract getFormButtonTitle (): string
@@ -23,17 +28,34 @@ export abstract class UserEdit extends FormReactive implements OnInit {
23 this.serverConfig = this.serverService.getTmpConfig() 28 this.serverConfig = this.serverService.getTmpConfig()
24 this.serverService.getConfig() 29 this.serverService.getConfig()
25 .subscribe(config => this.serverConfig = config) 30 .subscribe(config => this.serverConfig = config)
31
32 this.buildRoles()
33 }
34
35 get subscribersCount () {
36 const forAccount = this.user
37 ? this.user.account.followersCount
38 : 0
39 const forChannels = this.user
40 ? this.user.videoChannels.map(c => c.followersCount).reduce((a, b) => a + b, 0)
41 : 0
42 return forAccount + forChannels
43 }
44
45 isInBigView () {
46 return this.screenService.getWindowInnerWidth() > 1600
26 } 47 }
27 48
28 getRoles () { 49 buildRoles () {
29 const authUser = this.auth.getUser() 50 const authUser = this.auth.getUser()
30 51
31 if (authUser.role === UserRole.ADMINISTRATOR) { 52 if (authUser.role === UserRole.ADMINISTRATOR) {
32 return Object.keys(USER_ROLE_LABELS) 53 this.roles = Object.keys(USER_ROLE_LABELS)
33 .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) 54 .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] }))
55 return
34 } 56 }
35 57
36 return [ 58 this.roles = [
37 { value: UserRole.USER.toString(), label: USER_ROLE_LABELS[UserRole.USER] } 59 { value: UserRole.USER.toString(), label: USER_ROLE_LABELS[UserRole.USER] }
38 ] 60 ]
39 } 61 }
@@ -72,9 +94,22 @@ export abstract class UserEdit extends FormReactive implements OnInit {
72 protected buildQuotaOptions () { 94 protected buildQuotaOptions () {
73 // These are used by a HTML select, so convert key into strings 95 // These are used by a HTML select, so convert key into strings
74 this.videoQuotaOptions = this.configService 96 this.videoQuotaOptions = this.configService
75 .videoQuotaOptions.map(q => ({ value: q.value.toString(), label: q.label })) 97 .videoQuotaOptions.map(q => ({
98 value: q.value?.toString(),
99 label: q.label,
100 disabled: q.disabled
101 }))
76 102
77 this.videoQuotaDailyOptions = this.configService 103 this.videoQuotaDailyOptions = this.configService
78 .videoQuotaDailyOptions.map(q => ({ value: q.value.toString(), label: q.label })) 104 .videoQuotaDailyOptions.map(q => ({
105 value: q.value?.toString(),
106 label: q.label,
107 disabled: q.disabled
108 }))
109
110 console.log(
111 this.videoQuotaOptions,
112 this.videoQuotaDailyOptions
113 )
79 } 114 }
80} 115}
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.html b/client/src/app/+admin/users/user-edit/user-password.component.html
index a1e1f6216..1238d1839 100644
--- a/client/src/app/+admin/users/user-edit/user-password.component.html
+++ b/client/src/app/+admin/users/user-edit/user-password.component.html
@@ -2,7 +2,7 @@
2 <div class="form-group"> 2 <div class="form-group">
3 3
4 <div class="input-group"> 4 <div class="input-group">
5 <input id="password" [attr.type]="showPassword ? 'text' : 'password'" 5 <input id="password" [attr.type]="showPassword ? 'text' : 'password'" class="form-control"
6 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 6 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
7 > 7 >
8 <div class="input-group-append"> 8 <div class="input-group-append">
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.ts b/client/src/app/+admin/users/user-edit/user-password.component.ts
index 5b3040440..ecad000f7 100644
--- a/client/src/app/+admin/users/user-edit/user-password.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-password.component.ts
@@ -23,8 +23,6 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
23 constructor ( 23 constructor (
24 protected formValidatorService: FormValidatorService, 24 protected formValidatorService: FormValidatorService,
25 private userValidatorsService: UserValidatorsService, 25 private userValidatorsService: UserValidatorsService,
26 private route: ActivatedRoute,
27 private router: Router,
28 private notifier: Notifier, 26 private notifier: Notifier,
29 private userService: UserService, 27 private userService: UserService,
30 private i18n: I18n 28 private i18n: I18n
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts
index d1682a99d..e0e1fbddf 100644
--- a/client/src/app/+admin/users/user-edit/user-update.component.ts
+++ b/client/src/app/+admin/users/user-edit/user-update.component.ts
@@ -4,13 +4,15 @@ import { Subscription } from 'rxjs'
4import { AuthService, Notifier } from '@app/core' 4import { AuthService, Notifier } from '@app/core'
5import { ServerService } from '../../../core' 5import { ServerService } from '../../../core'
6import { UserEdit } from './user-edit' 6import { UserEdit } from './user-edit'
7import { User, UserUpdate } from '../../../../../../shared' 7import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared'
8import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 9import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' 10import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
11import { ConfigService } from '@app/+admin/config/shared/config.service' 11import { ConfigService } from '@app/+admin/config/shared/config.service'
12import { UserService } from '@app/shared' 12import { UserService } from '@app/shared'
13import { UserAdminFlag } from '@shared/models/users/user-flag.model' 13import { UserAdminFlag } from '@shared/models/users/user-flag.model'
14import { User } from '@app/shared/users/user.model'
15import { ScreenService } from '@app/shared/misc/screen.service'
14 16
15@Component({ 17@Component({
16 selector: 'my-user-update', 18 selector: 'my-user-update',
@@ -19,9 +21,6 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model'
19}) 21})
20export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { 22export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
21 error: string 23 error: string
22 userId: number
23 userEmail: string
24 username: string
25 24
26 private paramsSub: Subscription 25 private paramsSub: Subscription
27 26
@@ -29,6 +28,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
29 protected formValidatorService: FormValidatorService, 28 protected formValidatorService: FormValidatorService,
30 protected serverService: ServerService, 29 protected serverService: ServerService,
31 protected configService: ConfigService, 30 protected configService: ConfigService,
31 protected screenService: ScreenService,
32 protected auth: AuthService, 32 protected auth: AuthService,
33 private userValidatorsService: UserValidatorsService, 33 private userValidatorsService: UserValidatorsService,
34 private route: ActivatedRoute, 34 private route: ActivatedRoute,
@@ -45,7 +45,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
45 ngOnInit () { 45 ngOnInit () {
46 super.ngOnInit() 46 super.ngOnInit()
47 47
48 const defaultValues = { videoQuota: '-1', videoQuotaDaily: '-1' } 48 const defaultValues = {
49 role: UserRole.USER.toString(),
50 videoQuota: '-1',
51 videoQuotaDaily: '-1'
52 }
53
49 this.buildForm({ 54 this.buildForm({
50 email: this.userValidatorsService.USER_EMAIL, 55 email: this.userValidatorsService.USER_EMAIL,
51 role: this.userValidatorsService.USER_ROLE, 56 role: this.userValidatorsService.USER_ROLE,
@@ -56,7 +61,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
56 61
57 this.paramsSub = this.route.params.subscribe(routeParams => { 62 this.paramsSub = this.route.params.subscribe(routeParams => {
58 const userId = routeParams['id'] 63 const userId = routeParams['id']
59 this.userService.getUser(userId).subscribe( 64 this.userService.getUser(userId, true).subscribe(
60 user => this.onUserFetched(user), 65 user => this.onUserFetched(user),
61 66
62 err => this.error = err.message 67 err => this.error = err.message
@@ -78,9 +83,9 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
78 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) 83 userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
79 userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) 84 userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10)
80 85
81 this.userService.updateUser(this.userId, userUpdate).subscribe( 86 this.userService.updateUser(this.user.id, userUpdate).subscribe(
82 () => { 87 () => {
83 this.notifier.success(this.i18n('User {{username}} updated.', { username: this.username })) 88 this.notifier.success(this.i18n('User {{username}} updated.', { username: this.user.username }))
84 this.router.navigate([ '/admin/users/list' ]) 89 this.router.navigate([ '/admin/users/list' ])
85 }, 90 },
86 91
@@ -92,15 +97,19 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
92 return false 97 return false
93 } 98 }
94 99
100 isPasswordOptional () {
101 return false
102 }
103
95 getFormButtonTitle () { 104 getFormButtonTitle () {
96 return this.i18n('Update user') 105 return this.i18n('Update user')
97 } 106 }
98 107
99 resetPassword () { 108 resetPassword () {
100 this.userService.askResetPassword(this.userEmail).subscribe( 109 this.userService.askResetPassword(this.user.email).subscribe(
101 () => { 110 () => {
102 this.notifier.success( 111 this.notifier.success(
103 this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username }) 112 this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.user.username })
104 ) 113 )
105 }, 114 },
106 115
@@ -108,14 +117,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
108 ) 117 )
109 } 118 }
110 119
111 private onUserFetched (userJson: User) { 120 private onUserFetched (userJson: UserType) {
112 this.userId = userJson.id 121 this.user = new User(userJson)
113 this.username = userJson.username
114 this.userEmail = userJson.email
115 122
116 this.form.patchValue({ 123 this.form.patchValue({
117 email: userJson.email, 124 email: userJson.email,
118 role: userJson.role, 125 role: userJson.role.toString(),
119 videoQuota: userJson.videoQuota, 126 videoQuota: userJson.videoQuota,
120 videoQuotaDaily: userJson.videoQuotaDaily, 127 videoQuotaDaily: userJson.videoQuotaDaily,
121 byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST 128 byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST
diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html
index ca05bac19..768a3034d 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/users/user-list/user-list.component.html
@@ -8,9 +8,12 @@
8</div> 8</div>
9 9
10<p-table 10<p-table
11 [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 11 [value]="users" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
12 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" 12 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
13 [(selection)]="selectedUsers" 13 [(selection)]="selectedUsers"
14 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
15 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
16 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
14> 17>
15 <ng-template pTemplate="caption"> 18 <ng-template pTemplate="caption">
16 <div class="caption"> 19 <div class="caption">
@@ -22,11 +25,13 @@
22 </my-action-dropdown> 25 </my-action-dropdown>
23 </div> 26 </div>
24 27
25 <div> 28 <div class="has-feedback has-clear">
26 <input 29 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." 30 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onSearch($event.target.value)" 31 (keyup)="onSearch($event)"
29 > 32 >
33 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
34 <span class="sr-only" i18n>Clear filters</span>
30 </div> 35 </div>
31 </div> 36 </div>
32 </ng-template> 37 </ng-template>
@@ -37,11 +42,12 @@
37 <p-tableHeaderCheckbox></p-tableHeaderCheckbox> 42 <p-tableHeaderCheckbox></p-tableHeaderCheckbox>
38 </th> 43 </th>
39 <th style="width: 40px"></th> 44 <th style="width: 40px"></th>
40 <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> 45 <th pResizableColumn i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
41 <th i18n>Email</th> 46 <th i18n>Email</th>
42 <th i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th> 47 <th style="width: 140px;" i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th>
43 <th i18n>Role</th> 48 <th style="width: 120px;" i18n>Role</th>
44 <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 49 <th style="width: 140px;" pResizableColumn i18n>Auth plugin</th>
50 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
45 <th style="width: 50px;"></th> 51 <th style="width: 50px;"></th>
46 </tr> 52 </tr>
47 </ng-template> 53 </ng-template>
@@ -49,19 +55,30 @@
49 <ng-template pTemplate="body" let-expanded="expanded" let-user> 55 <ng-template pTemplate="body" let-expanded="expanded" let-user>
50 56
51 <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }"> 57 <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }">
52 <td class="expand-cell"> 58 <td>
53 <p-tableCheckbox [value]="user"></p-tableCheckbox> 59 <p-tableCheckbox [value]="user"></p-tableCheckbox>
54 </td> 60 </td>
55 61
56 <td> 62 <td class="expand-cell">
57 <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> 63 <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user">
58 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> 64 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
59 </span> 65 </span>
60 </td> 66 </td>
61 67
62 <td> 68 <td>
63 <a i18n-title title="Go to the account page" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]"> 69 <a i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]">
64 {{ user.username }} 70 <div class="chip two-lines">
71 <img
72 class="avatar"
73 [src]="user?.account?.avatar?.path"
74 (error)="switchToDefaultAvatar($event)"
75 alt="Avatar"
76 >
77 <div>
78 {{ user.account.displayName }}
79 <span class="text-muted">{{ user.username }}</span>
80 </div>
81 </div>
65 <span i18n *ngIf="user.blocked" class="banned-info">(banned)</span> 82 <span i18n *ngIf="user.blocked" class="banned-info">(banned)</span>
66 </a> 83 </a>
67 </td> 84 </td>
@@ -81,7 +98,13 @@
81 98
82 <td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td> 99 <td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td>
83 <td>{{ user.roleLabel }}</td> 100 <td>{{ user.roleLabel }}</td>
84 <td [title]="user.createdAt">{{ user.createdAt }}</td> 101
102 <td>
103 <ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container>
104 </td>
105
106 <td [title]="user.createdAt">{{ user.createdAt | date: 'short' }}</td>
107
85 <td class="action-cell"> 108 <td class="action-cell">
86 <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> 109 <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
87 </my-user-moderation-dropdown> 110 </my-user-moderation-dropdown>
diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss
index 5274be01c..99b22aaea 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/users/user-list/user-list.component.scss
@@ -24,3 +24,12 @@ tr.banned {
24 @include peertube-input-text(250px); 24 @include peertube-input-text(250px);
25 } 25 }
26} 26}
27
28p-tableCheckbox {
29 position: relative;
30 top: -2.5px;
31}
32
33.chip {
34 @include chip;
35}
diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts
index 7c5e8eaa4..da50b7ed0 100644
--- a/client/src/app/+admin/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/users/user-list/user-list.component.ts
@@ -1,12 +1,13 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { AuthService, Notifier } from '@app/core' 2import { AuthService, Notifier } from '@app/core'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/api'
4import { ConfirmService, ServerService } from '../../../core' 4import { ConfirmService, ServerService } from '../../../core'
5import { RestPagination, RestTable, UserService } from '../../../shared' 5import { RestPagination, RestTable, UserService } from '../../../shared'
6import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { ServerConfig, User } from '../../../../../../shared' 7import { ServerConfig, User } from '../../../../../../shared'
8import { UserBanModalComponent } from '@app/shared/moderation' 8import { UserBanModalComponent } from '@app/shared/moderation'
9import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' 9import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
10import { Actor } from '@app/shared/actor/actor.model'
10 11
11@Component({ 12@Component({
12 selector: 'my-user-list', 13 selector: 'my-user-list',
@@ -18,7 +19,6 @@ export class UserListComponent extends RestTable implements OnInit {
18 19
19 users: User[] = [] 20 users: User[] = []
20 totalRecords = 0 21 totalRecords = 0
21 rowsPerPage = 10
22 sort: SortMeta = { field: 'createdAt', order: 1 } 22 sort: SortMeta = { field: 'createdAt', order: 1 }
23 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 23 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
24 24
@@ -86,6 +86,10 @@ export class UserListComponent extends RestTable implements OnInit {
86 ] 86 ]
87 } 87 }
88 88
89 getIdentifier () {
90 return 'UserListComponent'
91 }
92
89 openBanUserModal (users: User[]) { 93 openBanUserModal (users: User[]) {
90 for (const user of users) { 94 for (const user of users) {
91 if (user.username === 'root') { 95 if (user.username === 'root') {
@@ -101,6 +105,10 @@ export class UserListComponent extends RestTable implements OnInit {
101 this.loadData() 105 this.loadData()
102 } 106 }
103 107
108 switchToDefaultAvatar ($event: Event) {
109 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
110 }
111
104 async unbanUsers (users: User[]) { 112 async unbanUsers (users: User[]) {
105 const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length }) 113 const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length })
106 114
diff --git a/client/src/app/+admin/users/users.routes.ts b/client/src/app/+admin/users/users.routes.ts
index 8b3791bd3..2d4f9305e 100644
--- a/client/src/app/+admin/users/users.routes.ts
+++ b/client/src/app/+admin/users/users.routes.ts
@@ -5,6 +5,7 @@ import { UserRight } from '../../../../../shared'
5import { UsersComponent } from './users.component' 5import { UsersComponent } from './users.component'
6import { UserCreateComponent, UserUpdateComponent } from './user-edit' 6import { UserCreateComponent, UserUpdateComponent } from './user-edit'
7import { UserListComponent } from './user-list' 7import { UserListComponent } from './user-list'
8import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
8 9
9export const UsersRoutes: Routes = [ 10export const UsersRoutes: Routes = [
10 { 11 {
@@ -36,6 +37,9 @@ export const UsersRoutes: Routes = [
36 meta: { 37 meta: {
37 title: 'Create a user' 38 title: 'Create a user'
38 } 39 }
40 },
41 resolve: {
42 serverConfig: ServerConfigResolver
39 } 43 }
40 }, 44 },
41 { 45 {
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html
index a96a11f5e..fb9e6546e 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html
@@ -3,7 +3,7 @@
3</div> 3</div>
4 4
5<p-table 5<p-table
6 [value]="blockedAccounts" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 6 [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage"
7 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 7 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)"
8> 8>
9 9
@@ -11,6 +11,7 @@
11 <tr> 11 <tr>
12 <th i18n>Account</th> 12 <th i18n>Account</th>
13 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> 13 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
14 <th></th> <!-- column for action buttons -->
14 </tr> 15 </tr>
15 </ng-template> 16 </ng-template>
16 17
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
index e3025dec4..fd1fabcdb 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { AccountBlock, BlocklistService } from '@app/shared/blocklist' 6import { AccountBlock, BlocklistService } from '@app/shared/blocklist'
7 7
8@Component({ 8@Component({
@@ -13,7 +13,6 @@ import { AccountBlock, BlocklistService } from '@app/shared/blocklist'
13export class MyAccountBlocklistComponent extends RestTable implements OnInit { 13export class MyAccountBlocklistComponent extends RestTable implements OnInit {
14 blockedAccounts: AccountBlock[] = [] 14 blockedAccounts: AccountBlock[] = []
15 totalRecords = 0 15 totalRecords = 0
16 rowsPerPage = 10
17 sort: SortMeta = { field: 'createdAt', order: -1 } 16 sort: SortMeta = { field: 'createdAt', order: -1 }
18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 17 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
19 18
@@ -29,6 +28,10 @@ export class MyAccountBlocklistComponent extends RestTable implements OnInit {
29 this.initialize() 28 this.initialize()
30 } 29 }
31 30
31 getIdentifier () {
32 return 'MyAccountBlocklistComponent'
33 }
34
32 unblockAccount (accountBlock: AccountBlock) { 35 unblockAccount (accountBlock: AccountBlock) {
33 const blockedAccount = accountBlock.blockedAccount 36 const blockedAccount = accountBlock.blockedAccount
34 37
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html
index 329cfb08f..6359b4461 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html
@@ -3,7 +3,7 @@
3</div> 3</div>
4 4
5<p-table 5<p-table
6 [value]="blockedServers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 6 [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage"
7 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" 7 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)"
8> 8>
9 9
@@ -11,7 +11,7 @@
11 <tr> 11 <tr>
12 <th i18n>Instance</th> 12 <th i18n>Instance</th>
13 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> 13 <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
14 <th></th> 14 <th></th> <!-- column for action buttons -->
15 </tr> 15 </tr>
16 </ng-template> 16 </ng-template>
17 17
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
index 4c5cc28b8..483c11804 100644
--- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
+++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { RestPagination, RestTable } from '@app/shared' 4import { RestPagination, RestTable } from '@app/shared'
5import { SortMeta } from 'primeng/components/common/sortmeta' 5import { SortMeta } from 'primeng/api'
6import { ServerBlock } from '../../../../../shared' 6import { ServerBlock } from '../../../../../shared'
7import { BlocklistService } from '@app/shared/blocklist' 7import { BlocklistService } from '@app/shared/blocklist'
8 8
@@ -14,7 +14,6 @@ import { BlocklistService } from '@app/shared/blocklist'
14export class MyAccountServerBlocklistComponent extends RestTable implements OnInit { 14export class MyAccountServerBlocklistComponent extends RestTable implements OnInit {
15 blockedServers: ServerBlock[] = [] 15 blockedServers: ServerBlock[] = []
16 totalRecords = 0 16 totalRecords = 0
17 rowsPerPage = 10
18 sort: SortMeta = { field: 'createdAt', order: -1 } 17 sort: SortMeta = { field: 'createdAt', order: -1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
20 19
@@ -30,6 +29,10 @@ export class MyAccountServerBlocklistComponent extends RestTable implements OnIn
30 this.initialize() 29 this.initialize()
31 } 30 }
32 31
32 getIdentifier () {
33 return 'MyAccountServerBlocklistComponent'
34 }
35
33 unblockServer (serverBlock: ServerBlock) { 36 unblockServer (serverBlock: ServerBlock) {
34 const host = serverBlock.blockedServer.host 37 const host = serverBlock.blockedServer.host
35 38
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.html b/client/src/app/+my-account/my-account-history/my-account-history.component.html
index 4c361cec3..56d63f299 100644
--- a/client/src/app/+my-account/my-account-history/my-account-history.component.html
+++ b/client/src/app/+my-account/my-account-history/my-account-history.component.html
@@ -11,7 +11,7 @@
11</div> 11</div>
12 12
13 13
14<div class="no-history" i18n *ngIf="pagination.totalItems === 0">You don't have videos history yet.</div> 14<div class="no-history" i18n *ngIf="pagination.totalItems === 0">You don't have any video history yet.</div>
15 15
16<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos"> 16<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos">
17 <div class="video" *ngFor="let video of videos"> 17 <div class="video" *ngFor="let video of videos">
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.scss b/client/src/app/+my-account/my-account-history/my-account-history.component.scss
index af6395fb1..9eeeaf310 100644
--- a/client/src/app/+my-account/my-account-history/my-account-history.component.scss
+++ b/client/src/app/+my-account/my-account-history/my-account-history.component.scss
@@ -12,6 +12,8 @@
12.top-buttons { 12.top-buttons {
13 margin-bottom: 20px; 13 margin-bottom: 20px;
14 display: flex; 14 display: flex;
15 align-items: center;
16 flex-wrap: wrap;
15 17
16 .history-switch { 18 .history-switch {
17 display: flex; 19 display: flex;
@@ -38,3 +40,20 @@
38 flex-grow: 1; 40 flex-grow: 1;
39 } 41 }
40} 42}
43
44@media screen and (max-width: $mobile-view) {
45 .top-buttons {
46 .history-switch label, .delete-history {
47 @include ellipsis;
48 }
49
50 .history-switch label {
51 width: 60%;
52 }
53
54 .delete-history {
55 margin-left: auto;
56 max-width: 32%;
57 }
58 }
59}
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.ts b/client/src/app/+my-account/my-account-history/my-account-history.component.ts
index 13607119e..5f0ccee50 100644
--- a/client/src/app/+my-account/my-account-history/my-account-history.component.ts
+++ b/client/src/app/+my-account/my-account-history/my-account-history.component.ts
@@ -11,6 +11,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
11import { UserHistoryService } from '@app/shared/users/user-history.service' 11import { UserHistoryService } from '@app/shared/users/user-history.service'
12import { UserService } from '@app/shared' 12import { UserService } from '@app/shared'
13import { Notifier, ServerService } from '@app/core' 13import { Notifier, ServerService } from '@app/core'
14import { LocalStorageService } from '@app/shared/misc/storage.service'
14 15
15@Component({ 16@Component({
16 selector: 'my-account-history', 17 selector: 'my-account-history',
@@ -35,6 +36,7 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn
35 protected userService: UserService, 36 protected userService: UserService,
36 protected notifier: Notifier, 37 protected notifier: Notifier,
37 protected screenService: ScreenService, 38 protected screenService: ScreenService,
39 protected storageService: LocalStorageService,
38 private confirmService: ConfirmService, 40 private confirmService: ConfirmService,
39 private videoService: VideoService, 41 private videoService: VideoService,
40 private userHistoryService: UserHistoryService 42 private userHistoryService: UserHistoryService
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
index 43d1f82ab..73f7c7b24 100644
--- a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
+++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss
@@ -23,3 +23,13 @@
23my-user-notifications { 23my-user-notifications {
24 font-size: 15px; 24 font-size: 15px;
25} 25}
26
27@media screen and (max-width: $mobile-view) {
28 .header {
29 flex-direction: column;
30
31 & >:first-child {
32 margin-bottom: 15px;
33 }
34 }
35}
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html
index 674a4e8a2..a155d90e0 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html
+++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html
@@ -21,9 +21,10 @@
21 21
22 <div class="modal-footer inputs"> 22 <div class="modal-footer inputs">
23 <div class="form-group inputs"> 23 <div class="form-group inputs">
24 <span i18n class="action-button action-button-cancel" (click)="dismiss()"> 24 <input
25 Cancel 25 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
26 </span> 26 (click)="dismiss()" (key.enter)="dismiss()"
27 >
27 28
28 <input 29 <input
29 type="submit" i18n-value value="Submit" class="action-button-submit" 30 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
index 6df929ec9..d5682914e 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts
@@ -53,7 +53,7 @@ export class MyAccountAcceptOwnershipComponent extends FormReactive implements O
53 show (videoChangeOwnership: VideoChangeOwnership) { 53 show (videoChangeOwnership: VideoChangeOwnership) {
54 this.videoChangeOwnership = videoChangeOwnership 54 this.videoChangeOwnership = videoChangeOwnership
55 this.modalService 55 this.modalService
56 .open(this.modal) 56 .open(this.modal, { centered: true })
57 .result 57 .result
58 .then(() => this.acceptOwnership()) 58 .then(() => this.acceptOwnership())
59 .catch(() => this.videoChangeOwnership = undefined) 59 .catch(() => this.videoChangeOwnership = undefined)
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html
index c5fd3ccb9..354176a11 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html
+++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html
@@ -1,7 +1,7 @@
1<p-table 1<p-table
2 [value]="videoChangeOwnerships" 2 [value]="videoChangeOwnerships"
3 [lazy]="true" 3 [lazy]="true"
4 [paginator]="true" 4 [paginator]="totalRecords > 0"
5 [totalRecords]="totalRecords" 5 [totalRecords]="totalRecords"
6 [rows]="rowsPerPage" 6 [rows]="rowsPerPage"
7 [sortField]="sort.field" 7 [sortField]="sort.field"
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
index aeeb0e5a7..f0a6303d1 100644
--- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { RestPagination, RestTable } from '@app/shared' 3import { RestPagination, RestTable } from '@app/shared'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { VideoChangeOwnership } from '../../../../../shared' 5import { VideoChangeOwnership } from '../../../../../shared'
6import { VideoOwnershipService } from '@app/shared/video-ownership' 6import { VideoOwnershipService } from '@app/shared/video-ownership'
7import { Account } from '@app/shared/account/account.model' 7import { Account } from '@app/shared/account/account.model'
@@ -14,7 +14,6 @@ import { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership
14export class MyAccountOwnershipComponent extends RestTable implements OnInit { 14export class MyAccountOwnershipComponent extends RestTable implements OnInit {
15 videoChangeOwnerships: VideoChangeOwnership[] = [] 15 videoChangeOwnerships: VideoChangeOwnership[] = []
16 totalRecords = 0 16 totalRecords = 0
17 rowsPerPage = 10
18 sort: SortMeta = { field: 'createdAt', order: -1 } 17 sort: SortMeta = { field: 'createdAt', order: -1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
20 19
@@ -31,6 +30,10 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit {
31 this.initialize() 30 this.initialize()
32 } 31 }
33 32
33 getIdentifier () {
34 return 'MyAccountOwnershipComponent'
35 }
36
34 createByString (account: Account) { 37 createByString (account: Account) {
35 return Account.CREATE_BY_STRING(account.name, account.host) 38 return Account.CREATE_BY_STRING(account.name, account.host)
36 } 39 }
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index 018d6f996..f44b60ec9 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -5,9 +5,6 @@ import { LoginGuard } from '../core'
5import { MyAccountComponent } from './my-account.component' 5import { MyAccountComponent } from './my-account.component'
6import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 6import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
7import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' 7import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
8import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component'
9import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component'
10import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component'
11import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' 8import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
12import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' 9import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
13import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component' 10import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component'
@@ -49,30 +46,7 @@ const myAccountRoutes: Routes = [
49 46
50 { 47 {
51 path: 'video-channels', 48 path: 'video-channels',
52 component: MyAccountVideoChannelsComponent, 49 loadChildren: () => import('./my-account-video-channels/my-account-video-channels.module').then(m => m.MyAccountVideoChannelsModule)
53 data: {
54 meta: {
55 title: 'Account video channels'
56 }
57 }
58 },
59 {
60 path: 'video-channels/create',
61 component: MyAccountVideoChannelCreateComponent,
62 data: {
63 meta: {
64 title: 'Create new video channel'
65 }
66 }
67 },
68 {
69 path: 'video-channels/update/:videoChannelId',
70 component: MyAccountVideoChannelUpdateComponent,
71 data: {
72 meta: {
73 title: 'Update video channel'
74 }
75 }
76 }, 50 },
77 51
78 { 52 {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html
index 76886c73e..f39f66696 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.html
@@ -14,7 +14,7 @@
14 <div class="form-group"> 14 <div class="form-group">
15 <label i18n for="new-email">New email</label> 15 <label i18n for="new-email">New email</label>
16 <input 16 <input
17 type="email" id="new-email" i18n-placeholder placeholder="Your new email" 17 type="email" id="new-email" i18n-placeholder placeholder="Your new email" class="form-control"
18 formControlName="new-email" [ngClass]="{ 'input-error': formErrors['new-email'] }" 18 formControlName="new-email" [ngClass]="{ 'input-error': formErrors['new-email'] }"
19 > 19 >
20 <div *ngIf="formErrors['new-email']" class="form-error"> 20 <div *ngIf="formErrors['new-email']" class="form-error">
@@ -25,7 +25,7 @@
25 <div class="form-group"> 25 <div class="form-group">
26 <input 26 <input
27 type="password" id="password" i18n-placeholder placeholder="Your password" autocomplete="off" 27 type="password" id="password" i18n-placeholder placeholder="Your password" autocomplete="off"
28 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 28 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" class="form-control"
29 > 29 >
30 <div *ngIf="formErrors['password']" class="form-error"> 30 <div *ngIf="formErrors['password']" class="form-error">
31 {{ formErrors['password'] }} 31 {{ formErrors['password'] }}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss
index 81eba3ec9..aec709ea0 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.scss
@@ -1,6 +1,11 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
4input[type=password], 9input[type=password],
5input[type=email] { 10input[type=email] {
6 @include peertube-input-text(340px); 11 @include peertube-input-text(340px);
@@ -16,7 +21,7 @@ input[type=submit] {
16.current-email, 21.current-email,
17.pending-email { 22.pending-email {
18 font-size: 16px; 23 font-size: 16px;
19 margin: 15px 0; 24 margin-bottom: 15px;
20 25
21 .email { 26 .email {
22 font-weight: $font-semibold; 27 font-weight: $font-semibold;
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
index cec70c6b5..4756cfecd 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.html
@@ -5,7 +5,7 @@
5 <label i18n for="current-password">Change password</label> 5 <label i18n for="current-password">Change password</label>
6 <input 6 <input
7 type="password" id="current-password" i18n-placeholder placeholder="Current password" autocomplete="current-password" 7 type="password" id="current-password" i18n-placeholder placeholder="Current password" autocomplete="current-password"
8 formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }" 8 formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }" class="form-control"
9 > 9 >
10 <div *ngIf="formErrors['current-password']" class="form-error"> 10 <div *ngIf="formErrors['current-password']" class="form-error">
11 {{ formErrors['current-password'] }} 11 {{ formErrors['current-password'] }}
@@ -13,7 +13,7 @@
13 13
14 <input 14 <input
15 type="password" id="new-password" i18n-placeholder placeholder="New password" autocomplete="new-password" 15 type="password" id="new-password" i18n-placeholder placeholder="New password" autocomplete="new-password"
16 formControlName="new-password" [ngClass]="{ 'input-error': formErrors['new-password'] }" 16 formControlName="new-password" [ngClass]="{ 'input-error': formErrors['new-password'] }" class="form-control"
17 > 17 >
18 <div *ngIf="formErrors['new-password']" class="form-error"> 18 <div *ngIf="formErrors['new-password']" class="form-error">
19 {{ formErrors['new-password'] }} 19 {{ formErrors['new-password'] }}
@@ -21,7 +21,7 @@
21 21
22 <input 22 <input
23 type="password" id="new-confirmed-password" i18n-placeholder placeholder="Confirm new password" autocomplete="new-password" 23 type="password" id="new-confirmed-password" i18n-placeholder placeholder="Confirm new password" autocomplete="new-password"
24 formControlName="new-confirmed-password" 24 formControlName="new-confirmed-password" class="form-control"
25 > 25 >
26 <div *ngIf="formErrors['new-confirmed-password']" class="form-error"> 26 <div *ngIf="formErrors['new-confirmed-password']" class="form-error">
27 {{ formErrors['new-confirmed-password'] }} 27 {{ formErrors['new-confirmed-password'] }}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss
index e641482f0..381afae07 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss
@@ -1,6 +1,11 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
4input[type=password] { 9input[type=password] {
5 @include peertube-input-text(340px); 10 @include peertube-input-text(340px);
6 display: block; 11 display: block;
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html
index c542cc675..6e22abeed 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html
@@ -1,5 +1,5 @@
1<div class="delete-me"> 1<div class="delete-me">
2 <p i18n>Once you delete your account, there is no going back. Please be certain.</p> 2 <p i18n>Once you delete your account, there is no going back. You will be asked to confirm this action.</p>
3 3
4 <button (click)="deleteMe()" i18n>Delete your account</button> 4 <button (click)="deleteMe()" i18n>Delete your account</button>
5</div> \ No newline at end of file 5</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss
index 0ca310468..7f7806732 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss
@@ -7,5 +7,6 @@
7 button { 7 button {
8 @include peertube-button; 8 @include peertube-button;
9 @include grey-button; 9 @include grey-button;
10 @include disable-outline;
10 } 11 }
11} \ No newline at end of file 12} \ No newline at end of file
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
index 41021c592..25d862867 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
@@ -24,7 +24,7 @@ export class MyAccountDangerZoneComponent {
24 24
25 async deleteMe () { 25 async deleteMe () {
26 const res = await this.confirmService.confirmWithInput( 26 const res = await this.confirmService.confirmWithInput(
27 this.i18n('Are you sure you want to delete your account? This will delete all your data, including channels, videos etc.'), 27 this.i18n('Are you sure you want to delete your account? This will delete all your data, including channels, videos and comments. Content cached by other servers and other third-parties might make longer to be deleted.'),
28 this.i18n('Type your username to confirm'), 28 this.i18n('Type your username to confirm'),
29 this.user.username, 29 this.user.username,
30 this.i18n('Delete your account'), 30 this.i18n('Delete your account'),
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
index f034c6bb3..0d0ddc0f2 100644
--- 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
@@ -1,9 +1,10 @@
1<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form"> 1<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
2
2 <div class="form-group"> 3 <div class="form-group">
3 <label i18n for="theme">Theme</label> 4 <label i18n for="theme">Theme</label>
4 5
5 <div class="peertube-select-container"> 6 <div class="peertube-select-container">
6 <select formControlName="theme" id="theme"> 7 <select formControlName="theme" id="theme" class="form-control">
7 <option i18n value="instance-default">instance default</option> 8 <option i18n value="instance-default">instance default</option>
8 <option i18n value="default">peertube default</option> 9 <option i18n value="default">peertube default</option>
9 10
@@ -12,5 +13,5 @@
12 </div> 13 </div>
13 </div> 14 </div>
14 15
15 <input type="submit" i18n-value value="Save" [disabled]="!form.valid"> 16 <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid">
16</form> 17</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
index 629f01733..7818dfc02 100644
--- 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
@@ -1,6 +1,11 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
4input[type=submit] { 9input[type=submit] {
5 @include peertube-button; 10 @include peertube-button;
6 @include orange-button; 11 @include orange-button;
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
index 441f89f10..b6c17c0e3 100644
--- 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
@@ -1,21 +1,26 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit, OnDestroy } from '@angular/core'
2import { Notifier, ServerService } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { ServerConfig, UserUpdateMe } from '../../../../../../shared' 3import { ServerConfig, UserUpdateMe } from '../../../../../../shared'
4import { AuthService } from '../../../core' 4import { AuthService } from '../../../core'
5import { FormReactive, User, UserService } from '../../../shared' 5import { FormReactive } from '../../../shared/forms/form-reactive'
6import { User, UserService } from '../../../shared/users'
6import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
8import { Subject } from 'rxjs' 9import { Subject, Subscription } from 'rxjs'
9 10
10@Component({ 11@Component({
11 selector: 'my-account-interface-settings', 12 selector: 'my-account-interface-settings',
12 templateUrl: './my-account-interface-settings.component.html', 13 templateUrl: './my-account-interface-settings.component.html',
13 styleUrls: [ './my-account-interface-settings.component.scss' ] 14 styleUrls: [ './my-account-interface-settings.component.scss' ]
14}) 15})
15export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit { 16export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy {
16 @Input() user: User = null 17 @Input() user: User = null
18 @Input() reactiveUpdate = false
19 @Input() notifyOnUpdate = true
17 @Input() userInformationLoaded: Subject<any> 20 @Input() userInformationLoaded: Subject<any>
18 21
22 formValuesWatcher: Subscription
23
19 private serverConfig: ServerConfig 24 private serverConfig: ServerConfig
20 25
21 constructor ( 26 constructor (
@@ -48,9 +53,17 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements
48 this.form.patchValue({ 53 this.form.patchValue({
49 theme: this.user.theme 54 theme: this.user.theme
50 }) 55 })
56
57 if (this.reactiveUpdate) {
58 this.formValuesWatcher = this.form.valueChanges.subscribe(val => this.updateInterfaceSettings())
59 }
51 }) 60 })
52 } 61 }
53 62
63 ngOnDestroy () {
64 this.formValuesWatcher?.unsubscribe()
65 }
66
54 updateInterfaceSettings () { 67 updateInterfaceSettings () {
55 const theme = this.form.value['theme'] 68 const theme = this.form.value['theme']
56 69
@@ -58,14 +71,19 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements
58 theme 71 theme
59 } 72 }
60 73
61 this.userService.updateMyProfile(details).subscribe( 74 if (this.authService.isLoggedIn()) {
62 () => { 75 this.userService.updateMyProfile(details).subscribe(
63 this.authService.refreshUserInformation() 76 () => {
77 this.authService.refreshUserInformation()
64 78
65 this.notifier.success(this.i18n('Interface settings updated.')) 79 if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
66 }, 80 },
67 81
68 err => this.notifier.error(err.message) 82 err => this.notifier.error(err.message)
69 ) 83 )
84 } else {
85 this.userService.updateMyAnonymousProfile(details)
86 if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
87 }
70 } 88 }
71} 89}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
index 7cd5c3b46..75e52fa1b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
@@ -8,15 +8,16 @@
8 8
9 &:first-child { 9 &:first-child {
10 font-size: 16px; 10 font-size: 16px;
11
12 & > div {
13 font-weight: $font-semibold;
14 }
15 } 11 }
16 12
17 & > div { 13 & > div {
14 padding: 10px;
18 width: 350px; 15 width: 350px;
19 16
17 &:nth-child(2) {
18 max-width: 60px !important;
19 }
20
20 @media screen and (max-width: $small-view) { 21 @media screen and (max-width: $small-view) {
21 width: auto; 22 width: auto;
22 23
@@ -25,9 +26,4 @@
25 } 26 }
26 } 27 }
27 } 28 }
28
29 & > div {
30 padding: 10px
31 }
32} 29}
33
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html
index 05c0b5ddc..818e34ee0 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html
@@ -5,7 +5,7 @@
5 <div class="form-group"> 5 <div class="form-group">
6 <label i18n for="display-name">Display name</label> 6 <label i18n for="display-name">Display name</label>
7 <input 7 <input
8 type="text" id="display-name" 8 type="text" id="display-name" class="form-control"
9 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" 9 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
10 > 10 >
11 <div *ngIf="formErrors['display-name']" class="form-error"> 11 <div *ngIf="formErrors['display-name']" class="form-error">
@@ -16,7 +16,7 @@
16 <div class="form-group"> 16 <div class="form-group">
17 <label i18n for="description">Description</label> 17 <label i18n for="description">Description</label>
18 <textarea 18 <textarea
19 id="description" formControlName="description" 19 id="description" formControlName="description" class="form-control"
20 [ngClass]="{ 'input-error': formErrors['description'] }" 20 [ngClass]="{ 'input-error': formErrors['description'] }"
21 ></textarea> 21 ></textarea>
22 <div *ngIf="formErrors.description" class="form-error"> 22 <div *ngIf="formErrors.description" class="form-error">
@@ -24,5 +24,5 @@
24 </div> 24 </div>
25 </div> 25 </div>
26 26
27 <input type="submit" i18n-value value="Update my profile" [disabled]="!form.valid"> 27 <input type="submit" i18n-value value="Save" [disabled]="!form.valid">
28</form> 28</form>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss
index 6aabb60f4..5995bae4a 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss
@@ -1,6 +1,11 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
4.form-group:first-child { 9.form-group:first-child {
5 margin-bottom: 15px; 10 margin-bottom: 15px;
6} 11}
@@ -11,12 +16,6 @@ input[type=text] {
11 display: block; 16 display: block;
12} 17}
13 18
14textarea {
15 @include peertube-textarea(500px, 150px);
16
17 display: block;
18}
19
20input[type=submit] { 19input[type=submit] {
21 @include peertube-button; 20 @include peertube-button;
22 @include orange-button; 21 @include orange-button;
@@ -24,3 +23,9 @@ input[type=submit] {
24 margin-top: 15px; 23 margin-top: 15px;
25} 24}
26 25
26textarea {
27 @include peertube-textarea(500px, 150px);
28 max-width: 100%;
29
30 display: block;
31}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index 9f187b574..f1c466545 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -1,34 +1,89 @@
1<my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info> 1<div class="form-row"> <!-- profile grid -->
2 <div class="form-group col-12 col-lg-4 col-xl-3">
3 <div i18n class="account-title">PROFILE</div>
4 </div>
5
6 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
7 <my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info>
8
9 <div class="user-quota mb-3">
10 <div>
11 <div class="progress" i18n-title title="Total video quota">
12 <div class="progress-bar" role="progressbar" [style]="{ width: userVideoQuotaPercentage + '%' }" [attr.aria-valuenow]="userVideoQuotaUsed" aria-valuemin="0" [attr.aria-valuemax]="userVideoQuota">{{ userVideoQuotaUsed | bytes: 0 }}</div>
13 <span class="ml-auto mr-2">{{ userVideoQuota }}</span>
14 </div>
15 </div>
16
17 <div *ngIf="hasDailyQuota()" class="mt-3">
18 <div class="progress" i18n-title title="Daily video quota">
19 <div class="progress-bar secondary" role="progressbar" [style]="{ width: userVideoQuotaDailyPercentage + '%' }" [attr.aria-valuenow]="userVideoQuotaUsedDaily" aria-valuemin="0" [attr.aria-valuemax]="userVideoQuotaDaily">{{ userVideoQuotaUsedDaily | bytes: 0 }}</div>
20 <span class="ml-auto mr-2">{{ userVideoQuotaDaily }}</span>
21 </div>
22 </div>
23 </div>
24
25 <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile>
26 </div>
27</div>
2 28
3<div class="user-quota"> 29<div class="form-row mt-5"> <!-- video settings grid -->
4 <div> 30 <div class="form-group col-12 col-lg-4 col-xl-3">
5 <span i18n class="user-quota-label">Total video quota:</span> 31 <div class="anchor" id="video-settings"></div> <!-- video settings anchor -->
6 <ng-container i18n>{{ userVideoQuotaUsed | bytes: 0 }} used</ng-container> / {{ userVideoQuota }} 32 <div i18n class="account-title">VIDEO SETTINGS</div>
7 </div> 33 </div>
8 34
9 <div *ngIf="hasDailyQuota()"> 35 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
10 <span i18n class="user-quota-label">Daily video quota:</span> 36 <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
11 <ng-container>{{ userVideoQuotaUsedDaily | bytes: 0 }} used</ng-container> / {{ userVideoQuotaDaily }}
12 </div> 37 </div>
13</div> 38</div>
14 39
15<div i18n class="account-title">Profile</div> 40<div class="form-row mt-5"> <!-- notifications grid -->
16<my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> 41 <div class="form-group col-12 col-lg-4 col-xl-3">
42 <div class="anchor" id="notifications"></div> <!-- notifications anchor -->
43 <div i18n class="account-title">NOTIFICATIONS</div>
44 </div>
17 45
18<div i18n class="account-title">Video settings</div> 46 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
19<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> 47 <my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
48 </div>
49</div>
20 50
21<div i18n class="account-title">Notifications</div> 51<div class="form-row mt-5"> <!-- interface grid -->
22<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences> 52 <div class="form-group col-12 col-lg-4 col-xl-3">
53 <div i18n class="account-title">INTERFACE</div>
54 </div>
23 55
24<div i18n class="account-title">Interface</div> 56 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
25<my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings> 57 <my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings>
58 </div>
59</div>
26 60
27<div i18n class="account-title">Password</div> 61<div class="form-row mt-5"> <!-- password grid -->
28<my-account-change-password></my-account-change-password> 62 <div class="form-group col-12 col-lg-4 col-xl-3">
63 <div i18n class="account-title">PASSWORD</div>
64 </div>
65
66 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
67 <my-account-change-password></my-account-change-password>
68 </div>
69</div>
70
71<div class="form-row mt-5"> <!-- email grid -->
72 <div class="form-group col-12 col-lg-4 col-xl-3">
73 <div i18n class="account-title">EMAIL</div>
74 </div>
29 75
30<div i18n class="account-title">Email</div> 76 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
31<my-account-change-email></my-account-change-email> 77 <my-account-change-email></my-account-change-email>
78 </div>
79</div>
80
81<div class="form-row mt-5"> <!-- danger zone grid -->
82 <div class="form-group col-12 col-lg-4 col-xl-3">
83 <div i18n class="account-title">DANGER ZONE</div>
84 </div>
32 85
33<div i18n class="account-title">Danger zone</div> 86 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
34<my-account-danger-zone [user]="user"></my-account-danger-zone> 87 <my-account-danger-zone [user]="user"></my-account-danger-zone>
88 </div>
89</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
index d0395aca9..3e1792e3e 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
@@ -5,15 +5,23 @@
5 font-size: 15px; 5 font-size: 15px;
6 margin-top: 20px; 6 margin-top: 20px;
7 7
8 .user-quota-label { 8 label {
9 margin-right: 5px; 9 margin-right: 5px;
10 font-weight: $font-semibold;
11 } 10 }
12} 11}
13 12
14.account-title { 13.account-title {
15 @include in-content-small-title; 14 @include settings-big-title;
15}
16
17.progress {
18 @include progressbar;
19 width: 500px;
20 max-width: 100%;
21}
16 22
17 margin-top: 55px; 23@media screen and (max-width: $small-view) {
18 margin-bottom: 30px; 24 .progress {
25 width: 100%;
26 }
19} 27}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
index e314cdbea..5f2db9854 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
@@ -1,26 +1,30 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit, AfterViewChecked } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { BytesPipe } from 'ngx-pipes' 3import { BytesPipe } from 'ngx-pipes'
4import { AuthService } from '../../core' 4import { AuthService } from '../../core'
5import { User } from '../../shared' 5import { User } from '../../shared'
6import { UserService } from '../../shared/users' 6import { UserService } from '../../shared/users'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { ViewportScroller } from '@angular/common'
8 9
9@Component({ 10@Component({
10 selector: 'my-account-settings', 11 selector: 'my-account-settings',
11 templateUrl: './my-account-settings.component.html', 12 templateUrl: './my-account-settings.component.html',
12 styleUrls: [ './my-account-settings.component.scss' ] 13 styleUrls: [ './my-account-settings.component.scss' ]
13}) 14})
14export class MyAccountSettingsComponent implements OnInit { 15export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
15 user: User = null 16 user: User = null
16 17
17 userVideoQuota = '0' 18 userVideoQuota = '0'
18 userVideoQuotaUsed = 0 19 userVideoQuotaUsed = 0
20 userVideoQuotaPercentage = 15
19 21
20 userVideoQuotaDaily = '0' 22 userVideoQuotaDaily = '0'
21 userVideoQuotaUsedDaily = 0 23 userVideoQuotaUsedDaily = 0
24 userVideoQuotaDailyPercentage = 15
22 25
23 constructor ( 26 constructor (
27 private viewportScroller: ViewportScroller,
24 private userService: UserService, 28 private userService: UserService,
25 private authService: AuthService, 29 private authService: AuthService,
26 private notifier: Notifier, 30 private notifier: Notifier,
@@ -38,12 +42,14 @@ export class MyAccountSettingsComponent implements OnInit {
38 () => { 42 () => {
39 if (this.user.videoQuota !== -1) { 43 if (this.user.videoQuota !== -1) {
40 this.userVideoQuota = new BytesPipe().transform(this.user.videoQuota, 0).toString() 44 this.userVideoQuota = new BytesPipe().transform(this.user.videoQuota, 0).toString()
45 this.userVideoQuotaPercentage = this.user.videoQuota * 100 / this.userVideoQuotaUsed
41 } else { 46 } else {
42 this.userVideoQuota = this.i18n('Unlimited') 47 this.userVideoQuota = this.i18n('Unlimited')
43 } 48 }
44 49
45 if (this.user.videoQuotaDaily !== -1) { 50 if (this.user.videoQuotaDaily !== -1) {
46 this.userVideoQuotaDaily = new BytesPipe().transform(this.user.videoQuotaDaily, 0).toString() 51 this.userVideoQuotaDaily = new BytesPipe().transform(this.user.videoQuotaDaily, 0).toString()
52 this.userVideoQuotaDailyPercentage = this.user.videoQuotaDaily * 100 / this.userVideoQuotaUsedDaily
47 } else { 53 } else {
48 this.userVideoQuotaDaily = this.i18n('Unlimited') 54 this.userVideoQuotaDaily = this.i18n('Unlimited')
49 } 55 }
@@ -57,6 +63,10 @@ export class MyAccountSettingsComponent implements OnInit {
57 }) 63 })
58 } 64 }
59 65
66 ngAfterViewChecked () {
67 if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
68 }
69
60 onAvatarChange (formData: FormData) { 70 onAvatarChange (formData: FormData) {
61 this.userService.changeAvatar(formData) 71 this.userService.changeAvatar(formData)
62 .subscribe( 72 .subscribe(
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
index 17d8cde06..0dda33af2 100644
--- 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
@@ -1,5 +1,5 @@
1<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form"> 1<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
2 <div class="form-group"> 2 <div class="form-group form-group-select">
3 <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label> 3 <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
4 <my-help> 4 <my-help>
5 <ng-template ptTemplate="customHtml"> 5 <ng-template ptTemplate="customHtml">
@@ -10,7 +10,8 @@
10 </my-help> 10 </my-help>
11 11
12 <div class="peertube-select-container"> 12 <div class="peertube-select-container">
13 <select id="nsfwPolicy" formControlName="nsfwPolicy"> 13 <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
14 <option i18n value="undefined" disabled>Policy for sensitive videos</option>
14 <option i18n value="do_not_list">Do not list</option> 15 <option i18n value="do_not_list">Do not list</option>
15 <option i18n value="blur">Blur thumbnails</option> 16 <option i18n value="blur">Blur thumbnails</option>
16 <option i18n value="display">Display</option> 17 <option i18n value="display">Display</option>
@@ -18,7 +19,7 @@
18 </div> 19 </div>
19 </div> 20 </div>
20 21
21 <div class="form-group"> 22 <div class="form-group form-group-select">
22 <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label> 23 <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label>
23 <my-help> 24 <my-help>
24 <ng-template ptTemplate="customHtml"> 25 <ng-template ptTemplate="customHtml">
@@ -28,33 +29,47 @@
28 29
29 <div> 30 <div>
30 <p-multiSelect 31 <p-multiSelect
31 inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" showToggleAll="true" 32 inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
32 [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()" 33 [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
33 emptyFilterMessage="No results found" i18n-emptyFilterMessage 34 emptyFilterMessage="No results found" i18n-emptyFilterMessage
34 ></p-multiSelect> 35 ></p-multiSelect>
35 </div> 36 </div>
36 </div> 37 </div>
37 38
39 <ng-content select="inner-title"></ng-content>
40
38 <div class="form-group"> 41 <div class="form-group">
39 <my-peertube-checkbox 42 <my-peertube-checkbox
40 inputName="webTorrentEnabled" formControlName="webTorrentEnabled" 43 inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true"
41 i18n-labelText labelText="Use P2P to exchange parts of the video with others" 44 i18n-labelText labelText="Help share videos being played"
42 ></my-peertube-checkbox> 45 >
46 <ng-container ngProjectAs="description">
47 <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>
48 </ng-container>
49 </my-peertube-checkbox>
43 </div> 50 </div>
44 51
45 <div class="form-group"> 52 <div class="form-group">
46 <my-peertube-checkbox 53 <my-peertube-checkbox
47 inputName="autoPlayVideo" formControlName="autoPlayVideo" 54 inputName="autoPlayVideo" formControlName="autoPlayVideo"
48 i18n-labelText labelText="Automatically plays video" 55 i18n-labelText labelText="Automatically play videos"
49 ></my-peertube-checkbox> 56 >
57 <ng-container ngProjectAs="description">
58 <span i18n>When on a video page, directly start playing the video.</span>
59 </ng-container>
60 </my-peertube-checkbox>
50 </div> 61 </div>
51 62
52 <div class="form-group"> 63 <div class="form-group">
53 <my-peertube-checkbox 64 <my-peertube-checkbox
54 inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo" 65 inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo"
55 i18n-labelText labelText="Automatically starts playing next video" 66 i18n-labelText labelText="Automatically start playing the next video"
56 ></my-peertube-checkbox> 67 >
68 <ng-container ngProjectAs="description">
69 <span i18n>When a video ends, follow up with the next suggested video.</span>
70 </ng-container>
71 </my-peertube-checkbox>
57 </div> 72 </div>
58 73
59 <input type="submit" i18n-value value="Save" [disabled]="!form.valid"> 74 <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid">
60</form> 75</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
index 1881be762..430250b87 100644
--- 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
@@ -1,11 +1,15 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
4input[type=submit] { 9input[type=submit] {
5 @include peertube-button; 10 @include peertube-button;
6 @include orange-button; 11 @include orange-button;
7 12
8 display: block;
9 margin-top: 15px; 13 margin-top: 15px;
10} 14}
11 15
@@ -13,4 +17,8 @@ input[type=submit] {
13 @include peertube-select-container(340px); 17 @include peertube-select-container(340px);
14 18
15 margin-bottom: 30px; 19 margin-bottom: 30px;
16} \ No newline at end of file 20}
21
22.form-group-select {
23 margin-bottom: 30px;
24}
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
index a66159b3f..0aaa54cd7 100644
--- 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
@@ -1,24 +1,31 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit, OnDestroy } from '@angular/core'
2import { Notifier, ServerService } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { UserUpdateMe } from '../../../../../../shared' 3import { UserUpdateMe } from '../../../../../../shared/models/users'
4import { User, UserService } from '@app/shared/users'
4import { AuthService } from '../../../core' 5import { AuthService } from '../../../core'
5import { FormReactive, User, UserService } from '../../../shared' 6import { FormReactive } from '@app/shared/forms/form-reactive'
6import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
8import { forkJoin, Subject } from 'rxjs' 9import { forkJoin, Subject, Subscription } from 'rxjs'
9import { SelectItem } from 'primeng/api' 10import { SelectItem } from 'primeng/api'
10import { first } from 'rxjs/operators' 11import { first } from 'rxjs/operators'
12import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
13import { pick } from 'lodash-es'
11 14
12@Component({ 15@Component({
13 selector: 'my-account-video-settings', 16 selector: 'my-account-video-settings',
14 templateUrl: './my-account-video-settings.component.html', 17 templateUrl: './my-account-video-settings.component.html',
15 styleUrls: [ './my-account-video-settings.component.scss' ] 18 styleUrls: [ './my-account-video-settings.component.scss' ]
16}) 19})
17export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit { 20export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
18 @Input() user: User = null 21 @Input() user: User = null
22 @Input() reactiveUpdate = false
23 @Input() notifyOnUpdate = true
19 @Input() userInformationLoaded: Subject<any> 24 @Input() userInformationLoaded: Subject<any>
20 25
21 languageItems: SelectItem[] = [] 26 languageItems: SelectItem[] = []
27 defaultNSFWPolicy: NSFWPolicyType
28 formValuesWatcher: Subscription
22 29
23 constructor ( 30 constructor (
24 protected formValidatorService: FormValidatorService, 31 protected formValidatorService: FormValidatorService,
@@ -32,6 +39,8 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
32 } 39 }
33 40
34 ngOnInit () { 41 ngOnInit () {
42 let oldForm: any
43
35 this.buildForm({ 44 this.buildForm({
36 nsfwPolicy: null, 45 nsfwPolicy: null,
37 webTorrentEnabled: null, 46 webTorrentEnabled: null,
@@ -42,8 +51,9 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
42 51
43 forkJoin([ 52 forkJoin([
44 this.serverService.getVideoLanguages(), 53 this.serverService.getVideoLanguages(),
54 this.serverService.getConfig(),
45 this.userInformationLoaded.pipe(first()) 55 this.userInformationLoaded.pipe(first())
46 ]).subscribe(([ languages ]) => { 56 ]).subscribe(([ languages, config ]) => {
47 this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ] 57 this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
48 this.languageItems = this.languageItems 58 this.languageItems = this.languageItems
49 .concat(languages.map(l => ({ label: l.label, value: l.id }))) 59 .concat(languages.map(l => ({ label: l.label, value: l.id })))
@@ -52,17 +62,32 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
52 ? this.user.videoLanguages 62 ? this.user.videoLanguages
53 : this.languageItems.map(l => l.value) 63 : this.languageItems.map(l => l.value)
54 64
65 this.defaultNSFWPolicy = config.instance.defaultNSFWPolicy
66
55 this.form.patchValue({ 67 this.form.patchValue({
56 nsfwPolicy: this.user.nsfwPolicy, 68 nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
57 webTorrentEnabled: this.user.webTorrentEnabled, 69 webTorrentEnabled: this.user.webTorrentEnabled,
58 autoPlayVideo: this.user.autoPlayVideo === true, 70 autoPlayVideo: this.user.autoPlayVideo === true,
59 autoPlayNextVideo: this.user.autoPlayNextVideo, 71 autoPlayNextVideo: this.user.autoPlayNextVideo,
60 videoLanguages 72 videoLanguages
61 }) 73 })
74
75 if (this.reactiveUpdate) {
76 oldForm = { ...this.form.value }
77 this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
78 const updatedKey = Object.keys(formValue).find(k => formValue[k] !== oldForm[k])
79 oldForm = { ...this.form.value }
80 this.updateDetails([updatedKey])
81 })
82 }
62 }) 83 })
63 } 84 }
64 85
65 updateDetails () { 86 ngOnDestroy () {
87 this.formValuesWatcher?.unsubscribe()
88 }
89
90 updateDetails (onlyKeys?: string[]) {
66 const nsfwPolicy = this.form.value[ 'nsfwPolicy' ] 91 const nsfwPolicy = this.form.value[ 'nsfwPolicy' ]
67 const webTorrentEnabled = this.form.value['webTorrentEnabled'] 92 const webTorrentEnabled = this.form.value['webTorrentEnabled']
68 const autoPlayVideo = this.form.value['autoPlayVideo'] 93 const autoPlayVideo = this.form.value['autoPlayVideo']
@@ -81,7 +106,7 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
81 } 106 }
82 } 107 }
83 108
84 const details: UserUpdateMe = { 109 let details: UserUpdateMe = {
85 nsfwPolicy, 110 nsfwPolicy,
86 webTorrentEnabled, 111 webTorrentEnabled,
87 autoPlayVideo, 112 autoPlayVideo,
@@ -89,15 +114,22 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI
89 videoLanguages 114 videoLanguages
90 } 115 }
91 116
92 this.userService.updateMyProfile(details).subscribe( 117 if (onlyKeys) details = pick(details, onlyKeys)
93 () => {
94 this.notifier.success(this.i18n('Video settings updated.'))
95 118
96 this.authService.refreshUserInformation() 119 if (this.authService.isLoggedIn()) {
97 }, 120 this.userService.updateMyProfile(details).subscribe(
121 () => {
122 this.authService.refreshUserInformation()
98 123
99 err => this.notifier.error(err.message) 124 if (this.notifyOnUpdate) this.notifier.success(this.i18n('Video settings updated.'))
100 ) 125 },
126
127 err => this.notifier.error(err.message)
128 )
129 } else {
130 this.userService.updateMyAnonymousProfile(details)
131 if (this.notifyOnUpdate) this.notifier.success(this.i18n('Display/Video settings updated.'))
132 }
101 } 133 }
102 134
103 getDefaultVideoLanguageLabel () { 135 getDefaultVideoLanguageLabel () {
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
index 7ac3c910f..ba8d56689 100644
--- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
+++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
@@ -41,4 +41,27 @@
41 } 41 }
42} 42}
43 43
44@media screen and (max-width: $small-view) {
45 .video-channels-header {
46 text-align: center;
47 }
48
49 .video-channel {
50 .video-channel-info {
51 padding-bottom: 10px;
52 text-align: center;
53
54 .video-channel-names {
55 flex-direction: column;
56 align-items: center !important;
57 margin: auto;
58 }
59 }
60
61 img {
62 margin-right: 0;
63 }
64 }
65}
66
44 67
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
index f87df87df..048d143cd 100644
--- 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
@@ -1,72 +1,105 @@
1<my-actor-avatar-info 1<nav aria-label="breadcrumb">
2 *ngIf="isCreation() === false && videoChannelToUpdate" 2 <ol class="breadcrumb">
3 [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" 3 <li class="breadcrumb-item">
4></my-actor-avatar-info> 4 <a routerLink="/my-account/video-channels" i18n>My Channels</a>
5 </li>
5 6
6<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a video channel</div> 7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
7 18
8<div *ngIf="error" class="alert alert-danger">{{ error }}</div> 19<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
9 20
10<form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> 21<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
11 <div class="form-group" *ngIf="isCreation() === true">
12 <label i18n for="name">Name</label>
13 <div class="input-group">
14 <input
15 type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
16 formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }"
17 >
18 <div class="input-group-append">
19 <span class="input-group-text">@{{ instanceHost }}</span>
20 </div>
21 </div>
22 <div *ngIf="formErrors['name']" class="form-error">
23 {{ formErrors['name'] }}
24 </div>
25 </div>
26 22
27 <div class="form-group"> 23 <div class="form-row"> <!-- channel grid -->
28 <label i18n for="display-name">Display name</label> 24 <div class="form-group col-12 col-lg-4 col-xl-3">
29 <input 25 <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
30 type="text" id="display-name" 26 <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
31 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
32 >
33 <div *ngIf="formErrors['display-name']" class="form-error">
34 {{ formErrors['display-name'] }}
35 </div> 27 </div>
36 </div>
37 28
38 <div class="form-group"> 29 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
39 <label i18n for="description">Description</label> 30
40 <textarea 31 <div class="form-group" *ngIf="isCreation()">
41 id="description" formControlName="description" 32 <label i18n for="name">Name</label>
42 [ngClass]="{ 'input-error': formErrors['description'] }" 33 <div class="input-group">
43 ></textarea> 34 <input
44 <div *ngIf="formErrors.description" class="form-error"> 35 type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
45 {{ formErrors.description }} 36 formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
46 </div> 37 >
47 </div> 38 <div class="input-group-append">
39 <span class="input-group-text">@{{ instanceHost }}</span>
40 </div>
41 </div>
42 <div *ngIf="formErrors['name']" class="form-error">
43 {{ formErrors['name'] }}
44 </div>
45 </div>
46
47 <my-actor-avatar-info
48 *ngIf="!isCreation() && videoChannelToUpdate"
49 [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
50 ></my-actor-avatar-info>
51
52 <div class="form-group">
53 <label i18n for="display-name">Display name</label>
54 <input
55 type="text" id="display-name" class="form-control"
56 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
57 >
58 <div *ngIf="formErrors['display-name']" class="form-error">
59 {{ formErrors['display-name'] }}
60 </div>
61 </div>
62
63 <div class="form-group">
64 <label i18n for="description">Description</label>
65 <textarea
66 id="description" formControlName="description" class="form-control"
67 [ngClass]="{ 'input-error': formErrors['description'] }"
68 ></textarea>
69 <div *ngIf="formErrors.description" class="form-error">
70 {{ formErrors.description }}
71 </div>
72 </div>
73
74 <div class="form-group">
75 <label for="support">Support</label>
76 <my-help
77 helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
78 When you will upload a video in this channel, the video support field will be automatically filled by this text."
79 ></my-help>
80 <my-markdown-textarea
81 id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
82 [classes]="{ 'input-error': formErrors['support'] }"
83 ></my-markdown-textarea>
84 <div *ngIf="formErrors.support" class="form-error">
85 {{ formErrors.support }}
86 </div>
87 </div>
88
89 <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
90 <my-peertube-checkbox
91 inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
92 i18n-labelText labelText="Overwrite support field of all videos of this channel"
93 ></my-peertube-checkbox>
94 </div>
48 95
49 <div class="form-group">
50 <label for="support">Support</label>
51 <my-help
52 helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
53When you will upload a video in this channel, the video support field will be automatically filled by this text."
54 ></my-help>
55 <my-markdown-textarea
56 id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced"
57 [classes]="{ 'input-error': formErrors['support'] }"
58 ></my-markdown-textarea>
59 <div *ngIf="formErrors.support" class="form-error">
60 {{ formErrors.support }}
61 </div> 96 </div>
62 </div> 97 </div>
63 98
64 <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()"> 99 <div class="form-row"> <!-- submit placement block -->
65 <my-peertube-checkbox 100 <div class="col-md-7 col-xl-5"></div>
66 inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate" 101 <div class="col-md-5 col-xl-5 d-inline-flex">
67 i18n-labelText labelText="Overwrite support field of all videos of this channel" 102 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
68 ></my-peertube-checkbox> 103 </div>
69 </div> 104 </div>
70
71 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
72</form> 105</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
index d35e0ed64..8f8af655c 100644
--- 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
@@ -1,8 +1,13 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-sub-title { 4label {
5 margin-bottom: 20px; 5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.video-channel-title {
10 @include settings-big-title;
6} 11}
7 12
8my-actor-avatar-info { 13my-actor-avatar-info {
@@ -18,14 +23,22 @@ my-actor-avatar-info {
18 height: 30px; 23 height: 30px;
19} 24}
20 25
21input[type=text] { 26input {
22 @include peertube-input-text(340px); 27 &[type=text] {
28 @include peertube-input-text(340px);
23 29
24 display: block; 30 display: block;
31
32 &#name {
33 width: auto;
34 flex-grow: 1;
35 }
36 }
25 37
26 &#name { 38 &[type=submit] {
27 width: auto; 39 @include peertube-button;
28 flex-grow: 1; 40 @include orange-button;
41 margin-left: auto;
29 } 42 }
30} 43}
31 44
@@ -39,7 +52,16 @@ textarea {
39 @include peertube-select-container(340px); 52 @include peertube-select-container(340px);
40} 53}
41 54
42input[type=submit] { 55.breadcrumb {
43 @include peertube-button; 56 @include breadcrumb;
44 @include orange-button; 57}
58
59@media screen and (max-width: $small-view) {
60 input[type=text]#name {
61 width: auto !important;
62 }
63
64 label[for=name] + div, textarea {
65 width: 100%;
66 }
45} 67}
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
index 7479442d1..355cb4f55 100644
--- 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
@@ -9,7 +9,7 @@ export abstract class MyAccountVideoChannelEdit extends FormReactive {
9 abstract isCreation (): boolean 9 abstract isCreation (): boolean
10 abstract getFormButtonTitle (): string 10 abstract getFormButtonTitle (): string
11 11
12 // FIXME: We need this method so angular does not complain in the child template 12 // We need this method so angular does not complain in child template that doesn't need this
13 onAvatarChange (formData: FormData) { /* empty */ } 13 onAvatarChange (formData: FormData) { /* empty */ }
14 14
15 // Should be implemented by the child 15 // Should be implemented by the child
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
index 000000000..94037e18f
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts
@@ -0,0 +1,41 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
4import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
5import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
6
7const myAccountVideoChannelsRoutes: Routes = [
8 {
9 path: '',
10 component: MyAccountVideoChannelsComponent,
11 data: {
12 meta: {
13 title: 'Account video channels'
14 }
15 }
16 },
17 {
18 path: 'create',
19 component: MyAccountVideoChannelCreateComponent,
20 data: {
21 meta: {
22 title: 'Create new video channel'
23 }
24 }
25 },
26 {
27 path: 'update/:videoChannelId',
28 component: MyAccountVideoChannelUpdateComponent,
29 data: {
30 meta: {
31 title: 'Update video channel'
32 }
33 }
34 }
35]
36
37@NgModule({
38 imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ],
39 exports: [ RouterModule ]
40})
41export 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
index 11e87ba79..03d45227e 100644
--- 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
@@ -1,12 +1,12 @@
1<div class="video-channels-header"> 1<div class="video-channels-header">
2 <a class="create-button" routerLink="create"> 2 <a class="create-button" routerLink="create">
3 <my-global-icon iconName="add"></my-global-icon> 3 <my-global-icon iconName="add"></my-global-icon>
4 <ng-container i18n>Create a new video channel</ng-container> 4 <ng-container i18n>Create video channel</ng-container>
5 </a> 5 </a>
6</div> 6</div>
7 7
8<div class="video-channels"> 8<div class="video-channels">
9 <div *ngFor="let videoChannel of videoChannels" class="video-channel"> 9 <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
10 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]"> 10 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
11 <img [src]="videoChannel.avatarUrl" alt="Avatar" /> 11 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
12 </a> 12 </a>
@@ -17,13 +17,16 @@
17 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> 17 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
18 </a> 18 </a>
19 19
20 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> 20 <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
21
22 <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
23 <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
24 </div>
21 </div> 25 </div>
22 26
23 <div class="video-channel-buttons"> 27 <div class="video-channel-buttons">
24 <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
25
26 <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button> 28 <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
29 <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
27 </div> 30 </div>
28 </div> 31 </div>
29</div> 32</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
index 20582e478..e1acf6cd6 100644
--- 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
@@ -6,13 +6,14 @@
6} 6}
7 7
8::ng-deep .action-button { 8::ng-deep .action-button {
9 &.action-button-delete { 9 &.action-button-edit {
10 margin-right: 10px; 10 margin-right: 10px;
11 } 11 }
12} 12}
13 13
14.video-channel { 14.video-channel {
15 @include row-blocks; 15 @include row-blocks;
16 padding-bottom: 0;
16 17
17 img { 18 img {
18 @include avatar(80px); 19 @include avatar(80px);
@@ -58,15 +59,28 @@
58 margin: 20px 0 50px; 59 margin: 20px 0 50px;
59} 60}
60 61
61@media screen and (max-width: 800px) { 62::ng-deep .chartjs-render-monitor {
63 position: relative;
64 top: 1px;
65}
66
67@media screen and (max-width: $small-view) {
62 .video-channels-header { 68 .video-channels-header {
63 text-align: center; 69 text-align: center;
64 } 70 }
65 71
66 .video-channel { 72 .video-channel {
67 .video-channel-names { 73 padding-bottom: 10px;
68 flex-direction: column; 74
69 align-items: center !important; 75 .video-channel-info {
76 padding-bottom: 10px;
77 text-align: center;
78
79 .video-channel-names {
80 flex-direction: column;
81 align-items: center !important;
82 margin: auto;
83 }
70 } 84 }
71 85
72 img { 86 img {
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
index 3b01b6c9f..75d6d8acd 100644
--- 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
@@ -4,9 +4,12 @@ import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm' 4import { ConfirmService } from '../../core/confirm'
5import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 5import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
6import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 6import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
7import { ScreenService } from '@app/shared/misc/screen.service'
7import { User } from '@app/shared' 8import { User } from '@app/shared'
8import { flatMap } from 'rxjs/operators' 9import { flatMap } from 'rxjs/operators'
9import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { min, minBy, max, maxBy } from 'lodash-es'
12import { ChartData } from 'chart.js'
10 13
11@Component({ 14@Component({
12 selector: 'my-account-video-channels', 15 selector: 'my-account-video-channels',
@@ -15,6 +18,9 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
15}) 18})
16export class MyAccountVideoChannelsComponent implements OnInit { 19export class MyAccountVideoChannelsComponent implements OnInit {
17 videoChannels: VideoChannel[] = [] 20 videoChannels: VideoChannel[] = []
21 videoChannelsChartData: ChartData[]
22 videoChannelsMinimumDailyViews = 0
23 videoChannelsMaximumDailyViews: number
18 24
19 private user: User 25 private user: User
20 26
@@ -23,6 +29,7 @@ export class MyAccountVideoChannelsComponent implements OnInit {
23 private notifier: Notifier, 29 private notifier: Notifier,
24 private confirmService: ConfirmService, 30 private confirmService: ConfirmService,
25 private videoChannelService: VideoChannelService, 31 private videoChannelService: VideoChannelService,
32 private screenService: ScreenService,
26 private i18n: I18n 33 private i18n: I18n
27 ) {} 34 ) {}
28 35
@@ -32,6 +39,59 @@ export class MyAccountVideoChannelsComponent implements OnInit {
32 this.loadVideoChannels() 39 this.loadVideoChannels()
33 } 40 }
34 41
42 get isInSmallView () {
43 return this.screenService.isInSmallView()
44 }
45
46 get chartOptions () {
47 return {
48 legend: {
49 display: false
50 },
51 scales: {
52 xAxes: [{
53 display: false
54 }],
55 yAxes: [{
56 display: false,
57 ticks: {
58 min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
59 max: Math.max(1, this.videoChannelsMaximumDailyViews)
60 }
61 }]
62 },
63 layout: {
64 padding: {
65 left: 15,
66 right: 15,
67 top: 10,
68 bottom: 0
69 }
70 },
71 elements: {
72 point: {
73 radius: 0
74 }
75 },
76 tooltips: {
77 mode: 'index',
78 intersect: false,
79 custom: function (tooltip: any) {
80 if (!tooltip) return
81 // disable displaying the color box
82 tooltip.displayColors = false
83 },
84 callbacks: {
85 label: (tooltip: any, data: any) => `${tooltip.value} views`
86 }
87 },
88 hover: {
89 mode: 'index',
90 intersect: false
91 }
92 }
93 }
94
35 async deleteVideoChannel (videoChannel: VideoChannel) { 95 async deleteVideoChannel (videoChannel: VideoChannel) {
36 const res = await this.confirmService.confirmWithInput( 96 const res = await this.confirmService.confirmWithInput(
37 this.i18n( 97 this.i18n(
@@ -63,7 +123,37 @@ export class MyAccountVideoChannelsComponent implements OnInit {
63 123
64 private loadVideoChannels () { 124 private loadVideoChannels () {
65 this.authService.userInformationLoaded 125 this.authService.userInformationLoaded
66 .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account))) 126 .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
67 .subscribe(res => this.videoChannels = res.data) 127 .subscribe(res => {
128 this.videoChannels = res.data
129
130 // chart data
131 this.videoChannelsChartData = this.videoChannels.map(v => ({
132 labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
133 datasets: [
134 {
135 label: this.i18n('Views for the day'),
136 data: v.viewsPerDay.map(day => day.views),
137 fill: false,
138 borderColor: "#c6c6c6"
139 }
140 ]
141 } as ChartData))
142
143 // chart options that depend on chart data:
144 // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
145 this.videoChannelsMinimumDailyViews = min(
146 this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute
147 v.viewsPerDay,
148 day => day.views
149 ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
150 )
151 this.videoChannelsMaximumDailyViews = max(
152 this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute
153 v.viewsPerDay,
154 day => day.views
155 ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
156 )
157 })
68 } 158 }
69} 159}
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
index 000000000..87d6b762f
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts
@@ -0,0 +1,25 @@
1import { NgModule } from '@angular/core'
2import { ChartModule } from 'primeng/chart'
3import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module'
4import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
5import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
6import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
7import { SharedModule } from '@app/shared'
8
9@NgModule({
10 imports: [
11 MyAccountVideoChannelsRoutingModule,
12 SharedModule,
13 ChartModule
14 ],
15
16 declarations: [
17 MyAccountVideoChannelsComponent,
18 MyAccountVideoChannelCreateComponent,
19 MyAccountVideoChannelUpdateComponent
20 ],
21
22 exports: [],
23 providers: []
24})
25export class MyAccountVideoChannelsModule { }
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html
index 329948cb5..37c6ad6b4 100644
--- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html
+++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html
@@ -1,6 +1,7 @@
1<p-table 1<p-table
2 [value]="videoImports" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" 2 [value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" 3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
4 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
4> 5>
5 <ng-template pTemplate="header"> 6 <ng-template pTemplate="header">
6 <tr> 7 <tr>
@@ -15,8 +16,8 @@
15 16
16 <ng-template pTemplate="body" let-expanded="expanded" let-videoImport> 17 <ng-template pTemplate="body" let-expanded="expanded" let-videoImport>
17 <tr> 18 <tr>
18 <td> 19 <td class="expand-cell">
19 <span *ngIf="videoImport.error" class="expander" [pRowToggler]="videoImport"> 20 <span *ngIf="videoImport.error" class="expander" [pRowToggler]="videoImport" i18n-ngbTooltip ngbTooltip="See the error">
20 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> 21 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
21 </span> 22 </span>
22 </td> 23 </td>
@@ -28,13 +29,14 @@
28 </ng-template> 29 </ng-template>
29 </td> 30 </td>
30 31
31 <td *ngIf="isVideoImportPending(videoImport)"> 32 <td>
32 {{ videoImport.video?.name }} 33 <ng-container *ngIf="isVideoImportPending(videoImport)">{{ videoImport.video?.name }}</ng-container>
33 </td> 34 <ng-container *ngIf="isVideoImportSuccess(videoImport) && videoImport.video">
34 <td *ngIf="isVideoImportSuccess(videoImport) && videoImport.video"> 35 <a [href]="getVideoUrl(videoImport.video)" target="_blank" rel="noopener noreferrer">{{ videoImport.video?.name }}</a>
35 <a [href]="getVideoUrl(videoImport.video)" target="_blank" rel="noopener noreferrer">{{ videoImport.video?.name }}</a> 36 </ng-container>
37 <ng-container *ngIf="isVideoImportSuccess(videoImport) && !videoImport.video" i18n>This video was deleted</ng-container>
38 <ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container>
36 </td> 39 </td>
37 <td *ngIf="isVideoImportFailed(videoImport)"></td>
38 40
39 <td>{{ videoImport.state.label }}</td> 41 <td>{{ videoImport.state.label }}</td>
40 <td>{{ videoImport.createdAt }}</td> 42 <td>{{ videoImport.createdAt }}</td>
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
index 21a10c8ff..4452154eb 100644
--- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
+++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
@@ -1,8 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { RestPagination, RestTable } from '@app/shared' 2import { RestPagination, RestTable } from '@app/shared'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/api'
4import { Notifier } from '@app/core' 4import { Notifier } from '@app/core'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { VideoImport, VideoImportState } from '../../../../../shared/models/videos' 5import { VideoImport, VideoImportState } from '../../../../../shared/models/videos'
7import { VideoImportService } from '@app/shared/video-import' 6import { VideoImportService } from '@app/shared/video-import'
8 7
@@ -14,14 +13,12 @@ import { VideoImportService } from '@app/shared/video-import'
14export class MyAccountVideoImportsComponent extends RestTable implements OnInit { 13export class MyAccountVideoImportsComponent extends RestTable implements OnInit {
15 videoImports: VideoImport[] = [] 14 videoImports: VideoImport[] = []
16 totalRecords = 0 15 totalRecords = 0
17 rowsPerPage = 10
18 sort: SortMeta = { field: 'createdAt', order: 1 } 16 sort: SortMeta = { field: 'createdAt', order: 1 }
19 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 17 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
20 18
21 constructor ( 19 constructor (
22 private notifier: Notifier, 20 private notifier: Notifier,
23 private videoImportService: VideoImportService, 21 private videoImportService: VideoImportService
24 private i18n: I18n
25 ) { 22 ) {
26 super() 23 super()
27 } 24 }
@@ -30,6 +27,10 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit
30 this.initialize() 27 this.initialize()
31 } 28 }
32 29
30 getIdentifier () {
31 return 'MyAccountVideoImportsComponent'
32 }
33
33 isVideoImportSuccess (videoImport: VideoImport) { 34 isVideoImportSuccess (videoImport: VideoImport) {
34 return videoImport.state.id === VideoImportState.SUCCESS 35 return videoImport.state.id === VideoImportState.SUCCESS
35 } 36 }
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
index 82321459f..05335dc1a 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -1,71 +1,102 @@
1<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div> 1<nav aria-label="breadcrumb">
2 <ol class="breadcrumb">
3 <li class="breadcrumb-item">
4 <a routerLink="/my-account/video-playlists" i18n>My Playlists</a>
5 </li>
6
7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="videoPlaylistToUpdate" [routerLink]="[ '/my-account/video-playlists/update', videoPlaylistToUpdate?.uuid ]">{{ videoPlaylistToUpdate?.displayName }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
2 18
3<div *ngIf="error" class="alert alert-danger">{{ error }}</div> 19<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
4 20
5<form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> 21<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
6 <div class="row">
7 <div class="col-md-12 col-xl-6">
8 <div class="form-group">
9 <label i18n for="displayName">Display name</label>
10 <input
11 type="text" id="displayName"
12 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
13 >
14 <div *ngIf="formErrors['displayName']" class="form-error">
15 {{ formErrors['displayName'] }}
16 </div>
17 </div>
18 22
19 <div class="form-group"> 23 <div class="form-row"> <!-- playlist grid -->
20 <label i18n for="description">Description</label> 24 <div class="form-group col-12 col-lg-4 col-xl-3">
21 <textarea 25 <div *ngIf="isCreation()" class="video-playlist-title" i18n>NEW PLAYLIST</div>
22 id="description" formControlName="description" 26 <div *ngIf="!isCreation() && videoPlaylistToUpdate" class="video-playlist-title" i18n>PLAYLIST</div>
23 [ngClass]="{ 'input-error': formErrors['description'] }"
24 ></textarea>
25 <div *ngIf="formErrors.description" class="form-error">
26 {{ formErrors.description }}
27 </div>
28 </div>
29 </div> 27 </div>
30 28
31 <div class="col-md-12 col-xl-6"> 29 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
32 <div class="form-group"> 30
33 <label i18n for="privacy">Privacy</label> 31 <div class="col-md-12 col-xl-6">
34 <div class="peertube-select-container"> 32 <div class="form-group">
35 <select id="privacy" formControlName="privacy"> 33 <label i18n for="displayName">Display name</label>
36 <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option> 34 <input
37 </select> 35 type="text" id="displayName"
36 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
37 >
38 <div *ngIf="formErrors['displayName']" class="form-error">
39 {{ formErrors['displayName'] }}
40 </div>
38 </div> 41 </div>
39 42
40 <div *ngIf="formErrors.privacy" class="form-error"> 43 <div class="form-group">
41 {{ formErrors.privacy }} 44 <label i18n for="description">Description</label>
45 <textarea
46 id="description" formControlName="description"
47 [ngClass]="{ 'input-error': formErrors['description'] }"
48 ></textarea>
49 <div *ngIf="formErrors.description" class="form-error">
50 {{ formErrors.description }}
51 </div>
42 </div> 52 </div>
43 </div> 53 </div>
44 54
45 <div class="form-group"> 55 <div class="col-md-12 col-xl-6">
46 <label i18n>Channel</label> 56 <div class="form-group">
47 <div class="peertube-select-container"> 57 <label i18n for="privacy">Privacy</label>
48 <select formControlName="videoChannelId"> 58 <div class="peertube-select-container">
49 <option></option> 59 <select id="privacy" formControlName="privacy">
50 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> 60 <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
51 </select> 61 </select>
62 </div>
63
64 <div *ngIf="formErrors.privacy" class="form-error">
65 {{ formErrors.privacy }}
66 </div>
52 </div> 67 </div>
53 68
54 <div *ngIf="formErrors['videoChannelId']" class="form-error"> 69 <div class="form-group">
55 {{ formErrors['videoChannelId'] }} 70 <label i18n>Channel</label>
71 <div class="peertube-select-container">
72 <select formControlName="videoChannelId">
73 <option></option>
74 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
75 </select>
76 </div>
77
78 <div *ngIf="formErrors['videoChannelId']" class="form-error">
79 {{ formErrors['videoChannelId'] }}
80 </div>
56 </div> 81 </div>
57 </div>
58 82
59 <div class="form-group"> 83 <div class="form-group">
60 <label i18n>Playlist thumbnail</label> 84 <label i18n>Playlist thumbnail</label>
85
86 <my-preview-upload
87 i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
88 previewWidth="223px" previewHeight="122px"
89 ></my-preview-upload>
90 </div>
91 </div>
61 92
62 <my-preview-upload 93 <div class="form-row"> <!-- submit placement block -->
63 i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile" 94 <div class="col-md-7 col-xl-5"></div>
64 previewWidth="223px" previewHeight="122px" 95 <div class="col-md-5 col-xl-5 d-inline-flex">
65 ></my-preview-upload> 96 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
97 </div>
66 </div> 98 </div>
67 </div> 99 </div>
68 </div> 100 </div>
69 101
70 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
71</form> 102</form>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
index 5af846d8e..08fab1101 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
@@ -1,8 +1,13 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.form-sub-title { 4label {
5 margin-bottom: 20px; 5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.video-playlist-title {
10 @include settings-big-title;
6} 11}
7 12
8input[type=text] { 13input[type=text] {
@@ -25,3 +30,7 @@ input[type=submit] {
25 @include peertube-button; 30 @include peertube-button;
26 @include orange-button; 31 @include orange-button;
27} 32}
33
34.breadcrumb {
35 @include breadcrumb;
36}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
index 9657ac11d..a4ca0f45d 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
@@ -11,6 +11,11 @@
11 11
12 display: flex; 12 display: flex;
13 justify-content: center; 13 justify-content: center;
14
15 /* fix ellipsis dots background color */
16 ::ng-deep .miniature-name::after {
17 background-color: var(--submenuColor) !important;
18 }
14} 19}
15 20
16// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples 21// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples
@@ -37,3 +42,9 @@
37.videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) { 42.videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) {
38 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 43 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
39} 44}
45
46@media screen and (max-width: $small-view) {
47 .playlist-info {
48 margin-top: -$sub-menu-margin-bottom-small-view;
49 }
50}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
index dd6a0e55b..86844ce01 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
@@ -5,7 +5,7 @@
5 5
6 <a class="create-button" routerLink="create"> 6 <a class="create-button" routerLink="create">
7 <my-global-icon iconName="add"></my-global-icon> 7 <my-global-icon iconName="add"></my-global-icon>
8 <ng-container i18n>Create a new playlist</ng-container> 8 <ng-container i18n>Create playlist</ng-container>
9 </a> 9 </a>
10</div> 10</div>
11 11
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
index 4e4156b22..4381d74b0 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
@@ -43,7 +43,7 @@
43 } 43 }
44} 44}
45 45
46@media screen and (max-width: 800px) { 46@media screen and (max-width: $small-view) {
47 .video-playlists-header { 47 .video-playlists-header {
48 text-align: center; 48 text-align: center;
49 } 49 }
@@ -67,3 +67,22 @@
67 } 67 }
68 } 68 }
69} 69}
70
71@media only screen and (min-width: $mobile-view) and (max-width: $small-view) {
72 .video-playlists-header {
73 input[type=text] {
74 width: 42% !important;
75 }
76 }
77}
78
79@media screen and (max-width: $mobile-view) {
80 .video-playlists-header {
81 flex-direction: column;
82
83 input[type=text] {
84 width: 100% !important;
85 margin-bottom: 12px;
86 }
87 }
88}
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
index 8248cc94f..40bae7668 100644
--- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
+++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
@@ -54,3 +54,47 @@ my-delete-button,
54my-edit-button { 54my-edit-button {
55 margin-right: 10px; 55 margin-right: 10px;
56} 56}
57
58@media screen and (max-width: $small-view) {
59 .videos-header {
60 flex-direction: column;
61 }
62
63 ::ng-deep {
64 .video-miniature {
65 align-items: center;
66
67 .video-bottom,
68 .video-bottom .video-miniature-information {
69 /* same width than a.video-thumbnail */
70 max-width: 223px !important;
71 }
72 }
73 }
74
75 my-delete-button,
76 my-edit-button {
77 margin-right: 0px;
78
79 ::ng-deep {
80 span, a {
81 margin-right: 0px;
82 }
83 }
84 }
85
86 my-delete-button,
87 my-edit-button,
88 my-button {
89 margin-top: 15px;
90 width: 100%;
91 text-align: center;
92
93 ::ng-deep {
94 .action-button {
95 /* same width than a.video-thumbnail */
96 width: 223px;
97 }
98 }
99 }
100}
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
index 22f127904..9d809d2bf 100644
--- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
+++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html
@@ -18,9 +18,10 @@
18 18
19 <div class="modal-footer inputs"> 19 <div class="modal-footer inputs">
20 <div class="form-group inputs"> 20 <div class="form-group inputs">
21 <span i18n class="action-button action-button-cancel" (click)="dismiss()"> 21 <input
22 Cancel 22 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
23 </span> 23 (click)="dismiss()" (key.enter)="dismiss()"
24 >
24 25
25 <input 26 <input
26 type="submit" i18n-value value="Submit" class="action-button-submit" 27 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
index 36d1ea091..f4e2b5955 100644
--- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
+++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts
@@ -43,7 +43,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni
43 show (video: Video) { 43 show (video: Video) {
44 this.video = video 44 this.video = video
45 this.modalService 45 this.modalService
46 .open(this.modal) 46 .open(this.modal, { centered: true })
47 .result 47 .result
48 .then(() => this.changeOwnership()) 48 .then(() => this.changeOwnership())
49 .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing 49 .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing
diff --git a/client/src/app/+my-account/my-account.component.html b/client/src/app/+my-account/my-account.component.html
index 3999252be..d885eb243 100644
--- a/client/src/app/+my-account/my-account.component.html
+++ b/client/src/app/+my-account/my-account.component.html
@@ -1,7 +1,7 @@
1<div class="row"> 1<div class="row">
2 <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> 2 <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
3 3
4 <div class="margin-content"> 4 <div class="margin-content pb-5">
5 <router-outlet></router-outlet> 5 <router-outlet></router-outlet>
6 </div> 6 </div>
7</div> 7</div>
diff --git a/client/src/app/+my-account/my-account.component.scss b/client/src/app/+my-account/my-account.component.scss
index 4f111efdf..fd47aec86 100644
--- a/client/src/app/+my-account/my-account.component.scss
+++ b/client/src/app/+my-account/my-account.component.scss
@@ -1,3 +1,8 @@
1.row { 1.row {
2 flex-direction: column; 2 flex-direction: column;
3 width: 100%;
4
5 & > my-top-menu-dropdown:nth-child(1) {
6 flex-grow: 1;
7 }
3} 8}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 6cf1499d3..72b9fd9f2 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -1,11 +1,10 @@
1import { TableModule } from 'primeng/table'
2import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { TableModule } from 'primeng/table'
3import { AutoCompleteModule } from 'primeng/autocomplete' 3import { AutoCompleteModule } from 'primeng/autocomplete'
4import { InputSwitchModule } from 'primeng/inputswitch' 4import { InputSwitchModule } from 'primeng/inputswitch'
5import { SharedModule } from '../shared' 5import { SharedModule } from '../shared'
6import { MyAccountRoutingModule } from './my-account-routing.module' 6import { MyAccountRoutingModule } from './my-account-routing.module'
7import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' 7import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
8import { MyAccountVideoSettingsComponent } from './my-account-settings/my-account-video-settings/my-account-video-settings.component'
9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 8import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
10import { MyAccountComponent } from './my-account.component' 9import { MyAccountComponent } from './my-account.component'
11import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' 10import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
@@ -13,10 +12,6 @@ import { VideoChangeOwnershipComponent } from './my-account-videos/video-change-
13import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component' 12import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
14import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component' 13import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component'
15import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component' 14import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component'
16import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component'
17import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component'
18import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component'
19import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
20import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' 15import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
21import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone' 16import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone'
22import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' 17import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
@@ -37,7 +32,6 @@ import {
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' 32} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
38import { DragDropModule } from '@angular/cdk/drag-drop' 33import { DragDropModule } from '@angular/cdk/drag-drop'
39import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' 34import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
40import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
41 35
42@NgModule({ 36@NgModule({
43 imports: [ 37 imports: [
@@ -54,20 +48,14 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account
54 MyAccountComponent, 48 MyAccountComponent,
55 MyAccountSettingsComponent, 49 MyAccountSettingsComponent,
56 MyAccountChangePasswordComponent, 50 MyAccountChangePasswordComponent,
57 MyAccountVideoSettingsComponent,
58 MyAccountProfileComponent, 51 MyAccountProfileComponent,
59 MyAccountChangeEmailComponent, 52 MyAccountChangeEmailComponent,
60 MyAccountInterfaceSettingsComponent,
61 53
62 MyAccountVideosComponent, 54 MyAccountVideosComponent,
63 55
64 VideoChangeOwnershipComponent, 56 VideoChangeOwnershipComponent,
65 MyAccountOwnershipComponent, 57 MyAccountOwnershipComponent,
66 MyAccountAcceptOwnershipComponent, 58 MyAccountAcceptOwnershipComponent,
67 MyAccountVideoChannelsComponent,
68 MyAccountVideoChannelCreateComponent,
69 MyAccountVideoChannelUpdateComponent,
70 ActorAvatarInfoComponent,
71 MyAccountVideoImportsComponent, 59 MyAccountVideoImportsComponent,
72 MyAccountDangerZoneComponent, 60 MyAccountDangerZoneComponent,
73 MyAccountSubscriptionsComponent, 61 MyAccountSubscriptionsComponent,
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
index 8bdff2f5a..82f5123de 100644
--- a/client/src/app/+my-account/shared/actor-avatar-info.component.html
+++ b/client/src/app/+my-account/shared/actor-avatar-info.component.html
@@ -1,6 +1,16 @@
1<ng-container *ngIf="actor"> 1<ng-container *ngIf="actor">
2 <div class="actor"> 2 <div class="actor">
3 <img [src]="actor.avatarUrl" alt="Avatar" /> 3 <div class="d-flex">
4 <img [src]="actor.avatarUrl" alt="Avatar" />
5
6 <div class="actor-img-edit-container">
7 <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
8 <my-global-icon iconName="edit"></my-global-icon>
9 <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
10 </div>
11 </div>
12 </div>
13
4 14
5 <div class="actor-info"> 15 <div class="actor-info">
6 <div class="actor-info-names"> 16 <div class="actor-info-names">
@@ -10,10 +20,4 @@
10 <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> 20 <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
11 </div> 21 </div>
12 </div> 22 </div>
13
14 <div class="button-file">
15 <span i18n>Change the avatar</span>
16 <input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()" />
17 </div>
18 <div i18n class="file-max-size">(extensions: {{ avatarExtensions }}, max size: {{ maxAvatarSize | bytes }})</div>
19</ng-container> \ No newline at end of file 23</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
index 86f8108b9..5a66ecfd2 100644
--- a/client/src/app/+my-account/shared/actor-avatar-info.component.scss
+++ b/client/src/app/+my-account/shared/actor-avatar-info.component.scss
@@ -5,12 +5,42 @@
5 display: flex; 5 display: flex;
6 6
7 img { 7 img {
8 @include avatar(50px); 8 @include avatar(100px);
9 9
10 margin-right: 15px; 10 margin-right: 15px;
11 } 11 }
12 12
13 .actor-img-edit-container {
14 position: relative;
15 width: 0;
16
17 .actor-img-edit-button {
18 @include peertube-button-file(21px);
19 @include button-with-icon(19px);
20
21 margin-top: 10px;
22 margin-bottom: 5px;
23 border-radius: 50%;
24 top: 55px;
25 right: 45px;
26 cursor: pointer;
27
28 input {
29 width: 30px;
30 height: 30px;
31 }
32
33 my-global-icon {
34 right: 7px;
35 }
36 }
37 }
38
13 .actor-info { 39 .actor-info {
40 justify-content: center;
41 display: inline-flex;
42 flex-direction: column;
43
14 .actor-info-names { 44 .actor-info-names {
15 display: flex; 45 display: flex;
16 align-items: center; 46 align-items: center;
@@ -35,21 +65,7 @@
35 65
36 .actor-info-followers { 66 .actor-info-followers {
37 font-size: 15px; 67 font-size: 15px;
68 padding-bottom: .5rem;
38 } 69 }
39 } 70 }
40} 71}
41
42.button-file {
43 @include peertube-button-file(160px);
44
45 margin-top: 10px;
46 margin-bottom: 5px;
47}
48
49.file-max-size {
50 display: inline-block;
51 font-size: 13px;
52
53 position: relative;
54 top: -10px;
55}
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
index 101dfa556..8e4a7a602 100644
--- a/client/src/app/+my-account/shared/actor-avatar-info.component.ts
+++ b/client/src/app/+my-account/shared/actor-avatar-info.component.ts
@@ -4,6 +4,8 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
4import { Account } from '@app/shared/account/account.model' 4import { Account } from '@app/shared/account/account.model'
5import { Notifier } from '@app/core' 5import { Notifier } from '@app/core'
6import { ServerConfig } from '@shared/models' 6import { ServerConfig } from '@shared/models'
7import { BytesPipe } from 'ngx-pipes'
8import { I18n } from '@ngx-translate/i18n-polyfill'
7 9
8@Component({ 10@Component({
9 selector: 'my-actor-avatar-info', 11 selector: 'my-actor-avatar-info',
@@ -11,18 +13,25 @@ import { ServerConfig } from '@shared/models'
11 styleUrls: [ './actor-avatar-info.component.scss' ] 13 styleUrls: [ './actor-avatar-info.component.scss' ]
12}) 14})
13export class ActorAvatarInfoComponent implements OnInit { 15export class ActorAvatarInfoComponent implements OnInit {
14 @ViewChild('avatarfileInput', { static: false }) avatarfileInput: ElementRef<HTMLInputElement> 16 @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
15 17
16 @Input() actor: VideoChannel | Account 18 @Input() actor: VideoChannel | Account
17 19
18 @Output() avatarChange = new EventEmitter<FormData>() 20 @Output() avatarChange = new EventEmitter<FormData>()
19 21
22 maxSizeText: string
23
20 private serverConfig: ServerConfig 24 private serverConfig: ServerConfig
25 private bytesPipe: BytesPipe
21 26
22 constructor ( 27 constructor (
23 private serverService: ServerService, 28 private serverService: ServerService,
24 private notifier: Notifier 29 private notifier: Notifier,
25 ) {} 30 private i18n: I18n
31 ) {
32 this.bytesPipe = new BytesPipe()
33 this.maxSizeText = this.i18n('max size')
34 }
26 35
27 ngOnInit (): void { 36 ngOnInit (): void {
28 this.serverConfig = this.serverService.getTmpConfig() 37 this.serverConfig = this.serverService.getTmpConfig()
@@ -47,7 +56,11 @@ export class ActorAvatarInfoComponent implements OnInit {
47 return this.serverConfig.avatar.file.size.max 56 return this.serverConfig.avatar.file.size.max
48 } 57 }
49 58
59 get maxAvatarSizeInBytes () {
60 return this.bytesPipe.transform(this.maxAvatarSize)
61 }
62
50 get avatarExtensions () { 63 get avatarExtensions () {
51 return this.serverConfig.avatar.file.extensions.join(',') 64 return this.serverConfig.avatar.file.extensions.join(', ')
52 } 65 }
53} 66}
diff --git a/client/src/app/+signup/+register/register-step-channel.component.html b/client/src/app/+signup/+register/register-step-channel.component.html
index 88ff6e3ff..170c2964e 100644
--- a/client/src/app/+signup/+register/register-step-channel.component.html
+++ b/client/src/app/+signup/+register/register-step-channel.component.html
@@ -40,7 +40,7 @@
40 </div> 40 </div>
41 41
42 <div class="name-information" i18n> 42 <div class="name-information" i18n>
43 The channel name is a unique identifier of your channel on this instance. It's like an address mail, so other people can find your channel. 43 The channel name is a unique identifier of your channel on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it.
44 </div> 44 </div>
45 45
46 <div *ngIf="formErrors.name" class="form-error"> 46 <div *ngIf="formErrors.name" class="form-error">
diff --git a/client/src/app/+signup/+register/register-step-user.component.html b/client/src/app/+signup/+register/register-step-user.component.html
index a2a657660..1bd378b13 100644
--- a/client/src/app/+signup/+register/register-step-user.component.html
+++ b/client/src/app/+signup/+register/register-step-user.component.html
@@ -21,7 +21,7 @@
21 <div class="input-group"> 21 <div class="input-group">
22 <input 22 <input
23 type="text" id="username" i18n-placeholder placeholder="Example: jane_doe" 23 type="text" id="username" i18n-placeholder placeholder="Example: jane_doe"
24 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" 24 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }"
25 > 25 >
26 <div class="input-group-append"> 26 <div class="input-group-append">
27 <span class="input-group-text">@{{ instanceHost }}</span> 27 <span class="input-group-text">@{{ instanceHost }}</span>
@@ -29,7 +29,7 @@
29 </div> 29 </div>
30 30
31 <div class="name-information" i18n> 31 <div class="name-information" i18n>
32 The username is a unique identifier of your account on this instance. It's like an address mail, so other people can find you. 32 The username is a unique identifier of your account on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it.
33 </div> 33 </div>
34 34
35 <div *ngIf="formErrors.username" class="form-error"> 35 <div *ngIf="formErrors.username" class="form-error">
@@ -41,7 +41,7 @@
41 <label for="email" i18n>Email</label> 41 <label for="email" i18n>Email</label>
42 <input 42 <input
43 type="text" id="email" i18n-placeholder placeholder="Email" 43 type="text" id="email" i18n-placeholder placeholder="Email"
44 formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }" 44 formControlName="email" class="form-control" [ngClass]="{ 'input-error': formErrors['email'] }"
45 > 45 >
46 <div *ngIf="formErrors.email" class="form-error"> 46 <div *ngIf="formErrors.email" class="form-error">
47 {{ formErrors.email }} 47 {{ formErrors.email }}
@@ -52,7 +52,7 @@
52 <label for="password" i18n>Password</label> 52 <label for="password" i18n>Password</label>
53 <input 53 <input
54 type="password" id="password" i18n-placeholder placeholder="Password" autocomplete="new-password" 54 type="password" id="password" i18n-placeholder placeholder="Password" autocomplete="new-password"
55 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 55 formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }"
56 > 56 >
57 <div *ngIf="formErrors.password" class="form-error"> 57 <div *ngIf="formErrors.password" class="form-error">
58 {{ formErrors.password }} 58 {{ formErrors.password }}
diff --git a/client/src/app/+signup/+register/register.component.scss b/client/src/app/+signup/+register/register.component.scss
index 2f62dd59d..e135b5cb4 100644
--- a/client/src/app/+signup/+register/register.component.scss
+++ b/client/src/app/+signup/+register/register.component.scss
@@ -44,7 +44,7 @@
44 } 44 }
45 } 45 }
46 46
47 @media screen and (max-width: 500px) { 47 @media screen and (max-width: $mobile-view) {
48 width: auto; 48 width: auto;
49 } 49 }
50 } 50 }
diff --git a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
index 2e4180632..ece9d1022 100644
--- a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
@@ -8,7 +8,7 @@
8 <label i18n for="verify-email-email">Email</label> 8 <label i18n for="verify-email-email">Email</label>
9 <input 9 <input
10 type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required 10 type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required
11 formControlName="verify-email-email" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }" 11 formControlName="verify-email-email" class="form-control" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }"
12 > 12 >
13 <div *ngIf="formErrors['verify-email-email']" class="form-error"> 13 <div *ngIf="formErrors['verify-email-email']" class="form-error">
14 {{ formErrors['verify-email-email'] }} 14 {{ formErrors['verify-email-email'] }}
diff --git a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html
index 9655668d7..c02213ebb 100644
--- a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html
+++ b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html
@@ -1,19 +1,19 @@
1<div *ngIf="videoChannel" class="row"> 1<div *ngIf="videoChannel" class="row">
2 <div class="description col-md-6 col-sm-12"> 2 <div class="description col-md-6 col-sm-12">
3 <div class="block"> 3 <div class="block">
4 <div i18n class="small-title">Description</div> 4 <div i18n class="small-title">DESCRIPTION</div>
5 <div class="content" [innerHtml]="getVideoChannelDescription()"></div> 5 <div class="content" [innerHtml]="getVideoChannelDescription()"></div>
6 </div> 6 </div>
7 7
8 <div class="block" *ngIf="supportHTML"> 8 <div class="block" *ngIf="supportHTML">
9 <div i18n class="small-title">Support this channel</div> 9 <div i18n class="small-title">SUPPORT THIS CHANNEL</div>
10 <div class="content" [innerHtml]="supportHTML"></div> 10 <div class="content" [innerHtml]="supportHTML"></div>
11 </div> 11 </div>
12 </div> 12 </div>
13 13
14 <div class="stats col-md-6 col-sm-12"> 14 <div class="stats col-md-6 col-sm-12">
15 <div class="block"> 15 <div class="block">
16 <div i18n class="small-title">Stats</div> 16 <div i18n class="small-title">STATS</div>
17 <div i18n class="content">Created {{ videoChannel.createdAt | date }}</div> 17 <div i18n class="content">Created {{ videoChannel.createdAt | date }}</div>
18 </div> 18 </div>
19 </div> 19 </div>
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
index f4fe14662..9eaa3ba32 100644
--- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
+++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
@@ -12,6 +12,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
12import { Subscription } from 'rxjs' 12import { Subscription } from 'rxjs'
13import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
14import { Notifier, ServerService } from '@app/core' 14import { Notifier, ServerService } from '@app/core'
15import { UserService } from '@app/shared'
16import { LocalStorageService } from '@app/shared/misc/storage.service'
15 17
16@Component({ 18@Component({
17 selector: 'my-video-channel-videos', 19 selector: 'my-video-channel-videos',
@@ -34,9 +36,11 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
34 protected serverService: ServerService, 36 protected serverService: ServerService,
35 protected route: ActivatedRoute, 37 protected route: ActivatedRoute,
36 protected authService: AuthService, 38 protected authService: AuthService,
39 protected userService: UserService,
37 protected notifier: Notifier, 40 protected notifier: Notifier,
38 protected confirmService: ConfirmService, 41 protected confirmService: ConfirmService,
39 protected screenService: ScreenService, 42 protected screenService: ScreenService,
43 protected storageService: LocalStorageService,
40 private videoChannelService: VideoChannelService, 44 private videoChannelService: VideoChannelService,
41 private videoService: VideoService 45 private videoService: VideoService
42 ) { 46 ) {
@@ -72,7 +76,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
72 .getVideoChannelVideos(this.videoChannel, newPagination, this.sort) 76 .getVideoChannelVideos(this.videoChannel, newPagination, this.sort)
73 .pipe( 77 .pipe(
74 tap(({ total }) => { 78 tap(({ total }) => {
75 this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published ${total} videos}}`, { total }) 79 this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published {{total}} videos}}`, { total })
76 }) 80 })
77 ) 81 )
78 } 82 }
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html
index f0bb083ca..43b5cd92e 100644
--- a/client/src/app/+video-channels/video-channels.component.html
+++ b/client/src/app/+video-channels/video-channels.component.html
@@ -7,32 +7,40 @@
7 <div class="actor-info"> 7 <div class="actor-info">
8 <div class="actor-names"> 8 <div class="actor-names">
9 <div class="actor-display-name">{{ videoChannel.displayName }}</div> 9 <div class="actor-display-name">{{ videoChannel.displayName }}</div>
10 <div class="actor-name">{{ videoChannel.nameWithHost }} 10 <div class="actor-name">
11 <button ngxClipboard [cbContent]="videoChannel.nameWithHost" (click)="activateCopiedMessage()" 11 <span>{{ videoChannel.nameWithHost }}</span>
12 <button [cdkCopyToClipboard]="videoChannel.nameWithHostForced" (click)="activateCopiedMessage()"
12 class="btn btn-outline-secondary btn-sm copy-button" 13 class="btn btn-outline-secondary btn-sm copy-button"
13 > 14 >
14 <span class="glyphicon glyphicon-copy"></span> 15 <span class="glyphicon glyphicon-copy"></span>
15 </button> 16 </button>
16 </div> 17 </div>
18 </div>
17 19
18 <div class="right-buttons"> 20 <div class="right-buttons">
19 <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a> 21 <a *ngIf="isChannelManageable && !isInSmallView" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>
20 <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button> 22 Manage channel
21 </div> 23 </a>
24 <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
22 </div> 25 </div>
23 <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
24 26
25 <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner"> 27 <div class="actor-lower">
26 <span i18n>Created by {{ videoChannel.ownerBy }}</span> 28 <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
27 <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" /> 29
28 </a> 30 <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
31 <span class="d-inline-flex"><span i18n class="d-none d-sm-block mr-1">Created by</span>{{ videoChannel.ownerBy }}</span>
32 <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
33 </a>
34 </div>
29 </div> 35 </div>
30 </div> 36 </div>
31 37
32 <div class="links"> 38 <div class="links w-100">
33 <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> 39 <ng-template #linkTemplate let-item="item">
34 <a i18n routerLink="video-playlists" routerLinkActive="active" class="title-page">Video playlists</a> 40 <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
35 <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> 41 </ng-template>
42
43 <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
36 </div> 44 </div>
37 </div> 45 </div>
38 46
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss
index 50b69e7ac..0a49f53cf 100644
--- a/client/src/app/+video-channels/video-channels.component.scss
+++ b/client/src/app/+video-channels/video-channels.component.scss
@@ -1,3 +1,9 @@
1// Bootstrap grid utilities require functions, variables and mixins
2@import 'node_modules/bootstrap/scss/functions';
3@import 'node_modules/bootstrap/scss/variables';
4@import 'node_modules/bootstrap/scss/mixins';
5@import 'node_modules/bootstrap/scss/grid';
6
1@import '_variables'; 7@import '_variables';
2@import '_mixins'; 8@import '_mixins';
3 9
@@ -8,6 +14,21 @@
8 width: 100%; 14 width: 100%;
9 } 15 }
10 16
17 .actor-info {
18 display: grid !important;
19 grid-template-columns: 1fr auto;
20 grid-template-rows: 1fr auto / 1fr auto;
21 grid-template-areas: "name buttons" "lower buttons";
22
23 @include media-breakpoint-down(lg) {
24 grid-template-areas: "name name" "lower buttons";
25 }
26 }
27
28 .actor-names {
29 grid-area: name;
30 }
31
11 .actor-name { 32 .actor-name {
12 flex-grow: 1; 33 flex-grow: 1;
13 34
@@ -23,7 +44,19 @@
23 display: flex; 44 display: flex;
24 height: max-content; 45 height: max-content;
25 margin-left: auto; 46 margin-left: auto;
26 margin-top: 20px; 47 margin-top: 10px;
48
49 grid-row: buttons-start / span buttons-end;
50 grid-column: buttons-start;
51
52 @include media-breakpoint-down(lg) {
53 flex-flow: column-reverse;
54
55 a {
56 margin-top: 0.25rem;
57 margin-right: 0 !important;
58 }
59 }
27 60
28 a { 61 a {
29 @include peertube-button-outline; 62 @include peertube-button-outline;
@@ -33,4 +66,17 @@
33 my-subscribe-button { 66 my-subscribe-button {
34 height: min-content; 67 height: min-content;
35 } 68 }
36} \ No newline at end of file 69}
70
71@media screen and (max-width: $mobile-view) {
72 .sub-menu {
73 .actor {
74 flex-direction: column;
75
76 .actor-info .actor-names {
77 flex-direction: column;
78 align-items: normal;
79 }
80 }
81 }
82}
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts
index 7b335b13f..a3563c747 100644
--- a/client/src/app/+video-channels/video-channels.component.ts
+++ b/client/src/app/+video-channels/video-channels.component.ts
@@ -9,16 +9,19 @@ import { AuthService, Notifier } from '@app/core'
9import { Hotkey, HotkeysService } from 'angular2-hotkeys' 9import { Hotkey, HotkeysService } from 'angular2-hotkeys'
10import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' 10import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
13import { ScreenService } from '@app/shared/misc/screen.service'
12 14
13@Component({ 15@Component({
14 templateUrl: './video-channels.component.html', 16 templateUrl: './video-channels.component.html',
15 styleUrls: [ './video-channels.component.scss' ] 17 styleUrls: [ './video-channels.component.scss' ]
16}) 18})
17export class VideoChannelsComponent implements OnInit, OnDestroy { 19export class VideoChannelsComponent implements OnInit, OnDestroy {
18 @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent 20 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
19 21
20 videoChannel: VideoChannel 22 videoChannel: VideoChannel
21 hotkeys: Hotkey[] 23 hotkeys: Hotkey[]
24 links: ListOverflowItem[] = []
22 isChannelManageable = false 25 isChannelManageable = false
23 26
24 private routeSub: Subscription 27 private routeSub: Subscription
@@ -30,7 +33,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
30 private authService: AuthService, 33 private authService: AuthService,
31 private videoChannelService: VideoChannelService, 34 private videoChannelService: VideoChannelService,
32 private restExtractor: RestExtractor, 35 private restExtractor: RestExtractor,
33 private hotkeysService: HotkeysService 36 private hotkeysService: HotkeysService,
37 private screenService: ScreenService
34 ) { } 38 ) { }
35 39
36 ngOnInit () { 40 ngOnInit () {
@@ -62,6 +66,12 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
62 }, undefined, this.i18n('Subscribe to the account')) 66 }, undefined, this.i18n('Subscribe to the account'))
63 ] 67 ]
64 if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys) 68 if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys)
69
70 this.links = [
71 { label: this.i18n('VIDEOS'), routerLink: 'videos' },
72 { label: this.i18n('VIDEO PLAYLISTS'), routerLink: 'video-playlists' },
73 { label: this.i18n('ABOUT'), routerLink: 'about' }
74 ]
65 } 75 }
66 76
67 ngOnDestroy () { 77 ngOnDestroy () {
@@ -71,6 +81,10 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
71 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) 81 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
72 } 82 }
73 83
84 get isInSmallView () {
85 return this.screenService.isInSmallView()
86 }
87
74 isUserLoggedIn () { 88 isUserLoggedIn () {
75 return this.authService.isLoggedIn() 89 return this.authService.isLoggedIn()
76 } 90 }
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index b5a677d15..a87f4ce1b 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -4,10 +4,13 @@ import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
4import { PreloadSelectedModulesList } from './core' 4import { PreloadSelectedModulesList } from './core'
5import { AppComponent } from '@app/app.component' 5import { AppComponent } from '@app/app.component'
6import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy' 6import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
7import { MenuGuards } from '@app/core/routing/menu-guard.service'
7 8
8const routes: Routes = [ 9const routes: Routes = [
9 { 10 {
10 path: 'admin', 11 path: 'admin',
12 canActivate: [ MenuGuards.close() ],
13 canDeactivate: [ MenuGuards.open() ],
11 loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) 14 loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
12 }, 15 },
13 { 16 {
@@ -54,6 +57,7 @@ const routes: Routes = [
54 }) 57 })
55 ], 58 ],
56 providers: [ 59 providers: [
60 MenuGuards.guards,
57 PreloadSelectedModulesList, 61 PreloadSelectedModulesList,
58 { provide: RouteReuseStrategy, useClass: CustomReuseStrategy } 62 { provide: RouteReuseStrategy, useClass: CustomReuseStrategy }
59 ], 63 ],
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html
index 2660c5377..b0d2e5050 100644
--- a/client/src/app/app.component.html
+++ b/client/src/app/app.component.html
@@ -2,36 +2,31 @@
2 2
3<my-hotkeys-cheatsheet></my-hotkeys-cheatsheet> 3<my-hotkeys-cheatsheet></my-hotkeys-cheatsheet>
4 4
5<div [ngClass]="{ 'user-logged-in': isUserLoggedIn(), 'user-not-logged-in': !isUserLoggedIn() }"> 5<div class="peertube-container" [ngClass]="{ 'user-logged-in': isUserLoggedIn(), 'user-not-logged-in': !isUserLoggedIn() }">
6 <div class="header"> 6 <div class="header">
7 7
8 <div class="top-left-block" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }"> 8 <div class="top-left-block">
9 <span class="icon icon-menu" (click)="toggleMenu()"></span> 9 <span class="icon icon-menu" (click)="menu.toggleMenu()"></span>
10 10
11 <a class="peertube-title" [routerLink]="defaultRoute" title="Homepage"> 11 <a class="peertube-title" [routerLink]="defaultRoute" title="Homepage" i18n-title>
12 <span class="icon icon-logo"></span> 12 <span class="icon icon-logo"></span>
13 <span class="instance-name">{{ instanceName }}</span> 13 <span class="instance-name">{{ instanceName }}</span>
14 </a> 14 </a>
15 </div> 15 </div>
16 16
17 <div class="header-right" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }"> 17 <div class="header-right">
18 <my-header></my-header> 18 <my-header class="w-100 d-flex justify-content-end"></my-header>
19 </div> 19 </div>
20 </div> 20 </div>
21 21
22 <div class="sub-header-container"> 22 <div class="sub-header-container">
23 <my-menu *ngIf="isMenuDisplayed"></my-menu> 23 <my-menu *ngIf="menu.isMenuDisplayed"></my-menu>
24 24
25 <div id="content" tabindex="-1" class="main-col container-fluid" [ngClass]="{ expanded: isMenuDisplayed === false }"> 25 <div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }">
26 26
27 <div class="main-row"> 27 <div class="main-row">
28 <router-outlet></router-outlet> 28 <router-outlet></router-outlet>
29 </div> 29 </div>
30
31 <footer class="row">
32 <a href="https://joinpeertube.org" title="PeerTube website" target="_blank" rel="noopener noreferrer" i18n>Powered by PeerTube</a>&nbsp;-&nbsp;
33 <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" title="PeerTube license" target="_blank" rel="noopener noreferrer">CopyLeft 2015-2020</a>
34 </footer>
35 </div> 30 </div>
36 </div> 31 </div>
37</div> 32</div>
@@ -59,3 +54,5 @@
59 <my-welcome-modal #welcomeModal></my-welcome-modal> 54 <my-welcome-modal #welcomeModal></my-welcome-modal>
60 <my-instance-config-warning-modal #instanceConfigWarningModal></my-instance-config-warning-modal> 55 <my-instance-config-warning-modal #instanceConfigWarningModal></my-instance-config-warning-modal>
61</ng-template> 56</ng-template>
57
58<my-custom-modal #customModal></my-custom-modal>
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss
index 51a7a3dd1..0c33dc4a1 100644
--- a/client/src/app/app.component.scss
+++ b/client/src/app/app.component.scss
@@ -1,6 +1,10 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.peertube-container {
5 padding-bottom: 20px;
6}
7
4.main-row { 8.main-row {
5 min-height: calc(100vh - #{$header-height} - #{$footer-height} - #{$footer-margin}); 9 min-height: calc(100vh - #{$header-height} - #{$footer-height} - #{$footer-margin});
6} 10}
@@ -8,6 +12,7 @@
8.sub-header-container { 12.sub-header-container {
9 margin-top: $header-height; 13 margin-top: $header-height;
10 background-color: var(--mainBackgroundColor); 14 background-color: var(--mainBackgroundColor);
15 width: 100%;
11} 16}
12 17
13.header { 18.header {
@@ -16,12 +21,12 @@
16 top: 0; 21 top: 0;
17 width: 100%; 22 width: 100%;
18 background-color: var(--mainBackgroundColor); 23 background-color: var(--mainBackgroundColor);
19 z-index: 1000; 24 z-index: z(header);
20 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); 25 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
21 display: flex; 26 display: flex;
22 27
23 .top-left-block { 28 .top-left-block {
24 z-index: 1001; 29 z-index: 1;
25 height: $header-height; 30 height: $header-height;
26 display: flex; 31 display: flex;
27 align-items: center; 32 align-items: center;
@@ -61,7 +66,7 @@
61 } 66 }
62 } 67 }
63 68
64 @media screen and (max-width: 500px) { 69 @media screen and (max-width: $mobile-view) {
65 width: 70px; 70 width: 70px;
66 71
67 .peertube-title { 72 .peertube-title {
@@ -83,11 +88,3 @@
83 flex: 1; 88 flex: 1;
84 } 89 }
85} 90}
86
87footer {
88 padding: 10px 0;
89 font-size: 11px;
90 margin-top: $footer-margin;
91 height: $footer-height;
92 justify-content: center;
93}
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 03eb83cb8..12c0efd8a 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -1,13 +1,12 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'
2import { DomSanitizer, SafeHtml } from '@angular/platform-browser' 2import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
3import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular/router' 3import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular/router'
4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' 4import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
5import { is18nPath } from '../../../shared/models/i18n' 5import { is18nPath } from '../../../shared/models/i18n'
6import { ScreenService } from '@app/shared/misc/screen.service' 6import { ScreenService } from '@app/shared/misc/screen.service'
7import { debounceTime, filter, map, pairwise } from 'rxjs/operators' 7import { filter, map, pairwise } from 'rxjs/operators'
8import { Hotkey, HotkeysService } from 'angular2-hotkeys' 8import { Hotkey, HotkeysService } from 'angular2-hotkeys'
9import { I18n } from '@ngx-translate/i18n-polyfill' 9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { fromEvent } from 'rxjs'
11import { PlatformLocation, ViewportScroller } from '@angular/common' 10import { PlatformLocation, ViewportScroller } from '@angular/common'
12import { PluginService } from '@app/core/plugins/plugin.service' 11import { PluginService } from '@app/core/plugins/plugin.service'
13import { HooksService } from '@app/core/plugins/hooks.service' 12import { HooksService } from '@app/core/plugins/hooks.service'
@@ -15,21 +14,21 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
15import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants' 14import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants'
16import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' 15import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
17import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' 16import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
17import { CustomModalComponent } from '@app/modal/custom-modal.component'
18import { ServerConfig, UserRole } from '@shared/models' 18import { ServerConfig, UserRole } from '@shared/models'
19import { User } from '@app/shared' 19import { User } from '@app/shared'
20import { InstanceService } from '@app/shared/instance/instance.service' 20import { InstanceService } from '@app/shared/instance/instance.service'
21import { MenuService } from './core/menu/menu.service'
21 22
22@Component({ 23@Component({
23 selector: 'my-app', 24 selector: 'my-app',
24 templateUrl: './app.component.html', 25 templateUrl: './app.component.html',
25 styleUrls: [ './app.component.scss' ] 26 styleUrls: [ './app.component.scss' ]
26}) 27})
27export class AppComponent implements OnInit { 28export class AppComponent implements OnInit, AfterViewInit {
28 @ViewChild('welcomeModal', { static: false }) welcomeModal: WelcomeModalComponent 29 @ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent
29 @ViewChild('instanceConfigWarningModal', { static: false }) instanceConfigWarningModal: InstanceConfigWarningModalComponent 30 @ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent
30 31 @ViewChild('customModal') customModal: CustomModalComponent
31 isMenuDisplayed = true
32 isMenuChangedByUser = false
33 32
34 customCSS: SafeHtml 33 customCSS: SafeHtml
35 34
@@ -50,7 +49,8 @@ export class AppComponent implements OnInit {
50 private themeService: ThemeService, 49 private themeService: ThemeService,
51 private hooks: HooksService, 50 private hooks: HooksService,
52 private location: PlatformLocation, 51 private location: PlatformLocation,
53 private modalService: NgbModal 52 private modalService: NgbModal,
53 public menu: MenuService
54 ) { } 54 ) { }
55 55
56 get instanceName () { 56 get instanceName () {
@@ -78,37 +78,23 @@ export class AppComponent implements OnInit {
78 this.authService.refreshUserInformation() 78 this.authService.refreshUserInformation()
79 } 79 }
80 80
81 // Do not display menu on small screens
82 if (this.screenService.isInSmallView()) {
83 this.isMenuDisplayed = false
84 }
85
86 this.initRouteEvents() 81 this.initRouteEvents()
87 this.injectJS() 82 this.injectJS()
88 this.injectCSS() 83 this.injectCSS()
89 84
90 this.initHotkeys() 85 this.initHotkeys()
91 86
92 fromEvent(window, 'resize')
93 .pipe(debounceTime(200))
94 .subscribe(() => this.onResize())
95
96 this.location.onPopState(() => this.modalService.dismissAll(POP_STATE_MODAL_DISMISS)) 87 this.location.onPopState(() => this.modalService.dismissAll(POP_STATE_MODAL_DISMISS))
97 88
98 this.openModalsIfNeeded() 89 this.openModalsIfNeeded()
99 } 90 }
100 91
101 isUserLoggedIn () { 92 ngAfterViewInit () {
102 return this.authService.isLoggedIn() 93 this.pluginService.initializeCustomModal(this.customModal)
103 }
104
105 toggleMenu () {
106 this.isMenuDisplayed = !this.isMenuDisplayed
107 this.isMenuChangedByUser = true
108 } 94 }
109 95
110 onResize () { 96 isUserLoggedIn () {
111 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser 97 return this.authService.isLoggedIn()
112 } 98 }
113 99
114 private initRouteEvents () { 100 private initRouteEvents () {
@@ -176,7 +162,7 @@ export class AppComponent implements OnInit {
176 eventsObs.pipe( 162 eventsObs.pipe(
177 filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart), 163 filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart),
178 filter(() => this.screenService.isInSmallView()) 164 filter(() => this.screenService.isInSmallView())
179 ).subscribe(() => this.isMenuDisplayed = false) // User clicked on a link in the menu, change the page 165 ).subscribe(() => this.menu.isMenuDisplayed = false) // User clicked on a link in the menu, change the page
180 } 166 }
181 167
182 private injectJS () { 168 private injectJS () {
@@ -249,7 +235,7 @@ export class AppComponent implements OnInit {
249 }, undefined, this.i18n('Focus the search bar')), 235 }, undefined, this.i18n('Focus the search bar')),
250 236
251 new Hotkey('b', (event: KeyboardEvent): boolean => { 237 new Hotkey('b', (event: KeyboardEvent): boolean => {
252 this.toggleMenu() 238 this.menu.toggleMenu()
253 return false 239 return false
254 }, undefined, this.i18n('Toggle the left menu')), 240 }, undefined, this.i18n('Toggle the left menu')),
255 241
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index dda705811..5a3b109da 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -4,22 +4,23 @@ import { ServerService } from '@app/core'
4import { ResetPasswordModule } from '@app/reset-password' 4import { ResetPasswordModule } from '@app/reset-password'
5 5
6import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' 6import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
7import { ClipboardModule } from 'ngx-clipboard'
8import 'focus-visible' 7import 'focus-visible'
9 8
10import { AppRoutingModule } from './app-routing.module' 9import { AppRoutingModule } from './app-routing.module'
11import { AppComponent } from './app.component' 10import { AppComponent } from './app.component'
12import { CoreModule } from './core' 11import { CoreModule } from './core'
13import { HeaderComponent } from './header' 12import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
14import { LoginModule } from './login' 13import { LoginModule } from './login'
15import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 14import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
16import { SharedModule } from './shared' 15import { SharedModule } from './shared'
17import { VideosModule } from './videos' 16import { VideosModule } from './videos'
18import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
20import { SearchModule } from '@app/search' 17import { SearchModule } from '@app/search'
21import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' 18import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
22import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' 19import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
20import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
21import { APP_BASE_HREF } from '@angular/common'
22import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
23import { CustomModalComponent } from '@app/modal/custom-modal.component'
23 24
24export function metaFactory (serverService: ServerService): MetaLoader { 25export function metaFactory (serverService: ServerService): MetaLoader {
25 return new MetaStaticLoader({ 26 return new MetaStaticLoader({
@@ -40,16 +41,19 @@ export function metaFactory (serverService: ServerService): MetaLoader {
40 41
41 MenuComponent, 42 MenuComponent,
42 LanguageChooserComponent, 43 LanguageChooserComponent,
44 QuickSettingsModalComponent,
43 AvatarNotificationComponent, 45 AvatarNotificationComponent,
44 HeaderComponent, 46 HeaderComponent,
47 SearchTypeaheadComponent,
48 SuggestionsComponent,
49 SuggestionComponent,
45 50
51 CustomModalComponent,
46 WelcomeModalComponent, 52 WelcomeModalComponent,
47 InstanceConfigWarningModalComponent 53 InstanceConfigWarningModalComponent
48 ], 54 ],
49 imports: [ 55 imports: [
50 BrowserModule, 56 BrowserModule,
51 // FIXME: https://github.com/maxisam/ngx-clipboard/issues/133
52 ClipboardModule,
53 57
54 CoreModule, 58 CoreModule,
55 SharedModule, 59 SharedModule,
@@ -69,22 +73,22 @@ export function metaFactory (serverService: ServerService): MetaLoader {
69 73
70 AppRoutingModule // Put it after all the module because it has the 404 route 74 AppRoutingModule // Put it after all the module because it has the 404 route
71 ], 75 ],
76
72 providers: [ 77 providers: [
73 { 78 {
79 provide: APP_BASE_HREF,
80 useValue: '/'
81 },
82
83 {
74 provide: TRANSLATIONS, 84 provide: TRANSLATIONS,
75 useFactory: (locale: string) => { 85 useFactory: (locale: string) => {
76 // On dev mode, test localization
77 if (isOnDevLocale()) {
78 locale = buildFileLocale(getDevLocale())
79 return require(`raw-loader!../locale/angular.${locale}.xlf`)
80 }
81
82 // Default locale, nothing to translate 86 // Default locale, nothing to translate
83 const completeLocale = getCompleteLocale(locale) 87 const completeLocale = getCompleteLocale(locale)
84 if (isDefaultLocale(completeLocale)) return '' 88 if (isDefaultLocale(completeLocale)) return ''
85 89
86 const fileLocale = buildFileLocale(locale) 90 const fileLocale = buildFileLocale(locale)
87 return require(`raw-loader!../locale/angular.${fileLocale}.xlf`) 91 return require(`raw-loader!../locale/angular.${fileLocale}.xlf`).default
88 }, 92 },
89 deps: [ LOCALE_ID ] 93 deps: [ LOCALE_ID ]
90 }, 94 },
diff --git a/client/src/app/core/auth/auth-user.model.ts b/client/src/app/core/auth/auth-user.model.ts
index 1447daead..4ad904beb 100644
--- a/client/src/app/core/auth/auth-user.model.ts
+++ b/client/src/app/core/auth/auth-user.model.ts
@@ -67,17 +67,6 @@ class Tokens {
67} 67}
68 68
69export class AuthUser extends User implements ServerMyUserModel { 69export class AuthUser extends User implements ServerMyUserModel {
70 private static KEYS = {
71 ID: 'id',
72 ROLE: 'role',
73 EMAIL: 'email',
74 VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
75 USERNAME: 'username',
76 NSFW_POLICY: 'nsfw_policy',
77 WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
78 AUTO_PLAY_VIDEO: 'auto_play_video'
79 }
80
81 tokens: Tokens 70 tokens: Tokens
82 specialPlaylists: MyUserSpecialPlaylist[] 71 specialPlaylists: MyUserSpecialPlaylist[]
83 72
@@ -106,10 +95,6 @@ export class AuthUser extends User implements ServerMyUserModel {
106 peertubeLocalStorage.removeItem(this.KEYS.USERNAME) 95 peertubeLocalStorage.removeItem(this.KEYS.USERNAME)
107 peertubeLocalStorage.removeItem(this.KEYS.ID) 96 peertubeLocalStorage.removeItem(this.KEYS.ID)
108 peertubeLocalStorage.removeItem(this.KEYS.ROLE) 97 peertubeLocalStorage.removeItem(this.KEYS.ROLE)
109 peertubeLocalStorage.removeItem(this.KEYS.NSFW_POLICY)
110 peertubeLocalStorage.removeItem(this.KEYS.WEBTORRENT_ENABLED)
111 peertubeLocalStorage.removeItem(this.KEYS.VIDEOS_HISTORY_ENABLED)
112 peertubeLocalStorage.removeItem(this.KEYS.AUTO_PLAY_VIDEO)
113 peertubeLocalStorage.removeItem(this.KEYS.EMAIL) 98 peertubeLocalStorage.removeItem(this.KEYS.EMAIL)
114 Tokens.flush() 99 Tokens.flush()
115 } 100 }
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index 9ae008e39..de8c509d1 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -29,6 +29,7 @@ type UserLoginWithUserInformation = UserLoginWithUsername & User
29export class AuthService { 29export class AuthService {
30 private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local' 30 private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local'
31 private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token' 31 private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token'
32 private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token'
32 private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me' 33 private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me'
33 private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { 34 private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
34 CLIENT_ID: 'client_id', 35 CLIENT_ID: 'client_id',
@@ -145,7 +146,7 @@ export class AuthService {
145 return !!this.getAccessToken() 146 return !!this.getAccessToken()
146 } 147 }
147 148
148 login (username: string, password: string) { 149 login (username: string, password: string, token?: string) {
149 // Form url encoded 150 // Form url encoded
150 const body = { 151 const body = {
151 client_id: this.clientId, 152 client_id: this.clientId,
@@ -157,6 +158,8 @@ export class AuthService {
157 password 158 password
158 } 159 }
159 160
161 if (token) Object.assign(body, { externalAuthToken: token })
162
160 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 163 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
161 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) 164 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
162 .pipe( 165 .pipe(
@@ -168,7 +171,16 @@ export class AuthService {
168 } 171 }
169 172
170 logout () { 173 logout () {
171 // TODO: make an HTTP request to revoke the tokens 174 const authHeaderValue = this.getRequestHeaderValue()
175 const headers = new HttpHeaders().set('Authorization', authHeaderValue)
176
177 this.http.post<void>(AuthService.BASE_REVOKE_TOKEN_URL, {}, { headers })
178 .subscribe(
179 () => { /* nothing to do */ },
180
181 err => console.error(err)
182 )
183
172 this.user = null 184 this.user = null
173 185
174 AuthUser.flush() 186 AuthUser.flush()
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts
index 5943af4da..a1734ad80 100644
--- a/client/src/app/core/core.module.ts
+++ b/client/src/app/core/core.module.ts
@@ -13,6 +13,7 @@ import { throwIfAlreadyLoaded } from './module-import-guard'
13import { LoginGuard, RedirectService, UserRightGuard } from './routing' 13import { LoginGuard, RedirectService, UserRightGuard } from './routing'
14import { ServerService } from './server' 14import { ServerService } from './server'
15import { ThemeService } from './theme' 15import { ThemeService } from './theme'
16import { MenuService } from './menu'
16import { HotkeyModule } from 'angular2-hotkeys' 17import { HotkeyModule } from 'angular2-hotkeys'
17import { CheatSheetComponent } from './hotkeys' 18import { CheatSheetComponent } from './hotkeys'
18import { ToastModule } from 'primeng/toast' 19import { ToastModule } from 'primeng/toast'
@@ -59,6 +60,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
59 ConfirmService, 60 ConfirmService,
60 ServerService, 61 ServerService,
61 ThemeService, 62 ThemeService,
63 MenuService,
62 LoginGuard, 64 LoginGuard,
63 UserRightGuard, 65 UserRightGuard,
64 UnloggedGuard, 66 UnloggedGuard,
diff --git a/client/src/app/core/hotkeys/hotkeys.component.scss b/client/src/app/core/hotkeys/hotkeys.component.scss
index 3aa0b6252..a970260c9 100644
--- a/client/src/app/core/hotkeys/hotkeys.component.scss
+++ b/client/src/app/core/hotkeys/hotkeys.component.scss
@@ -1,3 +1,6 @@
1@import '_variables';
2@import '_mixins';
3
1.cfp-hotkeys-container { 4.cfp-hotkeys-container {
2 display: flex !important; 5 display: flex !important;
3 align-items: center; 6 align-items: center;
@@ -23,7 +26,7 @@
23} 26}
24 27
25.cfp-hotkeys-container.fade.in { 28.cfp-hotkeys-container.fade.in {
26 z-index: 10002; 29 z-index: z(hotkeys);
27 visibility: visible; 30 visibility: visible;
28 opacity: 1; 31 opacity: 1;
29} 32}
@@ -91,7 +94,7 @@
91 cursor: pointer; 94 cursor: pointer;
92} 95}
93 96
94@media all and (max-width: 500px) { 97@media all and (max-width: $mobile-view) {
95 .cfp-hotkeys { 98 .cfp-hotkeys {
96 font-size: 0.8em; 99 font-size: 0.8em;
97 } 100 }
diff --git a/client/src/app/core/menu/index.ts b/client/src/app/core/menu/index.ts
new file mode 100644
index 000000000..516a49aca
--- /dev/null
+++ b/client/src/app/core/menu/index.ts
@@ -0,0 +1 @@
export * from './menu.service'
diff --git a/client/src/app/core/menu/menu.service.ts b/client/src/app/core/menu/menu.service.ts
new file mode 100644
index 000000000..ecb2bceb7
--- /dev/null
+++ b/client/src/app/core/menu/menu.service.ts
@@ -0,0 +1,32 @@
1import { Injectable } from '@angular/core'
2import { ScreenService } from '@app/shared/misc/screen.service'
3import { fromEvent } from 'rxjs'
4import { debounceTime } from 'rxjs/operators'
5
6@Injectable()
7export class MenuService {
8 isMenuDisplayed = true
9 isMenuChangedByUser = false
10
11 constructor (
12 private screenService: ScreenService
13 ) {
14 // Do not display menu on small screens
15 if (this.screenService.isInSmallView()) {
16 this.isMenuDisplayed = false
17 }
18
19 fromEvent(window, 'resize')
20 .pipe(debounceTime(200))
21 .subscribe(() => this.onResize())
22 }
23
24 toggleMenu () {
25 this.isMenuDisplayed = !this.isMenuDisplayed
26 this.isMenuChangedByUser = true
27 }
28
29 onResize () {
30 this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
31 }
32}
diff --git a/client/src/app/core/plugins/hooks.service.ts b/client/src/app/core/plugins/hooks.service.ts
index a6a444c32..2fbf406d1 100644
--- a/client/src/app/core/plugins/hooks.service.ts
+++ b/client/src/app/core/plugins/hooks.service.ts
@@ -1,9 +1,8 @@
1import { from, Observable } from 'rxjs'
2import { mergeMap, switchMap } from 'rxjs/operators'
1import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
2import { PluginService } from '@app/core/plugins/plugin.service' 4import { PluginService } from '@app/core/plugins/plugin.service'
3import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model' 5import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model'
4import { from, Observable } from 'rxjs'
5import { mergeMap, switchMap } from 'rxjs/operators'
6import { ServerService } from '@app/core/server'
7import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type' 6import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
8 7
9type RawFunction<U, T> = (params: U) => T 8type RawFunction<U, T> = (params: U) => T
@@ -11,10 +10,7 @@ type ObservableFunction<U, T> = RawFunction<U, Observable<T>>
11 10
12@Injectable() 11@Injectable()
13export class HooksService { 12export class HooksService {
14 constructor ( 13 constructor (private pluginService: PluginService) { }
15 private server: ServerService,
16 private pluginService: PluginService
17 ) { }
18 14
19 wrapObsFun 15 wrapObsFun
20 <P, R, H1 extends ClientFilterHookName, H2 extends ClientFilterHookName> 16 <P, R, H1 extends ClientFilterHookName, H2 extends ClientFilterHookName>
diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts
index da5114048..c6efcac6d 100644
--- a/client/src/app/core/plugins/plugin.service.ts
+++ b/client/src/app/core/plugins/plugin.service.ts
@@ -13,13 +13,16 @@ import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.ty
13import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model' 13import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model'
14import { HttpClient } from '@angular/common/http' 14import { HttpClient } from '@angular/common/http'
15import { AuthService } from '@app/core/auth' 15import { AuthService } from '@app/core/auth'
16import { Notifier } from '@app/core/notification'
16import { RestExtractor } from '@app/shared/rest' 17import { RestExtractor } from '@app/shared/rest'
18import { MarkdownService } from '@app/shared/renderer'
17import { PluginType } from '@shared/models/plugins/plugin.type' 19import { PluginType } from '@shared/models/plugins/plugin.type'
18import { PublicServerSetting } from '@shared/models/plugins/public-server.setting' 20import { PublicServerSetting } from '@shared/models/plugins/public-server.setting'
19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 21import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
20import { RegisterClientHelpers } from '../../../types/register-client-option.model' 22import { RegisterClientHelpers } from '../../../types/register-client-option.model'
21import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model' 23import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model'
22import { importModule } from '@app/shared/misc/utils' 24import { importModule } from '@app/shared/misc/utils'
25import { CustomModalComponent } from '@app/modal/custom-modal.component'
23 26
24interface HookStructValue extends RegisterClientHookOptions { 27interface HookStructValue extends RegisterClientHookOptions {
25 plugin: ServerConfigPlugin 28 plugin: ServerConfigPlugin
@@ -44,11 +47,14 @@ export class PluginService implements ClientHook {
44 common: new ReplaySubject<boolean>(1), 47 common: new ReplaySubject<boolean>(1),
45 search: new ReplaySubject<boolean>(1), 48 search: new ReplaySubject<boolean>(1),
46 'video-watch': new ReplaySubject<boolean>(1), 49 'video-watch': new ReplaySubject<boolean>(1),
47 signup: new ReplaySubject<boolean>(1) 50 signup: new ReplaySubject<boolean>(1),
51 login: new ReplaySubject<boolean>(1)
48 } 52 }
49 53
50 translationsObservable: Observable<PluginTranslation> 54 translationsObservable: Observable<PluginTranslation>
51 55
56 customModal: CustomModalComponent
57
52 private plugins: ServerConfigPlugin[] = [] 58 private plugins: ServerConfigPlugin[] = []
53 private scopes: { [ scopeName: string ]: PluginInfo[] } = {} 59 private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
54 private loadedScripts: { [ script: string ]: boolean } = {} 60 private loadedScripts: { [ script: string ]: boolean } = {}
@@ -60,6 +66,8 @@ export class PluginService implements ClientHook {
60 constructor ( 66 constructor (
61 private router: Router, 67 private router: Router,
62 private authService: AuthService, 68 private authService: AuthService,
69 private notifier: Notifier,
70 private markdownRenderer: MarkdownService,
63 private server: ServerService, 71 private server: ServerService,
64 private zone: NgZone, 72 private zone: NgZone,
65 private authHttp: HttpClient, 73 private authHttp: HttpClient,
@@ -80,6 +88,10 @@ export class PluginService implements ClientHook {
80 }) 88 })
81 } 89 }
82 90
91 initializeCustomModal (customModal: CustomModalComponent) {
92 this.customModal = customModal
93 }
94
83 ensurePluginsAreBuilt () { 95 ensurePluginsAreBuilt () {
84 return this.pluginsBuilt.asObservable() 96 return this.pluginsBuilt.asObservable()
85 .pipe(first(), shareReplay()) 97 .pipe(first(), shareReplay())
@@ -272,6 +284,32 @@ export class PluginService implements ClientHook {
272 return this.authService.isLoggedIn() 284 return this.authService.isLoggedIn()
273 }, 285 },
274 286
287 notifier: {
288 info: (text: string, title?: string, timeout?: number) => this.notifier.info(text, title, timeout),
289 error: (text: string, title?: string, timeout?: number) => this.notifier.error(text, title, timeout),
290 success: (text: string, title?: string, timeout?: number) => this.notifier.success(text, title, timeout)
291 },
292
293 showModal: (input: {
294 title: string,
295 content: string,
296 close?: boolean,
297 cancel?: { value: string, action?: () => void },
298 confirm?: { value: string, action?: () => void }
299 }) => {
300 this.customModal.show(input)
301 },
302
303 markdownRenderer: {
304 textMarkdownToHTML: (textMarkdown: string) => {
305 return this.markdownRenderer.textMarkdownToHTML(textMarkdown)
306 },
307
308 enhancedMarkdownToHTML: (enhancedMarkdown: string) => {
309 return this.markdownRenderer.enhancedMarkdownToHTML(enhancedMarkdown)
310 }
311 },
312
275 translate: (value: string) => { 313 translate: (value: string) => {
276 return this.translationsObservable 314 return this.translationsObservable
277 .pipe(map(allTranslations => allTranslations[npmName])) 315 .pipe(map(allTranslations => allTranslations[npmName]))
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts
index a9f61acec..c0f9f04e0 100644
--- a/client/src/app/core/routing/custom-reuse-strategy.ts
+++ b/client/src/app/core/routing/custom-reuse-strategy.ts
@@ -1,5 +1,7 @@
1import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' 1import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
2import { Injectable } from '@angular/core'
2 3
4@Injectable()
3export class CustomReuseStrategy implements RouteReuseStrategy { 5export class CustomReuseStrategy implements RouteReuseStrategy {
4 storedRouteHandles = new Map<string, DetachedRouteHandle>() 6 storedRouteHandles = new Map<string, DetachedRouteHandle>()
5 recentlyUsed: string 7 recentlyUsed: string
@@ -76,6 +78,6 @@ export class CustomReuseStrategy implements RouteReuseStrategy {
76 } 78 }
77 79
78 private isReuseEnabled (route: ActivatedRouteSnapshot) { 80 private isReuseEnabled (route: ActivatedRouteSnapshot) {
79 return route.data.reuse && route.data.reuse.enabled && route.queryParams['a-state'] 81 return route.data.reuse && route.data.reuse.enabled && route.queryParams[ 'a-state' ]
80 } 82 }
81} 83}
diff --git a/client/src/app/core/routing/index.ts b/client/src/app/core/routing/index.ts
index 9f0b4eac5..58b83bb2a 100644
--- a/client/src/app/core/routing/index.ts
+++ b/client/src/app/core/routing/index.ts
@@ -2,3 +2,4 @@ export * from './login-guard.service'
2export * from './user-right-guard.service' 2export * from './user-right-guard.service'
3export * from './preload-selected-modules-list' 3export * from './preload-selected-modules-list'
4export * from './redirect.service' 4export * from './redirect.service'
5export * from './menu-guard.service'
diff --git a/client/src/app/core/routing/menu-guard.service.ts b/client/src/app/core/routing/menu-guard.service.ts
new file mode 100644
index 000000000..907d145fd
--- /dev/null
+++ b/client/src/app/core/routing/menu-guard.service.ts
@@ -0,0 +1,48 @@
1import { Injectable } from '@angular/core'
2import { CanActivate, CanDeactivate } from '@angular/router'
3import { MenuService } from '@app/core/menu'
4import { ScreenService } from '@app/shared/misc/screen.service'
5
6abstract class MenuGuard implements CanActivate, CanDeactivate<any> {
7 display = true
8 canDeactivate = this.canActivate
9
10 constructor (protected menu: MenuService, protected screen: ScreenService, display: boolean) {
11 this.display = display
12 }
13
14 canActivate (): boolean {
15 // small screens already have the site-wide onResize from screenService
16 // > medium screens have enough space to fit the administrative menus
17 if (!this.screen.isInMobileView() && this.screen.isInMediumView()) {
18 this.menu.isMenuDisplayed = this.display
19 }
20 return true
21 }
22}
23
24@Injectable()
25export class OpenMenuGuard extends MenuGuard {
26 constructor (menu: MenuService, screen: ScreenService) { super(menu, screen, true) }
27}
28
29@Injectable()
30export class CloseMenuGuard extends MenuGuard {
31 constructor (menu: MenuService, screen: ScreenService) { super(menu, screen, false) }
32}
33
34@Injectable()
35export class MenuGuards {
36 public static guards = [
37 OpenMenuGuard,
38 CloseMenuGuard
39 ]
40
41 static open () {
42 return OpenMenuGuard
43 }
44
45 static close () {
46 return CloseMenuGuard
47 }
48}
diff --git a/client/src/app/core/routing/preload-selected-modules-list.ts b/client/src/app/core/routing/preload-selected-modules-list.ts
index 3bca60317..64af68225 100644
--- a/client/src/app/core/routing/preload-selected-modules-list.ts
+++ b/client/src/app/core/routing/preload-selected-modules-list.ts
@@ -1,7 +1,9 @@
1import { Observable, timer as observableTimer, of as ofObservable } from 'rxjs' 1import { Observable, of as ofObservable, timer as observableTimer } from 'rxjs'
2import { switchMap } from 'rxjs/operators' 2import { switchMap } from 'rxjs/operators'
3import { PreloadingStrategy, Route } from '@angular/router' 3import { PreloadingStrategy, Route } from '@angular/router'
4import { Injectable } from '@angular/core'
4 5
6@Injectable()
5export class PreloadSelectedModulesList implements PreloadingStrategy { 7export class PreloadSelectedModulesList implements PreloadingStrategy {
6 preload (route: Route, load: Function): Observable<any> { 8 preload (route: Route, load: Function): Observable<any> {
7 if (!route.data || !route.data.preload) return ofObservable(null) 9 if (!route.data || !route.data.preload) return ofObservable(null)
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 3997ce6db..eac8f85e4 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -9,6 +9,7 @@ import { VideoConstant } from '../../../../../shared/models/videos'
9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' 9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
11import { sortBy } from '@app/shared/misc/utils' 11import { sortBy } from '@app/shared/misc/utils'
12import { ServerStats } from '@shared/models/server'
12 13
13@Injectable() 14@Injectable()
14export class ServerService { 15export class ServerService {
@@ -16,6 +17,8 @@ export class ServerService {
16 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 17 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
17 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 18 private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
18 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' 19 private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
20 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
21
19 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' 22 private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
20 23
21 configReloaded = new Subject<void>() 24 configReloaded = new Subject<void>()
@@ -44,8 +47,16 @@ export class ServerService {
44 css: '' 47 css: ''
45 } 48 }
46 }, 49 },
50 search: {
51 remoteUri: {
52 users: true,
53 anonymous: false
54 }
55 },
47 plugin: { 56 plugin: {
48 registered: [] 57 registered: [],
58 registeredExternalAuths: [],
59 registeredIdAndPassAuths: []
49 }, 60 },
50 theme: { 61 theme: {
51 registered: [], 62 registered: [],
@@ -238,6 +249,10 @@ export class ServerService {
238 return this.localeObservable.pipe(first()) 249 return this.localeObservable.pipe(first())
239 } 250 }
240 251
252 getServerStats () {
253 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
254 }
255
241 private loadAttributeEnum <T extends string | number> ( 256 private loadAttributeEnum <T extends string | number> (
242 baseUrl: string, 257 baseUrl: string,
243 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 258 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
@@ -250,17 +265,19 @@ export class ServerService {
250 .pipe(map(data => ({ data, translations }))) 265 .pipe(map(data => ({ data, translations })))
251 }), 266 }),
252 map(({ data, translations }) => { 267 map(({ data, translations }) => {
253 const hashToPopulate: VideoConstant<T>[] = [] 268 const hashToPopulate: VideoConstant<T>[] = Object.keys(data)
254 269 .map(dataKey => {
255 Object.keys(data) 270 const label = data[ dataKey ]
256 .forEach(dataKey => { 271
257 const label = data[ dataKey ] 272 const id = attributeName === 'languages'
258 273 ? dataKey as T
259 hashToPopulate.push({ 274 : parseInt(dataKey, 10) as T
260 id: (attributeName === 'languages' ? dataKey : parseInt(dataKey, 10)) as T, 275
261 label: peertubeTranslate(label, translations) 276 return {
262 }) 277 id,
263 }) 278 label: peertubeTranslate(label, translations)
279 }
280 })
264 281
265 if (sort === true) sortBy(hashToPopulate, 'label') 282 if (sort === true) sortBy(hashToPopulate, 'label')
266 283
diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts
index 2c5873cb3..c0189ad32 100644
--- a/client/src/app/core/theme/theme.service.ts
+++ b/client/src/app/core/theme/theme.service.ts
@@ -4,16 +4,14 @@ import { ServerService } from '@app/core/server'
4import { environment } from '../../../environments/environment' 4import { environment } from '../../../environments/environment'
5import { PluginService } from '@app/core/plugins/plugin.service' 5import { PluginService } from '@app/core/plugins/plugin.service'
6import { ServerConfig, ServerConfigTheme } from '@shared/models' 6import { ServerConfig, ServerConfigTheme } from '@shared/models'
7import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
8import { first } from 'rxjs/operators' 7import { first } from 'rxjs/operators'
8import { User } from '@app/shared/users/user.model'
9import { UserService } from '@app/shared/users/user.service'
10import { LocalStorageService } from '@app/shared/misc/storage.service'
9 11
10@Injectable() 12@Injectable()
11export class ThemeService { 13export class ThemeService {
12 14
13 private static KEYS = {
14 LAST_ACTIVE_THEME: 'last_active_theme'
15 }
16
17 private oldThemeName: string 15 private oldThemeName: string
18 private themes: ServerConfigTheme[] = [] 16 private themes: ServerConfigTheme[] = []
19 17
@@ -24,8 +22,10 @@ export class ThemeService {
24 22
25 constructor ( 23 constructor (
26 private auth: AuthService, 24 private auth: AuthService,
25 private userService: UserService,
27 private pluginService: PluginService, 26 private pluginService: PluginService,
28 private server: ServerService 27 private server: ServerService,
28 private localStorageService: LocalStorageService
29 ) {} 29 ) {}
30 30
31 initialize () { 31 initialize () {
@@ -77,11 +77,11 @@ export class ThemeService {
77 private getCurrentTheme () { 77 private getCurrentTheme () {
78 if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name 78 if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
79 79
80 if (this.auth.isLoggedIn()) { 80 const theme = this.auth.isLoggedIn()
81 const theme = this.auth.getUser().theme 81 ? this.auth.getUser().theme
82 if (theme !== 'instance-default') return theme 82 : this.userService.getAnonymousUser().theme
83 }
84 83
84 if (theme !== 'instance-default') return theme
85 return this.serverConfig.theme.default 85 return this.serverConfig.theme.default
86 } 86 }
87 87
@@ -111,9 +111,9 @@ export class ThemeService {
111 111
112 this.pluginService.reloadLoadedScopes() 112 this.pluginService.reloadLoadedScopes()
113 113
114 peertubeLocalStorage.setItem(ThemeService.KEYS.LAST_ACTIVE_THEME, JSON.stringify(theme)) 114 this.localStorageService.setItem(User.KEYS.THEME, JSON.stringify(theme), false)
115 } else { 115 } else {
116 peertubeLocalStorage.removeItem(ThemeService.KEYS.LAST_ACTIVE_THEME) 116 this.localStorageService.removeItem(User.KEYS.THEME, false)
117 } 117 }
118 118
119 this.oldThemeName = currentTheme 119 this.oldThemeName = currentTheme
@@ -126,6 +126,10 @@ export class ThemeService {
126 126
127 if (!this.auth.isLoggedIn()) { 127 if (!this.auth.isLoggedIn()) {
128 this.updateCurrentTheme() 128 this.updateCurrentTheme()
129
130 this.localStorageService.watch([User.KEYS.THEME]).subscribe(
131 () => this.updateCurrentTheme()
132 )
129 } 133 }
130 134
131 this.auth.userInformationLoaded 135 this.auth.userInformationLoaded
@@ -134,7 +138,7 @@ export class ThemeService {
134 } 138 }
135 139
136 private loadAndSetFromLocalStorage () { 140 private loadAndSetFromLocalStorage () {
137 const lastActiveThemeString = peertubeLocalStorage.getItem(ThemeService.KEYS.LAST_ACTIVE_THEME) 141 const lastActiveThemeString = this.localStorageService.getItem(User.KEYS.THEME)
138 if (!lastActiveThemeString) return 142 if (!lastActiveThemeString) return
139 143
140 try { 144 try {
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html
index 4fd18f9bd..49e219187 100644
--- a/client/src/app/header/header.component.html
+++ b/client/src/app/header/header.component.html
@@ -1,8 +1,4 @@
1<input 1<my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead>
2 type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
3 [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
4>
5<span (click)="doSearch()" class="icon icon-search"></span>
6 2
7<a class="upload-button" routerLink="/videos/upload"> 3<a class="upload-button" routerLink="/videos/upload">
8 <my-global-icon iconName="upload"></my-global-icon> 4 <my-global-icon iconName="upload"></my-global-icon>
diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss
index 2bbde74bc..91b390773 100644
--- a/client/src/app/header/header.component.scss
+++ b/client/src/app/header/header.component.scss
@@ -1,51 +1,8 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4#search-video { 4my-search-typeahead {
5 @include peertube-input-text($search-input-width);
6 padding-left: 10px;
7 margin-right: 15px; 5 margin-right: 15px;
8 padding-right: 40px; // For the search icon
9 font-size: 14px;
10
11 transition: box-shadow .3s ease;
12
13 /* light border style */
14 border: 1px solid var(--mainBackgroundColor);
15 box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
16
17 &:focus {
18 box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
19 }
20
21 &::placeholder {
22 color: var(--inputPlaceholderColor);
23 }
24
25 &:focus::placeholder {
26 opacity: 0 !important;
27 }
28
29 @media screen and (max-width: 800px) {
30 width: calc(100% - 150px);
31 }
32
33 @media screen and (max-width: 600px) {
34 width: calc(100% - 70px);
35 }
36}
37
38.icon.icon-search {
39 @include icon(25px);
40 height: 21px;
41
42 background-color: var(--mainForegroundColor);
43 mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
44
45 // yolo
46 position: absolute;
47 margin-left: -50px;
48 margin-top: 5px;
49} 6}
50 7
51.upload-button { 8.upload-button {
@@ -56,10 +13,6 @@
56 color: var(--mainBackgroundColor) !important; 13 color: var(--mainBackgroundColor) !important;
57 margin-right: 25px; 14 margin-right: 25px;
58 15
59 @media screen and (max-width: 800px) {
60 margin-right: 0;
61 }
62
63 @media screen and (max-width: 600px) { 16 @media screen and (max-width: 600px) {
64 margin-right: 10px; 17 margin-right: 10px;
65 padding: 0 10px; 18 padding: 0 10px;
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts
index 92a7eded6..cce76b0d1 100644
--- a/client/src/app/header/header.component.ts
+++ b/client/src/app/header/header.component.ts
@@ -1,10 +1,4 @@
1import { filter, first, map, tap } from 'rxjs/operators' 1import { Component } from '@angular/core'
2import { Component, OnInit } from '@angular/core'
3import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'
4import { getParameterByName } from '../shared/misc/utils'
5import { AuthService, Notifier, ServerService } from '@app/core'
6import { of } from 'rxjs'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8 2
9@Component({ 3@Component({
10 selector: 'my-header', 4 selector: 'my-header',
@@ -12,54 +6,4 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
12 styleUrls: [ './header.component.scss' ] 6 styleUrls: [ './header.component.scss' ]
13}) 7})
14 8
15export class HeaderComponent implements OnInit { 9export class HeaderComponent {}
16 searchValue = ''
17 ariaLabelTextForSearch = ''
18
19 constructor (
20 private router: Router,
21 private route: ActivatedRoute,
22 private auth: AuthService,
23 private serverService: ServerService,
24 private authService: AuthService,
25 private notifier: Notifier,
26 private i18n: I18n
27 ) {}
28
29 ngOnInit () {
30 this.ariaLabelTextForSearch = this.i18n('Search videos, channels')
31
32 this.router.events
33 .pipe(
34 filter(e => e instanceof NavigationEnd),
35 map(() => getParameterByName('search', window.location.href))
36 )
37 .subscribe(searchQuery => this.searchValue = searchQuery || '')
38 }
39
40 doSearch () {
41 const queryParams: Params = {}
42
43 if (window.location.pathname === '/search' && this.route.snapshot.queryParams) {
44 Object.assign(queryParams, this.route.snapshot.queryParams)
45 }
46
47 Object.assign(queryParams, { search: this.searchValue })
48
49 const o = this.auth.isLoggedIn()
50 ? this.loadUserLanguagesIfNeeded(queryParams)
51 : of(true)
52
53 o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
54 }
55
56 private loadUserLanguagesIfNeeded (queryParams: any) {
57 if (queryParams && queryParams.languageOneOf) return of(queryParams)
58
59 return this.auth.userInformationLoaded
60 .pipe(
61 first(),
62 tap(() => Object.assign(queryParams, { languageOneOf: this.auth.getUser().videoLanguages }))
63 )
64 }
65}
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts
index d98d2d00a..a882d4d1f 100644
--- a/client/src/app/header/index.ts
+++ b/client/src/app/header/index.ts
@@ -1 +1,4 @@
1export * from './header.component' 1export * from './header.component'
2export * from './search-typeahead.component'
3export * from './suggestions.component'
4export * from './suggestion.component'
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
new file mode 100644
index 000000000..710268664
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.html
@@ -0,0 +1,53 @@
1<div class="d-inline-flex position-relative" id="typeahead-container">
2 <input
3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()"
5 >
6 <span class="icon icon-search" (click)="doSearch()"></span>
7
8 <div class="position-absolute jump-to-suggestions">
9 <!-- suggestions -->
10 <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions>
11
12 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
13 <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
14 <ng-container *ngIf="activeResult.type === 'search-global'">
15 <div class="d-flex justify-content-between">
16 <label class="small-title" i18n>GLOBAL SEARCH</label>
17 <div class="advanced-search-status text-muted">
18 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span>
19 <i class="glyphicon glyphicon-globe"></i>
20 </div>
21 </div>
22 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
23 </ng-container>
24 </div>
25
26 <!-- search instructions, when search input is empty -->
27 <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden">
28 <div class="d-flex justify-content-between">
29 <label class="small-title" i18n>ADVANCED SEARCH</label>
30 <div class="advanced-search-status c-help">
31 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
32 <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span>
33 <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span>
34 <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
35 </span>
36 </div>
37 </div>
38 <ul>
39 <li>
40 <em>@channel_id@domain</em> <span class="flex-auto text-muted" i18n>channel</span>
41 </li>
42 <li>
43 <em>URL</em> <span class="text-muted" i18n>channel</span>
44 </li>
45 <li>
46 <em>UUID</em> <span class="text-muted" i18n>video</span>
47 </li>
48 </ul>
49 <span class="text-muted" i18n>Any other text will return matching video or channel names.</span>
50 </div>
51 </div>
52
53</div>
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss
new file mode 100644
index 000000000..33b88825f
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.scss
@@ -0,0 +1,145 @@
1@import '_mixins';
2@import '_variables';
3@import '_bootstrap-variables';
4@import '~bootstrap/scss/mixins/_breakpoints';
5
6#search-video {
7 @include peertube-input-text($search-input-width);
8 padding-left: 10px;
9 padding-right: 40px; // For the search icon
10 font-size: 14px;
11
12 &::placeholder {
13 color: var(--inputPlaceholderColor);
14 }
15}
16
17.icon.icon-search {
18 @include icon(25px);
19 height: 21px;
20
21 background-color: var(--mainForegroundColor);
22 mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
23
24 // yolo
25 position: absolute;
26 margin-left: -35px;
27 margin-top: 5px;
28}
29
30.jump-to-suggestions {
31 top: 100%;
32 left: 0;
33 z-index: z(typeahead);
34 width: 100%;
35}
36
37#typeahead-help,
38#typeahead-instructions,
39my-suggestions ::ng-deep ul {
40 border: 1px solid var(--mainBackgroundColor);
41 border-bottom-right-radius: 3px;
42 border-bottom-left-radius: 3px;
43 background: var(--mainBackgroundColor);
44 transition: .3s ease;
45 transition-property: box-shadow;
46}
47
48#typeahead-help,
49#typeahead-instructions {
50 margin-top: 10px;
51 width: 100%;
52 padding: .5rem 1rem;
53 white-space: normal;
54
55 ul {
56 list-style: none;
57 padding: 0;
58 margin-bottom: .5rem;
59
60 em {
61 font-weight: 600;
62 margin-right: 0.2rem;
63 font-style: normal;
64 }
65 }
66}
67
68#typeahead-container {
69 input {
70 border: 1px solid var(--mainBackgroundColor) !important;
71 box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
72 flex-grow: 1;
73 transition: box-shadow .3s ease, width .2s ease;
74 }
75
76 @media screen and (min-width: $mobile-view) {
77 margin-left: 10px;
78 }
79
80 @media screen and (max-width: $small-view) {
81 flex: 1;
82
83 input {
84 width: unset;
85 }
86 }
87
88 span {
89 right: 10px;
90 }
91
92 & > div:last-child {
93 // we have to switch the display and not the opacity,
94 // to avoid clashing with the rest of the interface.
95 display: none;
96 }
97
98 &:focus,
99 ::ng-deep &:focus-within {
100 & > div:last-child {
101 @media screen and (min-width: $mobile-view) {
102 display: initial !important;
103 }
104
105 #typeahead-help,
106 #typeahead-instructions,
107 my-suggestions ::ng-deep ul {
108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
109 }
110 }
111
112 ::ng-deep input {
113 box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
114 border-end-start-radius: 0;
115 border-end-end-radius: 0;
116
117 @include media-breakpoint-up(lg) {
118 width: 500px;
119 }
120 }
121 }
122}
123
124.glyphicon {
125 top: 3px;
126}
127
128.advanced-search-status {
129 height: max-content;
130 cursor: default;
131
132 &.c-help {
133 cursor: help;
134 }
135}
136
137.small-title {
138 @include in-content-small-title;
139
140 margin-bottom: .5rem;
141}
142
143::ng-deep my-suggestion {
144 width: 100%;
145}
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts
new file mode 100644
index 000000000..2bf1072f4
--- /dev/null
+++ b/client/src/app/header/search-typeahead.component.ts
@@ -0,0 +1,179 @@
1import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core'
2import { ActivatedRoute, Params, Router } from '@angular/router'
3import { AuthService, ServerService } from '@app/core'
4import { first, tap } from 'rxjs/operators'
5import { ListKeyManager } from '@angular/cdk/a11y'
6import { Result, SuggestionComponent } from './suggestion.component'
7import { of } from 'rxjs'
8import { ServerConfig } from '@shared/models'
9
10@Component({
11 selector: 'my-search-typeahead',
12 templateUrl: './search-typeahead.component.html',
13 styleUrls: [ './search-typeahead.component.scss' ]
14})
15export class SearchTypeaheadComponent implements OnInit, OnDestroy {
16 @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
17
18 hasChannel = false
19 inChannel = false
20 newSearch = true
21
22 search = ''
23 serverConfig: ServerConfig
24
25 inThisChannelText: string
26
27 keyboardEventsManager: ListKeyManager<SuggestionComponent>
28 results: Result[] = []
29
30 constructor (
31 private authService: AuthService,
32 private router: Router,
33 private route: ActivatedRoute,
34 private serverService: ServerService
35 ) {}
36
37 ngOnInit () {
38 this.route.queryParams
39 .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
40 .subscribe(params => this.search = params.search)
41 this.serverService.getConfig()
42 .subscribe(config => this.serverConfig = config)
43 }
44
45 ngOnDestroy () {
46 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
47 }
48
49 get activeResult () {
50 return this.keyboardEventsManager?.activeItem?.result
51 }
52
53 get areInstructionsDisplayed () {
54 return !this.search
55 }
56
57 get showHelp () {
58 return this.search && this.newSearch && this.activeResult?.type === 'search-global'
59 }
60
61 get canSearchAnyURI () {
62 if (!this.serverConfig) return false
63 return this.authService.isLoggedIn()
64 ? this.serverConfig.search.remoteUri.users
65 : this.serverConfig.search.remoteUri.anonymous
66 }
67
68 onSearchChange () {
69 this.computeResults()
70 }
71
72 computeResults () {
73 this.newSearch = true
74 let results: Result[] = []
75
76 if (this.search) {
77 results = [
78 /* Channel search is still unimplemented. Uncomment when it is.
79 {
80 text: this.search,
81 type: 'search-channel'
82 },
83 */
84 {
85 text: this.search,
86 type: 'search-instance',
87 default: true
88 },
89 /* Global search is still unimplemented. Uncomment when it is.
90 {
91 text: this.search,
92 type: 'search-global'
93 },
94 */
95 ...results
96 ]
97 }
98
99 this.results = results.filter(
100 (result: Result) => {
101 // if we're not in a channel or one of its videos/playlits, show all channel-related results
102 if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
103 // if we're in a channel, show all channel-related results except for the channel redirection itself
104 if (this.inChannel) return result.type !== 'channel'
105 // all other result types are kept
106 return true
107 }
108 )
109 }
110
111 setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) {
112 event.items.forEach(e => {
113 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
114 this.keyboardEventsManager.activeItem.active = true
115 } else {
116 e.active = false
117 }
118 })
119 }
120
121 initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
122 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
123
124 this.keyboardEventsManager = new ListKeyManager(event.items)
125
126 if (event.index !== undefined) {
127 this.keyboardEventsManager.setActiveItem(event.index)
128 } else {
129 this.keyboardEventsManager.setFirstItemActive()
130 }
131
132 this.keyboardEventsManager.change.subscribe(
133 _ => this.setEventItems(event)
134 )
135 }
136
137 handleKey (event: KeyboardEvent) {
138 event.stopImmediatePropagation()
139 if (!this.keyboardEventsManager) return
140
141 switch (event.key) {
142 case 'ArrowDown':
143 case 'ArrowUp':
144 this.keyboardEventsManager.onKeydown(event)
145 break
146 }
147 }
148
149 isOnSearch () {
150 return window.location.pathname === '/search'
151 }
152
153 doSearch () {
154 this.newSearch = false
155 const queryParams: Params = {}
156
157 if (this.isOnSearch() && this.route.snapshot.queryParams) {
158 Object.assign(queryParams, this.route.snapshot.queryParams)
159 }
160
161 Object.assign(queryParams, { search: this.search })
162
163 const o = this.authService.isLoggedIn()
164 ? this.loadUserLanguagesIfNeeded(queryParams)
165 : of(true)
166
167 o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
168 }
169
170 private loadUserLanguagesIfNeeded (queryParams: any) {
171 if (queryParams && queryParams.languageOneOf) return of(queryParams)
172
173 return this.authService.userInformationLoaded
174 .pipe(
175 first(),
176 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
177 )
178 }
179}
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html
new file mode 100644
index 000000000..d7ae3450a
--- /dev/null
+++ b/client/src/app/header/suggestion.component.html
@@ -0,0 +1,22 @@
1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
2 <div class="flex-shrink-0 mr-2 text-center">
3 <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
4 <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
5 </div>
6
7 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
8
9 <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div>
10
11 <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
12 <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
13 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
14 <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span>
15 <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
16 </div>
17
18 <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
19 Jump to channel
20 <span class="d-inline-block ml-1 v-align-middle">↵</span>
21 </div>
22</a> \ No newline at end of file
diff --git a/client/src/app/header/suggestion.component.scss b/client/src/app/header/suggestion.component.scss
new file mode 100644
index 000000000..1de2f43bd
--- /dev/null
+++ b/client/src/app/header/suggestion.component.scss
@@ -0,0 +1,32 @@
1@import '_mixins';
2
3a {
4 @include disable-default-a-behaviour;
5 width: 100%;
6
7 &, &:hover {
8 color: var(--mainForegroundColor);
9
10 &.focus-visible {
11 background-color: var(--mainHoverColor);
12 color: var(--mainBackgroundColor);
13 }
14 }
15}
16
17.bg-gray {
18 background-color: var(--mainBackgroundColor);
19}
20
21.text-gray-light {
22 color: var(--mainForegroundColor);
23}
24
25my-global-icon {
26 width: 17px;
27 position: relative;
28 top: -2px;
29 margin: 5px;
30
31 @include apply-svg-color(var(--mainForegroundColor));
32}
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts
new file mode 100644
index 000000000..69641b511
--- /dev/null
+++ b/client/src/app/header/suggestion.component.ts
@@ -0,0 +1,37 @@
1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'
2import { RouterLink } from '@angular/router'
3import { ListKeyManagerOption } from '@angular/cdk/a11y'
4
5export type Result = {
6 text: string
7 type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
8 routerLink?: RouterLink,
9 default?: boolean
10}
11
12@Component({
13 selector: 'my-suggestion',
14 templateUrl: './suggestion.component.html',
15 styleUrls: [ './suggestion.component.scss' ],
16 changeDetection: ChangeDetectionStrategy.OnPush
17})
18export class SuggestionComponent implements OnInit, ListKeyManagerOption {
19 @Input() result: Result
20 @Input() highlight: string
21 @Output() selected = new EventEmitter()
22
23 disabled = false
24 active = false
25
26 getLabel () {
27 return this.result.text
28 }
29
30 ngOnInit () {
31 if (this.result.default) this.active = true
32 }
33
34 selectItem () {
35 this.selected.emit(this.result)
36 }
37}
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html
new file mode 100644
index 000000000..8d017d78d
--- /dev/null
+++ b/client/src/app/header/suggestions.component.html
@@ -0,0 +1,6 @@
1<ul role="listbox" class="p-0 m-0">
2 <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
3 role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
4 <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
5 </li>
6</ul> \ No newline at end of file
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts
new file mode 100644
index 000000000..ee3ef73c2
--- /dev/null
+++ b/client/src/app/header/suggestions.component.ts
@@ -0,0 +1,24 @@
1import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
2import { SuggestionComponent } from './suggestion.component'
3
4@Component({
5 selector: 'my-suggestions',
6 templateUrl: './suggestions.component.html',
7 changeDetection: ChangeDetectionStrategy.OnPush
8})
9export class SuggestionsComponent implements AfterViewInit {
10 @Input() results: any[]
11 @Input() highlight: string
12 @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
13 @Output() init = new EventEmitter()
14
15 ngAfterViewInit () {
16 this.listItems.changes.subscribe(
17 _ => this.init.emit({ items: this.listItems })
18 )
19 }
20
21 hoverItem (index: number) {
22 this.init.emit({ items: this.listItems, index: index })
23 }
24}
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html
index 0b0bacff0..599b203ae 100644
--- a/client/src/app/login/login.component.html
+++ b/client/src/app/login/login.component.html
@@ -3,59 +3,79 @@
3 Login 3 Login
4 </div> 4 </div>
5 5
6 <div class="alert alert-info" *ngIf="signupAllowed === false" role="alert"> 6 <div class="alert alert-danger" i18n *ngIf="externalAuthError">
7 <h6 class="alert-heading" i18n> 7 Sorry but there was an issue with the external login process. Please <a routerLink="/about">contact an administrator</a>.
8 If you are looking for an account… 8 </div>
9 </h6>
10 9
11 <div i18n> 10 <ng-container *ngIf="!externalAuthError && !isAuthenticatedWithExternalAuth">
12 Currently this instance doesn't allow for user registration, but you can find an instance 11 <div class="looking-for-account alert alert-info" *ngIf="signupAllowed === false" role="alert">
13 that gives you the possibility to sign up for an account and upload your videos there. 12 <h6 class="alert-heading" i18n>
13 If you are looking for an account…
14 </h6>
14 15
15 <br /> 16 <div i18n>
17 Currently this instance doesn't allow for user registration, but you can find an instance
18 that gives you the possibility to sign up for an account and upload your videos there.
16 19
17 Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>. 20 <br />
18 </div>
19 </div>
20
21 <div *ngIf="error" class="alert alert-danger">{{ error }}
22 <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
23 </div>
24 21
25 <form role="form" (ngSubmit)="login()" [formGroup]="form"> 22 Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>.
26 <div class="form-group">
27 <div>
28 <label i18n for="username">User</label>
29 <input
30 type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1"
31 formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" #emailInput
32 >
33 <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account">
34 or create an account
35 </a>
36 </div> 23 </div>
24 </div>
37 25
38 <div *ngIf="formErrors.username" class="form-error"> 26 <div *ngIf="error" class="alert alert-danger">{{ error }}
39 {{ formErrors.username }} 27 <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
40 </div>
41 </div> 28 </div>
42 29
43 <div class="form-group"> 30 <div class="login-form-and-externals">
44 <label i18n for="password">Password</label> 31
45 <div> 32 <form role="form" (ngSubmit)="login()" [formGroup]="form">
46 <input 33 <div class="form-group">
47 type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password" 34 <div>
48 formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" 35 <label i18n for="username">User</label>
49 > 36 <input
50 <a i18n class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a> 37 type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1"
51 </div> 38 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" #usernameInput
52 <div *ngIf="formErrors.password" class="form-error"> 39 >
53 {{ formErrors.password }} 40 <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account">
41 or create an account
42 </a>
43 </div>
44
45 <div *ngIf="formErrors.username" class="form-error">
46 {{ formErrors.username }}
47 </div>
48 </div>
49
50 <div class="form-group">
51 <label i18n for="password">Password</label>
52 <div>
53 <input
54 type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password"
55 formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }"
56 >
57 <a i18n-title class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a>
58 </div>
59 <div *ngIf="formErrors.password" class="form-error">
60 {{ formErrors.password }}
61 </div>
62 </div>
63
64 <input type="submit" i18n-value value="Login" [disabled]="!form.valid">
65 </form>
66
67 <div class="external-login-blocks" *ngIf="getExternalLogins().length !== 0">
68 <div class="block-title" i18n>Or sign in with</div>
69
70 <div>
71 <a class="external-login-block" *ngFor="let auth of getExternalLogins()" [href]="getAuthHref(auth)" role="button">
72 {{ auth.authDisplayName }}
73 </a>
74 </div>
54 </div> 75 </div>
55 </div> 76 </div>
56 77
57 <input type="submit" i18n-value value="Login" [disabled]="!form.valid"> 78 </ng-container>
58 </form>
59</div> 79</div>
60 80
61<ng-template #forgotPasswordModal> 81<ng-template #forgotPasswordModal>
@@ -81,7 +101,10 @@
81 </div> 101 </div>
82 102
83 <div class="modal-footer inputs"> 103 <div class="modal-footer inputs">
84 <span i18n class="action-button action-button-cancel" (click)="hideForgotPasswordModal()">Cancel</span> 104 <input
105 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
106 (click)="hideForgotPasswordModal()" (key.enter)="hideForgotPasswordModal()"
107 >
85 108
86 <input 109 <input
87 type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit" 110 type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit"
diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss
index 8ac231475..db9f78f7c 100644
--- a/client/src/app/login/login.component.scss
+++ b/client/src/app/login/login.component.scss
@@ -21,9 +21,46 @@ input[type=submit] {
21 color: var(--mainForegroundColor); 21 color: var(--mainForegroundColor);
22 cursor: pointer; 22 cursor: pointer;
23 transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1); 23 transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1);
24 24
25 &:hover { 25 &:hover {
26 text-decoration: none !important; 26 text-decoration: none !important;
27 opacity: .7 !important; 27 opacity: .7 !important;
28 } 28 }
29} 29}
30
31.login-form-and-externals {
32 display: flex;
33 flex-wrap: wrap;
34 font-size: 15px;
35
36 form {
37 margin: 0 50px 20px 0;
38 }
39
40 .external-login-blocks {
41 min-width: 200px;
42
43 .block-title {
44 font-weight: $font-semibold;
45 }
46
47 .external-login-block {
48 @include disable-default-a-behaviour;
49
50 cursor: pointer;
51 border: 1px solid #d1d7e0;
52 border-radius: 5px;
53 color: var(--mainForegroundColor);
54 margin: 10px 10px 0 0;
55 display: flex;
56 justify-content: center;
57 align-items: center;
58 min-height: 35px;
59 min-width: 100px;
60
61 &:hover {
62 background-color: rgba(209, 215, 224, 0.5)
63 }
64 }
65 }
66}
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts
index 580f28822..fff4b43f6 100644
--- a/client/src/app/login/login.component.ts
+++ b/client/src/app/login/login.component.ts
@@ -1,4 +1,4 @@
1import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' 1import { Component, ElementRef, OnInit, ViewChild, AfterViewInit } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core' 2import { Notifier, RedirectService } from '@app/core'
3import { UserService } from '@app/shared' 3import { UserService } from '@app/shared'
4import { AuthService } from '../core' 4import { AuthService } from '../core'
@@ -8,7 +8,9 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val
8import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service' 8import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service'
9import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 9import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { ActivatedRoute } from '@angular/router' 10import { ActivatedRoute } from '@angular/router'
11import { ServerConfig } from '@shared/models/server/server-config.model' 11import { ServerConfig, RegisteredExternalAuthConfig } from '@shared/models/server/server-config.model'
12import { environment } from 'src/environments/environment'
13import { HooksService } from '@app/core/plugins/hooks.service'
12 14
13@Component({ 15@Component({
14 selector: 'my-login', 16 selector: 'my-login',
@@ -16,13 +18,17 @@ import { ServerConfig } from '@shared/models/server/server-config.model'
16 styleUrls: [ './login.component.scss' ] 18 styleUrls: [ './login.component.scss' ]
17}) 19})
18 20
19export class LoginComponent extends FormReactive implements OnInit { 21export class LoginComponent extends FormReactive implements OnInit, AfterViewInit {
20 @ViewChild('emailInput', { static: true }) input: ElementRef 22 @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef
21 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef 23 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
22 24
23 error: string = null 25 error: string = null
24 forgotPasswordEmail = '' 26 forgotPasswordEmail = ''
25 27
28 isAuthenticatedWithExternalAuth = false
29 externalAuthError = false
30 externalLogins: string[] = []
31
26 private openedForgotPasswordModal: NgbModalRef 32 private openedForgotPasswordModal: NgbModalRef
27 private serverConfig: ServerConfig 33 private serverConfig: ServerConfig
28 34
@@ -35,6 +41,7 @@ export class LoginComponent extends FormReactive implements OnInit {
35 private userService: UserService, 41 private userService: UserService,
36 private redirectService: RedirectService, 42 private redirectService: RedirectService,
37 private notifier: Notifier, 43 private notifier: Notifier,
44 private hooks: HooksService,
38 private i18n: I18n 45 private i18n: I18n
39 ) { 46 ) {
40 super() 47 super()
@@ -49,14 +56,40 @@ export class LoginComponent extends FormReactive implements OnInit {
49 } 56 }
50 57
51 ngOnInit () { 58 ngOnInit () {
52 this.serverConfig = this.route.snapshot.data.serverConfig 59 const snapshot = this.route.snapshot
60
61 this.serverConfig = snapshot.data.serverConfig
62
63 if (snapshot.queryParams.externalAuthToken) {
64 this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken)
65 return
66 }
67
68 if (snapshot.queryParams.externalAuthError) {
69 this.externalAuthError = true
70 return
71 }
53 72
54 this.buildForm({ 73 this.buildForm({
55 username: this.loginValidatorsService.LOGIN_USERNAME, 74 username: this.loginValidatorsService.LOGIN_USERNAME,
56 password: this.loginValidatorsService.LOGIN_PASSWORD 75 password: this.loginValidatorsService.LOGIN_PASSWORD
57 }) 76 })
77 }
78
79 ngAfterViewInit () {
80 if (this.usernameInput) {
81 this.usernameInput.nativeElement.focus()
82 }
83
84 this.hooks.runAction('action:login.init', 'login')
85 }
58 86
59 this.input.nativeElement.focus() 87 getExternalLogins () {
88 return this.serverConfig.plugin.registeredExternalAuths
89 }
90
91 getAuthHref (auth: RegisteredExternalAuthConfig) {
92 return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
60 } 93 }
61 94
62 login () { 95 login () {
@@ -68,11 +101,7 @@ export class LoginComponent extends FormReactive implements OnInit {
68 .subscribe( 101 .subscribe(
69 () => this.redirectService.redirectToPreviousRoute(), 102 () => this.redirectService.redirectToPreviousRoute(),
70 103
71 err => { 104 err => this.handleError(err)
72 if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.')
73 else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.')
74 else this.error = err.message
75 }
76 ) 105 )
77 } 106 }
78 107
@@ -99,4 +128,24 @@ export class LoginComponent extends FormReactive implements OnInit {
99 hideForgotPasswordModal () { 128 hideForgotPasswordModal () {
100 this.openedForgotPasswordModal.close() 129 this.openedForgotPasswordModal.close()
101 } 130 }
131
132 private loadExternalAuthToken (username: string, token: string) {
133 this.isAuthenticatedWithExternalAuth = true
134
135 this.authService.login(username, null, token)
136 .subscribe(
137 () => this.redirectService.redirectToPreviousRoute(),
138
139 err => {
140 this.handleError(err)
141 this.isAuthenticatedWithExternalAuth = false
142 }
143 )
144 }
145
146 private handleError (err: any) {
147 if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.')
148 else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.')
149 else this.error = err.message
150 }
102} 151}
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html
index 7975afba5..df2a102a3 100644
--- a/client/src/app/menu/avatar-notification.component.html
+++ b/client/src/app/menu/avatar-notification.component.html
@@ -30,7 +30,7 @@
30 </div> 30 </div>
31 31
32 <my-user-notifications 32 <my-user-notifications
33 [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10" 33 [ignoreLoadingBar]="true" [infiniteScroll]="false" [itemsPerPage]="10"
34 [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()" 34 [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()"
35 ></my-user-notifications> 35 ></my-user-notifications>
36 36
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts
index 989a11849..c447f031c 100644
--- a/client/src/app/menu/avatar-notification.component.ts
+++ b/client/src/app/menu/avatar-notification.component.ts
@@ -6,7 +6,6 @@ import { Notifier, UserNotificationSocket } from '@app/core'
6import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' 6import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
7import { NavigationEnd, Router } from '@angular/router' 7import { NavigationEnd, Router } from '@angular/router'
8import { filter } from 'rxjs/operators' 8import { filter } from 'rxjs/operators'
9import { UserNotificationsComponent } from '@app/shared'
10 9
11@Component({ 10@Component({
12 selector: 'my-avatar-notification', 11 selector: 'my-avatar-notification',
diff --git a/client/src/app/menu/language-chooser.component.scss b/client/src/app/menu/language-chooser.component.scss
index 72deb3952..6226a85cb 100644
--- a/client/src/app/menu/language-chooser.component.scss
+++ b/client/src/app/menu/language-chooser.component.scss
@@ -4,6 +4,13 @@
4.help-to-translate { 4.help-to-translate {
5 @include peertube-button-link; 5 @include peertube-button-link;
6 @include orange-button; 6 @include orange-button;
7
8 &.focus-visible,
9 &:focus {
10 box-shadow: none;
11 }
12
13 border-radius: 0;
7} 14}
8 15
9.modal-body { 16.modal-body {
diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts
index 4a6e4c75a..9bc934ad4 100644
--- a/client/src/app/menu/language-chooser.component.ts
+++ b/client/src/app/menu/language-chooser.component.ts
@@ -1,7 +1,9 @@
1import { Component, ElementRef, ViewChild } from '@angular/core' 1import { Component, ElementRef, ViewChild, Inject, LOCALE_ID } from '@angular/core'
2import { I18N_LOCALES } from '../../../../shared' 2import { I18N_LOCALES } from '../../../../shared'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { sortBy } from '@app/shared/misc/utils' 4import { sortBy } from '@app/shared/misc/utils'
5import { getCompleteLocale } from '@shared/models/i18n'
6import { isOnDevLocale, getDevLocale } from '@app/shared/i18n/i18n-utils'
5 7
6@Component({ 8@Component({
7 selector: 'my-language-chooser', 9 selector: 'my-language-chooser',
@@ -13,7 +15,10 @@ export class LanguageChooserComponent {
13 15
14 languages: { id: string, label: string }[] = [] 16 languages: { id: string, label: string }[] = []
15 17
16 constructor (private modalService: NgbModal) { 18 constructor (
19 private modalService: NgbModal,
20 @Inject(LOCALE_ID) private localeId: string
21 ) {
17 const l = Object.keys(I18N_LOCALES) 22 const l = Object.keys(I18N_LOCALES)
18 .map(k => ({ id: k, label: I18N_LOCALES[k] })) 23 .map(k => ({ id: k, label: I18N_LOCALES[k] }))
19 24
@@ -21,11 +26,18 @@ export class LanguageChooserComponent {
21 } 26 }
22 27
23 show () { 28 show () {
24 this.modalService.open(this.modal) 29 this.modalService.open(this.modal, { centered: true })
25 } 30 }
26 31
27 buildLanguageLink (lang: { id: string }) { 32 buildLanguageLink (lang: { id: string }) {
28 return window.location.origin + '/' + lang.id 33 return window.location.origin + '/' + lang.id
29 } 34 }
30 35
36 getCurrentLanguage () {
37 const english = 'English'
38 const locale = isOnDevLocale() ? getDevLocale() : getCompleteLocale(this.localeId)
39
40 if (locale) return I18N_LOCALES[locale] || english
41 return english
42 }
31} 43}
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 675fb597d..1cb51ef55 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -8,34 +8,65 @@
8 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a> 8 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a>
9 <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a> 9 <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a>
10 10
11 <div ngxClipboard [cbContent]="user.account?.nameWithHost" class="logged-in-username">{{ user.username }}</div> 11 <div class="logged-in-username">{{ user.username }}</div>
12 </div> 12 </div>
13 13
14 <div class="logged-in-more" ngbDropdown placement="bottom-right auto"> 14 <div class="logged-in-more" ngbDropdown [placement]="placement" container="body" autoClose="outside">
15 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon> 15 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon>
16 16
17 <div ngbDropdownMenu> 17 <div ngbDropdownMenu>
18 <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item"> 18 <a *ngIf="user.account" ngbDropdownItem ngbDropdownToggle class="dropdown-item" [routerLink]="[ '/accounts', user.account.nameWithHost ]">
19 <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>Public profile</ng-container> 19 <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>Public profile</ng-container>
20 </a> 20 </a>
21 21
22 <div class="dropdown-divider"></div> 22 <div class="dropdown-divider"></div>
23 23
24 <a routerLink="/my-account" class="dropdown-item"> 24 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account">
25 <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>Account settings</ng-container> 25 <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>Account settings</ng-container>
26 </a> 26 </a>
27 27
28 <a routerLink="/my-account/video-channels" class="dropdown-item"> 28 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/video-channels">
29 <my-global-icon iconName="folder"></my-global-icon> <ng-container i18n>Channels settings</ng-container> 29 <my-global-icon iconName="folder"></my-global-icon> <ng-container i18n>Channels settings</ng-container>
30 </a> 30 </a>
31 31
32 <div class="dropdown-divider"></div> 32 <div class="dropdown-divider"></div>
33 33
34 <a class="dropdown-item" href="https://joinpeertube.org/help" target="_blank" rel="noopener noreferrer"> 34 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()">
35 <my-global-icon iconName="help"></my-global-icon> <ng-container i18n>Help</ng-container> 35 <my-global-icon iconName="language"></my-global-icon>
36 <ng-container i18n>Interface: {{ language }}</ng-container>
37 <i class="ml-auto glyphicon glyphicon-menu-right"></i>
36 </a> 38 </a>
37 39
38 <a (click)="logout($event)" class="dropdown-item" href="#"> 40 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-settings">
41 <my-global-icon iconName="video-lang"></my-global-icon>
42 <ng-container i18n>Videos: {{ videoLanguages.join(', ') }}</ng-container>
43 <i class="ml-auto glyphicon glyphicon-menu-right"></i>
44 </a>
45
46 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-settings">
47 <my-global-icon class="hover-display-toggle" [ngClass]="{ 'not-displayed': user.nsfwPolicy === 'display' }" iconName="sensitive"></my-global-icon>
48 <my-global-icon class="hover-display-toggle" [ngClass]="{ 'not-displayed': user.nsfwPolicy !== 'display' }" iconName="unsensitive"></my-global-icon>
49 <ng-container i18n>Sensitive: {{ nsfwPolicy }}</ng-container>
50 <i class="ml-auto glyphicon glyphicon-menu-right"></i>
51 </a>
52
53 <a ngbDropdownItem class="dropdown-item" (click)="toggleUseP2P()">
54 <my-global-icon iconName="p2p"></my-global-icon>
55 <ng-container i18n>Help share videos</ng-container>
56 <input type="checkbox" [checked]="user.webTorrentEnabled"/><label class="ml-auto" for="switch">Toggle p2p</label>
57 </a>
58
59 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account">
60 <my-global-icon iconName="more-horizontal"></my-global-icon> <ng-container i18n>More account settings</ng-container>
61 </a>
62
63 <div class="dropdown-divider"></div>
64
65 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openHotkeysCheatSheet()">
66 <i class="icon icon-shortcuts"></i> <ng-container i18n>Keyboard shortcuts</ng-container>
67 </a>
68
69 <a ngbDropdownItem ngbDropdownToggle (click)="logout($event)" class="dropdown-item" href="#">
39 <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container> 70 <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container>
40 </a> 71 </a>
41 </div> 72 </div>
@@ -48,7 +79,7 @@
48 </div> 79 </div>
49 80
50 <div *ngIf="isLoggedIn" class="panel-block"> 81 <div *ngIf="isLoggedIn" class="panel-block">
51 <div i18n class="block-title">My library</div> 82 <div i18n class="block-title">MY LIBRARY</div>
52 83
53 <a routerLink="/my-account/videos" routerLinkActive="active"> 84 <a routerLink="/my-account/videos" routerLinkActive="active">
54 <my-global-icon iconName="videos"></my-global-icon> 85 <my-global-icon iconName="videos"></my-global-icon>
@@ -73,7 +104,7 @@
73 </div> 104 </div>
74 105
75 <div class="panel-block"> 106 <div class="panel-block">
76 <div i18n class="block-title">Videos</div> 107 <div i18n class="block-title">VIDEOS</div>
77 108
78 <a routerLink="/videos/overview" routerLinkActive="active"> 109 <a routerLink="/videos/overview" routerLinkActive="active">
79 <my-global-icon iconName="globe"></my-global-icon> 110 <my-global-icon iconName="globe"></my-global-icon>
@@ -100,32 +131,56 @@
100 <ng-container i18n>Local</ng-container> 131 <ng-container i18n>Local</ng-container>
101 </a> 132 </a>
102 </div> 133 </div>
134 </div>
103 135
136 <div class="footer">
104 <div class="panel-block"> 137 <div class="panel-block">
105 <div class="block-title" i18n>More</div>
106
107 <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> 138 <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active">
108 <my-global-icon iconName="administration"></my-global-icon> 139 <my-global-icon iconName="cog"></my-global-icon>
109 <ng-container i18n>Administration</ng-container> 140 <ng-container i18n>Administration</ng-container>
110 </a> 141 </a>
111 142 <a *ngIf="!isLoggedIn" (click)="openQuickSettings()">
112 <a routerLink="/about" routerLinkActive="active"> 143 <my-global-icon iconName="cog"></my-global-icon>
113 <my-global-icon iconName="about"></my-global-icon> 144 <ng-container i18n>Settings</ng-container>
145 </a>
146 <a routerLink="/about/instance">
147 <my-global-icon iconName="help"></my-global-icon>
114 <ng-container i18n>About</ng-container> 148 <ng-container i18n>About</ng-container>
115 </a> 149 </a>
116 </div> 150 </div>
117 </div>
118 151
119 <div class="footer d-flex justify-content-between"> 152 <div class="bottom-links">
120 <span class="language">
121 <span tabindex="0" role="button" (keyup.enter)="openLanguageChooser()" (click)="openLanguageChooser()" i18n-title title="Change the language" class="icon icon-language"></span>
122 </span>
123 153
124 <span class="shortcuts"> 154 <div class="footer-links">
125 <span tabindex="0" role="button" (keyup.enter)="openHotkeysCheatSheet()" (click)="openHotkeysCheatSheet()" i18n-title title="Show keyboard shortcuts" class="icon icon-shortcuts"></span> 155 <div *ngIf="isLoggedIn === false">
126 </span> 156 <span role="button" (click)="openLanguageChooser()" class="c-hand" i18n>Interface: {{ language }}</span>
157 </div>
158
159 <div>
160 <a i18n routerLink="/about/instance">Contact</a>
161 <a i18n href="https://joinpeertube.org/help" i18n-title title="Get help using PeerTube" target="_blank" rel="noopener noreferrer">Help</a>
162 <a i18n href="https://joinpeertube.org/faq" i18n-title title="Frequently asked questions about PeerTube" target="_blank" rel="noopener noreferrer">FAQ</a>
163 <a i18n routerLink="/about/instance" fragment="statistics">Stats</a>
164 <a i18n href="https://docs.joinpeertube.org/api-rest-reference.html" i18n-title title="API documentation" target="_blank" rel="noopener noreferrer">API</a>
165 <a (click)="openHotkeysCheatSheet()" class="c-hand" i18n>Shortcuts</a>
166 </div>
167 </div>
168
169 <div class="footer-copyleft">
170 <small class="d-inline" i18n-title title="powered by PeerTube - CopyLeft 2015-2020">
171 <a href="https://joinpeertube.org" i18n-title title="PeerTube website" target="_blank" rel="noopener noreferrer" i18n>
172 powered by PeerTube
173 </a>
174
175 <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" i18n-title title="PeerTube license" target="_blank" rel="noopener noreferrer">
176 <span aria-label="copyleft" class="d-inline-block" style="transform: rotateY(180deg)">&copy;</span> 2015-2020
177 </a>
178 </small>
179 </div>
180 </div>
127 </div> 181 </div>
128 </menu> 182 </menu>
129</div> 183</div>
130 184
131<my-language-chooser #languageChooserModal></my-language-chooser> 185<my-language-chooser #languageChooserModal></my-language-chooser>
186<my-quick-settings #quickSettingsModal></my-quick-settings>
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss
index b05173751..5bff0c328 100644
--- a/client/src/app/menu/menu.component.scss
+++ b/client/src/app/menu/menu.component.scss
@@ -6,7 +6,8 @@
6 height: calc(100vh - #{$header-height}); 6 height: calc(100vh - #{$header-height});
7 padding: 0; 7 padding: 0;
8 width: $menu-width; 8 width: $menu-width;
9 z-index: 10000; 9 z-index: z(menu);
10 scrollbar-color: var(--actionButtonColor) var(--menuBackgroundColor);
10} 11}
11 12
12menu { 13menu {
@@ -26,9 +27,13 @@ menu {
26 overflow-y: auto; 27 overflow-y: auto;
27 } 28 }
28 29
30 @media not all and (hover: hover) and (pointer: fine) {
31 overflow-y: auto;
32 }
33
29 &.logged-in { 34 &.logged-in {
30 .panel-block { 35 .panel-block {
31 margin-bottom: 25px; 36 margin-bottom: 20px;
32 } 37 }
33 38
34 .block-title { 39 .block-title {
@@ -87,22 +92,6 @@ menu {
87 @include apply-svg-color(var(--menuForegroundColor)); 92 @include apply-svg-color(var(--menuForegroundColor));
88 } 93 }
89 } 94 }
90
91 .dropdown-item {
92 @include dropdown-with-icon-item;
93
94 my-global-icon {
95 width: 22px;
96 height: 22px;
97
98 &[iconName="sign-out"] {
99 position: relative;
100 right: -1px;
101 height: 21px;
102 width: 21px;
103 }
104 }
105 }
106 } 95 }
107 } 96 }
108 97
@@ -142,7 +131,7 @@ menu {
142 } 131 }
143 132
144 .panel-block { 133 .panel-block {
145 margin-bottom: 45px; 134 margin-bottom: 15px;
146 135
147 a { 136 a {
148 @include disable-default-a-behaviour; 137 @include disable-default-a-behaviour;
@@ -197,58 +186,160 @@ menu {
197 } 186 }
198 187
199 .footer { 188 .footer {
200 padding-bottom: 15px;
201 padding-left: $menu-lateral-padding;
202 padding-right: $menu-lateral-padding;
203 width: $menu-width; 189 width: $menu-width;
190 padding-bottom: 15px;
204 191
205 .language, .shortcuts, .color-palette { 192 .bottom-links {
206 display: inline-block; 193 display: flex;
207 color: $menu-bottom-color; 194 flex-direction: column;
208 cursor: pointer; 195 padding: 0 $menu-lateral-padding;
209 font-size: 12px; 196 }
210 font-weight: $font-semibold;
211 197
212 .icon { 198 $footer-links-base-opacity: .8;
213 @include disable-outline;
214 @include icon(28px);
215 opacity: 0.9;
216 199
217 &.icon-language { 200 .footer-links {
218 position: relative; 201 &, > div {
219 top: -1px; 202 display: flex;
220 width: 28px; 203 flex-wrap: wrap;
221 height: 24px; 204 }
222 205
223 background-image: url('../../assets/images/menu/language.png'); 206 a, span[role=button] {
207 display: inline-block;
208 text-decoration: none;
209 color: var(--mainBackgroundColor);
210 opacity: $footer-links-base-opacity;
211 white-space: nowrap;
212 font-size: 90%;
213 font-weight: 500;
214 line-height: 1.4rem;
215 margin-right: 8px;
216
217 &.inline-global-icon {
218 display: inline-flex;
219 align-items: center;
220 white-space: nowrap;
221 height: 1.4rem;
222
223 my-global-icon {
224 @include apply-svg-color(var(--mainBackgroundColor));
225
226 display: flex;
227 width: auto;
228 height: 90%;
229 margin-right: .2rem;
230 }
224 } 231 }
232 }
233 }
225 234
226 &.icon-shortcuts { 235 .footer-copyleft small a {
227 position: relative; 236 @include disable-default-a-behaviour;
228 top: -1px;
229 width: 24px;
230 height: 24px;
231 237
232 background-image: url('../../assets/images/menu/keyboard.png'); 238 color: var(--mainBackgroundColor);
233 filter: invert(100%); 239 opacity: $footer-links-base-opacity - .2;
234 } 240 }
241 }
242}
235 243
236 &.icon-moonsun { 244.dropdown-menu {
237 margin-left: 10px; 245 width: calc(100% + 40px);
238 position: relative; 246}
239 top: -1px;
240 width: 24px;
241 height: 24px;
242 247
243 background-image: url('../../assets/images/menu/moonsun.svg'); 248.dropdown-item {
244 } 249 @include dropdown-with-icon-item;
245 250
246 &:hover { 251 cursor: pointer;
247 opacity: 1; 252 display: flex;
248 } 253 align-items: center;
249 } 254
255 i.glyphicon-menu-right {
256 opacity: .4;
257 }
258
259 my-global-icon {
260 &[iconName="cog"],
261 &[iconName="sign-out"] {
262 position: relative;
263 right: -2px;
264 height: 20px;
265 width: 20px;
250 } 266 }
251 } 267 }
268
269 my-global-icon.not-displayed {
270 display: none;
271 }
272
273 &:hover {
274 my-global-icon.hover-display-toggle.not-displayed {
275 display: inherit;
276 }
277 my-global-icon.hover-display-toggle {
278 display: none;
279 }
280 }
281}
282
283.more-settings {
284 text-transform: uppercase;
285 font-size: 80%;
286 color: #6c757d;
287}
288
289.icon {
290 @include disable-outline;
291 @include icon(22px);
292 opacity: 0.8;
293
294 &.icon-shortcuts {
295 position: relative;
296 top: -1px;
297 margin-right: 10px;
298
299 background-image: url('../../assets/images/menu/keyboard.png');
300 }
301}
302
303input[type=checkbox]{
304 position: absolute;
305 visibility: hidden;
306}
307
308label {
309 cursor: pointer;
310 text-indent: -9999px;
311 width: 35px;
312 height: 20px;
313 background: #cccccc;
314 display: block;
315 border-radius: 100px;
316 position: relative;
317 margin: 0;
318
319 &:after {
320 content: '';
321 position: absolute;
322 top: 3px;
323 left: 3px;
324 width: 14px;
325 height: 14px;
326 background: var(--mainBackgroundColor);
327 border-radius: 50%;
328 transition: 0.3s ease-out;
329 }
330
331 &:active:after {
332 width: 40px;
333 }
334}
335
336input:checked + label {
337 background: var(--mainColor);
338
339 &:after {
340 left: calc(100% - 3px);
341 transform: translateX(-100%);
342 }
252} 343}
253 344
254@media screen and (max-width: $mobile-view) { 345@media screen and (max-width: $mobile-view) {
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 1d7651e78..015c14bce 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -1,10 +1,14 @@
1import { Component, OnInit, ViewChild } from '@angular/core' 1import { Component, OnInit, ViewChild } from '@angular/core'
2import { UserRight } from '../../../../shared/models/users/user-right.enum' 2import { UserRight } from '../../../../shared/models/users/user-right.enum'
3import { AuthService, AuthStatus, RedirectService, ServerService, ThemeService } from '../core' 3import { AuthService, AuthStatus, RedirectService, ServerService } from '../core'
4import { User } from '../shared/users/user.model' 4import { User } from '@app/shared/users/user.model'
5import { UserService } from '@app/shared/users/user.service'
5import { LanguageChooserComponent } from '@app/menu/language-chooser.component' 6import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
6import { HotkeysService } from 'angular2-hotkeys' 7import { HotkeysService } from 'angular2-hotkeys'
7import { ServerConfig } from '@shared/models' 8import { ServerConfig, VideoConstant } from '@shared/models'
9import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { ScreenService } from '@app/shared/misc/screen.service'
8 12
9@Component({ 13@Component({
10 selector: 'my-menu', 14 selector: 'my-menu',
@@ -13,12 +17,17 @@ import { ServerConfig } from '@shared/models'
13}) 17})
14export class MenuComponent implements OnInit { 18export class MenuComponent implements OnInit {
15 @ViewChild('languageChooserModal', { static: true }) languageChooserModal: LanguageChooserComponent 19 @ViewChild('languageChooserModal', { static: true }) languageChooserModal: LanguageChooserComponent
20 @ViewChild('quickSettingsModal', { static: true }) quickSettingsModal: QuickSettingsModalComponent
16 21
17 user: User 22 user: User
18 isLoggedIn: boolean 23 isLoggedIn: boolean
24
19 userHasAdminAccess = false 25 userHasAdminAccess = false
20 helpVisible = false 26 helpVisible = false
21 27
28 videoLanguages: string[] = []
29
30 private languages: VideoConstant<string>[] = []
22 private serverConfig: ServerConfig 31 private serverConfig: ServerConfig
23 private routesPerRight: { [ role in UserRight ]?: string } = { 32 private routesPerRight: { [ role in UserRight ]?: string } = {
24 [UserRight.MANAGE_USERS]: '/admin/users', 33 [UserRight.MANAGE_USERS]: '/admin/users',
@@ -31,10 +40,25 @@ export class MenuComponent implements OnInit {
31 40
32 constructor ( 41 constructor (
33 private authService: AuthService, 42 private authService: AuthService,
43 private userService: UserService,
34 private serverService: ServerService, 44 private serverService: ServerService,
35 private redirectService: RedirectService, 45 private redirectService: RedirectService,
36 private hotkeysService: HotkeysService 46 private hotkeysService: HotkeysService,
37 ) {} 47 private screenService: ScreenService,
48 private i18n: I18n
49 ) { }
50
51 get isInMobileView () {
52 return this.screenService.isInMobileView()
53 }
54
55 get placement () {
56 if (this.isInMobileView) {
57 return 'left-top auto'
58 } else {
59 return 'right-top auto'
60 }
61 }
38 62
39 ngOnInit () { 63 ngOnInit () {
40 this.serverConfig = this.serverService.getTmpConfig() 64 this.serverConfig = this.serverService.getTmpConfig()
@@ -63,9 +87,35 @@ export class MenuComponent implements OnInit {
63 } 87 }
64 ) 88 )
65 89
66 this.hotkeysService.cheatSheetToggle.subscribe(isOpen => { 90 this.hotkeysService.cheatSheetToggle
67 this.helpVisible = isOpen 91 .subscribe(isOpen => this.helpVisible = isOpen)
68 }) 92
93 this.serverService.getVideoLanguages()
94 .subscribe(languages => {
95 this.languages = languages
96
97 this.authService.userInformationLoaded
98 .subscribe(() => this.buildUserLanguages())
99 })
100 }
101
102 get language () {
103 return this.languageChooserModal.getCurrentLanguage()
104 }
105
106 get nsfwPolicy () {
107 if (!this.user) return
108
109 switch (this.user.nsfwPolicy) {
110 case 'do_not_list':
111 return this.i18n('hide')
112
113 case 'blur':
114 return this.i18n('blur')
115
116 case 'display':
117 return this.i18n('display')
118 }
69 } 119 }
70 120
71 isRegistrationAllowed () { 121 isRegistrationAllowed () {
@@ -117,6 +167,40 @@ export class MenuComponent implements OnInit {
117 this.hotkeysService.cheatSheetToggle.next(!this.helpVisible) 167 this.hotkeysService.cheatSheetToggle.next(!this.helpVisible)
118 } 168 }
119 169
170 openQuickSettings () {
171 this.quickSettingsModal.show()
172 }
173
174 toggleUseP2P () {
175 if (!this.user) return
176 this.user.webTorrentEnabled = !this.user.webTorrentEnabled
177
178 this.userService.updateMyProfile({ webTorrentEnabled: this.user.webTorrentEnabled })
179 .subscribe(() => this.authService.refreshUserInformation())
180 }
181
182 langForLocale (localeId: string) {
183 if (localeId === '_unknown') return this.i18n('Unknown')
184
185 return this.languages.find(lang => lang.id === localeId).label
186 }
187
188 private buildUserLanguages () {
189 if (!this.user) {
190 this.videoLanguages = []
191 return
192 }
193
194 if (!this.user.videoLanguages) {
195 this.videoLanguages = [ this.i18n('any language') ]
196 return
197 }
198
199 this.videoLanguages = this.user.videoLanguages
200 .map(locale => this.langForLocale(locale))
201 .map(value => value === undefined ? '?' : value)
202 }
203
120 private computeIsUserHasAdminAccess () { 204 private computeIsUserHasAdminAccess () {
121 const right = this.getFirstAdminRightAvailable() 205 const right = this.getFirstAdminRightAvailable()
122 206
diff --git a/client/src/app/modal/custom-modal.component.html b/client/src/app/modal/custom-modal.component.html
new file mode 100644
index 000000000..06ecc2743
--- /dev/null
+++ b/client/src/app/modal/custom-modal.component.html
@@ -0,0 +1,20 @@
1<ng-template #modal let-hide="close">
2 <div class="modal-header">
3 <h4 class="modal-title">{{title}}</h4>
4 <my-global-icon *ngIf="close" iconName="cross" aria-label="Close" role="button" (click)="onCloseClick()"></my-global-icon>
5 </div>
6
7 <div class="modal-body" [innerHTML]="content"></div>
8
9 <div *ngIf="hasCancel() || hasConfirm()" class="modal-footer inputs">
10 <input
11 *ngIf="hasCancel()" type="button" role="button" value="{{cancel.value}}" class="action-button action-button-cancel"
12 (click)="onCancelClick()" (key.enter)="onCancelClick()"
13 >
14
15 <input
16 *ngIf="hasConfirm()" type="button" role="button" value="{{confirm.value}}" class="action-button action-button-confirm"
17 (click)="onConfirmClick()" (key.enter)="onConfirmClick()"
18 >
19 </div>
20</ng-template>
diff --git a/client/src/app/modal/custom-modal.component.scss b/client/src/app/modal/custom-modal.component.scss
new file mode 100644
index 000000000..a7fa30cf5
--- /dev/null
+++ b/client/src/app/modal/custom-modal.component.scss
@@ -0,0 +1,20 @@
1@import '_mixins';
2@import '_variables';
3
4.modal-body {
5 font-size: 15px;
6}
7
8li {
9 margin-bottom: 10px;
10}
11
12.action-button-cancel {
13 @include peertube-button;
14 @include grey-button;
15}
16
17.action-button-confirm {
18 @include peertube-button;
19 @include orange-button;
20}
diff --git a/client/src/app/modal/custom-modal.component.ts b/client/src/app/modal/custom-modal.component.ts
new file mode 100644
index 000000000..a98579085
--- /dev/null
+++ b/client/src/app/modal/custom-modal.component.ts
@@ -0,0 +1,93 @@
1import { Component, ElementRef, ViewChild, Input } from '@angular/core'
2import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
3
4@Component({
5 selector: 'my-custom-modal',
6 templateUrl: './custom-modal.component.html',
7 styleUrls: [ './custom-modal.component.scss' ]
8})
9export class CustomModalComponent {
10 @ViewChild('modal', { static: true }) modal: ElementRef
11
12 @Input() title: string
13 @Input() content: string
14 @Input() close?: boolean
15 @Input() cancel?: { value: string, action?: () => void }
16 @Input() confirm?: { value: string, action?: () => void }
17
18 private modalRef: NgbModalRef
19
20 constructor (
21 private modalService: NgbModal
22 ) { }
23
24 show (input: {
25 title: string,
26 content: string,
27 close?: boolean,
28 cancel?: { value: string, action?: () => void },
29 confirm?: { value: string, action?: () => void }
30 }) {
31 if (this.modalRef instanceof NgbModalRef && this.modalService.hasOpenModals()) {
32 console.error('Cannot open another custom modal, one is already opened.')
33 return
34 }
35
36 const { title, content, close, cancel, confirm } = input
37
38 this.title = title
39 this.content = content
40 this.close = close
41 this.cancel = cancel
42 this.confirm = confirm
43
44 this.modalRef = this.modalService.open(this.modal, {
45 centered: true,
46 backdrop: 'static',
47 keyboard: false,
48 size: 'lg'
49 })
50 }
51
52 onCancelClick () {
53 this.modalRef.close()
54
55 if (typeof this.cancel.action === 'function') {
56 this.cancel.action()
57 }
58
59 this.destroy()
60 }
61
62 onCloseClick () {
63 this.modalRef.close()
64 this.destroy()
65 }
66
67 onConfirmClick () {
68 this.modalRef.close()
69
70 if (typeof this.confirm.action === 'function') {
71 this.confirm.action()
72 }
73
74 this.destroy()
75 }
76
77 hasCancel () {
78 return typeof this.cancel !== 'undefined'
79 }
80
81 hasConfirm () {
82 return typeof this.confirm !== 'undefined'
83 }
84
85 private destroy () {
86 delete this.modalRef
87 delete this.title
88 delete this.content
89 delete this.close
90 delete this.cancel
91 delete this.confirm
92 }
93}
diff --git a/client/src/app/modal/instance-config-warning-modal.component.html b/client/src/app/modal/instance-config-warning-modal.component.html
index 93391c0a8..44c994bc8 100644
--- a/client/src/app/modal/instance-config-warning-modal.component.html
+++ b/client/src/app/modal/instance-config-warning-modal.component.html
@@ -21,7 +21,7 @@
21 <li i18n *ngIf="!about.instance.terms">Instance terms</li> 21 <li i18n *ngIf="!about.instance.terms">Instance terms</li>
22 </ul> 22 </ul>
23 23
24 <p> 24 <p i18n>
25 Please consider to configure these fields to help people to choose <strong>the appropriate instance</strong>. 25 Please consider to configure these fields to help people to choose <strong>the appropriate instance</strong>.
26 Without them, your instance may not be referenced on <a target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">JoinPeerTube website</a>. 26 Without them, your instance may not be referenced on <a target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">JoinPeerTube website</a>.
27 </p> 27 </p>
@@ -40,7 +40,10 @@
40 40
41 </my-peertube-checkbox> 41 </my-peertube-checkbox>
42 42
43 <span i18n class="action-button action-button-cancel" (click)="hide()">Close</span> 43 <input
44 type="button" role="button" i18n-value value="Close" class="action-button action-button-cancel"
45 (click)="hide()" (key.enter)="hide()"
46 >
44 </div> 47 </div>
45 48
46</ng-template> 49</ng-template>
diff --git a/client/src/app/modal/instance-config-warning-modal.component.ts b/client/src/app/modal/instance-config-warning-modal.component.ts
index 742a7dd41..5e1433548 100644
--- a/client/src/app/modal/instance-config-warning-modal.component.ts
+++ b/client/src/app/modal/instance-config-warning-modal.component.ts
@@ -24,7 +24,7 @@ export class InstanceConfigWarningModalComponent {
24 show (about: About) { 24 show (about: About) {
25 this.about = about 25 this.about = about
26 26
27 const ref = this.modalService.open(this.modal) 27 const ref = this.modalService.open(this.modal, { centered: true })
28 28
29 ref.result.finally(() => { 29 ref.result.finally(() => {
30 if (this.stopDisplayModal === true) this.doNotOpenAgain() 30 if (this.stopDisplayModal === true) this.doNotOpenAgain()
diff --git a/client/src/app/modal/quick-settings-modal.component.html b/client/src/app/modal/quick-settings-modal.component.html
new file mode 100644
index 000000000..e2ea51b92
--- /dev/null
+++ b/client/src/app/modal/quick-settings-modal.component.html
@@ -0,0 +1,20 @@
1<ng-template #modal let-hide="close">
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Settings</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8 <div i18n class="mb-4 quick-settings-title">Display settings</div>
9
10 <my-account-video-settings *ngIf="!isUserLoggedIn()" [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true">
11 <ng-container ngProjectAs="inner-title">
12 <div i18n class="mb-4 mt-4 quick-settings-title">Video settings</div>
13 </ng-container>
14 </my-account-video-settings>
15
16 <div i18n class="mb-4 mt-4 quick-settings-title">Interface settings</div>
17
18 <my-account-interface-settings *ngIf="!isUserLoggedIn()" [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"></my-account-interface-settings>
19 </div>
20</ng-template>
diff --git a/client/src/app/modal/quick-settings-modal.component.scss b/client/src/app/modal/quick-settings-modal.component.scss
new file mode 100644
index 000000000..ef21542f3
--- /dev/null
+++ b/client/src/app/modal/quick-settings-modal.component.scss
@@ -0,0 +1,39 @@
1@import '_mixins';
2
3.modal-button {
4 @include disable-default-a-behaviour;
5 transform: translateY(2px);
6
7 button {
8 @include peertube-button;
9 @include grey-button;
10 @include button-with-icon(18px, 4px, -1px);
11
12 my-global-icon {
13 @include apply-svg-color(#585858);
14 }
15 }
16
17 & + .modal-button {
18 margin-left: 1rem;
19 }
20}
21
22.icon {
23 @include disable-outline;
24 @include icon(22px);
25 opacity: 0.6;
26 margin-left: -1px;
27
28 &.icon-shortcuts {
29 position: relative;
30 top: -1px;
31 margin-right: 4px;
32
33 background-image: url('../../assets/images/menu/keyboard.png');
34 }
35}
36
37.quick-settings-title {
38 @include in-content-small-title;
39} \ No newline at end of file
diff --git a/client/src/app/modal/quick-settings-modal.component.ts b/client/src/app/modal/quick-settings-modal.component.ts
new file mode 100644
index 000000000..41d6c9f47
--- /dev/null
+++ b/client/src/app/modal/quick-settings-modal.component.ts
@@ -0,0 +1,62 @@
1import { Component, ViewChild, OnInit } from '@angular/core'
2import { AuthService, AuthStatus } from '@app/core'
3import { FormReactive, FormValidatorService, UserService, User } from '@app/shared'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { ReplaySubject } from 'rxjs'
7import { LocalStorageService } from '@app/shared/misc/storage.service'
8import { filter } from 'rxjs/operators'
9
10@Component({
11 selector: 'my-quick-settings',
12 templateUrl: './quick-settings-modal.component.html',
13 styleUrls: [ './quick-settings-modal.component.scss' ]
14})
15export class QuickSettingsModalComponent extends FormReactive implements OnInit {
16 @ViewChild('modal', { static: true }) modal: NgbModal
17
18 user: User
19 userInformationLoaded = new ReplaySubject<boolean>(1)
20
21 private openedModal: NgbModalRef
22
23 constructor (
24 protected formValidatorService: FormValidatorService,
25 private modalService: NgbModal,
26 private userService: UserService,
27 private authService: AuthService,
28 private localStorageService: LocalStorageService
29 ) {
30 super()
31 }
32
33 ngOnInit () {
34 this.user = this.userService.getAnonymousUser()
35 this.localStorageService.watch().subscribe(
36 () => this.user = this.userService.getAnonymousUser()
37 )
38 this.userInformationLoaded.next(true)
39
40 this.authService.loginChangedSource
41 .pipe(filter(status => status !== AuthStatus.LoggedIn))
42 .subscribe(
43 () => {
44 this.user = this.userService.getAnonymousUser()
45 this.userInformationLoaded.next(true)
46 }
47 )
48 }
49
50 isUserLoggedIn () {
51 return this.authService.isLoggedIn()
52 }
53
54 show () {
55 this.openedModal = this.modalService.open(this.modal, { centered: true })
56 }
57
58 hide () {
59 this.openedModal.close()
60 this.form.reset()
61 }
62}
diff --git a/client/src/app/modal/welcome-modal.component.html b/client/src/app/modal/welcome-modal.component.html
index 9b210eb4d..8bfcc4bf6 100644
--- a/client/src/app/modal/welcome-modal.component.html
+++ b/client/src/app/modal/welcome-modal.component.html
@@ -76,10 +76,14 @@
76 </div> 76 </div>
77 77
78 <div class="modal-footer inputs"> 78 <div class="modal-footer inputs">
79 <span i18n class="action-button action-button-understood" (click)="hide()">Remind me later</span> 79 <input
80 80 type="button" role="button" i18n-value value="Remind me later" class="action-button action-button-understood"
81 <a i18n (click)="doNotOpenAgain(); hide()" class="configure-instance-button" href="/admin/config/edit-custom" target="_blank" 81 (click)="hide()" (key.enter)="hide()"
82 rel="noopener noreferrer"> 82 >
83
84 <a i18n (click)="doNotOpenAgain(); hide()" (key.enter)="doNotOpenAgain(); hide()"
85 class="configure-instance-button" href="/admin/config/edit-custom" target="_blank"
86 rel="noopener noreferrer" ngbAutofocus>
83 Configure my instance 87 Configure my instance
84 </a> 88 </a>
85 </div> 89 </div>
diff --git a/client/src/app/modal/welcome-modal.component.ts b/client/src/app/modal/welcome-modal.component.ts
index 19a147b85..e022776e3 100644
--- a/client/src/app/modal/welcome-modal.component.ts
+++ b/client/src/app/modal/welcome-modal.component.ts
@@ -18,7 +18,8 @@ export class WelcomeModalComponent {
18 ) { } 18 ) { }
19 19
20 show () { 20 show () {
21 this.modalService.open(this.modal,{ 21 this.modalService.open(this.modal, {
22 centered: true,
22 backdrop: 'static', 23 backdrop: 'static',
23 keyboard: false, 24 keyboard: false,
24 size: 'lg' 25 size: 'lg'
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
index 07fb2c048..54fc7338f 100644
--- a/client/src/app/search/search-filters.component.html
+++ b/client/src/app/search/search-filters.component.html
@@ -46,6 +46,7 @@
46 type="text" id="original-publication-after" name="original-publication-after" 46 type="text" id="original-publication-after" name="original-publication-after"
47 i18n-placeholder placeholder="After..." 47 i18n-placeholder placeholder="After..."
48 [(ngModel)]="originallyPublishedStartYear" 48 [(ngModel)]="originallyPublishedStartYear"
49 class="form-control"
49 > 50 >
50 </div> 51 </div>
51 <div class="col-sm-6"> 52 <div class="col-sm-6">
@@ -55,6 +56,7 @@
55 type="text" id="original-publication-before" name="original-publication-before" 56 type="text" id="original-publication-before" name="original-publication-before"
56 i18n-placeholder placeholder="Before..." 57 i18n-placeholder placeholder="Before..."
57 [(ngModel)]="originallyPublishedEndYear" 58 [(ngModel)]="originallyPublishedEndYear"
59 class="form-control"
58 > 60 >
59 </div> 61 </div>
60 </div> 62 </div>
@@ -102,8 +104,8 @@
102 Reset 104 Reset
103 </button> 105 </button>
104 <div class="peertube-select-container"> 106 <div class="peertube-select-container">
105 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf"> 107 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf" class="form-control">
106 <option [value]="undefined" i18n>Any or no category set</option> 108 <option [value]="undefined" i18n>Display all categories</option>
107 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> 109 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
108 </select> 110 </select>
109 </div> 111 </div>
@@ -115,8 +117,8 @@
115 Reset 117 Reset
116 </button> 118 </button>
117 <div class="peertube-select-container"> 119 <div class="peertube-select-container">
118 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf"> 120 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf" class="form-control">
119 <option [value]="undefined" i18n>Any or no license set</option> 121 <option [value]="undefined" i18n>Display all licenses</option>
120 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> 122 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
121 </select> 123 </select>
122 </div> 124 </div>
@@ -128,8 +130,8 @@
128 Reset 130 Reset
129 </button> 131 </button>
130 <div class="peertube-select-container"> 132 <div class="peertube-select-container">
131 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf"> 133 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf" class="form-control">
132 <option [value]="undefined" i18n>Any or no language set</option> 134 <option [value]="undefined" i18n>Display all languages</option>
133 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> 135 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
134 </select> 136 </select>
135 </div> 137 </div>
@@ -146,7 +148,7 @@
146 [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf" 148 [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
147 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 149 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
148 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" 150 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
149 maxItems="5" modelAsStrings="true" 151 [maxItems]="5" [modelAsStrings]="true"
150 ></tag-input> 152 ></tag-input>
151 </div> 153 </div>
152 154
@@ -159,7 +161,7 @@
159 [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf" 161 [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
160 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 162 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
161 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" 163 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
162 maxItems="5" modelAsStrings="true" 164 [maxItems]="5" [modelAsStrings]="true"
163 ></tag-input> 165 ></tag-input>
164 </div> 166 </div>
165 </div> 167 </div>
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss
index 99af2e4c5..a88a1c0b0 100644
--- a/client/src/app/search/search-filters.component.scss
+++ b/client/src/app/search/search-filters.component.scss
@@ -66,65 +66,4 @@ input[type=submit] {
66 white-space: nowrap; 66 white-space: nowrap;
67} 67}
68 68
69::ng-deep { 69@include ng2-tags;
70 .ng2-tag-input {
71 border: none !important;
72 }
73
74 .ng2-tags-container {
75 display: flex;
76 align-items: center;
77 border: 1px solid #C6C6C6;
78 border-radius: 3px;
79 padding: 5px !important;
80 height: max-content;
81 }
82
83 tag-input-form {
84 input {
85 height: 30px !important;
86 font-size: 12px !important;
87
88 background-color: var(--mainBackgroundColor) !important;
89 color: var(--mainForegroundColor) !important;
90 }
91 }
92
93 tag {
94 background-color: $grey-background-color !important;
95 color: #000 !important;
96 border-radius: 3px !important;
97 font-size: 12px !important;
98 height: 30px !important;
99 line-height: 30px !important;
100 margin: 0 5px 0 0 !important;
101 cursor: default !important;
102 padding: 0 8px 0 10px !important;
103
104 div {
105 height: 100% !important;
106 }
107 }
108
109 delete-icon {
110 cursor: pointer !important;
111 height: auto !important;
112 vertical-align: middle !important;
113 padding-left: 6px !important;
114
115 svg {
116 position: relative;
117 top: -1px;
118 height: auto !important;
119 vertical-align: middle !important;
120
121 path {
122 fill: $grey-foreground-color !important;
123 }
124 }
125
126 &:hover {
127 transform: none !important;
128 }
129 }
130}
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
index d4d8bbcf7..641647e2e 100644
--- a/client/src/app/search/search.component.scss
+++ b/client/src/app/search/search.component.scss
@@ -82,11 +82,35 @@
82} 82}
83 83
84@media screen and (max-width: $small-view) { 84@media screen and (max-width: $small-view) {
85 .video-channel-names { 85 .search-result {
86 flex-direction: column !important; 86 .entry.video-channel,
87 .entry.video {
88 flex-direction: column;
89 height: auto;
90 justify-content: center;
91 align-items: center;
92 text-align: center;
93
94 img {
95 margin: 0;
96 }
97
98 img {
99 margin: 0;
100 }
87 101
88 .video-channel-name { 102 .video-channel-info .video-channel-names {
89 margin-left: 0 !important; 103 align-items: center;
104 flex-direction: column !important;
105
106 .video-channel-name {
107 margin-left: 0 !important;
108 }
109 }
110
111 my-subscribe-button {
112 margin-top: 5px;
113 }
90 } 114 }
91 } 115 }
92} 116}
@@ -100,12 +124,6 @@
100 } 124 }
101 125
102 .entry { 126 .entry {
103 flex-direction: column;
104 height: auto;
105 justify-content: center;
106 align-items: center;
107 text-align: center;
108
109 &.video { 127 &.video {
110 .video-info-name, 128 .video-info-name,
111 .video-info-account { 129 .video-info-account {
@@ -126,16 +144,6 @@
126 } 144 }
127 } 145 }
128 } 146 }
129
130 &.video-channel {
131 .video-channel-info .video-channel-names {
132 align-items: center;
133 }
134
135 my-subscribe-button {
136 margin-top: 5px;
137 }
138 }
139 } 147 }
140 } 148 }
141} 149}
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index dfd8d8823..075994dd3 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -141,7 +141,8 @@ export class SearchComponent implements OnInit, OnDestroy {
141 return this.advancedSearch.size() 141 return this.advancedSearch.size()
142 } 142 }
143 143
144 removeVideoFromArray (video: Video) { 144 // Add VideoChannel for typings, but the template already checks "video" argument is a video
145 removeVideoFromArray (video: Video | VideoChannel) {
145 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) 146 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
146 } 147 }
147 148
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index 7610fee8c..3cad5aaa7 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -11,6 +11,7 @@ import { Video } from '@app/shared/video/video.model'
11import { AdvancedSearch } from '@app/search/advanced-search.model' 11import { AdvancedSearch } from '@app/search/advanced-search.model'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 12import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 13import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
14import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
14 15
15@Injectable() 16@Injectable()
16export class SearchService { 17export class SearchService {
@@ -21,7 +22,11 @@ export class SearchService {
21 private restExtractor: RestExtractor, 22 private restExtractor: RestExtractor,
22 private restService: RestService, 23 private restService: RestService,
23 private videoService: VideoService 24 private videoService: VideoService
24 ) {} 25 ) {
26 // Add ability to override search endpoint if the user updated this local storage key
27 const searchUrl = peertubeLocalStorage.getItem('search-url')
28 if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl
29 }
25 30
26 searchVideos (parameters: { 31 searchVideos (parameters: {
27 search: string, 32 search: string,
diff --git a/client/src/app/shared/angular/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts
index 3a9a76411..9851468ee 100644
--- a/client/src/app/shared/angular/from-now.pipe.ts
+++ b/client/src/app/shared/angular/from-now.pipe.ts
@@ -12,9 +12,8 @@ export class FromNowPipe implements PipeTransform {
12 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) 12 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
13 13
14 let interval = Math.floor(seconds / 31536000) 14 let interval = Math.floor(seconds / 31536000)
15 if (interval > 1) { 15 if (interval > 1) return this.i18n('{{interval}} years ago', { interval })
16 return this.i18n('{{interval}} years ago', { interval }) 16 if (interval === 1) return this.i18n('{{interval}} year ago', { interval })
17 }
18 17
19 interval = Math.floor(seconds / 2592000) 18 interval = Math.floor(seconds / 2592000)
20 if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) 19 if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
@@ -35,6 +34,6 @@ export class FromNowPipe implements PipeTransform {
35 interval = Math.floor(seconds / 60) 34 interval = Math.floor(seconds / 60)
36 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) 35 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
37 36
38 return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) }) 37 return this.i18n('just now')
39 } 38 }
40} 39}
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
new file mode 100644
index 000000000..fb6042280
--- /dev/null
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -0,0 +1,54 @@
1import { PipeTransform, Pipe } from '@angular/core'
2import { SafeHtml } from '@angular/platform-browser'
3
4// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
5@Pipe({ name: 'highlight' })
6export class HighlightPipe implements PipeTransform {
7 /* use this for single match search */
8 static SINGLE_MATCH = 'Single-Match'
9 /* use this for single match search with a restriction that target should start with search string */
10 static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
11 /* use this for global search */
12 static MULTI_MATCH = 'Multi-Match'
13
14 // tslint:disable-next-line:no-empty
15 constructor () {}
16
17 transform (
18 contentString: string = null,
19 stringToHighlight: string = null,
20 option = 'Single-And-StartsWith-Match',
21 caseSensitive = false,
22 highlightStyleName = 'search-highlight'
23 ): SafeHtml {
24 if (stringToHighlight && contentString && option) {
25 let regex: any = ''
26 const caseFlag: string = !caseSensitive ? 'i' : ''
27 switch (option) {
28 case 'Single-Match': {
29 regex = new RegExp(stringToHighlight, caseFlag)
30 break
31 }
32 case 'Single-And-StartsWith-Match': {
33 regex = new RegExp('^' + stringToHighlight, caseFlag)
34 break
35 }
36 case 'Multi-Match': {
37 regex = new RegExp(stringToHighlight, 'g' + caseFlag)
38 break
39 }
40 default: {
41 // default will be a global case-insensitive match
42 regex = new RegExp(stringToHighlight, 'gi')
43 }
44 }
45 const replaced = contentString.replace(
46 regex,
47 (match) => `<span class="${highlightStyleName}">${match}</span>`
48 )
49 return replaced
50 } else {
51 return contentString
52 }
53 }
54}
diff --git a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
index f4d9aeb1f..45e023695 100644
--- a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
+++ b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts
@@ -10,31 +10,30 @@ export class TimestampRouteTransformerDirective {
10 public onClick ($event: Event) { 10 public onClick ($event: Event) {
11 const target = $event.target as HTMLLinkElement 11 const target = $event.target as HTMLLinkElement
12 12
13 if (target.hasAttribute('href')) { 13 if (target.hasAttribute('href') !== true) return
14 const ngxLink = document.createElement('a') 14
15 ngxLink.href = target.getAttribute('href') 15 const ngxLink = document.createElement('a')
16 16 ngxLink.href = target.getAttribute('href')
17 // we only care about reflective links 17
18 if (ngxLink.host !== window.location.host) return 18 // we only care about reflective links
19 19 if (ngxLink.host !== window.location.host) return
20 const ngxLinkParams = new URLSearchParams(ngxLink.search) 20
21 if (ngxLinkParams.has('start')) { 21 const ngxLinkParams = new URLSearchParams(ngxLink.search)
22 const separators = ['h', 'm', 's'] 22 if (ngxLinkParams.has('start') !== true) return
23 const start = ngxLinkParams 23
24 .get('start') 24 const separators = ['h', 'm', 's']
25 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator 25 const start = ngxLinkParams
26 .map(t => { 26 .get('start')
27 if (t.includes('h')) return parseInt(t, 10) * 3600 27 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
28 if (t.includes('m')) return parseInt(t, 10) * 60 28 .map(t => {
29 return parseInt(t, 10) 29 if (t.includes('h')) return parseInt(t, 10) * 3600
30 }) 30 if (t.includes('m')) return parseInt(t, 10) * 60
31 .reduce((acc, t) => acc + t) 31 return parseInt(t, 10)
32 this.timestampClicked.emit(start) 32 })
33 } 33 .reduce((acc, t) => acc + t)
34 34
35 $event.preventDefault() 35 this.timestampClicked.emit(start)
36 } 36
37 37 $event.preventDefault()
38 return
39 } 38 }
40} 39}
diff --git a/client/src/app/shared/angular/video-duration-formatter.pipe.ts b/client/src/app/shared/angular/video-duration-formatter.pipe.ts
index c92631a75..4b6767415 100644
--- a/client/src/app/shared/angular/video-duration-formatter.pipe.ts
+++ b/client/src/app/shared/angular/video-duration-formatter.pipe.ts
@@ -1,19 +1,28 @@
1import { Pipe, PipeTransform } from '@angular/core' 1import { Pipe, PipeTransform } from '@angular/core'
2 2import { I18n } from '@ngx-translate/i18n-polyfill'
3// Thanks: https://stackoverflow.com/a/46055604
4 3
5@Pipe({ 4@Pipe({
6 name: 'myVideoDurationFormatter' 5 name: 'myVideoDurationFormatter'
7}) 6})
8export class VideoDurationPipe implements PipeTransform { 7export class VideoDurationPipe implements PipeTransform {
8
9 constructor (private i18n: I18n) {
10
11 }
12
9 transform (value: number): string { 13 transform (value: number): string {
10 const minutes = Math.floor(value / 60) 14 const hours = Math.floor(value / 3600)
11 const hours = Math.floor(minutes / 60) 15 const minutes = Math.floor((value % 3600) / 60)
16 const seconds = value % 60
12 17
13 if (hours > 0) { 18 if (hours > 0) {
14 return hours + ' h ' + (minutes - hours * 60) + ' min ' + (value - (minutes - hours * 60) * 60) + ' sec' 19 return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
20 }
21
22 if (minutes > 0) {
23 return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
15 } 24 }
16 25
17 return minutes + ' min ' + (value - minutes * 60) + ' sec' 26 return this.i18n('{{seconds}} sec', { seconds })
18 } 27 }
19} 28}
diff --git a/client/src/app/shared/blocklist/blocklist.service.ts b/client/src/app/shared/blocklist/blocklist.service.ts
index c1f7312f0..5cf265bc1 100644
--- a/client/src/app/shared/blocklist/blocklist.service.ts
+++ b/client/src/app/shared/blocklist/blocklist.service.ts
@@ -76,10 +76,14 @@ export class BlocklistService {
76 76
77 /*********************** Instance -> Account blocklist ***********************/ 77 /*********************** Instance -> Account blocklist ***********************/
78 78
79 getInstanceAccountBlocklist (pagination: RestPagination, sort: SortMeta) { 79 getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) {
80 const { pagination, sort, search } = options
81
80 let params = new HttpParams() 82 let params = new HttpParams()
81 params = this.restService.addRestGetParams(params, pagination, sort) 83 params = this.restService.addRestGetParams(params, pagination, sort)
82 84
85 if (search) params = params.append('search', search)
86
83 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) 87 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
84 .pipe( 88 .pipe(
85 map(res => this.restExtractor.convertResultListDateToHuman(res)), 89 map(res => this.restExtractor.convertResultListDateToHuman(res)),
@@ -104,10 +108,14 @@ export class BlocklistService {
104 108
105 /*********************** Instance -> Server blocklist ***********************/ 109 /*********************** Instance -> Server blocklist ***********************/
106 110
107 getInstanceServerBlocklist (pagination: RestPagination, sort: SortMeta) { 111 getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) {
112 const { pagination, sort, search } = options
113
108 let params = new HttpParams() 114 let params = new HttpParams()
109 params = this.restService.addRestGetParams(params, pagination, sort) 115 params = this.restService.addRestGetParams(params, pagination, sort)
110 116
117 if (search) params = params.append('search', search)
118
111 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) 119 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
112 .pipe( 120 .pipe(
113 map(res => this.restExtractor.convertResultListDateToHuman(res)), 121 map(res => this.restExtractor.convertResultListDateToHuman(res)),
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html
index cd993db9f..952b3b6f8 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.html
+++ b/client/src/app/shared/buttons/action-dropdown.component.html
@@ -1,4 +1,4 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement" *ngIf="areActionsDisplayed(actions, entry)"> 1<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
2 <div 2 <div
3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }" 3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
4 ngbDropdownToggle role="button" 4 ngbDropdownToggle role="button"
@@ -24,17 +24,27 @@
24 </div> 24 </div>
25 </ng-template> 25 </ng-template>
26 26
27 <a *ngIf="action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"> 27 <a
28 *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
29 class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
30 >
28 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> 31 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
29 </a> 32 </a>
30 33
31 <span 34 <span
32 *ngIf="!action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" (click)="action.handler(entry)" 35 *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
33 class="custom-action dropdown-item" role="button" [title]="action.title || ''" 36 class="custom-action dropdown-item" role="button" [title]="action.title || ''" (click)="action.handler(entry)"
34 > 37 >
35 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> 38 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
36 </span> 39 </span>
37 40
41 <h6
42 *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
43 class="dropdown-header" role="button" [title]="action.title || ''" (click)="action.handler(entry)"
44 >
45 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
46 </h6>
47
38 </ng-container> 48 </ng-container>
39 </ng-container> 49 </ng-container>
40 50
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss
index 442c90984..7a030f32c 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.scss
+++ b/client/src/app/shared/buttons/action-dropdown.component.scss
@@ -51,6 +51,10 @@
51} 51}
52 52
53.dropdown-menu { 53.dropdown-menu {
54 .dropdown-header {
55 padding: 0.2rem 1rem;
56 }
57
54 .dropdown-item { 58 .dropdown-item {
55 display: flex; 59 display: flex;
56 cursor: pointer; 60 cursor: pointer;
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts
index a8b3ab16c..15f9556dc 100644
--- a/client/src/app/shared/buttons/action-dropdown.component.ts
+++ b/client/src/app/shared/buttons/action-dropdown.component.ts
@@ -9,6 +9,7 @@ export type DropdownAction<T> = {
9 handler?: (a: T) => any 9 handler?: (a: T) => any
10 linkBuilder?: (a: T) => (string | number)[] 10 linkBuilder?: (a: T) => (string | number)[]
11 isDisplayed?: (a: T) => boolean 11 isDisplayed?: (a: T) => boolean
12 isHeader?: boolean
12} 13}
13 14
14export type DropdownButtonSize = 'normal' | 'small' 15export type DropdownButtonSize = 'normal' | 'small'
@@ -26,6 +27,7 @@ export class ActionDropdownComponent<T> {
26 @Input() entry: T 27 @Input() entry: T
27 28
28 @Input() placement = 'bottom-left auto' 29 @Input() placement = 'bottom-left auto'
30 @Input() container: null | 'body'
29 31
30 @Input() buttonSize: DropdownButtonSize = 'normal' 32 @Input() buttonSize: DropdownButtonSize = 'normal'
31 @Input() buttonDirection: DropdownDirection = 'horizontal' 33 @Input() buttonDirection: DropdownDirection = 'horizontal'
@@ -34,10 +36,10 @@ export class ActionDropdownComponent<T> {
34 @Input() label: string 36 @Input() label: string
35 @Input() theme: DropdownTheme = 'grey' 37 @Input() theme: DropdownTheme = 'grey'
36 38
37 getActions () { 39 getActions (): DropdownAction<T>[][] {
38 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions 40 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
39 41
40 return [ this.actions ] 42 return [ this.actions as DropdownAction<T>[] ]
41 } 43 }
42 44
43 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean { 45 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 2a8cfc748..3ccfefd7e 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -10,11 +10,26 @@ my-small-loader ::ng-deep .root {
10.action-button { 10.action-button {
11 @include peertube-button-link; 11 @include peertube-button-link;
12 @include button-with-icon(21px, 0, -2px); 12 @include button-with-icon(21px, 0, -2px);
13}
13 14
14 // FIXME: Firefox does not apply global .orange-button icon color 15.orange-button {
15 &.orange-button { 16 @include peertube-button;
16 @include apply-svg-color(#fff) 17 @include orange-button;
17 } 18}
19
20.orange-button-link {
21 @include peertube-button-link;
22 @include orange-button;
23}
24
25.grey-button {
26 @include peertube-button;
27 @include grey-button;
28}
29
30.grey-button-link {
31 @include peertube-button-link;
32 @include grey-button;
18} 33}
19 34
20// In a table, try to minimize the space taken by this button 35// In a table, try to minimize the space taken by this button
diff --git a/client/src/app/shared/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts
index 1fe4f7b30..9cfe1a3bb 100644
--- a/client/src/app/shared/buttons/edit-button.component.ts
+++ b/client/src/app/shared/buttons/edit-button.component.ts
@@ -8,5 +8,5 @@ import { Component, Input } from '@angular/core'
8 8
9export class EditButtonComponent { 9export class EditButtonComponent {
10 @Input() label: string 10 @Input() label: string
11 @Input() routerLink: string[] = [] 11 @Input() routerLink: string[] | string = []
12} 12}
diff --git a/client/src/app/shared/confirm/confirm.component.html b/client/src/app/shared/confirm/confirm.component.html
index 65df1cd4d..dbc8c23e3 100644
--- a/client/src/app/shared/confirm/confirm.component.html
+++ b/client/src/app/shared/confirm/confirm.component.html
@@ -16,11 +16,15 @@
16 </div> 16 </div>
17 17
18 <div class="modal-footer inputs"> 18 <div class="modal-footer inputs">
19 <span i18n class="action-button action-button-cancel" (click)="dismiss()" role="button">Cancel</span> 19 <input
20 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
21 (click)="dismiss()" (key.enter)="dismiss()"
22 >
20 23
21 <input 24 <input
25 ngbAutofocus
22 type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()" 26 type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
23 (click)="close()" 27 (click)="close()" (key.enter)="confirm()"
24 > 28 >
25 </div> 29 </div>
26</ng-template> 30</ng-template>
diff --git a/client/src/app/shared/confirm/confirm.component.ts b/client/src/app/shared/confirm/confirm.component.ts
index 763454c4f..c6e40fe72 100644
--- a/client/src/app/shared/confirm/confirm.component.ts
+++ b/client/src/app/shared/confirm/confirm.component.ts
@@ -45,7 +45,6 @@ export class ConfirmComponent implements OnInit {
45 ) 45 )
46 } 46 }
47 47
48 @HostListener('document:keydown.enter')
49 confirm () { 48 confirm () {
50 if (this.openedModal) this.openedModal.close() 49 if (this.openedModal) this.openedModal.close()
51 } 50 }
@@ -60,7 +59,7 @@ export class ConfirmComponent implements OnInit {
60 showModal () { 59 showModal () {
61 this.inputValue = '' 60 this.inputValue = ''
62 61
63 this.openedModal = this.modalService.open(this.confirmModal) 62 this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
64 63
65 this.openedModal.result 64 this.openedModal.result
66 .then(() => this.confirmService.confirmResponse.next(true)) 65 .then(() => this.confirmService.confirmResponse.next(true))
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
index 767e3f026..d20754d11 100644
--- 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
@@ -56,7 +56,7 @@ export class CustomConfigValidatorsService {
56 } 56 }
57 57
58 this.SIGNUP_LIMIT = { 58 this.SIGNUP_LIMIT = {
59 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 59 VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
60 MESSAGES: { 60 MESSAGES: {
61 'required': this.i18n('Signup limit is required.'), 61 'required': this.i18n('Signup limit is required.'),
62 'min': this.i18n('Signup limit must be greater than 1.'), 62 'min': this.i18n('Signup limit must be greater than 1.'),
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
index 4dff3e422..13b9228d4 100644
--- a/client/src/app/shared/forms/form-validators/user-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts
@@ -8,6 +8,7 @@ export class UserValidatorsService {
8 readonly USER_USERNAME: BuildFormValidator 8 readonly USER_USERNAME: BuildFormValidator
9 readonly USER_EMAIL: BuildFormValidator 9 readonly USER_EMAIL: BuildFormValidator
10 readonly USER_PASSWORD: BuildFormValidator 10 readonly USER_PASSWORD: BuildFormValidator
11 readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
11 readonly USER_CONFIRM_PASSWORD: BuildFormValidator 12 readonly USER_CONFIRM_PASSWORD: BuildFormValidator
12 readonly USER_VIDEO_QUOTA: BuildFormValidator 13 readonly USER_VIDEO_QUOTA: BuildFormValidator
13 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator 14 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
@@ -56,6 +57,17 @@ export class UserValidatorsService {
56 } 57 }
57 } 58 }
58 59
60 this.USER_PASSWORD_OPTIONAL = {
61 VALIDATORS: [
62 Validators.minLength(6),
63 Validators.maxLength(255)
64 ],
65 MESSAGES: {
66 'minlength': this.i18n('Password must be at least 6 characters long.'),
67 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
68 }
69 }
70
59 this.USER_CONFIRM_PASSWORD = { 71 this.USER_CONFIRM_PASSWORD = {
60 VALIDATORS: [], 72 VALIDATORS: [],
61 MESSAGES: { 73 MESSAGES: {
diff --git a/client/src/app/shared/forms/input-readonly-copy.component.html b/client/src/app/shared/forms/input-readonly-copy.component.html
index 27571b63f..9566e9741 100644
--- a/client/src/app/shared/forms/input-readonly-copy.component.html
+++ b/client/src/app/shared/forms/input-readonly-copy.component.html
@@ -1,8 +1,8 @@
1<div class="input-group"> 1<div class="input-group input-group-sm">
2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> 2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
3 3
4 <div class="input-group-append"> 4 <div class="input-group-append">
5 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 5 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
6 <span class="glyphicon glyphicon-copy"></span> 6 <span class="glyphicon glyphicon-copy"></span>
7 </button> 7 </button>
8 </div> 8 </div>
diff --git a/client/src/app/shared/forms/markdown-textarea.component.html b/client/src/app/shared/forms/markdown-textarea.component.html
index 0925b9ad5..a519f3e0a 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.html
+++ b/client/src/app/shared/forms/markdown-textarea.component.html
@@ -1,17 +1,36 @@
1<div class="root" [ngStyle]="{ 'flex-direction': flexDirection }"> 1<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
2 <textarea 2 <textarea #textarea
3 [(ngModel)]="content" (ngModelChange)="onModelChange()" 3 [(ngModel)]="content" (ngModelChange)="onModelChange()"
4 [ngClass]="classes" [ngStyle]="{ width: textareaWidth, height: textareaHeight, 'margin-right': textareaMarginRight }" 4 class="form-control" [ngClass]="classes"
5 [ngStyle]="{ height: textareaHeight }"
5 [id]="name" [name]="name"> 6 [id]="name" [name]="name">
6 </textarea> 7 </textarea>
7 8
8 <ngb-tabset *ngIf="arePreviewsDisplayed()" class="previews" type="pills"> 9 <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
9 <ngb-tab *ngIf="truncate !== undefined" i18n-title title="Truncated preview"> 10 <ng-container ngbNavItem *ngIf="truncate !== undefined">
10 <ng-template ngbTabContent><div [innerHTML]="truncatedPreviewHTML"></div></ng-template> 11 <a ngbNavLink i18n>Truncated preview</a>
11 </ngb-tab>
12 12
13 <ngb-tab i18n-title title="Complete preview"> 13 <ng-template ngbNavContent>
14 <ng-template ngbTabContent><div [innerHTML]="previewHTML"></div></ng-template> 14 <div [innerHTML]="truncatedPreviewHTML"></div>
15 </ngb-tab> 15 </ng-template>
16 </ngb-tabset> 16 </ng-container>
17
18 <ng-container ngbNavItem>
19 <a ngbNavLink i18n>Complete preview</a>
20
21 <ng-template ngbNavContent>
22 <div [innerHTML]="previewHTML"></div>
23 </ng-template>
24 </ng-container>
25
26 <my-button
27 *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
28 ></my-button>
29
30 <my-button
31 *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
32 ></my-button>
33 </div>
34
35 <div [ngbNavOutlet]="nav"></div>
17</div> 36</div>
diff --git a/client/src/app/shared/forms/markdown-textarea.component.scss b/client/src/app/shared/forms/markdown-textarea.component.scss
index eacaf36a2..8e5739e45 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.scss
+++ b/client/src/app/shared/forms/markdown-textarea.component.scss
@@ -1,34 +1,250 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.root { 4$nav-preview-tab-height: 30px;
5 display: flex; 5$base-padding: 15px;
6$input-border-color: #C6C6C6;
7$input-border-radius: 3px;
8
9@mixin in-small-view {
10 .root {
11 display: flex;
12 flex-direction: column;
13
14 textarea {
15 @include peertube-textarea(100%, 150px);
16
17 background-color: var(--textareaBackgroundColor);
18 font-family: monospace;
19 font-size: 13px;
20 border-bottom: none;
21 border-bottom-left-radius: unset;
22 border-bottom-right-radius: unset;
23 }
6 24
7 textarea { 25 .nav-preview {
8 @include peertube-textarea(100%, 150px); 26 display: block;
27 text-align: right;
28 padding-top: 10px;
29 padding-bottom: 10px;
30 padding-left: 10px;
31 padding-right: 10px;
32 border-top: 1px dashed $input-border-color;
33 border-left: 1px solid $input-border-color;
34 border-right: 1px solid $input-border-color;
35 border-bottom: 1px solid $input-border-color;
36 border-bottom-right-radius: $input-border-radius;
9 37
10 margin-bottom: 15px; 38 border-bottom-left-radius: $input-border-radius;
39 ::ng-deep {
40 .nav-link {
41 display: none !important;
42 }
43
44 .grey-button {
45 padding: 0 12px 0 12px;
46 }
47 }
48 }
49
50 ::ng-deep {
51 .tab-content {
52 display: none;
53 }
54 }
11 } 55 }
56}
57
58@mixin nav-preview-medium {
59 display: flex;
60 flex-grow: 1;
61 border-bottom-left-radius: unset;
62 border-bottom-right-radius: unset;
63 border-bottom: 2px solid var(--mainColor);
12 64
13 .previews { 65 :first-child {
14 max-height: 150px; 66 margin-left: auto;
15 overflow-y: auto;
16 flex-grow: 1;
17 } 67 }
18 68
19 ::ng-deep { 69 ::ng-deep {
20 .nav-link { 70 .nav-link {
21 display: flex !important; 71 display: flex !important;
22 align-items: center; 72 align-items: center;
23 height: 30px !important; 73 height: $nav-preview-tab-height !important;
24 padding: 0 15px !important; 74 padding: 0 15px !important;
75 font-size: 85% !important;
76 opacity: .7;
77 }
78
79 .grey-button {
80 margin-left: 5px;
81 }
82 }
83}
84
85@mixin content-preview-base {
86 display: block;
87 min-height: 75px;
88 padding: $base-padding;
89 overflow-y: auto;
90 font-size: 15px;
91 word-wrap: break-word;
92}
93
94@mixin maximized-base {
95 flex-direction: row;
96 z-index: #{z(header) - 1};
97 position: fixed;
98 top: $header-height;
99 left: $menu-width;
100 max-height: none !important;
101 max-width: none !important;
102 width: calc(100% - #{$menu-width});
103 height: calc(100vh - #{$header-height}) !important;
104
105 $nav-preview-vertical-padding: 40px;
106
107 .nav-preview {
108 @include nav-preview-medium();
109 padding-top: #{$nav-preview-vertical-padding / 2};
110 padding-bottom: #{$nav-preview-vertical-padding / 2};
111 padding-left: 0px;
112 padding-right: 0px;
113 position: absolute;
114 background-color: var(--mainBackgroundColor);
115 width: 100% !important;
116 border-top: none;
117 border-left: none;
118 border-right: none;
119
120 :last-child {
121 margin-right: $not-expanded-horizontal-margins;
25 } 122 }
123 }
124
125 ::ng-deep .tab-content {
126 @include content-preview-base();
127 background-color: var(--mainBackgroundColor);
128 scrollbar-color: var(--actionButtonColor) var(--mainBackgroundColor);
129 }
26 130
27 .tab-content { 131 textarea,
28 min-height: 75px; 132 ::ng-deep .tab-content {
29 padding: 15px; 133 max-height: none !important;
30 font-size: 15px; 134 max-width: none !important;
31 word-wrap: break-word; 135 margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important;
136 height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important;
137 width: 50% !important;
138 border: none !important;
139 border-radius: unset !important;
140 }
141
142 :host-context(.expanded) {
143 .root.maximized {
144 left: 0;
145 width: 100%;
146 }
147 }
148}
149
150@mixin maximized-in-small-view {
151 .root.maximized {
152 @include maximized-base();
153
154 textarea {
155 display: none;
32 } 156 }
157
158 ::ng-deep .tab-content {
159 width: 100% !important;
160 }
161 }
162}
163
164@mixin maximized-tabs-in-mobile-view {
165 // Ellipsis on tabs for mobile view
166 .root.maximized {
167 .nav-preview {
168 ::ng-deep .nav-link {
169 @include ellipsis();
170
171 display: block !important;
172 max-width: 45% !important;
173 padding: 5px 0 !important;
174 margin-right: 10px !important;
175 text-align: center;
176
177 &:not(.active) {
178 max-width: 15% !important;
179 }
180
181 &.active {
182 padding: 5px 15px !important;
183 }
184 }
185 }
186 }
187}
188
189@mixin in-medium-view {
190 .root {
191 .nav-preview {
192 @include nav-preview-medium();
193 }
194
195 ::ng-deep .tab-content {
196 @include content-preview-base();
197 max-height: 210px;
198 border-bottom: 1px solid $input-border-color;
199 border-left: 1px solid $input-border-color;
200 border-right: 1px solid $input-border-color;
201 border-bottom-left-radius: $input-border-radius;
202 border-bottom-right-radius: $input-border-radius;
203 }
204 }
205}
206
207@mixin maximized-in-medium-view {
208 .root.maximized {
209 @include maximized-base();
210
211 textarea {
212 display: block;
213 padding: $base-padding;
214 border-right: 1px dashed $input-border-color !important;
215 resize: none;
216 scrollbar-color: var(--actionButtonColor) var(--textareaBackgroundColor);
217
218 &:focus {
219 box-shadow: none;
220 }
221 }
222 }
223}
224
225@include in-small-view();
226@include maximized-in-small-view();
227
228@media only screen and (max-width: $mobile-view) {
229 @include maximized-tabs-in-mobile-view();
230}
231
232@media only screen and (max-width: #{$mobile-view + $menu-width}) {
233 :host-context(.main-col:not(.expanded)) {
234 @include maximized-tabs-in-mobile-view();
235 }
236}
237
238@media only screen and (min-width: $small-view) {
239 :host-context(.expanded) {
240 @include in-medium-view();
241 }
242
243 @include maximized-in-medium-view();
244}
245
246@media only screen and (min-width: #{$small-view + $menu-width}) {
247 :host-context(.main-col:not(.expanded)) {
248 @include in-medium-view();
33 } 249 }
34} 250}
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts
index 19cd37573..dde7b4d98 100644
--- a/client/src/app/shared/forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/forms/markdown-textarea.component.ts
@@ -1,5 +1,5 @@
1import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 1import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
2import { Component, forwardRef, Input, OnInit } from '@angular/core' 2import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core'
3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
4import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
5import truncate from 'lodash-es/truncate' 5import truncate from 'lodash-es/truncate'
@@ -21,19 +21,19 @@ import { MarkdownService } from '@app/shared/renderer'
21 21
22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
23 @Input() content = '' 23 @Input() content = ''
24 @Input() classes: string[] = [] 24 @Input() classes: string[] | { [klass: string]: any[] | any } = []
25 @Input() textareaWidth = '100%' 25 @Input() textareaMaxWidth = '100%'
26 @Input() textareaHeight = '150px' 26 @Input() textareaHeight = '150px'
27 @Input() previewColumn = false
28 @Input() truncate: number 27 @Input() truncate: number
29 @Input() markdownType: 'text' | 'enhanced' = 'text' 28 @Input() markdownType: 'text' | 'enhanced' = 'text'
30 @Input() markdownVideo = false 29 @Input() markdownVideo = false
31 @Input() name = 'description' 30 @Input() name = 'description'
32 31
33 textareaMarginRight = '0' 32 @ViewChild('textarea') textareaElement: ElementRef
34 flexDirection = 'column' 33
35 truncatedPreviewHTML = '' 34 truncatedPreviewHTML = ''
36 previewHTML = '' 35 previewHTML = ''
36 isMaximized = false
37 37
38 private contentChanged = new Subject<string>() 38 private contentChanged = new Subject<string>()
39 39
@@ -51,11 +51,6 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
51 .subscribe(() => this.updatePreviews()) 51 .subscribe(() => this.updatePreviews())
52 52
53 this.contentChanged.next(this.content) 53 this.contentChanged.next(this.content)
54
55 if (this.previewColumn) {
56 this.flexDirection = 'row'
57 this.textareaMarginRight = '15px'
58 }
59 } 54 }
60 55
61 propagateChange = (_: any) => { /* empty */ } 56 propagateChange = (_: any) => { /* empty */ }
@@ -80,8 +75,26 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
80 this.contentChanged.next(this.content) 75 this.contentChanged.next(this.content)
81 } 76 }
82 77
83 arePreviewsDisplayed () { 78 onMaximizeClick () {
84 return this.screenService.isInSmallView() === false 79 this.isMaximized = !this.isMaximized
80
81 // Make sure textarea have the focus
82 this.textareaElement.nativeElement.focus()
83
84 // Make sure the window has no scrollbars
85 if (!this.isMaximized) {
86 this.unlockBodyScroll()
87 } else {
88 this.lockBodyScroll()
89 }
90 }
91
92 private lockBodyScroll () {
93 document.getElementById('content').classList.add('lock-scroll')
94 }
95
96 private unlockBodyScroll () {
97 document.getElementById('content').classList.remove('lock-scroll')
85 } 98 }
86 99
87 private async updatePreviews () { 100 private async updatePreviews () {
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html
index c740d852c..704f3e696 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.html
+++ b/client/src/app/shared/forms/peertube-checkbox.component.html
@@ -29,6 +29,8 @@
29 <ng-template *ngTemplateOutlet="helpTemplate"></ng-template> 29 <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
30 </ng-template> 30 </ng-template>
31 </my-help> 31 </my-help>
32
33 <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
32 </div> 34 </div>
33 35
34 <div class="ml-4 d-flex flex-column"> 36 <div class="ml-4 d-flex flex-column">
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss
index 3120509b3..c1233e8a5 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.scss
+++ b/client/src/app/shared/forms/peertube-checkbox.component.scss
@@ -34,4 +34,19 @@
34 .wrapper:empty { 34 .wrapper:empty {
35 display: none; 35 display: none;
36 } 36 }
37
38 .recommended {
39 margin-left: .5rem;
40 align-self: baseline;
41 display: inline-block;
42 padding: 4px 6px;
43 cursor: default;
44 border-radius: 3px;
45 font-size: 12px;
46 line-height: 12px;
47 font-weight: 500;
48 color: var(--inputPlaceholderColor);
49 background-color: rgba(217,225,232,.1);
50 border: 1px solid rgba(217,225,232,.5);
51 }
37} \ No newline at end of file 52} \ 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
index cb7ec8eda..89e79fecd 100644
--- a/client/src/app/shared/forms/peertube-checkbox.component.ts
+++ b/client/src/app/shared/forms/peertube-checkbox.component.ts
@@ -21,6 +21,7 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon
21 @Input() labelInnerHTML: string 21 @Input() labelInnerHTML: string
22 @Input() helpPlacement = 'top auto' 22 @Input() helpPlacement = 'top auto'
23 @Input() disabled = false 23 @Input() disabled = false
24 @Input() recommended = false
24 25
25 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>> 26 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>>
26 27
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss
index e7dbcd997..9671cc65f 100644
--- a/client/src/app/shared/forms/timestamp-input.component.scss
+++ b/client/src/app/shared/forms/timestamp-input.component.scss
@@ -1,8 +1,15 @@
1@import 'variables';
2
1p-inputmask { 3p-inputmask {
2 ::ng-deep input { 4 ::ng-deep input {
3 width: 80px; 5 width: 80px;
4 font-size: 15px; 6 font-size: 15px;
5 7
6 border: none; 8 border: none;
9
10 &:focus-within,
11 &:focus {
12 box-shadow: #{$focus-box-shadow-form} var(--mainColorLightest);
13 }
7 } 14 }
8} 15}
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index 806aca347..d2700f6c3 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -1,57 +1,62 @@
1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' 1import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
2import { HooksService } from '@app/core/plugins/hooks.service' 2import { HooksService } from '@app/core/plugins/hooks.service'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4 3
5const icons = { 4const icons = {
6 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'), 5 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default,
7 'user': require('!!raw-loader?!../../../assets/images/global/user.svg'), 6 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default,
8 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg'), 7 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default,
9 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg'), 8 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default,
10 'help': require('!!raw-loader?!../../../assets/images/global/help.svg'), 9 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default,
11 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg'), 10 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default,
12 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg'), 11 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default,
13 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg'), 12 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default,
14 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg'), 13 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default,
15 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg'), 14 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default,
16 'no': require('!!raw-loader?!../../../assets/images/global/no.svg'), 15 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default,
17 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg'), 16 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default,
18 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg'), 17 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default,
19 'history': require('!!raw-loader?!../../../assets/images/global/history.svg'), 18 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default,
20 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg'), 19 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default,
21 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg'), 20 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default,
22 'download': require('!!raw-loader?!../../../assets/images/global/download.svg'), 21 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default,
23 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg'), 22 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default,
24 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg'), 23 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default,
25 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg'), 24 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default,
26 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg'), 25 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default,
27 'server': require('!!raw-loader?!../../../assets/images/global/server.svg'), 26 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default,
28 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg'), 27 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default,
29 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg'), 28 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default,
30 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg'), 29 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default,
31 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg'), 30 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default,
32 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg'), 31 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default,
33 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg'), 32 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default,
34 'support': require('!!raw-loader?!../../../assets/images/video/support.svg'), 33 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default,
35 'like': require('!!raw-loader?!../../../assets/images/video/like.svg'), 34 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default,
36 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg'), 35 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default,
37 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg'), 36 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default,
38 'share': require('!!raw-loader?!../../../assets/images/video/share.svg'), 37 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default,
39 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg'), 38 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default,
40 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg'), 39 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default,
41 'play': require('!!raw-loader?!../../../assets/images/global/play.svg'), 40 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default,
42 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg'), 41 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default,
43 'about': require('!!raw-loader?!../../../assets/images/menu/about.svg'), 42 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default,
44 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg'), 43 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default,
45 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg'), 44 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default,
46 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg'), 45 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default,
47 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg'), 46 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default,
48 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg'), 47 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default,
49 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg'), 48 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default,
50 'administration': require('!!raw-loader?!../../../assets/images/menu/administration.svg'), 49 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default,
51 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg'), 50 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default,
52 'users': require('!!raw-loader?!../../../assets/images/global/users.svg'), 51 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default,
53 'search': require('!!raw-loader?!../../../assets/images/global/search.svg'), 52 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default,
54 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg') 53 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default,
54 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default,
55 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default,
56 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default,
57 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default,
58 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default,
59 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default
55} 60}
56 61
57export type GlobalIconName = keyof typeof icons 62export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html
index 5e1d5211b..7c3a2b588 100644
--- a/client/src/app/shared/images/preview-upload.component.html
+++ b/client/src/app/shared/images/preview-upload.component.html
@@ -1,13 +1,11 @@
1<div class="root"> 1<div class="root">
2 <div class="preview-container"> 2 <div class="preview-container">
3 <my-reactive-file 3 <my-reactive-file
4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" 4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
5 icon="edit" (fileChanged)="onFileChanged($event)" 5 icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
6 ></my-reactive-file> 6 ></my-reactive-file>
7 7
8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" /> 8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div> 9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
10 </div> 10 </div>
11
12 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div>
13</div> 11</div>
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss
index 257060239..8f3522115 100644
--- a/client/src/app/shared/images/preview-upload.component.scss
+++ b/client/src/app/shared/images/preview-upload.component.scss
@@ -16,11 +16,13 @@
16 } 16 }
17 17
18 .preview { 18 .preview {
19 border: 2px solid grey; 19 object-fit: cover;
20 border-radius: 4px; 20 border-radius: 4px;
21 max-width: 100%;
21 22
22 &.no-image { 23 &.no-image {
23 background-color: #ececec; 24 border: 2px solid grey;
25 background-color: var(--mainBackgroundColor);
24 } 26 }
25 } 27 }
26 } 28 }
diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts
index f56f5b1f8..7519734ba 100644
--- a/client/src/app/shared/images/preview-upload.component.ts
+++ b/client/src/app/shared/images/preview-upload.component.ts
@@ -3,6 +3,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' 3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5import { ServerConfig } from '@shared/models' 5import { ServerConfig } from '@shared/models'
6import { BytesPipe } from 'ngx-pipes'
7import { I18n } from '@ngx-translate/i18n-polyfill'
6 8
7@Component({ 9@Component({
8 selector: 'my-preview-upload', 10 selector: 'my-preview-upload',
@@ -24,14 +26,20 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
24 26
25 imageSrc: SafeResourceUrl 27 imageSrc: SafeResourceUrl
26 allowedExtensionsMessage = '' 28 allowedExtensionsMessage = ''
29 maxSizeText: string
27 30
28 private serverConfig: ServerConfig 31 private serverConfig: ServerConfig
29 private file: File 32 private bytesPipe: BytesPipe
33 private file: Blob
30 34
31 constructor ( 35 constructor (
32 private sanitizer: DomSanitizer, 36 private sanitizer: DomSanitizer,
33 private serverService: ServerService 37 private serverService: ServerService,
34 ) {} 38 private i18n: I18n
39 ) {
40 this.bytesPipe = new BytesPipe()
41 this.maxSizeText = this.i18n('max size')
42 }
35 43
36 get videoImageExtensions () { 44 get videoImageExtensions () {
37 return this.serverConfig.video.image.extensions 45 return this.serverConfig.video.image.extensions
@@ -41,6 +49,10 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
41 return this.serverConfig.video.image.size.max 49 return this.serverConfig.video.image.size.max
42 } 50 }
43 51
52 get maxVideoImageSizeInBytes () {
53 return this.bytesPipe.transform(this.maxVideoImageSize)
54 }
55
44 ngOnInit () { 56 ngOnInit () {
45 this.serverConfig = this.serverService.getTmpConfig() 57 this.serverConfig = this.serverService.getTmpConfig()
46 this.serverService.getConfig() 58 this.serverService.getConfig()
@@ -49,7 +61,7 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
49 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') 61 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
50 } 62 }
51 63
52 onFileChanged (file: File) { 64 onFileChanged (file: Blob) {
53 this.file = file 65 this.file = file
54 66
55 this.propagateChange(this.file) 67 this.propagateChange(this.file)
diff --git a/client/src/app/shared/instance/instance-features-table.component.html b/client/src/app/shared/instance/instance-features-table.component.html
index fd8b3354f..99b854d13 100644
--- a/client/src/app/shared/instance/instance-features-table.component.html
+++ b/client/src/app/shared/instance/instance-features-table.component.html
@@ -37,8 +37,8 @@
37 <tr> 37 <tr>
38 <td i18n class="sub-label">Video uploads</td> 38 <td i18n class="sub-label">Video uploads</td>
39 <td> 39 <td>
40 <span *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span> 40 <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
41 <span *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span> 41 <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
42 </td> 42 </td>
43 </tr> 43 </tr>
44 44
@@ -91,5 +91,16 @@
91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean> 91 <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
92 </td> 92 </td>
93 </tr> 93 </tr>
94
95 <tr>
96 <td i18n class="label" colspan="2">Search</td>
97 </tr>
98
99 <tr>
100 <td i18n class="sub-label">Users can resolve distant content</td>
101 <td>
102 <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
103 </td>
104 </tr>
94 </table> 105 </table>
95</div> 106</div>
diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts
index 8ec728f05..40aa8a4c0 100644
--- a/client/src/app/shared/instance/instance-statistics.component.ts
+++ b/client/src/app/shared/instance/instance-statistics.component.ts
@@ -1,9 +1,6 @@
1import { map } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { ServerStats } from '@shared/models/server' 2import { ServerStats } from '@shared/models/server'
6import { environment } from '../../../environments/environment' 3import { ServerService } from '@app/core'
7 4
8@Component({ 5@Component({
9 selector: 'my-instance-statistics', 6 selector: 'my-instance-statistics',
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment'
11 styleUrls: [ './instance-statistics.component.scss' ] 8 styleUrls: [ './instance-statistics.component.scss' ]
12}) 9})
13export class InstanceStatisticsComponent implements OnInit { 10export class InstanceStatisticsComponent implements OnInit {
14 private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
15
16 serverStats: ServerStats = null 11 serverStats: ServerStats = null
17 12
18 constructor ( 13 constructor (
19 private http: HttpClient, 14 private serverService: ServerService
20 private i18n: I18n
21 ) { 15 ) {
22 } 16 }
23 17
24 ngOnInit () { 18 ngOnInit () {
25 this.getStats() 19 this.serverService.getServerStats()
26 .subscribe( 20 .subscribe(res => this.serverStats = res)
27 res => {
28 this.serverStats = res
29 }
30 )
31 }
32
33 getStats () {
34 return this.http
35 .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL)
36 } 21 }
37} 22}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.html b/client/src/app/shared/menu/top-menu-dropdown.component.html
index 35511ee62..d577f757d 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.html
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.html
@@ -1,12 +1,22 @@
1<div class="sub-menu"> 1<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
2 <ng-container *ngFor="let menuEntry of menuEntries"> 2 <ng-container *ngFor="let menuEntry of menuEntries; index as id">
3 3
4 <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page">{{ menuEntry.label }}</a> 4 <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
5 5
6 <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> 6 <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry"
7 #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
7 <span 8 <span
9 *ngIf="isInSmallView"
10 [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
11 (click)="openModal(id)" role="button" class="title-page title-page-settings">
12 <ng-container i18n>{{ menuEntry.label }}</ng-container>
13 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
14 </span>
15
16 <span
17 *ngIf="!isInSmallView"
8 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor 18 (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
9 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" 19 (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
10 > 20 >
11 <ng-container i18n>{{ menuEntry.label }}</ng-container> 21 <ng-container i18n>{{ menuEntry.label }}</ng-container>
12 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container> 22 <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
@@ -20,6 +30,21 @@
20 </a> 30 </a>
21 </div> 31 </div>
22 </div> 32 </div>
23
24 </ng-container> 33 </ng-container>
25</div> 34</div>
35
36<ng-template #modal let-close="close" let-dismiss="dismiss">
37 <div class="modal-body">
38 <ng-container *ngFor="let menuEntry of menuEntries; index as id">
39 <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
40 <a *ngFor="let menuChild of menuEntry.children"
41 [ngClass]="{ icon: hasIcons }"
42 [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
43 <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName"></my-global-icon>
44
45 {{ menuChild.label }}
46 </a>
47 </div>
48 </ng-container>
49 </div>
50</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
index 1be699a88..5f90dcf80 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.scss
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss
@@ -25,3 +25,32 @@
25 25
26 top: -1px; 26 top: -1px;
27} 27}
28
29.sub-menu.no-scroll {
30 overflow-x: hidden;
31}
32
33.modal-body {
34 .hidden {
35 display: none;
36 }
37
38 a {
39 @include disable-default-a-behaviour;
40
41 color: currentColor;
42 box-sizing: border-box;
43 display: block;
44 font-size: 1.2rem;
45 padding: 9px 12px;
46 text-align: initial;
47 text-transform: unset;
48 width: 100%;
49
50 &.active {
51 color: var(--mainBackgroundColor) !important;
52 background-color: var(--mainHoverColor);
53 opacity: .9;
54 }
55 }
56}
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts
index 5ccdafb54..f98240804 100644
--- a/client/src/app/shared/menu/top-menu-dropdown.component.ts
+++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts
@@ -1,8 +1,14 @@
1import { Component, Input, OnDestroy, OnInit } from '@angular/core' 1import {
2 Component,
3 Input,
4 OnDestroy,
5 OnInit,
6 ViewChild
7} from '@angular/core'
2import { filter, take } from 'rxjs/operators' 8import { filter, take } from 'rxjs/operators'
3import { NavigationEnd, Router } from '@angular/router' 9import { NavigationEnd, Router } from '@angular/router'
4import { Subscription } from 'rxjs' 10import { Subscription } from 'rxjs'
5import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 11import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { GlobalIconName } from '@app/shared/images/global-icon.component' 12import { GlobalIconName } from '@app/shared/images/global-icon.component'
7import { ScreenService } from '@app/shared/misc/screen.service' 13import { ScreenService } from '@app/shared/misc/screen.service'
8 14
@@ -26,32 +32,40 @@ export type TopMenuDropdownParam = {
26export class TopMenuDropdownComponent implements OnInit, OnDestroy { 32export class TopMenuDropdownComponent implements OnInit, OnDestroy {
27 @Input() menuEntries: TopMenuDropdownParam[] = [] 33 @Input() menuEntries: TopMenuDropdownParam[] = []
28 34
35 @ViewChild('modal', { static: true }) modal: NgbModal
36
29 suffixLabels: { [ parentLabel: string ]: string } 37 suffixLabels: { [ parentLabel: string ]: string }
30 hasIcons = false 38 hasIcons = false
31 container: undefined | 'body' = undefined 39 container: undefined | 'body' = undefined
40 isModalOpened = false
41 currentMenuEntryIndex: number
32 42
33 private openedOnHover = false 43 private openedOnHover = false
34 private routeSub: Subscription 44 private routeSub: Subscription
35 45
36 constructor ( 46 constructor (
37 private router: Router, 47 private router: Router,
48 private modalService: NgbModal,
38 private screen: ScreenService 49 private screen: ScreenService
39 ) {} 50 ) { }
51
52 get isInSmallView () {
53 return this.screen.isInSmallView()
54 }
40 55
41 ngOnInit () { 56 ngOnInit () {
42 this.updateChildLabels(window.location.pathname) 57 this.updateChildLabels(window.location.pathname)
43 58
44 this.routeSub = this.router.events 59 this.routeSub = this.router.events
45 .pipe(filter(event => event instanceof NavigationEnd)) 60 .pipe(filter(event => event instanceof NavigationEnd))
46 .subscribe(() => this.updateChildLabels(window.location.pathname)) 61 .subscribe(() => this.updateChildLabels(window.location.pathname))
47 62
48 this.hasIcons = this.menuEntries.some( 63 this.hasIcons = this.menuEntries.some(
49 e => e.children && e.children.some(c => !!c.iconName) 64 e => e.children && e.children.some(c => !!c.iconName)
50 ) 65 )
51 66
52 // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view 67 // We have to set body for the container to avoid scroll overflow on mobile and small views
53 // But this break our hovering system 68 if (this.isInSmallView) {
54 if (this.screen.isInMobileView()) {
55 this.container = 'body' 69 this.container = 'body'
56 } 70 }
57 } 71 }
@@ -86,6 +100,27 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
86 this.openedOnHover = false 100 this.openedOnHover = false
87 } 101 }
88 102
103 openModal (index: number) {
104 this.currentMenuEntryIndex = index
105 this.isModalOpened = true
106
107 this.modalService.open(this.modal, {
108 centered: true,
109 beforeDismiss: async () => {
110 this.onModalDismiss()
111 return true
112 }
113 })
114 }
115
116 onModalDismiss () {
117 this.isModalOpened = false
118 }
119
120 dismissOtherModals () {
121 this.modalService.dismissAll()
122 }
123
89 private updateChildLabels (path: string) { 124 private updateChildLabels (path: string) {
90 this.suffixLabels = {} 125 this.suffixLabels = {}
91 126
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss
index f55a516e4..3c8b66cd5 100644
--- a/client/src/app/shared/misc/help.component.scss
+++ b/client/src/app/shared/misc/help.component.scss
@@ -17,6 +17,7 @@
17 17
18::ng-deep { 18::ng-deep {
19 .help-popover { 19 .help-popover {
20 z-index: z(help-popover) !important;
20 max-width: 300px; 21 max-width: 300px;
21 22
22 .popover-body { 23 .popover-body {
@@ -26,7 +27,7 @@
26 font-size: 13px; 27 font-size: 13px;
27 background-color: var(--mainBackgroundColor); 28 background-color: var(--mainBackgroundColor);
28 color: var(--mainForegroundColor); 29 color: var(--mainForegroundColor);
29 box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); 30 border-radius: 3px;
30 31
31 p { 32 p {
32 margin-bottom: 0; 33 margin-bottom: 0;
diff --git a/client/src/app/shared/misc/list-overflow.component.html b/client/src/app/shared/misc/list-overflow.component.html
new file mode 100644
index 000000000..986572801
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.html
@@ -0,0 +1,35 @@
1<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
2 <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
3 <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
4 </span>
5
6 <ng-container *ngIf="isMenuDisplayed()">
7 <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
8 <span class="glyphicon glyphicon-chevron-down"></span>
9 </button>
10
11 <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
12 <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
13 ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
14 >
15 <span class="glyphicon glyphicon-chevron-down"></span>
16 </button>
17
18 <div ngbDropdownMenu>
19 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
20 [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
21 {{ item.label }}
22 </a>
23 </div>
24 </div>
25 </ng-container>
26</div >
27
28<ng-template #modal let-close="close" let-dismiss="dismiss">
29 <div class="modal-body">
30 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
31 [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
32 {{ item.label }}
33 </a>
34 </div>
35</ng-template>
diff --git a/client/src/app/shared/misc/list-overflow.component.scss b/client/src/app/shared/misc/list-overflow.component.scss
new file mode 100644
index 000000000..1e5fe4c10
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.scss
@@ -0,0 +1,61 @@
1@import '_mixins';
2
3:host {
4 width: 100%;
5}
6
7.list-overflow-parent {
8 overflow: hidden;
9}
10
11.list-overflow-menu {
12 position: absolute;
13 right: 25px;
14}
15
16button {
17 width: 30px;
18 border: none;
19
20 &::after {
21 display: none;
22 }
23
24 &.routeActive {
25 &::after {
26 display: inherit;
27 border: 2px solid var(--mainColor);
28 position: relative;
29 right: 95%;
30 top: 50%;
31 }
32 }
33}
34
35::ng-deep .dropdown-menu {
36 margin-top: 0 !important;
37 position: static;
38 right: auto;
39 bottom: auto
40}
41
42.modal-body {
43 a {
44 @include disable-default-a-behaviour;
45
46 color: currentColor;
47 box-sizing: border-box;
48 display: block;
49 font-size: 1.2rem;
50 padding: 9px 12px;
51 text-align: initial;
52 text-transform: unset;
53 width: 100%;
54
55 &.active {
56 color: var(--mainBackgroundColor) !important;
57 background-color: var(--mainHoverColor);
58 opacity: .9;
59 }
60 }
61}
diff --git a/client/src/app/shared/misc/list-overflow.component.ts b/client/src/app/shared/misc/list-overflow.component.ts
new file mode 100644
index 000000000..30f43ba43
--- /dev/null
+++ b/client/src/app/shared/misc/list-overflow.component.ts
@@ -0,0 +1,120 @@
1import {
2 AfterViewInit,
3 ChangeDetectionStrategy,
4 ChangeDetectorRef,
5 Component,
6 ElementRef,
7 HostListener,
8 Input,
9 QueryList,
10 TemplateRef,
11 ViewChild,
12 ViewChildren
13} from '@angular/core'
14import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
15import { lowerFirst, uniqueId } from 'lodash-es'
16import { ScreenService } from './screen.service'
17import { take } from 'rxjs/operators'
18
19export interface ListOverflowItem {
20 label: string
21 routerLink: string | any[]
22}
23
24@Component({
25 selector: 'list-overflow',
26 templateUrl: './list-overflow.component.html',
27 styleUrls: [ './list-overflow.component.scss' ],
28 changeDetection: ChangeDetectionStrategy.OnPush
29})
30export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
31 @Input() items: T[]
32 @Input() itemTemplate: TemplateRef<{item: T}>
33
34 @ViewChild('modal', { static: true }) modal: ElementRef
35 @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
36 @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
37
38 showItemsUntilIndexExcluded: number
39 active = false
40 isInTouchScreen = false
41 isInMobileView = false
42
43 private openedOnHover = false
44
45 constructor (
46 private cdr: ChangeDetectorRef,
47 private modalService: NgbModal,
48 private screenService: ScreenService
49 ) {}
50
51 ngAfterViewInit () {
52 setTimeout(() => this.onWindowResize(), 0)
53 }
54
55 isMenuDisplayed () {
56 return !!this.showItemsUntilIndexExcluded
57 }
58
59 @HostListener('window:resize')
60 onWindowResize () {
61 this.isInTouchScreen = !!this.screenService.isInTouchScreen()
62 this.isInMobileView = !!this.screenService.isInMobileView()
63
64 const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
65 let showItemsUntilIndexExcluded: number
66 let accWidth = 0
67
68 for (const [index, el] of this.itemsRendered.toArray().entries()) {
69 accWidth += el.nativeElement.getBoundingClientRect().width
70 if (showItemsUntilIndexExcluded === undefined) {
71 showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
72 }
73
74 const e = document.getElementById(this.getId(index))
75 const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
76 e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
77 }
78
79 this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
80 this.cdr.markForCheck()
81 }
82
83 openDropdownOnHover (dropdown: NgbDropdown) {
84 this.openedOnHover = true
85 dropdown.open()
86
87 // Menu was closed
88 dropdown.openChange
89 .pipe(take(1))
90 .subscribe(() => this.openedOnHover = false)
91 }
92
93 dropdownAnchorClicked (dropdown: NgbDropdown) {
94 if (this.openedOnHover) {
95 this.openedOnHover = false
96 return
97 }
98
99 return dropdown.toggle()
100 }
101
102 closeDropdownIfHovered (dropdown: NgbDropdown) {
103 if (this.openedOnHover === false) return
104
105 dropdown.close()
106 this.openedOnHover = false
107 }
108
109 toggleModal () {
110 this.modalService.open(this.modal, { centered: true })
111 }
112
113 dismissOtherModals () {
114 this.modalService.dismissAll()
115 }
116
117 getId (id: number | string = uniqueId()): string {
118 return lowerFirst(this.constructor.name) + '_' + id
119 }
120}
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts
index 220d41d59..9c71a8c83 100644
--- a/client/src/app/shared/misc/screen.service.ts
+++ b/client/src/app/shared/misc/screen.service.ts
@@ -14,6 +14,10 @@ export class ScreenService {
14 return this.getWindowInnerWidth() < 800 14 return this.getWindowInnerWidth() < 800
15 } 15 }
16 16
17 isInMediumView () {
18 return this.getWindowInnerWidth() < 1100
19 }
20
17 isInMobileView () { 21 isInMobileView () {
18 return this.getWindowInnerWidth() < 500 22 return this.getWindowInnerWidth() < 500
19 } 23 }
diff --git a/client/src/app/shared/misc/storage.service.ts b/client/src/app/shared/misc/storage.service.ts
new file mode 100644
index 000000000..0d4a8ab53
--- /dev/null
+++ b/client/src/app/shared/misc/storage.service.ts
@@ -0,0 +1,40 @@
1import { Injectable } from '@angular/core'
2import { Observable, Subject } from 'rxjs'
3import {
4 peertubeLocalStorage,
5 peertubeSessionStorage
6} from './peertube-web-storage'
7import { filter } from 'rxjs/operators'
8
9abstract class StorageService {
10 protected instance: Storage
11 static storageSub = new Subject<string>()
12
13 watch (keys?: string[]): Observable<string> {
14 return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true))
15 }
16
17 getItem (key: string) {
18 return this.instance.getItem(key)
19 }
20
21 setItem (key: string, data: any, notifyOfUpdate = true) {
22 this.instance.setItem(key, data)
23 if (notifyOfUpdate) StorageService.storageSub.next(key)
24 }
25
26 removeItem (key: string, notifyOfUpdate = true) {
27 this.instance.removeItem(key)
28 if (notifyOfUpdate) StorageService.storageSub.next(key)
29 }
30}
31
32@Injectable()
33export class LocalStorageService extends StorageService {
34 protected instance: Storage = peertubeLocalStorage
35}
36
37@Injectable()
38export class SessionStorageService extends StorageService {
39 protected instance: Storage = peertubeSessionStorage
40}
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html
index f38ea543d..365eb1938 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.html
+++ b/client/src/app/shared/moderation/user-ban-modal.component.html
@@ -8,8 +8,10 @@
8 <div class="modal-body"> 8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 11 <textarea
12 </textarea> 12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
14 ></textarea>
13 <div *ngIf="formErrors.reason" class="form-error"> 15 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }} 16 {{ formErrors.reason }}
15 </div> 17 </div>
@@ -20,7 +22,10 @@
20 </div> 22 </div>
21 23
22 <div class="form-group inputs"> 24 <div class="form-group inputs">
23 <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span> 25 <input
26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
27 (click)="hide()" (key.enter)="hide()"
28 >
24 29
25 <input 30 <input
26 type="submit" i18n-value value="Ban this user" class="action-button-submit" 31 type="submit" i18n-value value="Ban this user" class="action-button-submit"
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts
index cf0e1577a..1647e3691 100644
--- a/client/src/app/shared/moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/moderation/user-ban-modal.component.ts
@@ -39,7 +39,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
39 39
40 openModal (user: User | User[]) { 40 openModal (user: User | User[]) {
41 this.usersToBan = user 41 this.usersToBan = user
42 this.openedModal = this.modalService.open(this.modal) 42 this.openedModal = this.modalService.open(this.modal, { centered: true })
43 } 43 }
44 44
45 hide () { 45 hide () {
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
index 11d8588f4..c8ccaa800 100644
--- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts
@@ -14,13 +14,13 @@ import { ServerConfig } from '@shared/models'
14 templateUrl: './user-moderation-dropdown.component.html' 14 templateUrl: './user-moderation-dropdown.component.html'
15}) 15})
16export class UserModerationDropdownComponent implements OnInit, OnChanges { 16export class UserModerationDropdownComponent implements OnInit, OnChanges {
17 @ViewChild('userBanModal', { static: false }) userBanModal: UserBanModalComponent 17 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
18 18
19 @Input() user: User 19 @Input() user: User
20 @Input() account: Account 20 @Input() account: Account
21 21
22 @Input() buttonSize: 'normal' | 'small' = 'normal' 22 @Input() buttonSize: 'normal' | 'small' = 'normal'
23 @Input() placement = 'left' 23 @Input() placement = 'left-top left-bottom auto'
24 @Input() label: string 24 @Input() label: string
25 25
26 @Output() userChanged = new EventEmitter() 26 @Output() userChanged = new EventEmitter()
diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts
index 79cb781f7..6d8af8052 100644
--- a/client/src/app/shared/overview/overview.service.ts
+++ b/client/src/app/shared/overview/overview.service.ts
@@ -1,5 +1,5 @@
1import { catchError, map, switchMap, tap } from 'rxjs/operators' 1import { catchError, map, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { forkJoin, Observable, of } from 'rxjs' 4import { forkJoin, Observable, of } from 'rxjs'
5import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' 5import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models'
@@ -21,9 +21,12 @@ export class OverviewService {
21 private serverService: ServerService 21 private serverService: ServerService
22 ) {} 22 ) {}
23 23
24 getVideosOverview (): Observable<VideosOverview> { 24 getVideosOverview (page: number): Observable<VideosOverview> {
25 let params = new HttpParams()
26 params = params.append('page', page + '')
27
25 return this.authHttp 28 return this.authHttp
26 .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos') 29 .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
27 .pipe( 30 .pipe(
28 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), 31 switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
29 catchError(err => this.restExtractor.handleError(err)) 32 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts
index 94a8aa4c6..1ddd8fe2f 100644
--- a/client/src/app/shared/renderer/html-renderer.service.ts
+++ b/client/src/app/shared/renderer/html-renderer.service.ts
@@ -19,15 +19,18 @@ export class HtmlRendererService {
19 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], 19 allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
20 allowedSchemes: [ 'http', 'https' ], 20 allowedSchemes: [ 'http', 'https' ],
21 allowedAttributes: { 21 allowedAttributes: {
22 'a': [ 'href', 'class', 'target' ] 22 'a': [ 'href', 'class', 'target', 'rel' ]
23 }, 23 },
24 transformTags: { 24 transformTags: {
25 a: (tagName, attribs) => { 25 a: (tagName, attribs) => {
26 let rel = 'noopener noreferrer'
27 if (attribs.rel === 'me') rel += ' me'
28
26 return { 29 return {
27 tagName, 30 tagName,
28 attribs: Object.assign(attribs, { 31 attribs: Object.assign(attribs, {
29 target: '_blank', 32 target: '_blank',
30 rel: 'noopener noreferrer' 33 rel
31 }) 34 })
32 } 35 }
33 } 36 }
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts
index 0d3fde537..f0c87326f 100644
--- a/client/src/app/shared/renderer/markdown.service.ts
+++ b/client/src/app/shared/renderer/markdown.service.ts
@@ -1,7 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { MarkdownIt } from 'markdown-it'
3import { buildVideoLink } from '../../../assets/player/utils' 2import { buildVideoLink } from '../../../assets/player/utils'
4import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' 3import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service'
4import * as MarkdownIt from 'markdown-it'
5 5
6type MarkdownParsers = { 6type MarkdownParsers = {
7 textMarkdownIt: MarkdownIt 7 textMarkdownIt: MarkdownIt
@@ -100,7 +100,7 @@ export class MarkdownService {
100 } 100 }
101 101
102 private async createMarkdownIt (config: MarkdownConfig) { 102 private async createMarkdownIt (config: MarkdownConfig) {
103 // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function 103 // FIXME: import('...') returns a struct module, containing a "default" field
104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
105 105
106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) 106 const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts
index c180346af..d4e6cf5f2 100644
--- a/client/src/app/shared/rest/rest-table.ts
+++ b/client/src/app/shared/rest/rest-table.ts
@@ -1,6 +1,5 @@
1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' 1import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
2import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' 2import { LazyLoadEvent, SortMeta } from 'primeng/api'
3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { RestPagination } from './rest-pagination' 3import { RestPagination } from './rest-pagination'
5import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
6import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 5import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
@@ -8,13 +7,17 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
8export abstract class RestTable { 7export abstract class RestTable {
9 8
10 abstract totalRecords: number 9 abstract totalRecords: number
11 abstract rowsPerPage: number
12 abstract sort: SortMeta 10 abstract sort: SortMeta
13 abstract pagination: RestPagination 11 abstract pagination: RestPagination
14 12
15 protected search: string 13 search: string
14 rowsPerPageOptions = [ 10, 20, 50, 100 ]
15 rowsPerPage = this.rowsPerPageOptions[0]
16 expandedRows = {}
17
16 private searchStream: Subject<string> 18 private searchStream: Subject<string>
17 private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name 19
20 abstract getIdentifier (): string
18 21
19 initialize () { 22 initialize () {
20 this.loadSort() 23 this.loadSort()
@@ -22,13 +25,13 @@ export abstract class RestTable {
22 } 25 }
23 26
24 loadSort () { 27 loadSort () {
25 const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) 28 const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey())
26 29
27 if (result) { 30 if (result) {
28 try { 31 try {
29 this.sort = JSON.parse(result) 32 this.sort = JSON.parse(result)
30 } catch (err) { 33 } catch (err) {
31 console.error('Cannot load sort of local storage key ' + this.sortLocalStorageKey, err) 34 console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err)
32 } 35 }
33 } 36 }
34 } 37 }
@@ -49,7 +52,7 @@ export abstract class RestTable {
49 } 52 }
50 53
51 saveSort () { 54 saveSort () {
52 peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) 55 peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
53 } 56 }
54 57
55 initSearch () { 58 initSearch () {
@@ -66,9 +69,37 @@ export abstract class RestTable {
66 }) 69 })
67 } 70 }
68 71
69 onSearch (search: string) { 72 onSearch (event: Event) {
70 this.searchStream.next(search) 73 const target = event.target as HTMLInputElement
74 this.searchStream.next(target.value)
75 }
76
77 onPage (event: { first: number, rows: number }) {
78 if (this.rowsPerPage !== event.rows) {
79 this.rowsPerPage = event.rows
80 this.pagination = {
81 start: event.first,
82 count: this.rowsPerPage
83 }
84 this.loadData()
85 }
86 this.expandedRows = {}
87 }
88
89 setTableFilter (filter: string) {
90 // FIXME: cannot use ViewChild, so create a component for the filter input
91 const filterInput = document.getElementById('table-filter') as HTMLInputElement
92 if (filterInput) filterInput.value = filter
93 }
94
95 resetSearch () {
96 this.searchStream.next('')
97 this.setTableFilter('')
71 } 98 }
72 99
73 protected abstract loadData (): void 100 protected abstract loadData (): void
101
102 private getSortLocalStorageKey () {
103 return 'rest-table-sort-' + this.getIdentifier()
104 }
74} 105}
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index 16bb6d82c..cd6db1f3c 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -1,10 +1,21 @@
1import { Injectable } from '@angular/core' 1import { SortMeta } from 'primeng/api'
2import { HttpParams } from '@angular/common/http' 2import { HttpParams } from '@angular/common/http'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { Injectable } from '@angular/core'
4import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model' 4import { ComponentPaginationLight } from './component-pagination.model'
5
6import { RestPagination } from './rest-pagination' 5import { RestPagination } from './rest-pagination'
7 6
7interface QueryStringFilterPrefixes {
8 [key: string]: {
9 prefix: string
10 handler?: (v: string) => string | number
11 multiple?: boolean
12 }
13}
14
15type ParseQueryStringFilterResult = {
16 [key: string]: string | number | (string | number)[]
17}
18
8@Injectable() 19@Injectable()
9export class RestService { 20export class RestService {
10 21
@@ -53,4 +64,48 @@ export class RestService {
53 64
54 return { start, count } 65 return { start, count }
55 } 66 }
67
68 parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult {
69 if (!q) return {}
70
71 // Tokenize the strings using spaces
72 const tokens = q.split(' ').filter(token => !!token)
73
74 // Build prefix array
75 const prefixeStrings = Object.values(prefixes)
76 .map(p => p.prefix)
77
78 // Search is the querystring minus defined filters
79 const searchTokens = tokens.filter(t => {
80 return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
81 })
82
83 const additionalFilters: ParseQueryStringFilterResult = {}
84
85 for (const prefixKey of Object.keys(prefixes)) {
86 const prefixObj = prefixes[prefixKey]
87 const prefix = prefixObj.prefix
88
89 const matchedTokens = tokens.filter(t => t.startsWith(prefix))
90 .map(t => t.slice(prefix.length)) // Keep the value filter
91 .map(t => {
92 if (prefixObj.handler) return prefixObj.handler(t)
93
94 return t
95 })
96 .filter(t => !!t)
97
98 if (matchedTokens.length === 0) continue
99
100 additionalFilters[prefixKey] = prefixObj.multiple === true
101 ? matchedTokens
102 : matchedTokens[0]
103 }
104
105 return {
106 search: searchTokens.join(' '),
107
108 ...additionalFilters
109 }
110 }
56} 111}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index b2eb13f73..01735c187 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -5,9 +5,10 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
5import { RouterModule } from '@angular/router' 5import { RouterModule } from '@angular/router'
6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' 6import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
7import { HelpComponent } from '@app/shared/misc/help.component' 7import { HelpComponent } from '@app/shared/misc/help.component'
8import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component'
8import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 9import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
9import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
10import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/api'
11import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 12import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
12import { ButtonComponent } from './buttons/button.component' 13import { ButtonComponent } from './buttons/button.component'
13import { DeleteButtonComponent } from './buttons/delete-button.component' 14import { DeleteButtonComponent } from './buttons/delete-button.component'
@@ -46,6 +47,7 @@ import {
46import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 47import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
47import { InputMaskModule } from 'primeng/inputmask' 48import { InputMaskModule } from 'primeng/inputmask'
48import { ScreenService } from '@app/shared/misc/screen.service' 49import { ScreenService } from '@app/shared/misc/screen.service'
50import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service'
49import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 51import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
50import { VideoCaptionService } from '@app/shared/video-caption' 52import { VideoCaptionService } from '@app/shared/video-caption'
51import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' 53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
@@ -56,7 +58,7 @@ import {
56 NgbDropdownModule, 58 NgbDropdownModule,
57 NgbModalModule, 59 NgbModalModule,
58 NgbPopoverModule, 60 NgbPopoverModule,
59 NgbTabsetModule, 61 NgbNavModule,
60 NgbTooltipModule 62 NgbTooltipModule
61} from '@ng-bootstrap/ng-bootstrap' 63} from '@ng-bootstrap/ng-bootstrap'
62import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' 64import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
@@ -88,16 +90,24 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
88import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' 90import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
89import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' 91import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
90import { FromNowPipe } from '@app/shared/angular/from-now.pipe' 92import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
93import { HighlightPipe } from '@app/shared/angular/highlight.pipe'
91import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' 94import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
92import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' 95import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
93import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' 96import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
94import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' 97import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
95import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' 98import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
96import { ClipboardModule } from 'ngx-clipboard'
97import { FollowService } from '@app/shared/instance/follow.service' 99import { FollowService } from '@app/shared/instance/follow.service'
98import { MultiSelectModule } from 'primeng/multiselect' 100import { MultiSelectModule } from 'primeng/multiselect'
99import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' 101import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
100import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' 102import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
103import { RedundancyService } from '@app/shared/video/redundancy.service'
104import { ClipboardModule } from '@angular/cdk/clipboard'
105import { InputSwitchModule } from 'primeng/inputswitch'
106
107import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
108import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
109import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
110import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service'
101 111
102@NgModule({ 112@NgModule({
103 imports: [ 113 imports: [
@@ -110,7 +120,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
110 NgbDropdownModule, 120 NgbDropdownModule,
111 NgbModalModule, 121 NgbModalModule,
112 NgbPopoverModule, 122 NgbPopoverModule,
113 NgbTabsetModule, 123 NgbNavModule,
114 NgbTooltipModule, 124 NgbTooltipModule,
115 NgbCollapseModule, 125 NgbCollapseModule,
116 126
@@ -119,7 +129,8 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
119 PrimeSharedModule, 129 PrimeSharedModule,
120 InputMaskModule, 130 InputMaskModule,
121 NgPipesModule, 131 NgPipesModule,
122 MultiSelectModule 132 MultiSelectModule,
133 InputSwitchModule
123 ], 134 ],
124 135
125 declarations: [ 136 declarations: [
@@ -147,6 +158,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
147 NumberFormatterPipe, 158 NumberFormatterPipe,
148 ObjectLengthPipe, 159 ObjectLengthPipe,
149 FromNowPipe, 160 FromNowPipe,
161 HighlightPipe,
150 PeerTubeTemplateDirective, 162 PeerTubeTemplateDirective,
151 VideoDurationPipe, 163 VideoDurationPipe,
152 164
@@ -155,6 +167,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
155 InfiniteScrollerDirective, 167 InfiniteScrollerDirective,
156 TextareaAutoResizeDirective, 168 TextareaAutoResizeDirective,
157 HelpComponent, 169 HelpComponent,
170 ListOverflowComponent,
158 171
159 ReactiveFileComponent, 172 ReactiveFileComponent,
160 PeertubeCheckboxComponent, 173 PeertubeCheckboxComponent,
@@ -175,7 +188,11 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
175 DateToggleComponent, 188 DateToggleComponent,
176 189
177 GlobalIconComponent, 190 GlobalIconComponent,
178 PreviewUploadComponent 191 PreviewUploadComponent,
192
193 MyAccountVideoSettingsComponent,
194 MyAccountInterfaceSettingsComponent,
195 ActorAvatarInfoComponent
179 ], 196 ],
180 197
181 exports: [ 198 exports: [
@@ -188,7 +205,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
188 NgbDropdownModule, 205 NgbDropdownModule,
189 NgbModalModule, 206 NgbModalModule,
190 NgbPopoverModule, 207 NgbPopoverModule,
191 NgbTabsetModule, 208 NgbNavModule,
192 NgbTooltipModule, 209 NgbTooltipModule,
193 NgbCollapseModule, 210 NgbCollapseModule,
194 211
@@ -226,6 +243,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
226 InfiniteScrollerDirective, 243 InfiniteScrollerDirective,
227 TextareaAutoResizeDirective, 244 TextareaAutoResizeDirective,
228 HelpComponent, 245 HelpComponent,
246 ListOverflowComponent,
229 InputReadonlyCopyComponent, 247 InputReadonlyCopyComponent,
230 248
231 ReactiveFileComponent, 249 ReactiveFileComponent,
@@ -250,8 +268,13 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
250 NumberFormatterPipe, 268 NumberFormatterPipe,
251 ObjectLengthPipe, 269 ObjectLengthPipe,
252 FromNowPipe, 270 FromNowPipe,
271 HighlightPipe,
253 PeerTubeTemplateDirective, 272 PeerTubeTemplateDirective,
254 VideoDurationPipe 273 VideoDurationPipe,
274
275 MyAccountVideoSettingsComponent,
276 MyAccountInterfaceSettingsComponent,
277 ActorAvatarInfoComponent
255 ], 278 ],
256 279
257 providers: [ 280 providers: [
@@ -275,6 +298,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
275 LoginValidatorsService, 298 LoginValidatorsService,
276 ResetPasswordValidatorsService, 299 ResetPasswordValidatorsService,
277 UserValidatorsService, 300 UserValidatorsService,
301 BatchDomainsValidatorsService,
278 VideoPlaylistValidatorsService, 302 VideoPlaylistValidatorsService,
279 VideoAbuseValidatorsService, 303 VideoAbuseValidatorsService,
280 VideoChannelValidatorsService, 304 VideoChannelValidatorsService,
@@ -296,10 +320,12 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop
296 320
297 I18nPrimengCalendarService, 321 I18nPrimengCalendarService,
298 ScreenService, 322 ScreenService,
323 LocalStorageService, SessionStorageService,
299 324
300 UserNotificationService, 325 UserNotificationService,
301 326
302 FollowService, 327 FollowService,
328 RedundancyService,
303 329
304 I18n 330 I18n
305 ] 331 ]
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html
index f08c88f3c..85b3d1fdb 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.html
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.html
@@ -55,7 +55,7 @@
55 </button> 55 </button>
56 56
57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> 57 <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
58 <my-remote-subscribe showHelp="true" [uri]="uri"></my-remote-subscribe> 58 <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
59 59
60 <div class="dropdown-divider"></div> 60 <div class="dropdown-divider"></div>
61 61
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss
index 114a12f06..b739c5ae2 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.scss
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.scss
@@ -13,9 +13,17 @@
13 font-size: 15px; 13 font-size: 15px;
14 } 14 }
15 15
16 &:not(.big) {
17 white-space: nowrap;
18 }
19
16 &.big { 20 &.big {
17 height: 35px; 21 height: 35px;
18 22
23 & > button:first-child {
24 width: 175px;
25 }
26
19 button .extra-text { 27 button .extra-text {
20 span:first-child { 28 span:first-child {
21 line-height: 80%; 29 line-height: 80%;
@@ -80,10 +88,6 @@
80 } 88 }
81 } 89 }
82 90
83 .dropdown-header {
84 padding-left: 1rem;
85 }
86
87 ::ng-deep form { 91 ::ng-deep form {
88 padding: 0.25rem 1rem; 92 padding: 0.25rem 1rem;
89 } 93 }
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 7707d7dda..3348fe75f 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -1,10 +1,32 @@
1import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared' 1import {
2 hasUserRight,
3 User as UserServerModel,
4 UserNotificationSetting,
5 UserRight,
6 UserRole
7} from '../../../../../shared/models/users'
8import { VideoChannel } from '../../../../../shared/models/videos'
2import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
3import { Account } from '@app/shared/account/account.model' 10import { Account } from '@app/shared/account/account.model'
4import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 11import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
5import { UserAdminFlag } from '@shared/models/users/user-flag.model' 12import { UserAdminFlag } from '@shared/models/users/user-flag.model'
6 13
7export class User implements UserServerModel { 14export class User implements UserServerModel {
15 static KEYS = {
16 ID: 'id',
17 ROLE: 'role',
18 EMAIL: 'email',
19 VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
20 USERNAME: 'username',
21 NSFW_POLICY: 'nsfw_policy',
22 WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
23 AUTO_PLAY_VIDEO: 'auto_play_video',
24 SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
25 AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
26 THEME: 'last_active_theme',
27 VIDEO_LANGUAGES: 'video_languages'
28 }
29
8 id: number 30 id: number
9 username: string 31 username: string
10 email: string 32 email: string
@@ -29,6 +51,11 @@ export class User implements UserServerModel {
29 videoQuotaDaily: number 51 videoQuotaDaily: number
30 videoQuotaUsed?: number 52 videoQuotaUsed?: number
31 videoQuotaUsedDaily?: number 53 videoQuotaUsedDaily?: number
54 videosCount?: number
55 videoAbusesCount?: number
56 videoAbusesAcceptedCount?: number
57 videoAbusesCreatedCount?: number
58 videoCommentsCount?: number
32 59
33 theme: string 60 theme: string
34 61
@@ -42,6 +69,10 @@ export class User implements UserServerModel {
42 noInstanceConfigWarningModal: boolean 69 noInstanceConfigWarningModal: boolean
43 noWelcomeModal: boolean 70 noWelcomeModal: boolean
44 71
72 pluginAuth: string | null
73
74 lastLoginDate: Date | null
75
45 createdAt: Date 76 createdAt: Date
46 77
47 constructor (hash: Partial<UserServerModel>) { 78 constructor (hash: Partial<UserServerModel>) {
@@ -57,11 +88,19 @@ export class User implements UserServerModel {
57 this.videoQuotaDaily = hash.videoQuotaDaily 88 this.videoQuotaDaily = hash.videoQuotaDaily
58 this.videoQuotaUsed = hash.videoQuotaUsed 89 this.videoQuotaUsed = hash.videoQuotaUsed
59 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily 90 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
91 this.videosCount = hash.videosCount
92 this.videoAbusesCount = hash.videoAbusesCount
93 this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
94 this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
95 this.videoCommentsCount = hash.videoCommentsCount
60 96
61 this.nsfwPolicy = hash.nsfwPolicy 97 this.nsfwPolicy = hash.nsfwPolicy
62 this.webTorrentEnabled = hash.webTorrentEnabled 98 this.webTorrentEnabled = hash.webTorrentEnabled
63 this.videosHistoryEnabled = hash.videosHistoryEnabled
64 this.autoPlayVideo = hash.autoPlayVideo 99 this.autoPlayVideo = hash.autoPlayVideo
100 this.autoPlayNextVideo = hash.autoPlayNextVideo
101 this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
102 this.videosHistoryEnabled = hash.videosHistoryEnabled
103 this.videoLanguages = hash.videoLanguages
65 104
66 this.theme = hash.theme 105 this.theme = hash.theme
67 106
@@ -77,6 +116,9 @@ export class User implements UserServerModel {
77 116
78 this.createdAt = hash.createdAt 117 this.createdAt = hash.createdAt
79 118
119 this.pluginAuth = hash.pluginAuth
120 this.lastLoginDate = hash.lastLoginDate
121
80 if (hash.account !== undefined) { 122 if (hash.account !== undefined) {
81 this.account = new Account(hash.account) 123 this.account = new Account(hash.account)
82 } 124 }
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index e24d91df3..abb4088b5 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -1,8 +1,8 @@
1import { from, Observable, of } from 'rxjs' 1import { from, Observable } from 'rxjs'
2import { catchError, concatMap, map, share, shareReplay, tap, toArray } from 'rxjs/operators' 2import { catchError, concatMap, map, shareReplay, toArray } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' 5import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { RestExtractor, RestPagination, RestService } from '../rest' 7import { RestExtractor, RestPagination, RestService } from '../rest'
8import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 8import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@@ -10,6 +10,10 @@ import { SortMeta } from 'primeng/api'
10import { BytesPipe } from 'ngx-pipes' 10import { BytesPipe } from 'ngx-pipes'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { UserRegister } from '@shared/models/users/user-register.model' 12import { UserRegister } from '@shared/models/users/user-register.model'
13import { User } from './user.model'
14import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
15import { has } from 'lodash-es'
16import { LocalStorageService, SessionStorageService } from '../misc/storage.service'
13 17
14@Injectable() 18@Injectable()
15export class UserService { 19export class UserService {
@@ -17,12 +21,14 @@ export class UserService {
17 21
18 private bytesPipe = new BytesPipe() 22 private bytesPipe = new BytesPipe()
19 23
20 private userCache: { [ id: number ]: Observable<User> } = {} 24 private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
21 25
22 constructor ( 26 constructor (
23 private authHttp: HttpClient, 27 private authHttp: HttpClient,
24 private restExtractor: RestExtractor, 28 private restExtractor: RestExtractor,
25 private restService: RestService, 29 private restService: RestService,
30 private localStorageService: LocalStorageService,
31 private sessionStorageService: SessionStorageService,
26 private i18n: I18n 32 private i18n: I18n
27 ) { } 33 ) { }
28 34
@@ -64,6 +70,30 @@ export class UserService {
64 ) 70 )
65 } 71 }
66 72
73 updateMyAnonymousProfile (profile: UserUpdateMe) {
74 const supportedKeys = {
75 // local storage keys
76 nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
77 webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
78 autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
79 autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
80 theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
81 videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
82
83 // session storage keys
84 autoPlayNextVideo: (val: boolean) =>
85 this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
86 }
87
88 for (const key of Object.keys(profile)) {
89 try {
90 if (has(supportedKeys, key)) supportedKeys[key](profile[key])
91 } catch (err) {
92 console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
93 }
94 }
95 }
96
67 deleteMe () { 97 deleteMe () {
68 const url = UserService.BASE_USERS_URL + 'me' 98 const url = UserService.BASE_USERS_URL + 'me'
69 99
@@ -187,7 +217,7 @@ export class UserService {
187 ) 217 )
188 } 218 }
189 219
190 updateUsers (users: User[], userUpdate: UserUpdate) { 220 updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
191 return from(users) 221 return from(users)
192 .pipe( 222 .pipe(
193 concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), 223 concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
@@ -204,18 +234,44 @@ export class UserService {
204 return this.userCache[userId] 234 return this.userCache[userId]
205 } 235 }
206 236
207 getUser (userId: number) { 237 getUser (userId: number, withStats = false) {
208 return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) 238 const params = new HttpParams().append('withStats', withStats + '')
239 return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
209 .pipe(catchError(err => this.restExtractor.handleError(err))) 240 .pipe(catchError(err => this.restExtractor.handleError(err)))
210 } 241 }
211 242
212 getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> { 243 getAnonymousUser () {
244 let videoLanguages
245
246 try {
247 videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
248 } catch (err) {
249 videoLanguages = null
250 console.error('Cannot parse desired video languages from localStorage.', err)
251 }
252
253 return new User({
254 // local storage keys
255 nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
256 webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
257 theme: this.localStorageService.getItem(User.KEYS.THEME) || 'default',
258 videoLanguages,
259
260 autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
261 autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
262
263 // session storage keys
264 autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
265 })
266 }
267
268 getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
213 let params = new HttpParams() 269 let params = new HttpParams()
214 params = this.restService.addRestGetParams(params, pagination, sort) 270 params = this.restService.addRestGetParams(params, pagination, sort)
215 271
216 if (search) params = params.append('search', search) 272 if (search) params = params.append('search', search)
217 273
218 return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) 274 return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
219 .pipe( 275 .pipe(
220 map(res => this.restExtractor.convertResultListDateToHuman(res)), 276 map(res => this.restExtractor.convertResultListDateToHuman(res)),
221 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), 277 map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
@@ -223,7 +279,7 @@ export class UserService {
223 ) 279 )
224 } 280 }
225 281
226 removeUser (usersArg: User | User[]) { 282 removeUser (usersArg: UserServerModel | UserServerModel[]) {
227 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 283 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
228 284
229 return from(users) 285 return from(users)
@@ -234,7 +290,7 @@ export class UserService {
234 ) 290 )
235 } 291 }
236 292
237 banUsers (usersArg: User | User[], reason?: string) { 293 banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
238 const body = reason ? { reason } : {} 294 const body = reason ? { reason } : {}
239 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 295 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
240 296
@@ -246,7 +302,7 @@ export class UserService {
246 ) 302 )
247 } 303 }
248 304
249 unbanUsers (usersArg: User | User[]) { 305 unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
250 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] 306 const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
251 307
252 return from(users) 308 return from(users)
@@ -257,7 +313,7 @@ export class UserService {
257 ) 313 )
258 } 314 }
259 315
260 private formatUser (user: User) { 316 private formatUser (user: UserServerModel) {
261 let videoQuota 317 let videoQuota
262 if (user.videoQuota === -1) { 318 if (user.videoQuota === -1) {
263 videoQuota = this.i18n('Unlimited') 319 videoQuota = this.i18n('Unlimited')
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts
index b0b59ea0c..700a30239 100644
--- a/client/src/app/shared/video-abuse/video-abuse.service.ts
+++ b/client/src/app/shared/video-abuse/video-abuse.service.ts
@@ -1,9 +1,9 @@
1import { catchError, map } from 'rxjs/operators' 1import { catchError, map } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { Observable } from 'rxjs' 5import { Observable } from 'rxjs'
6import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared' 6import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { RestExtractor, RestPagination, RestService } from '../rest' 8import { RestExtractor, RestPagination, RestService } from '../rest'
9 9
@@ -17,15 +17,48 @@ export class VideoAbuseService {
17 private restExtractor: RestExtractor 17 private restExtractor: RestExtractor
18 ) {} 18 ) {}
19 19
20 getVideoAbuses (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoAbuse>> { 20 getVideoAbuses (options: {
21 pagination: RestPagination,
22 sort: SortMeta,
23 search?: string
24 }): Observable<ResultList<VideoAbuse>> {
25 const { pagination, sort, search } = options
21 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' 26 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
22 27
23 let params = new HttpParams() 28 let params = new HttpParams()
24 params = this.restService.addRestGetParams(params, pagination, sort) 29 params = this.restService.addRestGetParams(params, pagination, sort)
25 30
31 if (search) {
32 const filters = this.restService.parseQueryStringFilter(search, {
33 id: { prefix: '#' },
34 state: {
35 prefix: 'state:',
36 handler: v => {
37 if (v === 'accepted') return VideoAbuseState.ACCEPTED
38 if (v === 'pending') return VideoAbuseState.PENDING
39 if (v === 'rejected') return VideoAbuseState.REJECTED
40
41 return undefined
42 }
43 },
44 videoIs: {
45 prefix: 'videoIs:',
46 handler: v => {
47 if (v === 'deleted') return v
48 if (v === 'blacklisted') return v
49
50 return undefined
51 }
52 },
53 searchReporter: { prefix: 'reporter:' },
54 searchReportee: { prefix: 'reportee:' }
55 })
56
57 params = this.restService.addObjectParams(params, filters)
58 }
59
26 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params }) 60 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
27 .pipe( 61 .pipe(
28 map(res => this.restExtractor.convertResultListDateToHuman(res)),
29 catchError(res => this.restExtractor.handleError(res)) 62 catchError(res => this.restExtractor.handleError(res))
30 ) 63 )
31 } 64 }
diff --git a/client/src/app/shared/video-blacklist/video-blacklist.service.ts b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
index 491fa698b..c0e13a651 100644
--- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts
+++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts
@@ -1,7 +1,7 @@
1import { catchError, map, concatMap, toArray } from 'rxjs/operators' 1import { catchError, map, concatMap, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/components/common/sortmeta' 4import { SortMeta } from 'primeng/api'
5import { from as observableFrom, Observable } from 'rxjs' 5import { from as observableFrom, Observable } from 'rxjs'
6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' 6import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
7import { Video } from '../video/video.model' 7import { Video } from '../video/video.model'
@@ -19,13 +19,19 @@ export class VideoBlacklistService {
19 private restExtractor: RestExtractor 19 private restExtractor: RestExtractor
20 ) {} 20 ) {}
21 21
22 listBlacklist (pagination: RestPagination, sort: SortMeta, type?: VideoBlacklistType): Observable<ResultList<VideoBlacklist>> { 22 listBlacklist (options: {
23 pagination: RestPagination,
24 sort: SortMeta,
25 search?: string
26 type?: VideoBlacklistType
27 }): Observable<ResultList<VideoBlacklist>> {
28 const { pagination, sort, search, type } = options
29
23 let params = new HttpParams() 30 let params = new HttpParams()
24 params = this.restService.addRestGetParams(params, pagination, sort) 31 params = this.restService.addRestGetParams(params, pagination, sort)
25 32
26 if (type) { 33 if (search) params = params.append('search', search)
27 params = params.set('type', type.toString()) 34 if (type) params = params.append('type', type.toString())
28 }
29 35
30 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) 36 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params })
31 .pipe( 37 .pipe(
diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts
index 309b614ae..617d6d44d 100644
--- a/client/src/app/shared/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/video-channel/video-channel.model.ts
@@ -1,4 +1,4 @@
1import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' 1import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos'
2import { Actor } from '../actor/actor.model' 2import { Actor } from '../actor/actor.model'
3import { Account } from '../../../../../shared/models/actors' 3import { Account } from '../../../../../shared/models/actors'
4 4
@@ -8,9 +8,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
8 support: string 8 support: string
9 isLocal: boolean 9 isLocal: boolean
10 nameWithHost: string 10 nameWithHost: string
11 nameWithHostForced: string
11 ownerAccount?: Account 12 ownerAccount?: Account
12 ownerBy?: string 13 ownerBy?: string
13 ownerAvatarUrl?: string 14 ownerAvatarUrl?: string
15 viewsPerDay?: ViewsPerDate[]
14 16
15 constructor (hash: ServerVideoChannel) { 17 constructor (hash: ServerVideoChannel) {
16 super(hash) 18 super(hash)
@@ -20,6 +22,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
20 this.support = hash.support 22 this.support = hash.support
21 this.isLocal = hash.isLocal 23 this.isLocal = hash.isLocal
22 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) 24 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
25 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
26
27 if (hash.viewsPerDay) {
28 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
29 }
23 30
24 if (hash.ownerAccount) { 31 if (hash.ownerAccount) {
25 this.ownerAccount = hash.ownerAccount 32 this.ownerAccount = hash.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
index adb4f4819..0e036bda7 100644
--- a/client/src/app/shared/video-channel/video-channel.service.ts
+++ b/client/src/app/shared/video-channel/video-channel.service.ts
@@ -44,13 +44,18 @@ export class VideoChannelService {
44 ) 44 )
45 } 45 }
46 46
47 listAccountVideoChannels (account: Account, componentPagination?: ComponentPaginationLight): Observable<ResultList<VideoChannel>> { 47 listAccountVideoChannels (
48 account: Account,
49 componentPagination?: ComponentPaginationLight,
50 withStats = false
51 ): Observable<ResultList<VideoChannel>> {
48 const pagination = componentPagination 52 const pagination = componentPagination
49 ? this.restService.componentPaginationToRestPagination(componentPagination) 53 ? this.restService.componentPaginationToRestPagination(componentPagination)
50 : { start: 0, count: 20 } 54 : { start: 0, count: 20 }
51 55
52 let params = new HttpParams() 56 let params = new HttpParams()
53 params = this.restService.addRestGetParams(params, pagination) 57 params = this.restService.addRestGetParams(params, pagination)
58 params = params.set('withStats', withStats + '')
54 59
55 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' 60 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
56 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) 61 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts
index 3e3fb7dfb..afd9e3fb5 100644
--- a/client/src/app/shared/video-import/video-import.service.ts
+++ b/client/src/app/shared/video-import/video-import.service.ts
@@ -9,7 +9,7 @@ import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/vid
9import { objectToFormData } from '@app/shared/misc/utils' 9import { objectToFormData } from '@app/shared/misc/utils'
10import { ResultList } from '../../../../../shared/models/result-list.model' 10import { ResultList } from '../../../../../shared/models/result-list.model'
11import { UserService } from '@app/shared/users/user.service' 11import { UserService } from '@app/shared/users/user.service'
12import { SortMeta } from 'primeng/components/common/sortmeta' 12import { SortMeta } from 'primeng/api'
13import { RestPagination } from '@app/shared/rest' 13import { RestPagination } from '@app/shared/rest'
14import { ServerService } from '@app/core' 14import { ServerService } from '@app/core'
15 15
diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts
index aa9e4839a..b95d5b792 100644
--- a/client/src/app/shared/video-ownership/video-ownership.service.ts
+++ b/client/src/app/shared/video-ownership/video-ownership.service.ts
@@ -5,7 +5,7 @@ import { environment } from '../../../environments/environment'
5import { RestExtractor, RestService } from '../rest' 5import { RestExtractor, RestService } from '../rest'
6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' 6import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos'
7import { Observable } from 'rxjs/index' 7import { Observable } from 'rxjs/index'
8import { SortMeta } from 'primeng/components/common/sortmeta' 8import { SortMeta } from 'primeng/api'
9import { ResultList, VideoChangeOwnership } from '../../../../../shared' 9import { ResultList, VideoChangeOwnership } from '../../../../../shared'
10import { RestPagination } from '@app/shared/rest' 10import { RestPagination } from '@app/shared/rest'
11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' 11import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model'
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
index 6e0989227..58108584b 100644
--- 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
@@ -62,7 +62,7 @@
62 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> 62 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
63 <my-global-icon iconName="add"></my-global-icon> 63 <my-global-icon iconName="add"></my-global-icon>
64 64
65 Create a private playlist 65 <span i18n>Create a private playlist</span>
66 </div> 66 </div>
67 67
68 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> 68 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
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
index f1b6cd601..1724449e8 100644
--- 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
@@ -4,7 +4,7 @@
4.header, 4.header,
5.dropdown-item, 5.dropdown-item,
6.input-container { 6.input-container {
7 padding: 6px 24px 10px 24px; 7 padding: 8px 24px;
8} 8}
9 9
10.header { 10.header {
@@ -54,11 +54,12 @@
54} 54}
55 55
56.playlist { 56.playlist {
57 display: flex; 57 display: inline-flex;
58 cursor: pointer; 58 cursor: pointer;
59 59
60 my-peertube-checkbox { 60 my-peertube-checkbox {
61 margin-right: 10px; 61 margin-right: 10px;
62 align-self: center;
62 } 63 }
63 64
64 .display-name { 65 .display-name {
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
index 4864581b5..a2c0724cd 100644
--- 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
@@ -18,7 +18,7 @@ import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-
18 changeDetection: ChangeDetectionStrategy.OnPush 18 changeDetection: ChangeDetectionStrategy.OnPush
19}) 19})
20export class VideoPlaylistElementMiniatureComponent implements OnInit { 20export class VideoPlaylistElementMiniatureComponent implements OnInit {
21 @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown 21 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
22 22
23 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
24 @Input() playlistElement: VideoPlaylistElement 24 @Input() playlistElement: VideoPlaylistElement
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 3c7a4b1fc..44b629542 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -15,6 +15,11 @@
15 top: 1px; 15 top: 1px;
16 margin-left: 5px; 16 margin-left: 5px;
17 width: max-content; 17 width: max-content;
18 opacity: 0;
19 transition: ease-in .2s opacity;
20 }
21 &:hover my-feed {
22 opacity: 1;
18 } 23 }
19 } 24 }
20 25
@@ -50,3 +55,15 @@
50 @include adapt-margin-content-width; 55 @include adapt-margin-content-width;
51} 56}
52 57
58@media screen and (max-width: $mobile-view) {
59 .videos-header {
60 flex-direction: column;
61 align-items: center;
62 height: auto;
63
64 .title-page {
65 margin-bottom: 10px;
66 margin-right: 0px;
67 }
68 }
69}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index c2fe6f754..b146d7014 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -1,4 +1,4 @@
1import { debounceTime, first, tap } from 'rxjs/operators' 1import { debounceTime, first, tap, throttleTime } from 'rxjs/operators'
2import { OnDestroy, OnInit } from '@angular/core' 2import { OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' 4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
14import { I18n } from '@ngx-translate/i18n-polyfill' 14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' 15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
16import { ServerConfig } from '@shared/models' 16import { ServerConfig } from '@shared/models'
17import { GlobalIconName } from '@app/shared/images/global-icon.component'
18import { UserService, User } from '../users'
19import { LocalStorageService } from '../misc/storage.service'
17 20
18enum GroupDate { 21enum GroupDate {
19 UNKNOWN = 0, 22 UNKNOWN = 0,
@@ -61,7 +64,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
61 64
62 actions: { 65 actions: {
63 routerLink: string 66 routerLink: string
64 iconName: string 67 iconName: GlobalIconName
65 label: string 68 label: string
66 }[] = [] 69 }[] = []
67 70
@@ -71,9 +74,11 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
71 74
72 protected abstract notifier: Notifier 75 protected abstract notifier: Notifier
73 protected abstract authService: AuthService 76 protected abstract authService: AuthService
77 protected abstract userService: UserService
74 protected abstract route: ActivatedRoute 78 protected abstract route: ActivatedRoute
75 protected abstract serverService: ServerService 79 protected abstract serverService: ServerService
76 protected abstract screenService: ScreenService 80 protected abstract screenService: ScreenService
81 protected abstract storageService: LocalStorageService
77 protected abstract router: Router 82 protected abstract router: Router
78 protected abstract i18n: I18n 83 protected abstract i18n: I18n
79 abstract titlePage: string 84 abstract titlePage: string
@@ -123,6 +128,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
123 if (this.loadOnInit === true) { 128 if (this.loadOnInit === true) {
124 loadUserObservable.subscribe(() => this.loadMoreVideos()) 129 loadUserObservable.subscribe(() => this.loadMoreVideos())
125 } 130 }
131
132 this.storageService.watch([
133 User.KEYS.NSFW_POLICY,
134 User.KEYS.VIDEO_LANGUAGES
135 ]).pipe(throttleTime(200)).subscribe(
136 () => {
137 this.loadUserVideoLanguagesIfNeeded()
138 if (this.hasDoneFirstQuery) this.reloadVideos()
139 }
140 )
126 } 141 }
127 142
128 ngOnDestroy () { 143 ngOnDestroy () {
@@ -278,7 +293,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
278 } 293 }
279 294
280 private loadUserVideoLanguagesIfNeeded () { 295 private loadUserVideoLanguagesIfNeeded () {
281 if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) { 296 if (!this.useUserVideoLanguagePreferences) {
297 return of(true)
298 }
299
300 if (!this.authService.isLoggedIn()) {
301 this.languageOneOf = this.userService.getAnonymousUser().videoLanguages
282 return of(true) 302 return of(true)
283 } 303 }
284 304
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index 9f613c5fa..f09c3d1fc 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -1,4 +1,4 @@
1import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' 1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs' 3import { fromEvent, Observable, Subscription } from 'rxjs'
4 4
@@ -53,7 +53,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterConten
53 const scrollableElement = this.onItself ? this.container : window 53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll') 54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe( 55 .pipe(
56 startWith(null as string), // FIXME: typings 56 startWith(true),
57 throttleTime(200, undefined, throttleOptions), 57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()), 58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current), 59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html
index 1a87bdcd4..8f06a6b02 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.html
+++ b/client/src/app/shared/video/modals/video-blacklist.component.html
@@ -8,8 +8,10 @@
8 8
9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 11 <textarea
12 </textarea> 12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
14 ></textarea>
13 <div *ngIf="formErrors.reason" class="form-error"> 15 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }} 16 {{ formErrors.reason }}
15 </div> 17 </div>
@@ -18,14 +20,19 @@
18 <div class="form-group" *ngIf="video.isLocal"> 20 <div class="form-group" *ngIf="video.isLocal">
19 <my-peertube-checkbox 21 <my-peertube-checkbox
20 inputName="unfederate" formControlName="unfederate" 22 inputName="unfederate" formControlName="unfederate"
21 i18n-labelText labelText="Unfederate the video (ask for its deletion from the remote instances)" 23 i18n-labelText labelText="Unfederate the video"
22 ></my-peertube-checkbox> 24 >
25 <ng-container ngProjectAs="description">
26 <span i18n>This will ask remote instances to delete it</span>
27 </ng-container>
28 </my-peertube-checkbox>
23 </div> 29 </div>
24 30
25 <div class="form-group inputs"> 31 <div class="form-group inputs">
26 <span i18n class="action-button action-button-cancel" (click)="hide()"> 32 <input
27 Cancel 33 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
28 </span> 34 (click)="hide()" (key.enter)="hide()"
35 >
29 36
30 <input 37 <input
31 type="submit" i18n-value value="Submit" class="action-button-submit" 38 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
index f0c70a365..6ef9c250b 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.ts
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core' 2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlacklistService } from '../../../shared/video-blacklist' 3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' 8import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
9import { Video } from '@app/shared/video/video.model'
10 10
11@Component({ 11@Component({
12 selector: 'my-video-blacklist', 12 selector: 'my-video-blacklist',
@@ -14,7 +14,7 @@ import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms
14 styleUrls: [ './video-blacklist.component.scss' ] 14 styleUrls: [ './video-blacklist.component.scss' ]
15}) 15})
16export class VideoBlacklistComponent extends FormReactive implements OnInit { 16export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 @Input() video: VideoDetails = null 17 @Input() video: Video = null
18 18
19 @ViewChild('modal', { static: true }) modal: NgbModal 19 @ViewChild('modal', { static: true }) modal: NgbModal
20 20
@@ -46,7 +46,7 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
46 } 46 }
47 47
48 show () { 48 show () {
49 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 49 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
50 } 50 }
51 51
52 hide () { 52 hide () {
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 8cca985b1..c65e371ee 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -1,7 +1,7 @@
1<ng-template #modal let-hide="close"> 1<ng-template #modal let-hide="close">
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 class="modal-title">Download 3 <h4 class="modal-title">
4 <span *ngIf="!videoCaptions" i18n>video</span> 4 <ng-container i18n>Download</ng-container>
5 5
6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block"> 6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
7 <span id="dropdownDownloadType" ngbDropdownToggle> 7 <span id="dropdownDownloadType" ngbDropdownToggle>
@@ -20,22 +20,67 @@
20 <div class="form-group"> 20 <div class="form-group">
21 <div class="input-group input-group-sm"> 21 <div class="input-group input-group-sm">
22 <div class="input-group-prepend peertube-select-container"> 22 <div class="input-group-prepend peertube-select-container">
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> 23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> 24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select> 25 </select>
26
26 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> 27 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
27 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> 28 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
28 </select> 29 </select>
29 </div> 30 </div>
31
30 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> 32 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
31 <div class="input-group-append"> 33 <div class="input-group-append">
32 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 34 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
33 <span class="glyphicon glyphicon-copy"></span> 35 <span class="glyphicon glyphicon-copy"></span>
34 </button> 36 </button>
35 </div> 37 </div>
36 </div> 38 </div>
37 </div> 39 </div>
38 40
41 <ng-container *ngIf="type === 'video' && videoFile?.metadata">
42 <div ngbNav #nav="ngbNav" class="nav-tabs">
43
44 <ng-container ngbNavItem>
45 <a ngbNavLink i18n>Format</a>
46 <ng-template ngbNavContent>
47 <div class="file-metadata">
48 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
49 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
50 <span class="metadata-attribute-value">{{ item.value.value }}</span>
51 </div>
52 </div>
53 </ng-template>
54 </ng-container>
55
56 <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
57 <a ngbNavLink i18n>Video stream</a>
58 <ng-template ngbNavContent>
59 <div class="file-metadata">
60 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
61 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
62 <span class="metadata-attribute-value">{{ item.value.value }}</span>
63 </div>
64 </div>
65 </ng-template>
66 </ng-container>
67
68 <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
69 <a ngbNavLink i18n>Audio stream</a>
70 <ng-template ngbNavContent>
71 <div class="file-metadata">
72 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
73 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
74 <span class="metadata-attribute-value">{{ item.value.value }}</span>
75 </div>
76 </div>
77 </ng-template>
78 </ng-container>
79 </div>
80
81 <div [ngbNavOutlet]="nav"></div>
82 </ng-container>
83
39 <div class="download-type" *ngIf="type === 'video'"> 84 <div class="download-type" *ngIf="type === 'video'">
40 <div class="peertube-radio-container"> 85 <div class="peertube-radio-container">
41 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> 86 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
@@ -50,9 +95,10 @@
50 </div> 95 </div>
51 96
52 <div class="modal-footer inputs"> 97 <div class="modal-footer inputs">
53 <span i18n class="action-button action-button-cancel" (click)="hide()"> 98 <input
54 Cancel 99 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
55 </span> 100 (click)="hide()" (key.enter)="hide()"
101 >
56 102
57 <input 103 <input
58 type="submit" i18n-value value="Download" class="action-button-submit" 104 type="submit" i18n-value value="Download" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
index 09dd91aa9..f28bc34ed 100644
--- a/client/src/app/shared/video/modals/video-download.component.scss
+++ b/client/src/app/shared/video/modals/video-download.component.scss
@@ -27,3 +27,38 @@
27 margin-right: 30px; 27 margin-right: 30px;
28 } 28 }
29} 29}
30
31.file-metadata {
32 padding: 1rem;
33}
34
35.file-metadata .metadata-attribute {
36 font-size: 13px;
37 display: block;
38 margin-bottom: 12px;
39
40 .metadata-attribute-label {
41 min-width: 142px;
42 padding-right: 5px;
43 display: inline-block;
44 color: $grey-foreground-color;
45 font-weight: $font-bold;
46 }
47
48 a.metadata-attribute-value {
49 @include disable-default-a-behaviour;
50 color: var(--mainForegroundColor);
51
52 &:hover {
53 opacity: 0.9;
54 }
55 }
56
57 &.metadata-attribute-tags {
58 .metadata-attribute-value:not(:nth-child(2)) {
59 &::before {
60 content: ', '
61 }
62 }
63 }
64}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index c1ceca263..d77187821 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -3,9 +3,15 @@ import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AuthService, Notifier } from '@app/core' 5import { AuthService, Notifier } from '@app/core'
6import { VideoPrivacy, VideoCaption } from '@shared/models' 6import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
7import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
8import { mapValues, pick } from 'lodash-es'
9import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
10import { BytesPipe } from 'ngx-pipes'
11import { VideoService } from '../video.service'
7 12
8type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
14type FileMetadata = { [key: string]: { label: string, value: string }}
9 15
10@Component({ 16@Component({
11 selector: 'my-video-download', 17 selector: 'my-video-download',
@@ -20,17 +26,28 @@ export class VideoDownloadComponent {
20 subtitleLanguageId: string 26 subtitleLanguageId: string
21 27
22 video: VideoDetails 28 video: VideoDetails
29 videoFile: VideoFile
30 videoFileMetadataFormat: FileMetadata
31 videoFileMetadataVideoStream: FileMetadata | undefined
32 videoFileMetadataAudioStream: FileMetadata | undefined
23 videoCaptions: VideoCaption[] 33 videoCaptions: VideoCaption[]
24 activeModal: NgbActiveModal 34 activeModal: NgbActiveModal
25 35
26 type: DownloadType = 'video' 36 type: DownloadType = 'video'
27 37
38 private bytesPipe: BytesPipe
39 private numbersPipe: NumberFormatterPipe
40
28 constructor ( 41 constructor (
29 private notifier: Notifier, 42 private notifier: Notifier,
30 private modalService: NgbModal, 43 private modalService: NgbModal,
44 private videoService: VideoService,
31 private auth: AuthService, 45 private auth: AuthService,
32 private i18n: I18n 46 private i18n: I18n
33 ) { } 47 ) {
48 this.bytesPipe = new BytesPipe()
49 this.numbersPipe = new NumberFormatterPipe()
50 }
34 51
35 get typeText () { 52 get typeText () {
36 return this.type === 'video' 53 return this.type === 'video'
@@ -48,9 +65,10 @@ export class VideoDownloadComponent {
48 this.video = video 65 this.video = video
49 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined 66 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
50 67
51 this.activeModal = this.modalService.open(this.modal) 68 this.activeModal = this.modalService.open(this.modal, { centered: true })
52 69
53 this.resolutionId = this.getVideoFiles()[0].resolution.id 70 this.resolutionId = this.getVideoFiles()[0].resolution.id
71 this.onResolutionIdChange()
54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 72 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
55 } 73 }
56 74
@@ -67,10 +85,27 @@ export class VideoDownloadComponent {
67 getLink () { 85 getLink () {
68 return this.type === 'subtitles' && this.videoCaptions 86 return this.type === 'subtitles' && this.videoCaptions
69 ? this.getSubtitlesLink() 87 ? this.getSubtitlesLink()
70 : this.getVideoLink() 88 : this.getVideoFileLink()
71 } 89 }
72 90
73 getVideoLink () { 91 async onResolutionIdChange () {
92 this.videoFile = this.getVideoFile()
93 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
94
95 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
96
97 this.videoFileMetadataFormat = this.videoFile
98 ? this.getMetadataFormat(this.videoFile.metadata.format)
99 : undefined
100 this.videoFileMetadataVideoStream = this.videoFile
101 ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
102 : undefined
103 this.videoFileMetadataAudioStream = this.videoFile
104 ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
105 : undefined
106 }
107
108 getVideoFile () {
74 // HTML select send us a string, so convert it to a number 109 // HTML select send us a string, so convert it to a number
75 this.resolutionId = parseInt(this.resolutionId.toString(), 10) 110 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
76 111
@@ -79,6 +114,12 @@ export class VideoDownloadComponent {
79 console.error('Could not find file with resolution %d.', this.resolutionId) 114 console.error('Could not find file with resolution %d.', this.resolutionId)
80 return 115 return
81 } 116 }
117 return file
118 }
119
120 getVideoFileLink () {
121 const file = this.videoFile
122 if (!file) return
82 123
83 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 124 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
84 ? '?access_token=' + this.auth.getAccessToken() 125 ? '?access_token=' + this.auth.getAccessToken()
@@ -104,4 +145,64 @@ export class VideoDownloadComponent {
104 switchToType (type: DownloadType) { 145 switchToType (type: DownloadType) {
105 this.type = type 146 this.type = type
106 } 147 }
148
149 getMetadataFormat (format: FfprobeFormat) {
150 const keyToTranslateFunction = {
151 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
152 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
153 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
154 'bit_rate': (value: number) => ({
155 label: this.i18n('Bitrate'),
156 value: `${this.numbersPipe.transform(value)}bps`
157 })
158 }
159
160 // flattening format
161 const sanitizedFormat = Object.assign(format, format.tags)
162 delete sanitizedFormat.tags
163
164 return mapValues(
165 pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
166 (val, key) => keyToTranslateFunction[key](val)
167 )
168 }
169
170 getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
171 const stream = streams.find(s => s.codec_type === type)
172 if (!stream) return undefined
173
174 let keyToTranslateFunction = {
175 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
176 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
177 'bit_rate': (value: number) => ({
178 label: this.i18n('Bitrate'),
179 value: `${this.numbersPipe.transform(value)}bps`
180 })
181 }
182
183 if (type === 'video') {
184 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
185 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
186 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
187 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
188 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
189 })
190 } else {
191 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
192 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
193 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
194 })
195 }
196
197 return mapValues(
198 pick(stream, Object.keys(keyToTranslateFunction)),
199 (val, key) => keyToTranslateFunction[key](val)
200 )
201 }
202
203 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
204 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
205 observable.subscribe(res => file.metadata = res)
206 return observable.toPromise()
207 }
107} 208}
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
index b9434da26..e336b6660 100644
--- a/client/src/app/shared/video/modals/video-report.component.html
+++ b/client/src/app/shared/video/modals/video-report.component.html
@@ -7,23 +7,25 @@
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div i18n class="information"> 9 <div i18n class="information">
10 Your report will be sent to moderators of {{ currentHost }}. 10 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>.
11 <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
12 </div> 11 </div>
13 12
14 <form novalidate [formGroup]="form" (ngSubmit)="report()"> 13 <form novalidate [formGroup]="form" (ngSubmit)="report()">
15 <div class="form-group"> 14 <div class="form-group">
16 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 15 <textarea
17 </textarea> 16 i18n-placeholder placeholder="Reason..." formControlName="reason"
17 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
18 ></textarea>
18 <div *ngIf="formErrors.reason" class="form-error"> 19 <div *ngIf="formErrors.reason" class="form-error">
19 {{ formErrors.reason }} 20 {{ formErrors.reason }}
20 </div> 21 </div>
21 </div> 22 </div>
22 23
23 <div class="form-group inputs"> 24 <div class="form-group inputs">
24 <span i18n class="action-button action-button-cancel" (click)="hide()"> 25 <input
25 Cancel 26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
26 </span> 27 (click)="hide()" (key.enter)="hide()"
28 >
27 29
28 <input 30 <input
29 type="submit" i18n-value value="Submit" class="action-button-submit" 31 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
index 1d368ff17..988fa03d4 100644
--- a/client/src/app/shared/video/modals/video-report.component.ts
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -1,13 +1,13 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms' 3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' 6import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse' 9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-report', 13 selector: 'my-video-report',
@@ -15,7 +15,7 @@ import { VideoAbuseService } from '@app/shared/video-abuse'
15 styleUrls: [ './video-report.component.scss' ] 15 styleUrls: [ './video-report.component.scss' ]
16}) 16})
17export class VideoReportComponent extends FormReactive implements OnInit { 17export class VideoReportComponent extends FormReactive implements OnInit {
18 @Input() video: VideoDetails = null 18 @Input() video: Video = null
19 19
20 @ViewChild('modal', { static: true }) modal: NgbModal 20 @ViewChild('modal', { static: true }) modal: NgbModal
21 21
@@ -53,7 +53,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
53 } 53 }
54 54
55 show () { 55 show () {
56 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 56 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
57 } 57 }
58 58
59 hide () { 59 hide () {
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644
index 000000000..fb918d73b
--- /dev/null
+++ b/client/src/app/shared/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { catchError, map, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
5import { SortMeta } from 'primeng/api'
6import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { concat, Observable } from 'rxjs'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
index afdeab18d..4e5fc6476 100644
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -1,8 +1,7 @@
1import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill' 2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' 3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' 4import { AuthService, ConfirmService, Notifier } from '@app/core'
5import { BlocklistService } from '@app/shared/blocklist'
6import { Video } from '@app/shared/video/video.model' 5import { Video } from '@app/shared/video/video.model'
7import { VideoService } from '@app/shared/video/video.service' 6import { VideoService } from '@app/shared/video/video.service'
8import { VideoDetails } from '@app/shared/video/video-details.model' 7import { VideoDetails } from '@app/shared/video/video-details.model'
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
14import { VideoBlacklistService } from '@app/shared/video-blacklist' 13import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service' 14import { ScreenService } from '@app/shared/misc/screen.service'
16import { VideoCaption } from '@shared/models' 15import { VideoCaption } from '@shared/models'
16import { RedundancyService } from '@app/shared/video/redundancy.service'
17 17
18export type VideoActionsDisplayType = { 18export type VideoActionsDisplayType = {
19 playlist?: boolean 19 playlist?: boolean
@@ -22,6 +22,7 @@ export type VideoActionsDisplayType = {
22 blacklist?: boolean 22 blacklist?: boolean
23 delete?: boolean 23 delete?: boolean
24 report?: boolean 24 report?: boolean
25 duplicate?: boolean
25} 26}
26 27
27@Component({ 28@Component({
@@ -30,12 +31,12 @@ export type VideoActionsDisplayType = {
30 styleUrls: [ './video-actions-dropdown.component.scss' ] 31 styleUrls: [ './video-actions-dropdown.component.scss' ]
31}) 32})
32export class VideoActionsDropdownComponent implements OnChanges { 33export class VideoActionsDropdownComponent implements OnChanges {
33 @ViewChild('playlistDropdown', { static: false }) playlistDropdown: NgbDropdown 34 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
34 @ViewChild('playlistAdd', { static: false }) playlistAdd: VideoAddToPlaylistComponent 35 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
35 36
36 @ViewChild('videoDownloadModal', { static: false }) videoDownloadModal: VideoDownloadComponent 37 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
37 @ViewChild('videoReportModal', { static: false }) videoReportModal: VideoReportComponent 38 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
38 @ViewChild('videoBlacklistModal', { static: false }) videoBlacklistModal: VideoBlacklistComponent 39 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
39 40
40 @Input() video: Video | VideoDetails 41 @Input() video: Video | VideoDetails
41 @Input() videoCaptions: VideoCaption[] = [] 42 @Input() videoCaptions: VideoCaption[] = []
@@ -46,7 +47,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
46 update: true, 47 update: true,
47 blacklist: true, 48 blacklist: true,
48 delete: true, 49 delete: true,
49 report: true 50 report: true,
51 duplicate: true
50 } 52 }
51 @Input() placement = 'left' 53 @Input() placement = 'left'
52 54
@@ -70,10 +72,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
70 private notifier: Notifier, 72 private notifier: Notifier,
71 private confirmService: ConfirmService, 73 private confirmService: ConfirmService,
72 private videoBlacklistService: VideoBlacklistService, 74 private videoBlacklistService: VideoBlacklistService,
73 private serverService: ServerService,
74 private screenService: ScreenService, 75 private screenService: ScreenService,
75 private videoService: VideoService, 76 private videoService: VideoService,
76 private blocklistService: BlocklistService, 77 private redundancyService: RedundancyService,
77 private i18n: I18n 78 private i18n: I18n
78 ) { } 79 ) { }
79 80
@@ -144,6 +145,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
144 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 145 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
145 } 146 }
146 147
148 canVideoBeDuplicated () {
149 return this.video.canBeDuplicatedBy(this.user)
150 }
151
147 /* Action handlers */ 152 /* Action handlers */
148 153
149 async unblacklistVideo () { 154 async unblacklistVideo () {
@@ -186,6 +191,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
186 ) 191 )
187 } 192 }
188 193
194 duplicateVideo () {
195 this.redundancyService.addVideoRedundancy(this.video)
196 .subscribe(
197 () => {
198 const message = this.i18n('This video will be duplicated by your instance.')
199 this.notifier.success(message)
200 },
201
202 err => this.notifier.error(err.message)
203 )
204 }
205
189 onVideoBlacklisted () { 206 onVideoBlacklisted () {
190 this.videoBlacklisted.emit() 207 this.videoBlacklisted.emit()
191 } 208 }
@@ -234,6 +251,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
234 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() 251 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
235 }, 252 },
236 { 253 {
254 label: this.i18n('Mirror'),
255 handler: () => this.duplicateVideo(),
256 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
257 iconName: 'cloud-download'
258 },
259 {
237 label: this.i18n('Delete'), 260 label: this.i18n('Delete'),
238 handler: () => this.removeVideo(), 261 handler: () => this.removeVideo(),
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), 262 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 46c49c15b..8e948ce42 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -2,7 +2,10 @@
2 <my-video-thumbnail 2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" 3 [video]="video" [nsfw]="isVideoBlur"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" 4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
5 ></my-video-thumbnail> 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 </my-video-thumbnail>
6 9
7 <div class="video-bottom"> 10 <div class="video-bottom">
8 <div class="video-miniature-information"> 11 <div class="video-miniature-information">
@@ -19,11 +22,6 @@
19 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container> 22 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
20 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container> 23 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
21 </span> 24 </span>
22
23 <ng-container *ngIf="displayOptions.privacyLabel">
24 <span *ngIf="isUnlistedVideo()" class="badge badge-warning ml-1" i18n>Unlisted</span>
25 <span *ngIf="isPrivateVideo()" class="badge badge-danger ml-1" i18n>Private</span>
26 </ng-container>
27 </span> 25 </span>
28 26
29 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> 27 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
@@ -50,9 +48,9 @@
50 </div> 48 </div>
51 49
52 <div class="video-actions"> 50 <div class="video-actions">
53 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> 51 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
54 <my-video-actions-dropdown 52 <my-video-actions-dropdown
55 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" 53 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
56 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" 54 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()"
57 ></my-video-actions-dropdown> 55 ></my-video-actions-dropdown>
58 </div> 56 </div>
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index b63fd2989..f27800a24 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -85,8 +85,12 @@ $more-margin-right: 15px;
85 } 85 }
86 86
87 @media screen and (max-width: $small-view) { 87 @media screen and (max-width: $small-view) {
88 .video-miniature-information .video-miniature-name { 88 .video-miniature-information {
89 margin-top: 0; 89 margin: 0 10px;
90
91 .video-miniature-name {
92 margin-top: 0;
93 }
90 } 94 }
91 95
92 .video-actions { 96 .video-actions {
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 598a7a983..72b652448 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
64 update: true, 64 update: true,
65 blacklist: true, 65 blacklist: true,
66 delete: true, 66 delete: true,
67 report: true 67 report: true,
68 duplicate: true
68 } 69 }
69 showActions = false 70 showActions = false
70 serverConfig: ServerConfig 71 serverConfig: ServerConfig
@@ -199,7 +200,7 @@ export class VideoMiniatureComponent implements OnInit {
199 } 200 }
200 201
201 isWatchLaterPlaylistDisplayed () { 202 isWatchLaterPlaylistDisplayed () {
202 return this.inWatchLaterPlaylist !== undefined 203 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
203 } 204 }
204 205
205 private setUpBy () { 206 private setUpBy () {
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index b63085b81..fe5510c56 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -1,5 +1,5 @@
1<a 1<a
2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [title]="video.name" 2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
@@ -18,6 +18,9 @@
18 </ng-container> 18 </ng-container>
19 </div> 19 </div>
20 20
21 <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
22 <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
23
21 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> 24 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
22 25
23 <div class="play-overlay"> 26 <div class="play-overlay">
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 573a64987..5fca916f0 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -19,13 +19,24 @@
19 } 19 }
20 20
21 .video-thumbnail-watch-later-overlay, 21 .video-thumbnail-watch-later-overlay,
22 .video-thumbnail-label-overlay,
22 .video-thumbnail-duration-overlay { 23 .video-thumbnail-duration-overlay {
23 @include static-thumbnail-overlay; 24 @include static-thumbnail-overlay;
24 25
25 border-radius: 3px; 26 border-radius: 3px;
26 font-size: 12px; 27 font-size: 12px;
27 font-weight: $font-bold; 28 font-weight: $font-bold;
28 z-index: 1; 29 z-index: z(miniature);
30 }
31
32 .video-thumbnail-label-overlay {
33 position: absolute;
34 padding: 0 5px;
35 left: 5px;
36 top: 5px;
37
38 &.warning { background-color: orange; }
39 &.danger { background-color: red; }
29 } 40 }
30 41
31 .video-thumbnail-duration-overlay { 42 .video-thumbnail-duration-overlay {
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 2420ec715..111b4c8bb 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -12,7 +12,7 @@ export class VideoThumbnailComponent {
12 @Input() video: Video 12 @Input() video: Video
13 @Input() nsfw = false 13 @Input() nsfw = false
14 @Input() routerLink: any[] 14 @Input() routerLink: any[]
15 @Input() queryParams: any[] 15 @Input() queryParams: { [ p: string ]: any }
16 16
17 @Input() displayWatchLaterPlaylist: boolean 17 @Input() displayWatchLaterPlaylist: boolean
18 @Input() inWatchLaterPlaylist: boolean 18 @Input() inWatchLaterPlaylist: boolean
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index fb98d5382..546518cca 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel {
42 dislikes: number 42 dislikes: number
43 nsfw: boolean 43 nsfw: boolean
44 44
45 originInstanceUrl: string
46 originInstanceHost: string
47
45 waitTranscoding?: boolean 48 waitTranscoding?: boolean
46 state?: VideoConstant<VideoState> 49 state?: VideoConstant<VideoState>
47 scheduledUpdate?: VideoScheduleUpdate 50 scheduledUpdate?: VideoScheduleUpdate
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel {
86 this.waitTranscoding = hash.waitTranscoding 89 this.waitTranscoding = hash.waitTranscoding
87 this.state = hash.state 90 this.state = hash.state
88 this.description = hash.description 91 this.description = hash.description
92
89 this.duration = hash.duration 93 this.duration = hash.duration
90 this.durationLabel = durationToString(hash.duration) 94 this.durationLabel = durationToString(hash.duration)
95
91 this.id = hash.id 96 this.id = hash.id
92 this.uuid = hash.uuid 97 this.uuid = hash.uuid
98
93 this.isLocal = hash.isLocal 99 this.isLocal = hash.isLocal
94 this.name = hash.name 100 this.name = hash.name
101
95 this.thumbnailPath = hash.thumbnailPath 102 this.thumbnailPath = hash.thumbnailPath
96 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
104
97 this.previewPath = hash.previewPath 105 this.previewPath = hash.previewPath
98 this.previewUrl = absoluteAPIUrl + hash.previewPath 106 this.previewUrl = absoluteAPIUrl + hash.previewPath
107
99 this.embedPath = hash.embedPath 108 this.embedPath = hash.embedPath
100 this.embedUrl = absoluteAPIUrl + hash.embedPath 109 this.embedUrl = absoluteAPIUrl + hash.embedPath
110
101 this.views = hash.views 111 this.views = hash.views
102 this.likes = hash.likes 112 this.likes = hash.likes
103 this.dislikes = hash.dislikes 113 this.dislikes = hash.dislikes
114
104 this.nsfw = hash.nsfw 115 this.nsfw = hash.nsfw
116
105 this.account = hash.account 117 this.account = hash.account
106 this.channel = hash.channel 118 this.channel = hash.channel
107 119
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel {
124 this.blacklistedReason = hash.blacklistedReason 136 this.blacklistedReason = hash.blacklistedReason
125 137
126 this.userHistory = hash.userHistory 138 this.userHistory = hash.userHistory
139
140 this.originInstanceHost = this.account.host
141 this.originInstanceUrl = 'https://' + this.originInstanceHost
127 } 142 }
128 143
129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 144 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel {
152 isUpdatableBy (user: AuthUser) { 167 isUpdatableBy (user: AuthUser) {
153 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 168 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
154 } 169 }
170
171 canBeDuplicatedBy (user: AuthUser) {
172 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
173 }
155} 174}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 996202154..3aaf14990 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -27,10 +27,12 @@ import { objectToFormData } from '@app/shared/misc/utils'
27import { Account } from '@app/shared/account/account.model' 27import { Account } from '@app/shared/account/account.model'
28import { AccountService } from '@app/shared/account/account.service' 28import { AccountService } from '@app/shared/account/account.service'
29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
30import { ServerService } from '@app/core' 30import { ServerService, AuthService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
35import { FfprobeData } from 'fluent-ffmpeg'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos (parameters: { 38 getVideos (parameters: {
@@ -49,6 +51,8 @@ export class VideoService implements VideosProvider {
49 51
50 constructor ( 52 constructor (
51 private authHttp: HttpClient, 53 private authHttp: HttpClient,
54 private authService: AuthService,
55 private userService: UserService,
52 private restExtractor: RestExtractor, 56 private restExtractor: RestExtractor,
53 private restService: RestService, 57 private restService: RestService,
54 private serverService: ServerService, 58 private serverService: ServerService,
@@ -199,9 +203,10 @@ export class VideoService implements VideosProvider {
199 filter?: VideoFilter, 203 filter?: VideoFilter,
200 categoryOneOf?: number, 204 categoryOneOf?: number,
201 languageOneOf?: string[], 205 languageOneOf?: string[],
202 skipCount?: boolean 206 skipCount?: boolean,
207 nsfw?: boolean
203 }): Observable<ResultList<Video>> { 208 }): Observable<ResultList<Video>> {
204 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount } = parameters 209 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfw } = parameters
205 210
206 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 211 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
207 212
@@ -212,6 +217,15 @@ export class VideoService implements VideosProvider {
212 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '') 217 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '')
213 if (skipCount) params = params.set('skipCount', skipCount + '') 218 if (skipCount) params = params.set('skipCount', skipCount + '')
214 219
220 if (nsfw) {
221 params = params.set('nsfw', nsfw + '')
222 } else {
223 const nsfwPolicy = this.authService.isLoggedIn()
224 ? this.authService.getUser().nsfwPolicy
225 : this.userService.getAnonymousUser().nsfwPolicy
226 if (this.nsfwPolicyToFilter(nsfwPolicy)) params.set('nsfw', 'false')
227 }
228
215 if (languageOneOf) { 229 if (languageOneOf) {
216 for (const l of languageOneOf) { 230 for (const l of languageOneOf) {
217 params = params.append('languageOneOf[]', l) 231 params = params.append('languageOneOf[]', l)
@@ -278,6 +292,14 @@ export class VideoService implements VideosProvider {
278 return this.buildBaseFeedUrls(params) 292 return this.buildBaseFeedUrls(params)
279 } 293 }
280 294
295 getVideoFileMetadata (metadataUrl: string) {
296 return this.authHttp
297 .get<FfprobeData>(metadataUrl)
298 .pipe(
299 catchError(err => this.restExtractor.handleError(err))
300 )
301 }
302
281 removeVideo (id: number) { 303 removeVideo (id: number) {
282 return this.authHttp 304 return this.authHttp
283 .delete(VideoService.BASE_VIDEO_URL + id) 305 .delete(VideoService.BASE_VIDEO_URL + id)
@@ -368,4 +390,8 @@ export class VideoService implements VideosProvider {
368 catchError(err => this.restExtractor.handleError(err)) 390 catchError(err => this.restExtractor.handleError(err))
369 ) 391 )
370 } 392 }
393
394 private nsfwPolicyToFilter (policy: NSFWPolicyType) {
395 return policy === 'do_not_list'
396 }
371} 397}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
index 064420056..17e5beb24 100644
--- a/client/src/app/shared/video/videos-selection.component.ts
+++ b/client/src/app/shared/video/videos-selection.component.ts
@@ -22,6 +22,8 @@ import { VideoSortField } from '@app/shared/video/sort-field.type'
22import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 22import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
23import { I18n } from '@ngx-translate/i18n-polyfill' 23import { I18n } from '@ngx-translate/i18n-polyfill'
24import { ResultList } from '@shared/models' 24import { ResultList } from '@shared/models'
25import { UserService } from '../users'
26import { LocalStorageService } from '../misc/storage.service'
25 27
26export type SelectionType = { [ id: number ]: boolean } 28export type SelectionType = { [ id: number ]: boolean }
27 29
@@ -51,7 +53,9 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
51 protected route: ActivatedRoute, 53 protected route: ActivatedRoute,
52 protected notifier: Notifier, 54 protected notifier: Notifier,
53 protected authService: AuthService, 55 protected authService: AuthService,
56 protected userService: UserService,
54 protected screenService: ScreenService, 57 protected screenService: ScreenService,
58 protected storageService: LocalStorageService,
55 protected serverService: ServerService 59 protected serverService: ServerService
56 ) { 60 ) {
57 super() 61 super()
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
index 19043eee6..6a9e31b5a 100644
--- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
@@ -9,7 +9,7 @@
9 <div class="modal-body"> 9 <div class="modal-body">
10 <label i18n for="language">Language</label> 10 <label i18n for="language">Language</label>
11 <div class="peertube-select-container"> 11 <div class="peertube-select-container">
12 <select id="language" formControlName="language"> 12 <select id="language" formControlName="language" class="form-control">
13 <option></option> 13 <option></option>
14 <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option> 14 <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
15 </select> 15 </select>
@@ -23,6 +23,7 @@
23 <my-reactive-file 23 <my-reactive-file
24 formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file" 24 formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
25 [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true" 25 [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
26 i18n-ngbTooltip [ngbTooltip]="'(extensions: ' + videoCaptionExtensions.join(', ') + ')'"
26 ></my-reactive-file> 27 ></my-reactive-file>
27 </div> 28 </div>
28 29
@@ -32,9 +33,10 @@
32 </div> 33 </div>
33 34
34 <div class="modal-footer inputs"> 35 <div class="modal-footer inputs">
35 <span i18n class="action-button action-button-cancel" (click)="hide()"> 36 <input
36 Cancel 37 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
37 </span> 38 (click)="hide()" (key.enter)="hide()"
39 >
38 40
39 <input 41 <input
40 type="submit" i18n-value value="Add this caption" class="action-button-submit" 42 type="submit" i18n-value value="Add this caption" class="action-button-submit"
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
index c6da1877e..b257a16a9 100644
--- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
@@ -7,6 +7,11 @@
7 7
8.caption-file { 8.caption-file {
9 margin-top: 20px; 9 margin-top: 20px;
10 width: max-content;
11
12 ::ng-deep .root {
13 width: max-content;
14 }
10} 15}
11 16
12.warning-replace-caption { 17.warning-replace-caption {
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
index 1a9bf5171..9856aac9e 100644
--- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
@@ -56,7 +56,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
56 show () { 56 show () {
57 this.closingModal = false 57 this.closingModal = false
58 58
59 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 59 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
60 } 60 }
61 61
62 hide () { 62 hide () {
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html
index e40649d95..9a0e4f848 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html
@@ -1,13 +1,15 @@
1<div class="video-edit" [formGroup]="form"> 1<div class="video-edit" [formGroup]="form">
2 <ngb-tabset class="root-tabset bootstrap"> 2 <div ngbNav #nav="ngbNav" class="nav-tabs">
3 3
4 <ngb-tab i18n-title title="Basic info"> 4 <ng-container ngbNavItem>
5 <ng-template ngbTabContent> 5 <a ngbNavLink i18n>Basic info</a>
6
7 <ng-template ngbNavContent>
6 <div class="row"> 8 <div class="row">
7 <div class="col-md-8"> 9 <div class="col-video-edit">
8 <div class="form-group"> 10 <div class="form-group">
9 <label i18n for="name">Title</label> 11 <label i18n for="name">Title</label>
10 <input type="text" id="name" formControlName="name" /> 12 <input type="text" id="name" class="form-control" formControlName="name" />
11 <div *ngIf="formErrors.name" class="form-error"> 13 <div *ngIf="formErrors.name" class="form-error">
12 {{ formErrors.name }} 14 {{ formErrors.name }}
13 </div> 15 </div>
@@ -29,7 +31,7 @@
29 <tag-input 31 <tag-input
30 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" 32 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
31 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag" 33 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag"
32 formControlName="tags" maxItems="5" modelAsStrings="true" 34 formControlName="tags" [maxItems]="5" [modelAsStrings]="true"
33 ></tag-input> 35 ></tag-input>
34 </div> 36 </div>
35 37
@@ -44,7 +46,7 @@
44 </ng-template> 46 </ng-template>
45 </my-help> 47 </my-help>
46 48
47 <my-markdown-textarea truncate="250" formControlName="description" markdownVideo="true"></my-markdown-textarea> 49 <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea>
48 50
49 <div *ngIf="formErrors.description" class="form-error"> 51 <div *ngIf="formErrors.description" class="form-error">
50 {{ formErrors.description }} 52 {{ formErrors.description }}
@@ -52,11 +54,11 @@
52 </div> 54 </div>
53 </div> 55 </div>
54 56
55 <div class="col-md-4"> 57 <div class="col-video-edit">
56 <div class="form-group"> 58 <div class="form-group">
57 <label i18n>Channel</label> 59 <label i18n>Channel</label>
58 <div class="peertube-select-container"> 60 <div class="peertube-select-container">
59 <select formControlName="channelId"> 61 <select formControlName="channelId" class="form-control">
60 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> 62 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
61 </select> 63 </select>
62 </div> 64 </div>
@@ -65,7 +67,7 @@
65 <div class="form-group"> 67 <div class="form-group">
66 <label i18n for="category">Category</label> 68 <label i18n for="category">Category</label>
67 <div class="peertube-select-container"> 69 <div class="peertube-select-container">
68 <select id="category" formControlName="category"> 70 <select id="category" formControlName="category" class="form-control">
69 <option></option> 71 <option></option>
70 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> 72 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
71 </select> 73 </select>
@@ -79,7 +81,7 @@
79 <div class="form-group"> 81 <div class="form-group">
80 <label i18n for="licence">Licence</label> 82 <label i18n for="licence">Licence</label>
81 <div class="peertube-select-container"> 83 <div class="peertube-select-container">
82 <select id="licence" formControlName="licence"> 84 <select id="licence" formControlName="licence" class="form-control">
83 <option></option> 85 <option></option>
84 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> 86 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
85 </select> 87 </select>
@@ -93,7 +95,7 @@
93 <div class="form-group"> 95 <div class="form-group">
94 <label i18n for="language">Language</label> 96 <label i18n for="language">Language</label>
95 <div class="peertube-select-container"> 97 <div class="peertube-select-container">
96 <select id="language" formControlName="language"> 98 <select id="language" formControlName="language" class="form-control">
97 <option></option> 99 <option></option>
98 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> 100 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
99 </select> 101 </select>
@@ -107,7 +109,7 @@
107 <div class="form-group"> 109 <div class="form-group">
108 <label i18n for="privacy">Privacy</label> 110 <label i18n for="privacy">Privacy</label>
109 <div class="peertube-select-container"> 111 <div class="peertube-select-container">
110 <select id="privacy" formControlName="privacy"> 112 <select id="privacy" formControlName="privacy" class="form-control">
111 <option></option> 113 <option></option>
112 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> 114 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
113 <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> 115 <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
@@ -155,10 +157,12 @@
155 </div> 157 </div>
156 </div> 158 </div>
157 </ng-template> 159 </ng-template>
158 </ngb-tab> 160 </ng-container>
161
162 <ng-container ngbNavItem>
163 <a ngbNavLink i18n>Captions</a>
159 164
160 <ngb-tab i18n-title title="Captions"> 165 <ng-template ngbNavContent>
161 <ng-template ngbTabContent>
162 <div class="captions"> 166 <div class="captions">
163 167
164 <div class="captions-header"> 168 <div class="captions-header">
@@ -206,10 +210,12 @@
206 210
207 </div> 211 </div>
208 </ng-template> 212 </ng-template>
209 </ngb-tab> 213 </ng-container>
214
215 <ng-container ngbNavItem>
216 <a ngbNavLink i18n>Advanced settings</a>
210 217
211 <ngb-tab i18n-title title="Advanced settings"> 218 <ng-template ngbNavContent>
212 <ng-template ngbTabContent>
213 <div class="row advanced-settings"> 219 <div class="row advanced-settings">
214 <div class="col-md-12 col-xl-8"> 220 <div class="col-md-12 col-xl-8">
215 221
@@ -226,7 +232,7 @@
226 <label i18n for="support">Support</label> 232 <label i18n for="support">Support</label>
227 <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> 233 <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help>
228 <my-markdown-textarea 234 <my-markdown-textarea
229 id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" 235 id="support" formControlName="support" markdownType="enhanced"
230 [classes]="{ 'input-error': formErrors['support'] }" 236 [classes]="{ 'input-error': formErrors['support'] }"
231 ></my-markdown-textarea> 237 ></my-markdown-textarea>
232 <div *ngIf="formErrors.support" class="form-error"> 238 <div *ngIf="formErrors.support" class="form-error">
@@ -262,10 +268,11 @@
262 </div> 268 </div>
263 </div> 269 </div>
264 </ng-template> 270 </ng-template>
265 </ngb-tab> 271 </ng-container>
266 272
267 </ngb-tabset> 273 </div>
268 274
275 <div [ngbNavOutlet]="nav"></div>
269</div> 276</div>
270 277
271<my-video-caption-add-modal 278<my-video-caption-add-modal
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
index 144914731..2f9067132 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
@@ -1,5 +1,16 @@
1@import '_variables'; 1// Bootstrap grid utilities require functions, variables and mixins
2@import '_mixins'; 2@import 'node_modules/bootstrap/scss/functions';
3@import 'node_modules/bootstrap/scss/variables';
4@import 'node_modules/bootstrap/scss/mixins';
5@import 'node_modules/bootstrap/scss/grid';
6
7@import 'variables';
8@import 'mixins';
9
10label {
11 font-weight: $font-regular;
12 font-size: 100%;
13}
3 14
4.peertube-select-container { 15.peertube-select-container {
5 @include peertube-select-container(auto); 16 @include peertube-select-container(auto);
@@ -19,6 +30,10 @@ my-peertube-checkbox {
19 margin-bottom: 1rem; 30 margin-bottom: 1rem;
20} 31}
21 32
33.nav-tabs {
34 margin-bottom: 15px;
35}
36
22.video-edit { 37.video-edit {
23 height: 100%; 38 height: 100%;
24 min-height: 300px; 39 min-height: 300px;
@@ -45,6 +60,7 @@ my-peertube-checkbox {
45 60
46 .captions-header { 61 .captions-header {
47 text-align: right; 62 text-align: right;
63 margin-bottom: 1rem;
48 64
49 .create-caption { 65 .create-caption {
50 @include create-button; 66 @include create-button;
@@ -59,6 +75,7 @@ my-peertube-checkbox {
59 a.caption-entry-label { 75 a.caption-entry-label {
60 @include disable-default-a-behaviour; 76 @include disable-default-a-behaviour;
61 77
78 flex-grow: 1;
62 color: #000; 79 color: #000;
63 80
64 &:hover { 81 &:hover {
@@ -144,69 +161,37 @@ p-calendar {
144 } 161 }
145} 162}
146 163
147::ng-deep { 164@include ng2-tags;
148 .root-tabset > .nav {
149 margin-bottom: 15px;
150 }
151 165
152 .ng2-tag-input { 166// columns for the video
153 border: none !important; 167.col-video-edit {
154 } 168 @include make-col-ready();
155 169
156 .ng2-tags-container { 170 @include media-breakpoint-up(md) {
157 display: flex; 171 @include make-col(7);
158 align-items: center;
159 border: 1px solid #C6C6C6;
160 border-radius: 3px;
161 padding: 5px !important;
162 height: max-content;
163 }
164 172
165 tag-input-form { 173 & + .col-video-edit {
166 input { 174 @include make-col(5);
167 height: 30px !important;
168 font-size: 12px !important;
169
170 background-color: var(--mainBackgroundColor) !important;
171 color: var(--mainForegroundColor) !important;
172 } 175 }
173 } 176 }
174 177
175 tag { 178 @include media-breakpoint-up(xl) {
176 background-color: $grey-background-color !important; 179 @include make-col(8);
177 color: #000 !important; 180
178 border-radius: 3px !important; 181 & + .col-video-edit {
179 font-size: 12px !important; 182 @include make-col(4);
180 height: 30px !important;
181 line-height: 30px !important;
182 margin: 0 5px 0 0 !important;
183 cursor: default !important;
184 padding: 0 8px 0 10px !important;
185
186 div {
187 height: 100% !important;
188 } 183 }
189 } 184 }
185}
190 186
191 delete-icon { 187:host-context(.expanded) {
192 cursor: pointer !important; 188 .col-video-edit {
193 height: auto !important; 189 @include media-breakpoint-up(md) {
194 vertical-align: middle !important; 190 @include make-col(8);
195 padding-left: 6px !important;
196
197 svg {
198 position: relative;
199 top: -1px;
200 height: auto !important;
201 vertical-align: middle !important;
202 191
203 path { 192 & + .col-video-edit {
204 fill: $grey-foreground-color !important; 193 @include make-col(4);
205 } 194 }
206 } 195 }
207
208 &:hover {
209 transform: none !important;
210 }
211 } 196 }
212} 197}
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
index 39b6daa93..1357d607c 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'
2import { TagInputModule } from 'ngx-chips' 2import { TagInputModule } from 'ngx-chips'
3import { SharedModule } from '../../../shared/' 3import { SharedModule } from '../../../shared/'
4import { VideoEditComponent } from './video-edit.component' 4import { VideoEditComponent } from './video-edit.component'
5import { CalendarModule } from 'primeng/components/calendar/calendar' 5import { CalendarModule } from 'primeng/calendar'
6import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' 6import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
7 7
8@NgModule({ 8@NgModule({
diff --git a/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts
new file mode 100644
index 000000000..7b1a38c62
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts
@@ -0,0 +1,30 @@
1import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core'
2
3@Directive({
4 selector: '[dragDrop]'
5})
6export class DragDropDirective {
7 @Output() fileDropped = new EventEmitter<FileList>()
8
9 @HostBinding('class.dragover') dragover = false
10
11 @HostListener('dragover', ['$event']) onDragOver (e: Event) {
12 e.preventDefault()
13 e.stopPropagation()
14 this.dragover = true
15 }
16
17 @HostListener('dragleave', ['$event']) public onDragLeave (e: Event) {
18 e.preventDefault()
19 e.stopPropagation()
20 this.dragover = false
21 }
22
23 @HostListener('drop', ['$event']) public ondrop (e: DragEvent) {
24 e.preventDefault()
25 e.stopPropagation()
26 this.dragover = false
27 const files = e.dataTransfer.files
28 if (files.length > 0) this.fileDropped.emit(files)
29 }
30}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
index c290fd4b1..c2ee3ad57 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
@@ -1,14 +1,13 @@
1<div *ngIf="!hasImportedVideo" class="upload-video-container"> 1<div *ngIf="!hasImportedVideo" class="upload-video-container" dragDrop (fileDropped)="setTorrentFile($event)">
2 <div class="first-step-block"> 2 <div class="first-step-block">
3 <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> 3 <my-global-icon class="upload-icon" iconName="upload"></my-global-icon>
4 4
5 <div class="button-file"> 5 <div class="button-file form-control" [ngbTooltip]="'(extensions: .torrent)'">
6 <span i18n>Select the torrent to import</span> 6 <span i18n>Select the torrent to import</span>
7 <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" /> 7 <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" />
8 </div> 8 </div>
9 <span class="button-file-extension">(.torrent)</span>
10 9
11 <div class="torrent-or-magnet" i18n>Or</div> 10 <div class="torrent-or-magnet" i18n-data-content data-content="OR"></div>
12 11
13 <div class="form-group form-group-magnet-uri"> 12 <div class="form-group form-group-magnet-uri">
14 <label i18n for="magnetUri">Paste magnet URI</label> 13 <label i18n for="magnetUri">Paste magnet URI</label>
@@ -21,13 +20,13 @@
21 </ng-template> 20 </ng-template>
22 </my-help> 21 </my-help>
23 22
24 <input type="text" id="magnetUri" [(ngModel)]="magnetUri" /> 23 <input type="text" id="magnetUri" [(ngModel)]="magnetUri" class="form-control" />
25 </div> 24 </div>
26 25
27 <div class="form-group"> 26 <div class="form-group">
28 <label i18n for="first-step-channel">Channel</label> 27 <label i18n for="first-step-channel">Channel</label>
29 <div class="peertube-select-container"> 28 <div class="peertube-select-container">
30 <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> 29 <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
31 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> 30 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
32 </select> 31 </select>
33 </div> 32 </div>
@@ -36,7 +35,7 @@
36 <div class="form-group"> 35 <div class="form-group">
37 <label i18n for="first-step-privacy">Privacy</label> 36 <label i18n for="first-step-privacy">Privacy</label>
38 <div class="peertube-select-container"> 37 <div class="peertube-select-container">
39 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> 38 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
40 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> 39 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
41 </select> 40 </select>
42 </div> 41 </div>
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss
index 6d59ed834..3b46475b4 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss
@@ -3,7 +3,11 @@
3 3
4.first-step-block { 4.first-step-block {
5 .torrent-or-magnet { 5 .torrent-or-magnet {
6 margin: 10px 0; 6 @include divider($color: var(--inputPlaceholderColor), $background: var(--submenuColor));
7
8 &[data-content] {
9 margin: 1.5rem 0;
10 }
7 } 11 }
8 12
9 .form-group-magnet-uri { 13 .form-group-magnet-uri {
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
index 74e1e755b..4d0b0b080 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
@@ -25,7 +25,7 @@ import { scrollToTop } from '@app/shared/misc/utils'
25export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { 25export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
26 @Output() firstStepDone = new EventEmitter<string>() 26 @Output() firstStepDone = new EventEmitter<string>()
27 @Output() firstStepError = new EventEmitter<void>() 27 @Output() firstStepError = new EventEmitter<void>()
28 @ViewChild('torrentfileInput', { static: false }) torrentfileInput: ElementRef<HTMLInputElement> 28 @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement>
29 29
30 magnetUri = '' 30 magnetUri = ''
31 31
@@ -72,6 +72,11 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
72 this.importVideo(torrentfile) 72 this.importVideo(torrentfile)
73 } 73 }
74 74
75 setTorrentFile (files: FileList) {
76 this.torrentfileInput.nativeElement.files = files
77 this.fileChange()
78 }
79
75 importVideo (torrentfile?: Blob) { 80 importVideo (torrentfile?: Blob) {
76 this.isImportingVideo = true 81 this.isImportingVideo = true
77 82
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
index 09d0b8272..9a26fe308 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
@@ -15,13 +15,13 @@
15 </ng-template> 15 </ng-template>
16 </my-help> 16 </my-help>
17 17
18 <input type="text" id="targetUrl" [(ngModel)]="targetUrl" /> 18 <input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" />
19 </div> 19 </div>
20 20
21 <div class="form-group"> 21 <div class="form-group">
22 <label i18n for="first-step-channel">Channel</label> 22 <label i18n for="first-step-channel">Channel</label>
23 <div class="peertube-select-container"> 23 <div class="peertube-select-container">
24 <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> 24 <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
25 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> 25 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
26 </select> 26 </select>
27 </div> 27 </div>
@@ -30,7 +30,7 @@
30 <div class="form-group"> 30 <div class="form-group">
31 <label i18n for="first-step-privacy">Privacy</label> 31 <label i18n for="first-step-privacy">Privacy</label>
32 <div class="peertube-select-container"> 32 <div class="peertube-select-container">
33 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> 33 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
34 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> 34 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
35 </select> 35 </select>
36 </div> 36 </div>
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
index a5578bebd..213c42333 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
@@ -11,7 +11,8 @@ import { VideoEdit } from '@app/shared/video/video-edit.model'
11import { FormValidatorService } from '@app/shared' 11import { FormValidatorService } from '@app/shared'
12import { VideoCaptionService } from '@app/shared/video-caption' 12import { VideoCaptionService } from '@app/shared/video-caption'
13import { VideoImportService } from '@app/shared/video-import' 13import { VideoImportService } from '@app/shared/video-import'
14import { scrollToTop } from '@app/shared/misc/utils' 14import { scrollToTop, getAbsoluteAPIUrl } from '@app/shared/misc/utils'
15import { switchMap, map } from 'rxjs/operators'
15 16
16@Component({ 17@Component({
17 selector: 'my-video-import-url', 18 selector: 'my-video-import-url',
@@ -76,31 +77,54 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
76 77
77 this.loadingBar.start() 78 this.loadingBar.start()
78 79
79 this.videoImportService.importVideoUrl(this.targetUrl, videoUpdate).subscribe( 80 this.videoImportService
80 res => { 81 .importVideoUrl(this.targetUrl, videoUpdate)
81 this.loadingBar.complete() 82 .pipe(
82 this.firstStepDone.emit(res.video.name) 83 switchMap(res => {
83 this.isImportingVideo = false 84 return this.videoCaptionService
84 this.hasImportedVideo = true 85 .listCaptions(res.video.id)
85 86 .pipe(
86 this.video = new VideoEdit(Object.assign(res.video, { 87 map(result => ({ video: res.video, videoCaptions: result.data }))
87 commentsEnabled: videoUpdate.commentsEnabled, 88 )
88 downloadEnabled: videoUpdate.downloadEnabled, 89 })
89 support: null, 90 )
90 thumbnailUrl: null, 91 .subscribe(
91 previewUrl: null 92 ({ video, videoCaptions }) => {
92 })) 93 this.loadingBar.complete()
93 94 this.firstStepDone.emit(video.name)
94 this.hydrateFormFromVideo() 95 this.isImportingVideo = false
95 }, 96 this.hasImportedVideo = true
96 97
97 err => { 98 const absoluteAPIUrl = getAbsoluteAPIUrl()
98 this.loadingBar.complete() 99
99 this.isImportingVideo = false 100 const thumbnailUrl = video.thumbnailPath
100 this.firstStepError.emit() 101 ? absoluteAPIUrl + video.thumbnailPath
101 this.notifier.error(err.message) 102 : null
102 } 103
103 ) 104 const previewUrl = video.previewPath
105 ? absoluteAPIUrl + video.previewPath
106 : null
107
108 this.video = new VideoEdit(Object.assign(video, {
109 commentsEnabled: videoUpdate.commentsEnabled,
110 downloadEnabled: videoUpdate.downloadEnabled,
111 support: null,
112 thumbnailUrl,
113 previewUrl
114 }))
115
116 this.videoCaptions = videoCaptions
117
118 this.hydrateFormFromVideo()
119 },
120
121 err => {
122 this.loadingBar.complete()
123 this.isImportingVideo = false
124 this.firstStepError.emit()
125 this.notifier.error(err.message)
126 }
127 )
104 } 128 }
105 129
106 updateSecondStep () { 130 updateSecondStep () {
@@ -133,5 +157,26 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
133 157
134 private hydrateFormFromVideo () { 158 private hydrateFormFromVideo () {
135 this.form.patchValue(this.video.toFormPatch()) 159 this.form.patchValue(this.video.toFormPatch())
160
161 const objects = [
162 {
163 url: 'thumbnailUrl',
164 name: 'thumbnailfile'
165 },
166 {
167 url: 'previewUrl',
168 name: 'previewfile'
169 }
170 ]
171
172 for (const obj of objects) {
173 fetch(this.video[obj.url])
174 .then(response => response.blob())
175 .then(data => {
176 this.form.patchValue({
177 [ obj.name ]: data
178 })
179 })
180 }
136 } 181 }
137} 182}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.scss b/client/src/app/videos/+video-edit/video-add-components/video-send.scss
index 8769dd302..ebe14c59e 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-send.scss
+++ b/client/src/app/videos/+video-edit/video-add-components/video-send.scss
@@ -41,14 +41,6 @@ $width-size: 190px;
41 } 41 }
42 42
43 .button-file { 43 .button-file {
44 @include peertube-button-file(auto); 44 @include peertube-button-file(max-content);
45
46 min-width: 190px;
47 }
48
49 .button-file-extension {
50 display: block;
51 font-size: 12px;
52 margin-top: 5px;
53 } 45 }
54} 46}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
index 0f904affb..950e55a52 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
@@ -1,17 +1,16 @@
1<div *ngIf="!isUploadingVideo" class="upload-video-container"> 1<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)">
2 <div class="first-step-block"> 2 <div class="first-step-block">
3 <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> 3 <my-global-icon class="upload-icon" iconName="upload"></my-global-icon>
4 4
5 <div class="button-file"> 5 <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
6 <span i18n>Select the file to upload</span> 6 <span i18n>Select the file to upload</span>
7 <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" /> 7 <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus />
8 </div> 8 </div>
9 <span class="button-file-extension">({{ videoExtensions }})</span>
10 9
11 <div class="form-group form-group-channel"> 10 <div class="form-group form-group-channel">
12 <label i18n for="first-step-channel">Channel</label> 11 <label i18n for="first-step-channel">Channel</label>
13 <div class="peertube-select-container"> 12 <div class="peertube-select-container">
14 <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> 13 <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
15 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> 14 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
16 </select> 15 </select>
17 </div> 16 </div>
@@ -20,7 +19,7 @@
20 <div class="form-group"> 19 <div class="form-group">
21 <label i18n for="first-step-privacy">Privacy</label> 20 <label i18n for="first-step-privacy">Privacy</label>
22 <div class="peertube-select-container"> 21 <div class="peertube-select-container">
23 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> 22 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
24 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> 23 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
25 <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> 24 <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
26 </select> 25 </select>
@@ -51,10 +50,12 @@
51</div> 50</div>
52 51
53<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> 52<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
54 <p-progressBar 53 <div class="progress" i18n-title title="Total video quota">
55 [value]="videoUploadPercents" 54 <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100">
56 [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }" 55 <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
57 ></p-progressBar> 56 <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
57 </div>
58 </div>
58 <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" /> 59 <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
59</div> 60</div>
60 61
@@ -68,7 +69,7 @@
68</div> 69</div>
69 70
70<!-- Hidden because we want to load the component --> 71<!-- Hidden because we want to load the component -->
71<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> 72<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form" class="mb-3">
72 <my-video-edit 73 <my-video-edit
73 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" 74 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
74 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" 75 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
index b5628e276..a4f87b0b8 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
@@ -2,7 +2,6 @@
2@import 'mixins'; 2@import 'mixins';
3 3
4.first-step-block { 4.first-step-block {
5
6 .form-group-channel { 5 .form-group-channel {
7 margin-bottom: 20px; 6 margin-bottom: 20px;
8 margin-top: 35px; 7 margin-top: 35px;
@@ -22,37 +21,21 @@
22 margin-top: 25px; 21 margin-top: 25px;
23 margin-bottom: 40px; 22 margin-bottom: 40px;
24 23
25 p-progressBar { 24 .progress {
25 @include progressbar;
26 flex-grow: 1; 26 flex-grow: 1;
27 27 height: 30px;
28 ::ng-deep .ui-progressbar { 28 font-size: 15px;
29 font-size: 15px !important; 29 background-color: rgba(11, 204, 41, 0.16);
30 height: 30px !important; 30
31 border-radius: 3px !important; 31 .progress-bar {
32 background-color: rgba(11, 204, 41, 0.16) !important; 32 background-color: $green;
33 33 line-height: 30px;
34 .ui-progressbar-value { 34 text-align: left;
35 background-color: #0BCC29 !important; 35 font-weight: $font-bold;
36 } 36
37 37 span {
38 .ui-progressbar-label { 38 margin-left: 18px;
39 text-align: left;
40 padding-left: 18px;
41 margin-top: 0 !important;
42 color: #fff !important;
43 line-height: 30px !important;
44 }
45 }
46
47 &.processing {
48 ::ng-deep .ui-progressbar-label {
49 // Same color as background to hide "100%"
50 color: rgba(11, 204, 41, 0.16) !important;
51
52 &::before {
53 content: 'Processing...';
54 color: #fff;
55 }
56 } 39 }
57 } 40 }
58 } 41 }
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
index aa87f9581..9ce3fbc6d 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
@@ -27,7 +27,7 @@ import { scrollToTop } from '@app/shared/misc/utils'
27export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { 27export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate {
28 @Output() firstStepDone = new EventEmitter<string>() 28 @Output() firstStepDone = new EventEmitter<string>()
29 @Output() firstStepError = new EventEmitter<void>() 29 @Output() firstStepError = new EventEmitter<void>()
30 @ViewChild('videofileInput', { static: false }) videofileInput: ElementRef<HTMLInputElement> 30 @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
31 31
32 // So that it can be accessed in the template 32 // So that it can be accessed in the template
33 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 33 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@@ -70,7 +70,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
70 } 70 }
71 71
72 get videoExtensions () { 72 get videoExtensions () {
73 return this.serverConfig.video.file.extensions.join(',') 73 return this.serverConfig.video.file.extensions.join(', ')
74 } 74 }
75 75
76 ngOnInit () { 76 ngOnInit () {
@@ -108,6 +108,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
108 return this.videofileInput.nativeElement.files[0] 108 return this.videofileInput.nativeElement.files[0]
109 } 109 }
110 110
111 setVideoFile (files: FileList) {
112 this.videofileInput.nativeElement.files = files
113 this.fileChange()
114 }
115
111 getAudioUploadLabel () { 116 getAudioUploadLabel () {
112 const videofile = this.getVideoFile() 117 const videofile = this.getVideoFile()
113 if (!videofile) return this.i18n('Upload') 118 if (!videofile) return this.i18n('Upload')
@@ -122,9 +127,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
122 cancelUpload () { 127 cancelUpload () {
123 if (this.videoUploadObservable !== null) { 128 if (this.videoUploadObservable !== null) {
124 this.videoUploadObservable.unsubscribe() 129 this.videoUploadObservable.unsubscribe()
130
125 this.isUploadingVideo = false 131 this.isUploadingVideo = false
126 this.videoUploadPercents = 0 132 this.videoUploadPercents = 0
127 this.videoUploadObservable = null 133 this.videoUploadObservable = null
134
135 this.firstStepError.emit()
136
128 this.notifier.info(this.i18n('Upload cancelled')) 137 this.notifier.info(this.i18n('Upload cancelled'))
129 } 138 }
130 } 139 }
diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html
index a99988600..79bfc6e5c 100644
--- a/client/src/app/videos/+video-edit/video-add.component.html
+++ b/client/src/app/videos/+video-edit/video-add.component.html
@@ -10,27 +10,37 @@
10 <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container> 10 <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
11 </div> 11 </div>
12 12
13 <ngb-tabset class="video-add-tabset root-tabset bootstrap" [ngClass]="{ 'hide-nav': secondStepType !== undefined }"> 13 <div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
14 <ng-container ngbNavItem>
15 <a ngbNavLink>
16 <span i18n>Upload a file</span>
17 </a>
14 18
15 <ngb-tab> 19 <ng-template ngbNavContent>
16 <ng-template ngbTabTitle><span i18n>Upload a file</span></ng-template>
17 <ng-template ngbTabContent>
18 <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload> 20 <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload>
19 </ng-template> 21 </ng-template>
20 </ngb-tab> 22 </ng-container>
21 23
22 <ngb-tab *ngIf="isVideoImportHttpEnabled()"> 24 <ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()">
23 <ng-template ngbTabTitle><span i18n>Import with URL</span></ng-template> 25 <a ngbNavLink>
24 <ng-template ngbTabContent> 26 <span i18n>Import with URL</span>
27 </a>
28
29 <ng-template ngbNavContent>
25 <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url> 30 <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url>
26 </ng-template> 31 </ng-template>
27 </ngb-tab> 32 </ng-container>
33
34 <ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()">
35 <a ngbNavLink>
36 <span i18n>Import with torrent</span>
37 </a>
28 38
29 <ngb-tab *ngIf="isVideoImportTorrentEnabled()"> 39 <ng-template ngbNavContent>
30 <ng-template ngbTabTitle><span i18n>Import with torrent</span></ng-template>
31 <ng-template ngbTabContent>
32 <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent> 40 <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent>
33 </ng-template> 41 </ng-template>
34 </ngb-tab> 42 </ng-container>
35 </ngb-tabset> 43 </div>
44
45 <div [ngbNavOutlet]="nav"></div>
36</div> 46</div>
diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss
index 7acab3744..1316e09e4 100644
--- a/client/src/app/videos/+video-edit/video-add.component.scss
+++ b/client/src/app/videos/+video-edit/video-add.component.scss
@@ -4,6 +4,7 @@
4$border-width: 3px; 4$border-width: 3px;
5$border-type: solid; 5$border-type: solid;
6$border-color: #EAEAEA; 6$border-color: #EAEAEA;
7$nav-link-height: 40px;
7 8
8.margin-content { 9.margin-content {
9 padding-top: 50px; 10 padding-top: 50px;
@@ -13,52 +14,76 @@ $border-color: #EAEAEA;
13 font-size: 15px; 14 font-size: 15px;
14} 15}
15 16
16::ng-deep .root-tabset.video-add-tabset { 17::ng-deep .video-add-nav {
17 margin-top: 50px; 18 border-bottom: $border-width $border-type $border-color;
19 margin: 50px 0 0 0 !important;
18 20
19 &.hide-nav > .nav { 21 &.hide-nav {
20 display: none !important; 22 display: none !important;
21 } 23 }
22 24
23 & > .nav { 25 a.nav-link {
24 border-bottom: $border-width $border-type $border-color; 26 @include disable-default-a-behaviour;
25 margin: 0 !important;
26 27
27 & > li { 28 margin-bottom: -$border-width;
28 margin-bottom: -$border-width; 29 height: $nav-link-height !important;
30 padding: 0 30px !important;
31 font-size: 15px;
32
33 &.active {
34 border: $border-width $border-type $border-color;
35 border-bottom: none;
36 background-color: var(--submenuColor) !important;
37
38 span {
39 border-bottom: 2px solid var(--mainColor);
40 font-weight: $font-bold;
41 }
29 } 42 }
43 }
44}
45
46::ng-deep .upload-video-container {
47 border: $border-width $border-type $border-color;
48 border-top: transparent;
30 49
31 a.nav-link { 50 background-color: var(--submenuColor);
32 @include disable-default-a-behaviour; 51 border-bottom-left-radius: 3px;
52 border-bottom-right-radius: 3px;
53 width: 100%;
54 min-height: 440px;
55 padding-bottom: 20px;
56 display: flex;
57 justify-content: center;
58 align-items: center;
33 59
34 height: 40px !important; 60 &.dragover {
35 padding: 0 30px !important; 61 border: 3px dashed var(--mainColor);
36 font-size: 15px; 62 }
63}
37 64
38 &.active { 65@mixin nav-scroll {
39 border: $border-width $border-type $border-color; 66 ::ng-deep .video-add-nav {
40 border-bottom: none; 67 height: #{$nav-link-height + $border-width * 2};
41 background-color: var(--submenuColor) !important; 68 overflow-x: auto;
69 white-space: nowrap;
70 flex-wrap: unset;
42 71
43 span { 72 /* Hide active tab style to not have a moving tab effect */
44 border-bottom: 2px solid var(--mainColor); 73 a.nav-link.active {
45 font-weight: $font-bold; 74 border: none;
46 } 75 background-color: var(--mainBackgroundColor) !important;
47 }
48 } 76 }
49 } 77 }
78}
79
80/* Make .video-add-nav tabs scrollable on small devices */
81@media screen and (max-width: $small-view) {
82 @include nav-scroll();
83}
50 84
51 .upload-video-container { 85@media screen and (max-width: #{$small-view + $menu-width}) {
52 border: $border-width $border-type $border-color; 86 :host-context(.main-col:not(.expanded)) {
53 border-top: none; 87 @include nav-scroll();
54
55 background-color: var(--submenuColor);
56 border-radius: 3px;
57 width: 100%;
58 min-height: 440px;
59 padding-bottom: 20px;
60 display: flex;
61 justify-content: center;
62 align-items: center;
63 } 88 }
64} 89}
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts
index 401d8a08f..30ab08ea0 100644
--- a/client/src/app/videos/+video-edit/video-add.component.ts
+++ b/client/src/app/videos/+video-edit/video-add.component.ts
@@ -12,9 +12,9 @@ import { ServerConfig } from '@shared/models'
12 styleUrls: [ './video-add.component.scss' ] 12 styleUrls: [ './video-add.component.scss' ]
13}) 13})
14export class VideoAddComponent implements OnInit, CanComponentDeactivate { 14export class VideoAddComponent implements OnInit, CanComponentDeactivate {
15 @ViewChild('videoUpload', { static: false }) videoUpload: VideoUploadComponent 15 @ViewChild('videoUpload') videoUpload: VideoUploadComponent
16 @ViewChild('videoImportUrl', { static: false }) videoImportUrl: VideoImportUrlComponent 16 @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
17 @ViewChild('videoImportTorrent', { static: false }) videoImportTorrent: VideoImportTorrentComponent 17 @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
18 18
19 secondStepType: 'upload' | 'import-url' | 'import-torrent' 19 secondStepType: 'upload' | 'import-url' | 'import-torrent'
20 videoName: string 20 videoName: string
diff --git a/client/src/app/videos/+video-edit/video-add.module.ts b/client/src/app/videos/+video-edit/video-add.module.ts
index 870f7cb97..b8f5a9a47 100644
--- a/client/src/app/videos/+video-edit/video-add.module.ts
+++ b/client/src/app/videos/+video-edit/video-add.module.ts
@@ -3,27 +3,28 @@ import { SharedModule } from '../../shared'
3import { VideoEditModule } from './shared/video-edit.module' 3import { VideoEditModule } from './shared/video-edit.module'
4import { VideoAddRoutingModule } from './video-add-routing.module' 4import { VideoAddRoutingModule } from './video-add-routing.module'
5import { VideoAddComponent } from './video-add.component' 5import { VideoAddComponent } from './video-add.component'
6import { DragDropDirective } from './video-add-components/drag-drop.directive'
6import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service' 7import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
7import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component' 8import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component'
8import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component' 9import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component'
9import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component' 10import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component'
10import { ProgressBarModule } from 'primeng/progressbar'
11 11
12@NgModule({ 12@NgModule({
13 imports: [ 13 imports: [
14 VideoAddRoutingModule, 14 VideoAddRoutingModule,
15 VideoEditModule, 15 VideoEditModule,
16 SharedModule, 16 SharedModule
17 ProgressBarModule
18 ], 17 ],
19 declarations: [ 18 declarations: [
20 VideoAddComponent, 19 VideoAddComponent,
21 VideoUploadComponent, 20 VideoUploadComponent,
22 VideoImportUrlComponent, 21 VideoImportUrlComponent,
23 VideoImportTorrentComponent 22 VideoImportTorrentComponent,
23 DragDropDirective
24 ], 24 ],
25 exports: [ 25 exports: [
26 VideoAddComponent 26 VideoAddComponent,
27 DragDropDirective
27 ], 28 ],
28 providers: [ 29 providers: [
29 CanDeactivateGuard 30 CanDeactivateGuard
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
index 3a9977df6..9b43d91da 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
@@ -17,7 +17,7 @@
17 </div> 17 </div>
18 18
19 <div class="comment-buttons"> 19 <div class="comment-buttons">
20 <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" i18n> 20 <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n>
21 Cancel 21 Cancel
22 </button> 22 </button>
23 <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n> 23 <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n>
@@ -29,15 +29,11 @@
29<ng-template #visitorModal let-modal> 29<ng-template #visitorModal let-modal>
30 <div class="modal-header"> 30 <div class="modal-header">
31 <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4> 31 <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4>
32 <button type="button" class="close" aria-label="Close" (click)="hideVisitorModal()"></button> 32 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon>
33 </div> 33 </div>
34 <div class="modal-body"> 34 <div class="modal-body">
35 <span i18n> 35 <span i18n>
36 If you have an account on this instance, you can login: 36 You can comment using an account on any ActivityPub-compatible instance.
37 </span>
38 <span class="btn btn-sm mx-3" role="button" (click)="gotoLogin()" i18n>login to comment</span>
39 <span i18n>
40 Otherwise, you can comment using an account on any ActivityPub-compatible instance.
41 On most platforms, you can find the video by typing its URL in the search bar and then comment it 37 On most platforms, you can find the video by typing its URL in the search bar and then comment it
42 from within the software's interface. 38 from within the software's interface.
43 </span> 39 </span>
@@ -47,8 +43,14 @@
47 <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe> 43 <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe>
48 </div> 44 </div>
49 <div class="modal-footer inputs"> 45 <div class="modal-footer inputs">
50 <span i18n class="action-button action-button-cancel" role="button" (click)="hideVisitorModal()"> 46 <input
51 Cancel 47 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
52 </span> 48 (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()"
49 >
50
51 <input
52 type="submit" i18n-value value="Login to comment" class="action-button-submit"
53 (click)="gotoLogin()"
54 >
53 </div> 55 </div>
54</ng-template> 56</ng-template>
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
index c04727ba0..b3725ab94 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
@@ -30,25 +30,33 @@ form {
30 } 30 }
31} 31}
32 32
33.cancel-button {
34 font-weight: $font-semibold;
35 display: inline-block;
36 padding: 0 10px 0 10px;
37 white-space: nowrap;
38 background: transparent;
39}
40
41.comment-buttons { 33.comment-buttons {
42 display: flex; 34 display: flex;
43 justify-content: flex-end; 35 justify-content: flex-end;
44 36
45 button { 37 button {
46 @include peertube-button; 38 @include peertube-button;
39 @include disable-outline;
40 @include disable-default-a-behaviour;
41
42 &:not(:last-child) {
43 margin-right: .5rem;
44 }
47 45
48 &:last-child { 46 &:last-child {
49 @include orange-button; 47 @include orange-button;
50 } 48 }
51 } 49 }
50
51 .cancel-button {
52 @include tertiary-button;
53
54 font-weight: $font-semibold;
55 display: inline-block;
56 padding: 0 10px 0 10px;
57 white-space: nowrap;
58 background: transparent;
59 }
52} 60}
53 61
54@media screen and (max-width: 600px) { 62@media screen and (max-width: 600px) {
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
index 1be96ad9e..e1a8f6260 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
@@ -11,7 +11,6 @@ import { VideoCommentService } from './video-comment.service'
11import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 11import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
12import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service' 12import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service'
13import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 13import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
14import { AuthService } from '@app/core/auth'
15 14
16@Component({ 15@Component({
17 selector: 'my-video-comment-add', 16 selector: 'my-video-comment-add',
@@ -25,7 +24,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
25 @Input() parentComments: VideoComment[] 24 @Input() parentComments: VideoComment[]
26 @Input() focusOnInit = false 25 @Input() focusOnInit = false
27 26
28 @Output() commentCreated = new EventEmitter<VideoCommentCreate>() 27 @Output() commentCreated = new EventEmitter<VideoComment>()
29 @Output() cancel = new EventEmitter() 28 @Output() cancel = new EventEmitter()
30 29
31 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal 30 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal
@@ -38,7 +37,6 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
38 private videoCommentValidatorsService: VideoCommentValidatorsService, 37 private videoCommentValidatorsService: VideoCommentValidatorsService,
39 private notifier: Notifier, 38 private notifier: Notifier,
40 private videoCommentService: VideoCommentService, 39 private videoCommentService: VideoCommentService,
41 private authService: AuthService,
42 private modalService: NgbModal, 40 private modalService: NgbModal,
43 private router: Router 41 private router: Router
44 ) { 42 ) {
@@ -57,7 +55,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
57 55
58 if (this.parentComment) { 56 if (this.parentComment) {
59 const mentions = this.parentComments 57 const mentions = this.parentComments
60 .filter(c => c.account.id !== this.user.account.id) // Don't add mention of ourselves 58 .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves
61 .map(c => '@' + c.by) 59 .map(c => '@' + c.by)
62 60
63 const mentionsSet = new Set(mentions) 61 const mentionsSet = new Set(mentions)
@@ -96,7 +94,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
96 this.addingComment = true 94 this.addingComment = true
97 95
98 const commentCreate: VideoCommentCreate = this.form.value 96 const commentCreate: VideoCommentCreate = this.form.value
99 let obs: Observable<any> 97 let obs: Observable<VideoComment>
100 98
101 if (this.parentComment) { 99 if (this.parentComment) {
102 obs = this.addCommentReply(commentCreate) 100 obs = this.addCommentReply(commentCreate)
@@ -139,6 +137,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
139 137
140 cancelCommentReply () { 138 cancelCommentReply () {
141 this.cancel.emit(null) 139 this.cancel.emit(null)
140 this.form.value['text'] = this.textareaElement.nativeElement.value = ''
142 } 141 }
143 142
144 private addCommentReply (commentCreate: VideoCommentCreate) { 143 private addCommentReply (commentCreate: VideoCommentCreate) {
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
new file mode 100644
index 000000000..1566d7369
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
@@ -0,0 +1,7 @@
1import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '../../../../../../shared/models/videos/video-comment.model'
2import { VideoComment } from '@app/videos/+video-watch/comment/video-comment.model'
3
4export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
5 comment: VideoComment
6 children: VideoCommentThreadTree[]
7}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
index 61f9335d1..868addd58 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
@@ -1,6 +1,5 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
2import { User, UserRight } from '../../../../../../shared/models/users' 2import { User, UserRight } from '../../../../../../shared/models/users'
3import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
4import { AuthService } from '@app/core/auth' 3import { AuthService } from '@app/core/auth'
5import { AccountService } from '@app/shared/account/account.service' 4import { AccountService } from '@app/shared/account/account.service'
6import { Video } from '@app/shared/video/video.model' 5import { Video } from '@app/shared/video/video.model'
@@ -10,6 +9,7 @@ import { Account } from '@app/shared/account/account.model'
10import { Notifier } from '@app/core' 9import { Notifier } from '@app/core'
11import { UserService } from '@app/shared' 10import { UserService } from '@app/shared'
12import { Actor } from '@app/shared/actor/actor.model' 11import { Actor } from '@app/shared/actor/actor.model'
12import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
13 13
14@Component({ 14@Component({
15 selector: 'my-video-comment', 15 selector: 'my-video-comment',
@@ -98,6 +98,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
98 return this.comment.account && this.isUserLoggedIn() && 98 return this.comment.account && this.isUserLoggedIn() &&
99 ( 99 (
100 this.user.account.id === this.comment.account.id || 100 this.user.account.id === this.comment.account.id ||
101 this.user.account.id === this.video.account.id ||
101 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) 102 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
102 ) 103 )
103 } 104 }
@@ -125,7 +126,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
125 const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) 126 const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true)
126 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) 127 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
127 this.newParentComments = this.parentComments.concat([ this.comment ]) 128 this.newParentComments = this.parentComments.concat([ this.comment ])
128 this.commentAccount = new Account(this.comment.account) 129
129 this.getUserIfNeeded(this.commentAccount) 130 if (this.comment.account) {
131 this.commentAccount = new Account(this.comment.account)
132 this.getUserIfNeeded(this.commentAccount)
133 } else {
134 this.comment.account = null
135 }
130 } 136 }
131} 137}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
index aaeb0ea9c..171fc4acc 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
@@ -1,5 +1,5 @@
1import { Account as AccountInterface } from '../../../../../../shared/models/actors' 1import { Account as AccountInterface } from '../../../../../../shared/models/actors'
2import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model' 2import { VideoComment as VideoCommentServerModel, VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
3import { Actor } from '@app/shared/actor/actor.model' 3import { Actor } from '@app/shared/actor/actor.model'
4import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' 4import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
5 5
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
index a81e5236a..0b0715390 100644
--- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
@@ -7,13 +7,14 @@ import { FeedFormat, ResultList } from '../../../../../../shared/models'
7import { 7import {
8 VideoComment as VideoCommentServerModel, 8 VideoComment as VideoCommentServerModel,
9 VideoCommentCreate, 9 VideoCommentCreate,
10 VideoCommentThreadTree 10 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
11} from '../../../../../../shared/models/videos/video-comment.model' 11} from '../../../../../../shared/models/videos/video-comment.model'
12import { environment } from '../../../../environments/environment' 12import { environment } from '../../../../environments/environment'
13import { RestExtractor, RestService } from '../../../shared/rest' 13import { RestExtractor, RestService } from '../../../shared/rest'
14import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model' 14import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model'
15import { CommentSortField } from '../../../shared/video/sort-field.type' 15import { CommentSortField } from '../../../shared/video/sort-field.type'
16import { VideoComment } from './video-comment.model' 16import { VideoComment } from './video-comment.model'
17import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
17 18
18@Injectable() 19@Injectable()
19export class VideoCommentService { 20export class VideoCommentService {
@@ -76,9 +77,9 @@ export class VideoCommentService {
76 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` 77 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
77 78
78 return this.authHttp 79 return this.authHttp
79 .get(url) 80 .get<VideoCommentThreadTreeServerModel>(url)
80 .pipe( 81 .pipe(
81 map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree)), 82 map(tree => this.extractVideoCommentTree(tree)),
82 catchError(err => this.restExtractor.handleError(err)) 83 catchError(err => this.restExtractor.handleError(err))
83 ) 84 )
84 } 85 }
@@ -138,12 +139,12 @@ export class VideoCommentService {
138 return { data: comments, total: totalComments } 139 return { data: comments, total: totalComments }
139 } 140 }
140 141
141 private extractVideoCommentTree (tree: VideoCommentThreadTree) { 142 private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) {
142 if (!tree) return tree 143 if (!tree) return tree as VideoCommentThreadTree
143 144
144 tree.comment = new VideoComment(tree.comment) 145 tree.comment = new VideoComment(tree.comment)
145 tree.children.forEach(c => this.extractVideoCommentTree(c)) 146 tree.children.forEach(c => this.extractVideoCommentTree(c))
146 147
147 return tree 148 return tree as VideoCommentThreadTree
148 } 149 }
149} 150}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html
index 2bf52ab86..a21042f09 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.html
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html
@@ -13,7 +13,7 @@
13 13
14 <div ngbDropdown class="d-inline-block ml-4"> 14 <div ngbDropdown class="d-inline-block ml-4">
15 <button class="btn btn-sm btn-outline-secondary" id="dropdownSortComments" ngbDropdownToggle i18n> 15 <button class="btn btn-sm btn-outline-secondary" id="dropdownSortComments" ngbDropdownToggle i18n>
16 Sort by 16 SORT BY
17 </button> 17 </button>
18 <div ngbDropdownMenu aria-labelledby="dropdownSortComments"> 18 <div ngbDropdownMenu aria-labelledby="dropdownSortComments">
19 <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button> 19 <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button>
@@ -38,7 +38,8 @@
38 (nearOfBottom)="onNearOfBottom()" 38 (nearOfBottom)="onNearOfBottom()"
39 [dataObservable]="onDataSubject.asObservable()" 39 [dataObservable]="onDataSubject.asObservable()"
40 > 40 >
41 <div #commentHighlightBlock id="highlighted-comment"> 41 <div>
42 <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
42 <my-video-comment 43 <my-video-comment
43 *ngIf="highlightedThread" 44 *ngIf="highlightedThread"
44 [comment]="highlightedThread" 45 [comment]="highlightedThread"
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss
index f95ff5aba..5ed1ac629 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.scss
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.scss
@@ -17,8 +17,20 @@
17 font-size: 13px; 17 font-size: 13px;
18} 18}
19 19
20.title-block .title-page { 20.title-block {
21 margin-right: 0; 21 .title-page {
22 margin-right: 0;
23 }
24
25 my-feed {
26 display: inline-block;
27 margin-left: 5px;
28 opacity: 0;
29 transition: ease-in .2s opacity;
30 }
31 &:hover my-feed {
32 opacity: 1;
33 }
22} 34}
23 35
24#dropdownSortComments { 36#dropdownSortComments {
@@ -28,11 +40,6 @@
28 transform: translateY(-7%); 40 transform: translateY(-7%);
29} 41}
30 42
31my-feed {
32 display: inline-block;
33 margin-left: 5px;
34}
35
36@media screen and (max-width: 600px) { 43@media screen and (max-width: 600px) {
37 .view-replies { 44 .view-replies {
38 margin-left: 46px; 45 margin-left: 46px;
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
index 974c61d6c..c6c28e3f7 100644
--- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
@@ -1,8 +1,7 @@
1import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { ConfirmService, Notifier } from '@app/core' 3import { ConfirmService, Notifier } from '@app/core'
4import { Subject, Subscription } from 'rxjs' 4import { Subject, Subscription } from 'rxjs'
5import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
6import { AuthService } from '../../../core/auth' 5import { AuthService } from '../../../core/auth'
7import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model' 6import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
8import { User } from '../../../shared/users' 7import { User } from '../../../shared/users'
@@ -13,6 +12,7 @@ import { VideoCommentService } from './video-comment.service'
13import { I18n } from '@ngx-translate/i18n-polyfill' 12import { I18n } from '@ngx-translate/i18n-polyfill'
14import { Syndication } from '@app/shared/video/syndication.model' 13import { Syndication } from '@app/shared/video/syndication.model'
15import { HooksService } from '@app/core/plugins/hooks.service' 14import { HooksService } from '@app/core/plugins/hooks.service'
15import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
16 16
17@Component({ 17@Component({
18 selector: 'my-video-comments', 18 selector: 'my-video-comments',
@@ -20,7 +20,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
20 styleUrls: ['./video-comments.component.scss'] 20 styleUrls: ['./video-comments.component.scss']
21}) 21})
22export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { 22export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
23 @ViewChild('commentHighlightBlock', { static: false }) commentHighlightBlock: ElementRef 23 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
24 @Input() video: VideoDetails 24 @Input() video: VideoDetails
25 @Input() user: User 25 @Input() user: User
26 26
@@ -96,6 +96,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
96 res => { 96 res => {
97 this.threadComments[commentId] = res 97 this.threadComments[commentId] = res
98 this.threadLoading[commentId] = false 98 this.threadLoading[commentId] = false
99 this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res })
99 100
100 if (highlightThread) { 101 if (highlightThread) {
101 this.highlightedThread = new VideoComment(res.comment) 102 this.highlightedThread = new VideoComment(res.comment)
@@ -130,6 +131,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
130 this.componentPagination.totalItems = res.total 131 this.componentPagination.totalItems = res.total
131 132
132 this.onDataSubject.next(res.data) 133 this.onDataSubject.next(res.data)
134 this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
133 }, 135 },
134 136
135 err => this.notifier.error(err.message) 137 err => this.notifier.error(err.message)
@@ -167,7 +169,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
167 let message = 'Do you really want to delete this comment?' 169 let message = 'Do you really want to delete this comment?'
168 170
169 if (commentToDelete.isLocal) { 171 if (commentToDelete.isLocal) {
170 message += this.i18n(' The deletion will be sent to remote instances, so they remove the comment too.') 172 message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.')
171 } else { 173 } else {
172 message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') 174 message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.')
173 } 175 }
@@ -181,7 +183,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
181 // Mark the comment as deleted 183 // Mark the comment as deleted
182 this.softDeleteComment(commentToDelete) 184 this.softDeleteComment(commentToDelete)
183 185
184 if (this.highlightedThread.id === commentToDelete.id) this.highlightedThread = undefined 186 if (this.highlightedThread?.id === commentToDelete.id) this.highlightedThread = undefined
185 }, 187 },
186 188
187 err => this.notifier.error(err.message) 189 err => this.notifier.error(err.message)
@@ -193,9 +195,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
193 } 195 }
194 196
195 onNearOfBottom () { 197 onNearOfBottom () {
196 this.componentPagination.currentPage++
197
198 if (hasMoreItems(this.componentPagination)) { 198 if (hasMoreItems(this.componentPagination)) {
199 this.componentPagination.currentPage++
199 this.loadMoreThreads() 200 this.loadMoreThreads()
200 } 201 }
201 } 202 }
@@ -219,7 +220,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
219 this.componentPagination.totalItems = null 220 this.componentPagination.totalItems = null
220 221
221 this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) 222 this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid)
222
223 this.loadMoreThreads() 223 this.loadMoreThreads()
224 } 224 }
225 } 225 }
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
index 549a9f30e..5e6a2d518 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.html
@@ -27,29 +27,33 @@
27 <div class="video"> 27 <div class="video">
28 <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div> 28 <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div>
29 29
30 <ngb-tabset class="root-tabset bootstrap" (tabChange)="onTabChange($event)"> 30 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeId">
31 31
32 <ngb-tab i18n-title title="URL" id="url"> 32 <ng-container ngbNavItem="url">
33 <ng-template ngbTabContent> 33 <a ngbNavLink i18n>URL</a>
34 34
35 <div class="tab-content"> 35 <ng-template ngbNavContent>
36 <div class="nav-content">
36 <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy> 37 <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy>
37 </div> 38 </div>
38
39 </ng-template> 39 </ng-template>
40 </ngb-tab> 40 </ng-container>
41
42 <ng-container ngbNavItem="qrcode">
43 <a ngbNavLink i18n>QR-Code</a>
41 44
42 <ngb-tab i18n-title title="QR-Code" id="qrcode"> 45 <ng-template ngbNavContent>
43 <ng-template ngbTabContent> 46 <div class="nav-content">
44 <div class="tab-content"> 47 <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode>
45 <qrcode [qrdata]="getVideoUrl()" size="256" level="Q"></qrcode>
46 </div> 48 </div>
47 </ng-template> 49 </ng-template>
48 </ngb-tab> 50 </ng-container>
51
52 <ng-container ngbNavItem="embed">
53 <a ngbNavLink i18n>Embed</a>
49 54
50 <ngb-tab i18n-title title="Embed" id="embed"> 55 <ng-template ngbNavContent>
51 <ng-template ngbTabContent> 56 <div class="nav-content">
52 <div class="tab-content">
53 <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy> 57 <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy>
54 58
55 <div i18n *ngIf="notSecure()" class="alert alert-warning"> 59 <div i18n *ngIf="notSecure()" class="alert alert-warning">
@@ -57,9 +61,11 @@
57 </div> 61 </div>
58 </div> 62 </div>
59 </ng-template> 63 </ng-template>
60 </ngb-tab> 64 </ng-container>
61 65
62 </ngb-tabset> 66 </div>
67
68 <div [ngbNavOutlet]="nav"></div>
63 69
64 <div class="filters"> 70 <div class="filters">
65 <div> 71 <div>
@@ -92,26 +98,6 @@
92 </div> 98 </div>
93 </div> 99 </div>
94 100
95 <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button"
96 [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic">
97
98 <ng-container *ngIf="isAdvancedCustomizationCollapsed">
99 <span class="glyphicon glyphicon-menu-down"></span>
100
101 <ng-container i18n>
102 More customization
103 </ng-container>
104 </ng-container>
105
106 <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
107 <span class="glyphicon glyphicon-menu-up"></span>
108
109 <ng-container i18n>
110 Less customization
111 </ng-container>
112 </ng-container>
113 </div>
114
115 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> 101 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
116 <div> 102 <div>
117 <div class="form-group stop-at"> 103 <div class="form-group stop-at">
@@ -174,12 +160,28 @@
174 </div> 160 </div>
175 </ng-container> 161 </ng-container>
176 </div> 162 </div>
163
164 <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button"
165 [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic">
166
167 <ng-container *ngIf="isAdvancedCustomizationCollapsed">
168 <span class="glyphicon glyphicon-menu-down"></span>
169
170 <ng-container i18n>
171 More customization
172 </ng-container>
173 </ng-container>
174
175 <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
176 <span class="glyphicon glyphicon-menu-up"></span>
177
178 <ng-container i18n>
179 Less customization
180 </ng-container>
181 </ng-container>
182 </div>
177 </div> 183 </div>
178 </div> 184 </div>
179 </div> 185 </div>
180 186
181 <div class="modal-footer inputs">
182 <span i18n class="action-button action-button-cancel" (click)="hide()">Close</span>
183 </div>
184
185</ng-template> 187</ng-template>
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
index 8b5952da6..091d4dc3b 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss
@@ -17,15 +17,11 @@ my-input-readonly-copy {
17 @include peertube-select-container(200px); 17 @include peertube-select-container(200px);
18} 18}
19 19
20.action-button-cancel {
21 margin-right: 0 !important;
22}
23
24.qr-code-group { 20.qr-code-group {
25 text-align: center; 21 text-align: center;
26} 22}
27 23
28.tab-content { 24.nav-content {
29 margin-top: 30px; 25 margin-top: 30px;
30 display: flex; 26 display: flex;
31 justify-content: center; 27 justify-content: center;
@@ -39,14 +35,12 @@ my-input-readonly-copy {
39 35
40.filters { 36.filters {
41 margin-top: 30px; 37 margin-top: 30px;
42 padding-top: 30px;
43 border-top: 1px solid $separator-border-color;
44 38
45 .advanced-filters-button { 39 .advanced-filters-button {
46 display: flex; 40 display: flex;
47 justify-content: center; 41 justify-content: center;
48 align-items: center; 42 align-items: center;
49 margin-top: 30px; 43 margin-top: 20px;
50 font-size: 16px; 44 font-size: 16px;
51 font-weight: $font-semibold; 45 font-weight: $font-semibold;
52 cursor: pointer; 46 cursor: pointer;
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
index a2b38b3a0..3550556a0 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts
@@ -1,9 +1,7 @@
1import { Component, ElementRef, Input, ViewChild } from '@angular/core' 1import { Component, ElementRef, Input, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { VideoDetails } from '../../../shared/video/video-details.model' 2import { VideoDetails } from '../../../shared/video/video-details.model'
4import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' 3import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModal, NgbTabChangeEvent } from '@ng-bootstrap/ng-bootstrap'
7import { VideoCaption } from '@shared/models' 5import { VideoCaption } from '@shared/models'
8import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 6import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
9 7
@@ -37,7 +35,7 @@ export class VideoShareComponent {
37 @Input() videoCaptions: VideoCaption[] = [] 35 @Input() videoCaptions: VideoCaption[] = []
38 @Input() playlist: VideoPlaylist = null 36 @Input() playlist: VideoPlaylist = null
39 37
40 activeId: 'url' | 'qrcode' | 'embed' 38 activeId: 'url' | 'qrcode' | 'embed' = 'url'
41 customizations: Customizations 39 customizations: Customizations
42 isAdvancedCustomizationCollapsed = true 40 isAdvancedCustomizationCollapsed = true
43 includeVideoInPlaylist = false 41 includeVideoInPlaylist = false
@@ -74,7 +72,7 @@ export class VideoShareComponent {
74 controls: true 72 controls: true
75 } 73 }
76 74
77 this.modalService.open(this.modal) 75 this.modalService.open(this.modal, { centered: true })
78 } 76 }
79 77
80 getVideoIframeCode () { 78 getVideoIframeCode () {
@@ -103,10 +101,6 @@ export class VideoShareComponent {
103 return window.location.protocol === 'http:' 101 return window.location.protocol === 'http:'
104 } 102 }
105 103
106 onTabChange (event: NgbTabChangeEvent) {
107 this.activeId = event.nextId as any
108 }
109
110 isInEmbedTab () { 104 isInEmbedTab () {
111 return this.activeId === 'embed' 105 return this.activeId === 'embed'
112 } 106 }
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.html b/client/src/app/videos/+video-watch/modal/video-support.component.html
index 608a4632b..935656d23 100644
--- a/client/src/app/videos/+video-watch/modal/video-support.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-support.component.html
@@ -7,6 +7,9 @@
7 <div class="modal-body" [innerHTML]="videoHTMLSupport"></div> 7 <div class="modal-body" [innerHTML]="videoHTMLSupport"></div>
8 8
9 <div class="modal-footer inputs"> 9 <div class="modal-footer inputs">
10 <span i18n class="action-button action-button-cancel" (click)="hide()">Maybe later</span> 10 <input
11 type="button" role="button" i18n-value value="Maybe later" class="action-button action-button-cancel"
12 (click)="hide()" (key.enter)="hide()"
13 >
11 </div> 14 </div>
12</ng-template> 15</ng-template>
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.ts b/client/src/app/videos/+video-watch/modal/video-support.component.ts
index b56a51fbf..0058172f2 100644
--- a/client/src/app/videos/+video-watch/modal/video-support.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-support.component.ts
@@ -21,7 +21,7 @@ export class VideoSupportComponent {
21 ) { } 21 ) { }
22 22
23 show () { 23 show () {
24 this.modalService.open(this.modal) 24 this.modalService.open(this.modal, { centered: true })
25 25
26 this.markdownService.enhancedMarkdownToHTML(this.video.support) 26 this.markdownService.enhancedMarkdownToHTML(this.video.support)
27 .then(r => this.videoHTMLSupport = r) 27 .then(r => this.videoHTMLSupport = r)
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
index c5ed36000..827c34d41 100644
--- a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
@@ -9,6 +9,7 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
9import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' 9import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
10import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' 10import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage'
11import { I18n } from '@ngx-translate/i18n-polyfill' 11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { SessionStorageService, LocalStorageService } from '@app/shared/misc/storage.service'
12 13
13@Component({ 14@Component({
14 selector: 'my-video-watch-playlist', 15 selector: 'my-video-watch-playlist',
@@ -42,16 +43,18 @@ export class VideoWatchPlaylistComponent {
42 private notifier: Notifier, 43 private notifier: Notifier,
43 private i18n: I18n, 44 private i18n: I18n,
44 private videoPlaylist: VideoPlaylistService, 45 private videoPlaylist: VideoPlaylistService,
46 private localStorageService: LocalStorageService,
47 private sessionStorageService: SessionStorageService,
45 private router: Router 48 private router: Router
46 ) { 49 ) {
47 // defaults to true 50 // defaults to true
48 this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() 51 this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn()
49 ? this.auth.getUser().autoPlayNextVideoPlaylist 52 ? this.auth.getUser().autoPlayNextVideoPlaylist
50 : peertubeLocalStorage.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' 53 : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false'
51 this.setAutoPlayNextVideoPlaylistSwitchText() 54 this.setAutoPlayNextVideoPlaylistSwitchText()
52 55
53 // defaults to false 56 // defaults to false
54 this.loopPlaylist = peertubeSessionStorage.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' 57 this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true'
55 this.setLoopPlaylistSwitchText() 58 this.setLoopPlaylistSwitchText()
56 } 59 }
57 60
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index bc3a3ffdd..0244860dd 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -84,12 +84,12 @@
84 placement="bottom auto" 84 placement="bottom auto"
85 > 85 >
86 <my-global-icon iconName="support"></my-global-icon> 86 <my-global-icon iconName="support"></my-global-icon>
87 <span class="icon-text" i18n>Support</span> 87 <span class="icon-text" i18n>SUPPORT</span>
88 </div> 88 </div>
89 89
90 <div (click)="showShareModal()" class="action-button" role="button"> 90 <div (click)="showShareModal()" class="action-button" role="button">
91 <my-global-icon iconName="share"></my-global-icon> 91 <my-global-icon iconName="share"></my-global-icon>
92 <span class="icon-text" i18n>Share</span> 92 <span class="icon-text" i18n>SHARE</span>
93 </div> 93 </div>
94 94
95 <div 95 <div
@@ -100,7 +100,7 @@
100 > 100 >
101 <div class="action-button action-button-save" ngbDropdownToggle role="button"> 101 <div class="action-button action-button-save" ngbDropdownToggle role="button">
102 <my-global-icon iconName="playlist-add"></my-global-icon> 102 <my-global-icon iconName="playlist-add"></my-global-icon>
103 <span class="icon-text" i18n>Save</span> 103 <span class="icon-text" i18n>SAVE</span>
104 </div> 104 </div>
105 105
106 <div ngbDropdownMenu> 106 <div ngbDropdownMenu>
@@ -188,6 +188,11 @@
188 <span class="video-attribute-value">{{ video.privacy.label }}</span> 188 <span class="video-attribute-value">{{ video.privacy.label }}</span>
189 </div> 189 </div>
190 190
191 <div *ngIf="video.isLocal === false" class="video-attribute">
192 <span i18n class="video-attribute-label">Origin instance</span>
193 <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a>
194 </div>
195
191 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> 196 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
192 <span i18n class="video-attribute-label">Originally published</span> 197 <span i18n class="video-attribute-label">Originally published</span>
193 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> 198 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
@@ -252,14 +257,16 @@
252 257
253 <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> 258 <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false">
254 <div class="privacy-concerns-text"> 259 <div class="privacy-concerns-text">
255 <strong i18n>Friendly Reminder: </strong> 260 <span class="mr-2">
256 <ng-container i18n> 261 <strong i18n>Friendly Reminder: </strong>
257 the sharing system used for this video implies that some technical information about your system (such as a public IP address) can be sent to other peers. 262 <ng-container i18n>
258 </ng-container> 263 the sharing system used for this video implies that some technical information about your system (such as a public IP address) can be sent to other peers.
259 <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube">More information</a> 264 </ng-container>
265 </span>
266 <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a>
260 </div> 267 </div>
261 268
262 <div i18n class="privacy-concerns-okay" (click)="acceptedPrivacyConcern()"> 269 <div i18n class="privacy-concerns-button privacy-concerns-okay" (click)="acceptedPrivacyConcern()">
263 OK 270 OK
264 </div> 271 </div>
265 </div> 272 </div>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index c92f773e4..977312a83 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -53,7 +53,6 @@ $video-info-margin-left: 44px;
53 background-color: #000; 53 background-color: #000;
54 display: flex; 54 display: flex;
55 justify-content: center; 55 justify-content: center;
56 margin: 0 -15px;
57 56
58 #videojs-wrapper { 57 #videojs-wrapper {
59 display: flex; 58 display: flex;
@@ -443,6 +442,7 @@ my-video-comments {
443 442
444// If the view is not expanded, take into account the menu 443// If the view is not expanded, take into account the menu
445.privacy-concerns { 444.privacy-concerns {
445 z-index: z(dropdown) + 1;
446 width: calc(100% - #{$menu-width}); 446 width: calc(100% - #{$menu-width});
447} 447}
448 448
@@ -462,13 +462,14 @@ my-video-comments {
462.privacy-concerns { 462.privacy-concerns {
463 position: fixed; 463 position: fixed;
464 bottom: 0; 464 bottom: 0;
465 z-index: z(privacymsg);
465 466
466 padding: 5px 15px; 467 padding: 5px 15px;
467 468
468 display: flex; 469 display: flex;
469 flex-wrap: nowrap; 470 flex-wrap: nowrap;
470 align-items: center; 471 align-items: center;
471 justify-content: flex-start; 472 justify-content: space-between;
472 background-color: rgba(0, 0, 0, 0.9); 473 background-color: rgba(0, 0, 0, 0.9);
473 color: #fff; 474 color: #fff;
474 475
@@ -487,11 +488,11 @@ my-video-comments {
487 } 488 }
488 } 489 }
489 490
490 .privacy-concerns-okay { 491 .privacy-concerns-button {
491 background-color: var(--mainColor);
492 padding: 5px 8px 5px 7px; 492 padding: 5px 8px 5px 7px;
493 margin-left: auto; 493 margin-left: auto;
494 border-radius: 3px; 494 border-radius: 3px;
495 white-space: nowrap;
495 cursor: pointer; 496 cursor: pointer;
496 transition: background-color 0.3s; 497 transition: background-color 0.3s;
497 font-weight: $font-semibold; 498 font-weight: $font-semibold;
@@ -500,6 +501,11 @@ my-video-comments {
500 background-color: #000; 501 background-color: #000;
501 } 502 }
502 } 503 }
504
505 .privacy-concerns-okay {
506 background-color: var(--mainColor);
507 margin-left: 10px;
508 }
503} 509}
504 510
505@media screen and (max-width: 1600px) { 511@media screen and (max-width: 1600px) {
@@ -545,7 +551,8 @@ my-video-comments {
545 551
546@media screen and (max-width: 600px) { 552@media screen and (max-width: 600px) {
547 .video-bottom { 553 .video-bottom {
548 margin: 20px 0 0 0 !important; 554 margin-top: 20px !important;
555 margin-bottom: 20px !important;
549 556
550 .video-info { 557 .video-info {
551 padding: 0; 558 padding: 0;
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index e09e44809..51e484275 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators'
2import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 2import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { RedirectService } from '@app/core/routing/redirect.service' 4import { RedirectService } from '@app/core/routing/redirect.service'
5import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' 5import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
6import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' 6import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
7import { MetaService } from '@ngx-meta/core' 7import { MetaService } from '@ngx-meta/core'
8import { AuthUser, Notifier, ServerService } from '@app/core' 8import { AuthUser, Notifier, ServerService } from '@app/core'
@@ -10,7 +10,7 @@ import { forkJoin, Observable, Subscription } from 'rxjs'
10import { Hotkey, HotkeysService } from 'angular2-hotkeys' 10import { Hotkey, HotkeysService } from 'angular2-hotkeys'
11import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' 11import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
12import { AuthService, ConfirmService } from '../../core' 12import { AuthService, ConfirmService } from '../../core'
13import { RestExtractor } from '../../shared' 13import { RestExtractor, UserService } from '../../shared'
14import { VideoDetails } from '../../shared/video/video-details.model' 14import { VideoDetails } from '../../shared/video/video-details.model'
15import { VideoService } from '../../shared/video/video.service' 15import { VideoService } from '../../shared/video/video.service'
16import { VideoShareComponent } from './modal/video-share.component' 16import { VideoShareComponent } from './modal/video-share.component'
@@ -35,7 +35,6 @@ import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watc
35import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' 35import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
36import { HooksService } from '@app/core/plugins/hooks.service' 36import { HooksService } from '@app/core/plugins/hooks.service'
37import { PlatformLocation } from '@angular/common' 37import { PlatformLocation } from '@angular/common'
38import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component'
39import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils' 38import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils'
40 39
41@Component({ 40@Component({
@@ -47,9 +46,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
47 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' 46 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
48 47
49 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent 48 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
50 @ViewChild('videoShareModal', { static: false }) videoShareModal: VideoShareComponent 49 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
51 @ViewChild('videoSupportModal', { static: false }) videoSupportModal: VideoSupportComponent 50 @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
52 @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent 51 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
53 52
54 player: any 53 player: any
55 playerElement: HTMLVideoElement 54 playerElement: HTMLVideoElement
@@ -95,6 +94,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
95 private confirmService: ConfirmService, 94 private confirmService: ConfirmService,
96 private metaService: MetaService, 95 private metaService: MetaService,
97 private authService: AuthService, 96 private authService: AuthService,
97 private userService: UserService,
98 private serverService: ServerService, 98 private serverService: ServerService,
99 private restExtractor: RestExtractor, 99 private restExtractor: RestExtractor,
100 private notifier: Notifier, 100 private notifier: Notifier,
@@ -118,6 +118,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
118 return this.authService.getUser() 118 return this.authService.getUser()
119 } 119 }
120 120
121 get anonymousUser () {
122 return this.userService.getAnonymousUser()
123 }
124
121 async ngOnInit () { 125 async ngOnInit () {
122 this.serverConfig = this.serverService.getTmpConfig() 126 this.serverConfig = this.serverService.getTmpConfig()
123 127
@@ -266,6 +270,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
266 this.redirectService.redirectToHomepage() 270 this.redirectService.redirectToHomepage()
267 } 271 }
268 272
273 declinedPrivacyConcern () {
274 peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false')
275 this.hasAlreadyAcceptedPrivacyConcern = false
276 }
277
269 acceptedPrivacyConcern () { 278 acceptedPrivacyConcern () {
270 peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') 279 peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true')
271 this.hasAlreadyAcceptedPrivacyConcern = true 280 this.hasAlreadyAcceptedPrivacyConcern = true
@@ -290,7 +299,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
290 isAutoPlayEnabled () { 299 isAutoPlayEnabled () {
291 return ( 300 return (
292 (this.user && this.user.autoPlayNextVideo) || 301 (this.user && this.user.autoPlayNextVideo) ||
293 peertubeSessionStorage.getItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' 302 this.anonymousUser.autoPlayNextVideo
294 ) 303 )
295 } 304 }
296 305
@@ -302,7 +311,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
302 isPlaylistAutoPlayEnabled () { 311 isPlaylistAutoPlayEnabled () {
303 return ( 312 return (
304 (this.user && this.user.autoPlayNextVideoPlaylist) || 313 (this.user && this.user.autoPlayNextVideoPlaylist) ||
305 peertubeSessionStorage.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' 314 this.anonymousUser.autoPlayNextVideoPlaylist
306 ) 315 )
307 } 316 }
308 317
@@ -532,7 +541,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
532 } 541 }
533 542
534 private autoplayNext () { 543 private autoplayNext () {
535 if (this.nextVideoUuid) { 544 if (this.playlist) {
545 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
546 } else if (this.nextVideoUuid) {
536 this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) 547 this.router.navigate([ '/videos/watch', this.nextVideoUuid ])
537 } 548 }
538 } 549 }
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts
index 5fa50ecbb..9b445269d 100644
--- a/client/src/app/videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch.module.ts
@@ -12,7 +12,6 @@ import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
12import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' 12import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
13import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' 13import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
14import { QRCodeModule } from 'angularx-qrcode' 14import { QRCodeModule } from 'angularx-qrcode'
15import { InputSwitchModule } from 'primeng/inputswitch'
16import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive' 15import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive'
17 16
18@NgModule({ 17@NgModule({
@@ -21,8 +20,7 @@ import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestam
21 SharedModule, 20 SharedModule,
22 NgbTooltipModule, 21 NgbTooltipModule,
23 QRCodeModule, 22 QRCodeModule,
24 RecommendationsModule, 23 RecommendationsModule
25 InputSwitchModule
26 ], 24 ],
27 25
28 declarations: [ 26 declarations: [
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html
index 476eca071..74f9ed2a5 100644
--- a/client/src/app/videos/recommendations/recommended-videos.component.html
+++ b/client/src/app/videos/recommendations/recommended-videos.component.html
@@ -7,8 +7,8 @@
7 <div *ngIf="!playlist" class="title-page-autoplay" 7 <div *ngIf="!playlist" class="title-page-autoplay"
8 [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto" 8 [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto"
9 > 9 >
10 <span i18n>Autoplay</span> 10 <span i18n>AUTOPLAY</span>
11 <p-inputSwitch [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch> 11 <p-inputSwitch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch>
12 </div> 12 </div>
13 </div> 13 </div>
14 14
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.scss b/client/src/app/videos/recommendations/recommended-videos.component.scss
index 1ab0c47ff..cde62f87f 100644
--- a/client/src/app/videos/recommendations/recommended-videos.component.scss
+++ b/client/src/app/videos/recommendations/recommended-videos.component.scss
@@ -25,25 +25,3 @@
25 font-weight: 600; 25 font-weight: 600;
26 } 26 }
27} 27}
28
29/* p-inputSwitch styles to reduce the switch size */
30
31::ng-deep {
32 p-inputswitch {
33 height: 20px;
34 }
35
36 .ui-inputswitch {
37 width: 2.5em !important;
38 height: 1.45em !important;
39
40 .ui-inputswitch-slider::before {
41 height: 1em !important;
42 width: 1em !important;
43 }
44 }
45
46 .ui-inputswitch-checked .ui-inputswitch-slider::before {
47 transform: translateX(1em) !important;
48 }
49}
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts
index ada6d3433..d4b4c929b 100644
--- a/client/src/app/videos/recommendations/recommended-videos.component.ts
+++ b/client/src/app/videos/recommendations/recommended-videos.component.ts
@@ -7,8 +7,8 @@ import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-
7import { User } from '@app/shared' 7import { User } from '@app/shared'
8import { AuthService, Notifier } from '@app/core' 8import { AuthService, Notifier } from '@app/core'
9import { UserService } from '@app/shared/users/user.service' 9import { UserService } from '@app/shared/users/user.service'
10import { peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage'
11import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { SessionStorageService } from '@app/shared/misc/storage.service'
12 12
13@Component({ 13@Component({
14 selector: 'my-recommended-videos', 14 selector: 'my-recommended-videos',
@@ -16,8 +16,6 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
16 styleUrls: [ './recommended-videos.component.scss' ] 16 styleUrls: [ './recommended-videos.component.scss' ]
17}) 17})
18export class RecommendedVideosComponent implements OnChanges { 18export class RecommendedVideosComponent implements OnChanges {
19 static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO = 'auto_play_next_video'
20
21 @Input() inputRecommendation: RecommendationInfo 19 @Input() inputRecommendation: RecommendationInfo
22 @Input() user: User 20 @Input() user: User
23 @Input() playlist: VideoPlaylist 21 @Input() playlist: VideoPlaylist
@@ -34,15 +32,21 @@ export class RecommendedVideosComponent implements OnChanges {
34 private authService: AuthService, 32 private authService: AuthService,
35 private notifier: Notifier, 33 private notifier: Notifier,
36 private i18n: I18n, 34 private i18n: I18n,
37 private store: RecommendedVideosStore 35 private store: RecommendedVideosStore,
36 private sessionStorageService: SessionStorageService
38 ) { 37 ) {
39 this.videos$ = this.store.recommendations$ 38 this.videos$ = this.store.recommendations$
40 this.hasVideos$ = this.store.hasRecommendations$ 39 this.hasVideos$ = this.store.hasRecommendations$
41 this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) 40 this.videos$.subscribe(videos => this.gotRecommendations.emit(videos))
42 41
43 this.autoPlayNextVideo = this.authService.isLoggedIn() 42 if (this.authService.isLoggedIn()) {
44 ? this.authService.getUser().autoPlayNextVideo 43 this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo
45 : peertubeSessionStorage.getItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false 44 } else {
45 this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false
46 this.sessionStorageService.watch([User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe(
47 () => this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
48 )
49 }
46 50
47 this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.') 51 this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.')
48 } 52 }
@@ -58,7 +62,7 @@ export class RecommendedVideosComponent implements OnChanges {
58 } 62 }
59 63
60 switchAutoPlayNextVideo () { 64 switchAutoPlayNextVideo () {
61 peertubeSessionStorage.setItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) 65 this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString())
62 66
63 if (this.authService.isLoggedIn()) { 67 if (this.authService.isLoggedIn()) {
64 const details = { 68 const details = {
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
index 59f65f95c..757b0e498 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -11,6 +11,8 @@ import { ScreenService } from '@app/shared/misc/screen.service'
11import { UserRight } from '../../../../../shared/models/users' 11import { UserRight } from '../../../../../shared/models/users'
12import { Notifier, ServerService } from '@app/core' 12import { Notifier, ServerService } from '@app/core'
13import { HooksService } from '@app/core/plugins/hooks.service' 13import { HooksService } from '@app/core/plugins/hooks.service'
14import { UserService } from '@app/shared'
15import { LocalStorageService } from '@app/shared/misc/storage.service'
14 16
15@Component({ 17@Component({
16 selector: 'my-videos-local', 18 selector: 'my-videos-local',
@@ -31,7 +33,9 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
31 protected route: ActivatedRoute, 33 protected route: ActivatedRoute,
32 protected notifier: Notifier, 34 protected notifier: Notifier,
33 protected authService: AuthService, 35 protected authService: AuthService,
36 protected userService: UserService,
34 protected screenService: ScreenService, 37 protected screenService: ScreenService,
38 protected storageService: LocalStorageService,
35 private videoService: VideoService, 39 private videoService: VideoService,
36 private hooks: HooksService 40 private hooks: HooksService
37 ) { 41 ) {
diff --git a/client/src/app/videos/video-list/video-most-liked.component.ts b/client/src/app/videos/video-list/video-most-liked.component.ts
index 6ff7a1e0e..b69fad05f 100644
--- a/client/src/app/videos/video-list/video-most-liked.component.ts
+++ b/client/src/app/videos/video-list/video-most-liked.component.ts
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
10import { Notifier, ServerService } from '@app/core' 10import { Notifier, ServerService } from '@app/core'
11import { HooksService } from '@app/core/plugins/hooks.service' 11import { HooksService } from '@app/core/plugins/hooks.service'
12import { UserService } from '@app/shared'
13import { LocalStorageService } from '@app/shared/misc/storage.service'
12 14
13@Component({ 15@Component({
14 selector: 'my-videos-most-liked', 16 selector: 'my-videos-most-liked',
@@ -28,7 +30,9 @@ export class VideoMostLikedComponent extends AbstractVideoList implements OnInit
28 protected route: ActivatedRoute, 30 protected route: ActivatedRoute,
29 protected notifier: Notifier, 31 protected notifier: Notifier,
30 protected authService: AuthService, 32 protected authService: AuthService,
33 protected userService: UserService,
31 protected screenService: ScreenService, 34 protected screenService: ScreenService,
35 protected storageService: LocalStorageService,
32 private videoService: VideoService, 36 private videoService: VideoService,
33 private hooks: HooksService 37 private hooks: HooksService
34 ) { 38 ) {
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html
index 5fe1f5c80..84999cfb2 100644
--- a/client/src/app/videos/video-list/video-overview.component.html
+++ b/client/src/app/videos/video-list/video-overview.component.html
@@ -2,35 +2,44 @@
2 2
3 <div class="no-results" i18n *ngIf="notResults">No results.</div> 3 <div class="no-results" i18n *ngIf="notResults">No results.</div>
4 4
5 <div class="section" *ngFor="let object of overview.categories"> 5 <div
6 <div class="section-title"> 6 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
7 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> 7 >
8 </div> 8 <ng-container *ngFor="let overview of overviews">
9 9
10 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> 10 <div class="section" *ngFor="let object of overview.categories">
11 </my-video-miniature> 11 <div class="section-title">
12 </div> 12 <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
13 </div>
13 14
14 <div class="section" *ngFor="let object of overview.tags"> 15 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false">
15 <div class="section-title"> 16 </my-video-miniature>
16 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> 17 </div>
17 </div>
18 18
19 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> 19 <div class="section" *ngFor="let object of overview.tags">
20 </my-video-miniature> 20 <div class="section-title">
21 </div> 21 <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
22 </div>
23
24 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false">
25 </my-video-miniature>
26 </div>
27
28 <div class="section channel" *ngFor="let object of overview.channels">
29 <div class="section-title">
30 <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
31 <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
32
33 <div>{{ object.channel.displayName }}</div>
34 </a>
35 </div>
22 36
23 <div class="section channel" *ngFor="let object of overview.channels"> 37 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false">
24 <div class="section-title"> 38 </my-video-miniature>
25 <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]"> 39 </div>
26 <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
27 40
28 <div>{{ object.channel.displayName }}</div> 41 </ng-container>
29 </a>
30 </div>
31 42
32 <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false">
33 </my-video-miniature>
34 </div> 43 </div>
35 44
36</div> 45</div>
diff --git a/client/src/app/videos/video-list/video-overview.component.ts b/client/src/app/videos/video-list/video-overview.component.ts
index 4fee92d54..101073949 100644
--- a/client/src/app/videos/video-list/video-overview.component.ts
+++ b/client/src/app/videos/video-list/video-overview.component.ts
@@ -5,6 +5,7 @@ import { VideosOverview } from '@app/shared/overview/videos-overview.model'
5import { OverviewService } from '@app/shared/overview' 5import { OverviewService } from '@app/shared/overview'
6import { Video } from '@app/shared/video/video.model' 6import { Video } from '@app/shared/video/video.model'
7import { ScreenService } from '@app/shared/misc/screen.service' 7import { ScreenService } from '@app/shared/misc/screen.service'
8import { Subject } from 'rxjs'
8 9
9@Component({ 10@Component({
10 selector: 'my-video-overview', 11 selector: 'my-video-overview',
@@ -12,13 +13,17 @@ import { ScreenService } from '@app/shared/misc/screen.service'
12 styleUrls: [ './video-overview.component.scss' ] 13 styleUrls: [ './video-overview.component.scss' ]
13}) 14})
14export class VideoOverviewComponent implements OnInit { 15export class VideoOverviewComponent implements OnInit {
15 overview: VideosOverview = { 16 onDataSubject = new Subject<any>()
16 categories: [], 17
17 channels: [], 18 overviews: VideosOverview[] = []
18 tags: []
19 }
20 notResults = false 19 notResults = false
21 20
21 private loaded = false
22 private currentPage = 1
23 private maxPage = 20
24 private lastWasEmpty = false
25 private isLoading = false
26
22 constructor ( 27 constructor (
23 private i18n: I18n, 28 private i18n: I18n,
24 private notifier: Notifier, 29 private notifier: Notifier,
@@ -32,20 +37,7 @@ export class VideoOverviewComponent implements OnInit {
32 } 37 }
33 38
34 ngOnInit () { 39 ngOnInit () {
35 this.overviewService.getVideosOverview() 40 this.loadMoreResults()
36 .subscribe(
37 overview => {
38 this.overview = overview
39
40 if (
41 this.overview.categories.length === 0 &&
42 this.overview.channels.length === 0 &&
43 this.overview.tags.length === 0
44 ) this.notResults = true
45 },
46
47 err => this.notifier.error(err.message)
48 )
49 } 41 }
50 42
51 buildVideoChannelBy (object: { videos: Video[] }) { 43 buildVideoChannelBy (object: { videos: Video[] }) {
@@ -61,4 +53,41 @@ export class VideoOverviewComponent implements OnInit {
61 53
62 return videos.slice(0, numberOfVideos * 2) 54 return videos.slice(0, numberOfVideos * 2)
63 } 55 }
56
57 onNearOfBottom () {
58 if (this.currentPage >= this.maxPage) return
59 if (this.lastWasEmpty) return
60 if (this.isLoading) return
61
62 this.currentPage++
63 this.loadMoreResults()
64 }
65
66 private loadMoreResults () {
67 this.isLoading = true
68
69 this.overviewService.getVideosOverview(this.currentPage)
70 .subscribe(
71 overview => {
72 this.isLoading = false
73
74 if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) {
75 this.lastWasEmpty = true
76 if (this.loaded === false) this.notResults = true
77
78 return
79 }
80
81 this.loaded = true
82 this.onDataSubject.next(overview)
83
84 this.overviews.push(overview)
85 },
86
87 err => {
88 this.notifier.error(err.message)
89 this.isLoading = false
90 }
91 )
92 }
64} 93}
diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts
index 7568f4536..c1ddd4fd4 100644
--- a/client/src/app/videos/video-list/video-recently-added.component.ts
+++ b/client/src/app/videos/video-list/video-recently-added.component.ts
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
10import { Notifier, ServerService } from '@app/core' 10import { Notifier, ServerService } from '@app/core'
11import { HooksService } from '@app/core/plugins/hooks.service' 11import { HooksService } from '@app/core/plugins/hooks.service'
12import { UserService } from '@app/shared'
13import { LocalStorageService } from '@app/shared/misc/storage.service'
12 14
13@Component({ 15@Component({
14 selector: 'my-videos-recently-added', 16 selector: 'my-videos-recently-added',
@@ -29,7 +31,9 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
29 protected router: Router, 31 protected router: Router,
30 protected notifier: Notifier, 32 protected notifier: Notifier,
31 protected authService: AuthService, 33 protected authService: AuthService,
34 protected userService: UserService,
32 protected screenService: ScreenService, 35 protected screenService: ScreenService,
36 protected storageService: LocalStorageService,
33 private videoService: VideoService, 37 private videoService: VideoService,
34 private hooks: HooksService 38 private hooks: HooksService
35 ) { 39 ) {
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts
index e29830b5b..fbe052277 100644
--- a/client/src/app/videos/video-list/video-trending.component.ts
+++ b/client/src/app/videos/video-list/video-trending.component.ts
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
9import { ScreenService } from '@app/shared/misc/screen.service' 9import { ScreenService } from '@app/shared/misc/screen.service'
10import { Notifier, ServerService } from '@app/core' 10import { Notifier, ServerService } from '@app/core'
11import { HooksService } from '@app/core/plugins/hooks.service' 11import { HooksService } from '@app/core/plugins/hooks.service'
12import { UserService } from '@app/shared'
13import { LocalStorageService } from '@app/shared/misc/storage.service'
12 14
13@Component({ 15@Component({
14 selector: 'my-videos-trending', 16 selector: 'my-videos-trending',
@@ -28,7 +30,9 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
28 protected route: ActivatedRoute, 30 protected route: ActivatedRoute,
29 protected notifier: Notifier, 31 protected notifier: Notifier,
30 protected authService: AuthService, 32 protected authService: AuthService,
33 protected userService: UserService,
31 protected screenService: ScreenService, 34 protected screenService: ScreenService,
35 protected storageService: LocalStorageService,
32 private videoService: VideoService, 36 private videoService: VideoService,
33 private hooks: HooksService 37 private hooks: HooksService
34 ) { 38 ) {
diff --git a/client/src/app/videos/video-list/video-user-subscriptions.component.ts b/client/src/app/videos/video-list/video-user-subscriptions.component.ts
index cf0b15054..036fd8dcb 100644
--- a/client/src/app/videos/video-list/video-user-subscriptions.component.ts
+++ b/client/src/app/videos/video-list/video-user-subscriptions.component.ts
@@ -10,6 +10,8 @@ import { ScreenService } from '@app/shared/misc/screen.service'
10import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' 10import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
11import { Notifier, ServerService } from '@app/core' 11import { Notifier, ServerService } from '@app/core'
12import { HooksService } from '@app/core/plugins/hooks.service' 12import { HooksService } from '@app/core/plugins/hooks.service'
13import { UserService } from '@app/shared'
14import { LocalStorageService } from '@app/shared/misc/storage.service'
13 15
14@Component({ 16@Component({
15 selector: 'my-videos-user-subscriptions', 17 selector: 'my-videos-user-subscriptions',
@@ -29,7 +31,9 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
29 protected route: ActivatedRoute, 31 protected route: ActivatedRoute,
30 protected notifier: Notifier, 32 protected notifier: Notifier,
31 protected authService: AuthService, 33 protected authService: AuthService,
34 protected userService: UserService,
32 protected screenService: ScreenService, 35 protected screenService: ScreenService,
36 protected storageService: LocalStorageService,
33 private videoService: VideoService, 37 private videoService: VideoService,
34 private hooks: HooksService 38 private hooks: HooksService
35 ) { 39 ) {